递归
尾递归
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