你可能不知道的递归

递归 尾递归 CPS trampoline memoize 缓存


本文使用 JavaScript 进行描述。
本文简要介绍了几种常见的递归用法。文中出现的代码仅供示意,不代表可以直接用于生产环境。

作者水平有(很)限(菜),疏漏之处敬请谅解。


尾递归

先来提一下尾递归,顾名思义就是递归调用的位置在函数的最后。

如果你对递归和尾递归等相关概念还不太熟悉,可以看看张大胖学递归归这篇文章。

这里需要解释的是,尾递归本身只是一种书写的方法,就是字面意思上的把递归调用的位置放到了函数最后,仅此而已,并不能提升递归的效率。
真正起到魔法作用,其实是尾递归调用优化。宣称自己是函数式语言的都支持这种优化。你也可以用“一个语言是否支持尾递归优化”来判断这种语言是否是函数式语言。;)
JavaScript 主流实现仍然缺少对尾递归调用优化的支持,所以本文的某些例子仍然有可能导致溢出。
不过,在不引起歧义的前提下,也可以用尾递归来指代尾递归调用优化。

蹦蹦床

刚才说某些语言并不支持尾递归调用优化,难道除了干等着语言的实现者来实现,就没有办法了么?
有!蹦蹦床就是其中一种比较好玩的东西。看名字就比较好玩,它的运行原理也很像一张蹦床。为什么说它是个蹦床呢?先看代码:

function trampoline(maybeFuction) {
  while (maybeFuction && maybeFuction instanceof Function) {
    // 如果参数是个函数,那么执行这个函数,拿到执行结果,作为下次循环的判断条件
    maybeFuction = maybeFuction();
  }

  // 如果不是函数,则就会跳出循环,返回最终结果
  return maybeFuction;
}

那么怎么使用呢?首先,我们需要对原有的尾递归函数做一点非常微小的改动。比如这是一个尾递归形式的斐波那契数列:

// 修改前
function fibonacci(n, a = 0, b = 1) {
  if (n === 0) {
    return a;
  } else {
    return fibonacci(n - 1, b, a + b);
  }
}

只需要把原来递归的动作包裹在函数中即可:

// 修改后
function fibonacci(n, a = 0, b = 1) {
  if (n === 0) {
    return a; // 蹦床会直接返回这个值
  } else {
    // 把要执行的动作放在函数里,这样蹦床就会弹起这个动作
    return () => fibonacci(n - 1, b, a + b);
  }
}

然后套上蹦床来执行,就不会发生栈溢出了。

trampoline(fibonacci(10000));

我们大致分析一下执行流程。如果返回值是函数,则证明接下来的动作其实并没有执行完毕,那就弹起来执行这个函数,如果再执行结果还是函数,那就再弹起来再执行,直到不是函数,则证明整个调用过程结束,可以返回结果了。
所以这张蹦床是只会弹起函数,如果不是函数,啪叽,摔地上了。

这种作法消灭函数调用栈的原理是,修改原来的递归函数,把递归的动作包裹在函数中直接返回,这样自然不会占用函数调用栈,最后其实真正触发执行的是交给了蹦床,而蹦床里用的是循环语句,也不会占用额外空间。

除了上面说的用法,蹦床还能用来改造相互递归调用,原理是一致的。感兴趣的同学可以自己查阅一下,在此就不赘述了。

Continuation-passing style

CPS (Continuation-passing style) 指的是一种编程风格,这种风格会把函数将要执行的下一步动作包裹在函数中,在需要递归的时候,把动作传入递归参数中,在下次需要的时候再执行。
是不是和上面的蹦床挺像?其实是有差距的,蹦床是一个“第三方”帮助函数,而 CPS 一种递归写法,两者甚至还能结合使用。
我们这里举一个二叉树遍历的栗子来进行说明。

先看看一般的递归写法,想要遍历二叉树,首先要有一颗二叉树,大概长这样。

/************

      1
     / \
    2   5
   / \
  3   4

*************/

tree = {
  value: 1,
  left: {
    value: 2,
    left: {
      value: 3
    },
    right: {
      value: 4
    }
  },
  right: {
    value: 5
  }
}

先来写一下前序遍历。我们回忆一下前序遍历的定义:首先访问根节点,然后访问左子树,最后访问右子树。对于左子树和右子树,再次用前序遍历的方式进行访问,直到没有节点可以访问为止。
这是个典型的递归定义,所以我们把根节点作为函数参数,进行递归处理

function preorder(nowNode) {
  if (nowNode != null) {
    console.log(nowNode.value); // 前序遍历,所以先打印根节点
    preorder(nowNode.left); // 然后进行左子树遍历
    preorder(nowNode.right); // 右子树遍历
  }
}

preorder(tree);

虽然这种写法很简单很清晰,但是,为了按要求实现遍历,必须要等到左子树遍历执行完才能执行右子树的遍历。也就意味着这种写法在递归发生时需要保存调用堆栈信息,没有办法享受到尾递归优化。
这里就需要 CPS 登场了。
首先,我们要想办法把接下来的动作能随参数携带,这样在递归的时候就可以直接返回,也就是所谓的尾递归。
但是,遍历动作没办法以简单的数值形式进行传递,所以我们把它包裹在函数中,也就是说,我们把接下来要做的动作放进函数里,传递给下一次递归。
所以,我们需要一个参数,就取名叫 continuation 吧

function preorder(nowNode, continuation) {
  if (nowNode != null) {
    console.log(nowNode.value); // 前序遍历,先打印根节点
    // 注意下面这行代码,依然先遍历左子树,所以 nowNode.left 作为递归的第一个参数
    // 遍历右子树的动作属于“接下来要做的事情”,所以包装在函数中,作为下次递归的 continuation 参数。
    // 而当前这次递归的 continuation 就变成了“接下来之后”“再接下来”做的事情,所以就是下次递归 continuation 参数中的 continuation 参数。
    // 比较绕,理解一下
    return preorder(nowNode.left, () => preorder(nowNode.right, continuation));
  } else {
    // 当前节点为 null 也就是说到底了,此时就要开始做“接下来要做的事情”了。
    return continuation();
  }
}

那么 continuation 的初始值是什么呢?第一次其实没有“接下来要做的事情”,所以初始值就是啥都不做的空函数。所以这样调用即可:

preorder(tree, () => {})

最后我们配合蹦床函数,使得在不支持尾递归优化的环境下也不会溢出,只需要非常简单的在递归的位置套上一层函数。

function preorder(nowNode, continuation) {
  if (nowNode != null) {
    console.log(nowNode.value);
    // 递归的位置包一层函数,让蹦床工作起来
    return () => preorder(nowNode.left, () => preorder(nowNode.right, continuation));
  } else {
    return continuation();
  }
}

trampoline(preorder(tree, () => {}));

可见,这种作法并不是完全消灭了递归传递的链条,而是把它从栈移动到了函数里,而函数所在的内存区域并没有像栈那样受限,所以本来在栈中会溢出的就不会溢出了。
最后再给出中序遍历和后续遍历的代码,以供对比参考。注意三种遍历方式的打印语句分别处于哪一层 continuation。

// 中序
function inorder(nowNode, continuation) {
  if (nowNode == null) {
    return continuation();
  } else {
    return inorder(
      nowNode.left,
      () => {
        console.log(nowNode.value);
        return inorder(nowNode.right, continuation);
      }
    );
  }
}

// 后序
function postorder(nowNode, continuation) {
  if (nowNode == null) {
    return continuation();
  } else {
    return postorder(
      nowNode.left,
      () => postorder(
        nowNode.right,
        () => {
          console.log(nowNode.value);
          return continuation();
        }
      )
    );
  }
}

这里留个问题,聪明的你能不能试着把 fibonacci 改写成 CPS 风格的代码呢?

CPS 好是好,就是太费脑子。那么有没有可能自动把一个递归函数转换成 CPS 呢?这样不就太爽啦?写出简单的递归代码,然后自动生成 CPS 代码,然后自动套上蹦床!小白啥也不做也能写出高效的递归了!

你想的真美好,现实是…还真有!甚至有些语言都内置了通用的自动转换的函数!真的是小白福音!
鉴于篇幅的原因(其实因为我不会),对于通用的 CPS 转换的原理感兴趣的同学可以阅读文末给出的参考文章(需要 lambda 演算相关的知识才能看懂)。

在递归中使用缓存

递归存在的另一个问题就是会重复计算相同的函数调用。(其实非递归形式也存在这种现象。)
一种通常的作法就是将函数结果缓存起来,下次遇到相同的调用时,直接返回上次计算的结果。

比如这是一个经典的斐波那契数列的递归写法:

function fibonacci(n) {
  if (n == 0 || n == 1) {
    return n;
  } else {
    return fibonacci(n - 1) + fibonacci(n - 2);
  }
}

我们可以像这样手动的给它加上缓存:

let cache = {};

function fibonacci(n) {
  if (n in cache) { // 命中缓存
    value = cache[n];
  } else {
    if (n == 0 || n == 1) {
      value = n;
    } else {
      value = fibonacci(n - 1) + fibonacci(n - 2);
    }

    cache[n] = value; // 存入缓存
  }
}

有了缓存,就可以免去多次重复计算的开销。但是这种必须修改原函数才能实现缓存的方式显然不够通用。
我们可以写一个高阶函数,使用函数的参数列表作为缓存的 key,利用闭包来存放缓存,最后生成一个带缓存的函数。

function memoize(func) {
  let cache = {};

  return function() {
    // arguments 是函数的参数信息的封装
    // [...arguments] 可以把它转成数组,作为缓存的 key
    // 如果参数不在缓存中,就使用 apply 调用原函数得到结果,并缓存
    if (arguments in cache) {
      return cache[[...arguments]];
    } else {
      return cache[[...arguments]] = func.apply(this, arguments);
    }
  };
}

这样就可以非侵入地定义任意带缓存的函数了:

// 原始的未经修改的 fibonacci
function fibonacci(n) {
  if (n == 0 || n == 1) {
    return n;
  } else {
    return fibonacci(n - 1) + fibonacci(n - 2);
  }
}

// 生成出带缓存的版本
let memoized_fibonacci = memoize(fibonacci);

memoized_fibonacci(10);

这种作法所依赖的前提条件是,递归的函数必须是 引用透明 的。
引用透明的意思也就是说,函数的返回值只与参数有关。如果 f 函数具有引用透明的性质,那么你今天调用 f(1) 的结果是 1 ,那么明天、明年调用它,在任何代码段中调用它,它的结果始终是 1
比如如果一个函数读取了数据库中的值,那么可能过一会儿调用的返回值就不同了,那它就不是一个引用透明的函数。
显然,引用透明的函数可以被安全缓存。

总结

递归中有太多有趣知识,但是工作中却很少使用,尤其是对于广大使用非函数式编程的程序员。这里抛砖引玉,希望能让各种程序员都能体会到递归的美妙!
本人也是小白级别,如果阅读过程中发现了什么错误疏漏、晦涩语句,欢迎在下面留言交流。


参考文章

性能优化:memoization
尾递归与Continuation
JavaScript调用栈、尾递归和手动优化
CPS变换与CPS变换编译
On Recursion, Continuations and Trampolines


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容