递归算法的本质是定义一个规则, 让程序根据规则去帮你完成一件事。然而递归被吐槽的最多的事它感人的性能和爆栈的可能性,有必要整理一下如何对递归程序做优化。
这里先以Fibonacci为例。
Scala代码:
def fib1(n: BigInt): BigInt = {
if(n == 0) 0
else if(n == 1) 1
else fib1(n - 1) + fib1(n - 2)
}
以上是Fibonacci的一般递归算法, 几乎就是把定义抄了上去, 然后程序在层层递归调用中不知不觉把结果算了出来。当n的数值过大时, 由于递归过深,临时变量塞满了栈空间导致stackoverflow. 有两种比较直观的解决方案: 改写成迭代或者尾递归。
Scala代码
def fib4(n: BigInt): BigInt = {
var n0 = 0
var n1 = 1
var i = 1
val l = while(i <= n) {
var tmp = n1
n1 = n0 + n1
n0 = tmp
i = i + 1
}
n0
}
以上是用迭代的方式实现Fibonacci。迭代是一种递推的方法, 核心思想是自下而上进行计算, 并将中间结果保存下来, 以避免递归算法对同一个值重复计算的复杂性。 直观的认识是, 我们可以建立一个大小为n的数组, 把f(1) …f(n – 1)的结果都保存下来。然而经过观察我们发现, 在求f(n)的时候, 我们只需要用到f(n – 1)和f(n – 2), 之前的结果完全可以抛弃。因此这里定义了n1, n2来保存中间结果, 每次循环重新计算n2的值, 并将老的n2赋值给n1。经过这种优化以后, 空间复杂度从o(n)降到了o(1)。 但这只是个特例, 递归改写成循环并不一定能减少空间复杂度。
这里唯一要注意的是边界值。 当循环以 <= n作为条件时, n = 0时不需要进入循环, n = 1时需要计算一次, 所以此处i = 0, 末尾返回较小的值
通过上面的分析, 我们发现通过循环可以将空间复杂度减少为o(1)。这意味着这个递归能在不构造栈的情况下被改写成尾递归。
def fib3(n: BigInt): BigInt = {
def fibInner(n: BigInt, acc1: BigInt, acc2: BigInt): BigInt = {
if(n == 0) acc1
else {
fibInner(n - 1, acc2, acc1 + acc2)
}
}
fibInner(n, 0, 1)
}
上面是尾递归算法。 可以看出尾递归的思路其实是迭代的思路, acc1和acc2代表迭代算法中的n0和n1, 每次迭代将acc1赋值为acc2, 将acc2赋值为acc1 + acc2, 和迭代算法如出一辙, 最后返回acc1,对应n0。
由上面分析可以得出一个简单结论:尾递归是迭代算法的一种变相实现, 如果我们推导不出迭代算法, 那么尾递归也同样推导不出。 同时形如f(n) = f(n – x) …. f(n – y)的单向递归都可以套用以上模式改写成循环或尾递归, acc(临时变量)的个数取决于x和y之间的跨度。
此外, 我们还可以使用CPS(Continuation-Passing Style)的方式来改写函数使其成为尾递归。
def fibCont(n: BigInt, continuation: BigInt => BigInt = (x => x)): BigInt = {
if (n < 2) continuation(n)
else {
fibCont(n - 1,
r1 => fibCont(n - 2, r2 => continuation(r1 + r2)));
}
}
Currying之后的函数如下
def fibCont(n: BigInt)(continuation: BigInt => BigInt = (x => x)): BigInt = {
if (n < 2) continuation(n)
else {
fibCont(n - 1)(r1 => fibCont(n - 2)(r2 => continuation(r1 + r2)));
}
}
Fibonacci的CPS函数可以理解为:要计算f(n), 就要先计算f(n – 1), 再将f(n – 1)的结果放到continuation函数中做接下来的事。而接下来的事无非就是计算f(n – 2), 再将两个数相加的结果作为下一层continuation的参数。
参考了很多大神的博客和资料以及自己的亲身实践以后, 得出如下结论: CPS的方法可以用上述模式把任何递归改写成使用continuation函数的方法, 但:1不能减少程序的复杂度, 2这种形式的尾递归不一定能被编译器优化(如Scala, 在上述函数前加上@tailrec无法通过编译)。CPS体现的是函数式编程的一种思想, 即把变量的值变换替换成函数的定义变换。换句话说, 上述的continuation在层层递归中函数体越拼越长, 到了递归出口才传入真正的参数来计算。 至于为什么空间复杂度并没有减小,是因为这种方式节省了临时变量压栈的空间,是以加长匿名函数体为代价的。 当函数体越来越长时, 该爆栈还是得爆栈。