微博上看到有人在讨论尾递归,想起以前曾看过老赵写的一篇相关的博客,介绍的比较详细了,相信很多人都看过,我也在下面留了言,但挑了个刺,表示文章在关键点上一带而过了,老赵自然是懂的,但看的人如果不深入思考,未必真正的明白,下面我说说我的理解。
什么是尾递归
什么是尾递归呢?(tail recursion), 顾名思议,就是一种“不一样的”递归,说到它的不一样,就得先说说一般的递归。对于一般的递归,比如下面的求阶乘,教科书上会告诉我们,如果这个函数调用的深度太深,很容易会有爆栈的危险。
// 先不考虑溢出问题
int func(int n)
{
if (n <= 1) return 1;
return (n * func(n-1));
}
原因很多人的都知道,让我们先回顾一下函数调用的大概过程:
1)调用开始前,调用方(或函数本身)会往栈上压相关的数据,参数,返回地址,局部变量等。
2)执行函数。
3)清理栈上相关的数据,返回。
因此,在函数 A 执行的时候,如果在第二步中,它又调用了另一个函数 B,B 又调用 C.... 栈就会不断地增长不断地装入数据,当这个调用链很深的时候,栈很容易就满 了,这就是一般递归函数所容易面临的大问题。
而尾递归在某些语言的实现上,能避免上述所说的问题,注意是某些语言上,尾递归本身并不能消除函数调用栈过长的问题,那什么是尾递归呢?在上面写的一般递归函数 func() 中,我们可以看到,func(n) 是依赖于 func(n-1) 的,func(n) 只有在得到 func(n-1) 的结果之后,才能计算它自己的返回值,因此理论上,在 func(n-1) 返回之前,func(n),不能结束返回。因此func(n)就必须保留它在栈上的数据,直到func(n-1)先返回,而尾递归的实现则可以在编译器的帮助下,消除这个限制:
// 先不考虑溢出
int tail_func(int n, int res)
{
if (n <= 1) return res;
return tail_func(n - 1, n * res);
}
// 像下面这样调用
tail_func(10000000000, 1);
从上可以看到尾递归把返回结果放到了调用的参数里。这个细小的变化导致,tail_func(n, res)不必像以前一样,非要等到拿到了tail_func(n-1, n*res)的返回值,才能计算它自己的返回结果 -- 它完全就等于tail_func(n-1, n*res)的返回值。因此理论上:tail_func(n)在调用tail_func(n-1)前,完全就可以先销毁自己放在栈上的东西。
这就是为什么尾递归如果在得到编译器的帮助下,是完全可以避免爆栈的原因:每一个函数在调用下一个函数之前,都能做到先把当前自己占用的栈给先释放了,尾递归的调用链上可以做到只有一个函数在使用栈,因此可以无限地调用!
尾递归的调用栈优化特性
相信读者都注意到了,我一直在强调,尾递归的实现依赖于编译器的帮助(或者说语言的规定),为什么这样说呢?先看下面的程序:
1 #include <stdio.h>
2
3 int tail_func(int n, int res)
4 {
5 if (n <= 1) return res;
6
7 return tail_func(n - 1, n * res);
8 }
9
10
11 int main()
12 {
13 int dummy[1024*1024]; // 尽可能占用栈。
14
15 tail_func(2048*2048, 1);
16
17 return 1;
18 }
上面这个程序在开了编译优化和没开编译优化的情况下编出来的结果是不一样的,如果不开启优化,直接 gcc -o tr func_tail.c 编译然后运行的话,程序会爆栈崩溃,但如果开优化的话:gcc -o tr -O2 func_tail.c,上面的程序最后就能正常运行。
这里面的原因就在于,尾递归的写法只是具备了使当前函数在调用下一个函数前把当前占有的栈销毁,但是会不会真的这样做,是要具体看编译器是否最终这样做,如果在语言层面上,没有规定要优化这种尾调用,那编译器就可以有自己的选择来做不同的实现,在这种情况下,尾递归就不一定能解决一般递归的问题。
我们可以先看看上面的例子在开优化与没开优化的情况下,编译出来的汇编代码有什么不同,首先是没开优化编译出来的汇编tail_func:
1 .LFB3:
2 pushq %rbp
3 .LCFI3:
4 movq %rsp, %rbp
5 .LCFI4:
6 subq $16, %rsp
7 .LCFI5:
8 movl %edi, -4(%rbp)
9 movl %esi, -8(%rbp)
10 cmpl $1, -4(%rbp)
11 jg .L4
12 movl -8(%rbp), %eax
13 movl %eax, -12(%rbp)
14 jmp .L3
15 .L4:
16 movl -8(%rbp), %eax
17 movl %eax, %esi
18 imull -4(%rbp), %esi
19 movl -4(%rbp), %edi
20 decl %edi
21 call tail_func
22 movl %eax, -12(%rbp)
23 .L3:
24 movl -12(%rbp), %eax
25 leave
26 ret
注意上面标红色的一条语句,call 指令就是直接进行了函数调用,它会先压栈,然后再 jmp 去 tail_func,而当前的栈还在用!就是说,尾递归的作用没有发挥。
再看看开了优化得到的汇编:
1 tail_func:
2 .LFB13:
3 cmpl $1, %edi
4 jle .L8
5 .p2align 4,,7
6 .L9:
7 imull %edi, %esi
8 decl %edi
9 cmpl $1, %edi
10 jg .L9
11 .L8:
12 movl %esi, %eax
13 ret
注意第7,第10行,尤其是第10行!tail_func() 里面没有函数调用!它只是把当前函数的第二个参数改了一下,直接就又跳到函数开始的地方。此处的实现本质其实就是:下一个函数调用继续延用了当前函数的栈!
这就是尾递归所能带来的效果: 控制栈的增长,且减少压栈,程序运行的效率也可能更高!
上面所写的是 c 的实现,正如前面所说的,这并不是所有语言都摆支持,有些语言,比如说 python, 尾递归的写法在 python 上就没有任何作用,该爆的时候还是会爆。
def func(n, res):
if (n <= 1):
return res
return func(n-1, n*res)
if __name__ =='__main__':
print func(4096, 1)
不仅仅是 python,据说 C# 也不支持,我在网上搜到了这个链接:https://connect.microsoft.com/VisualStudio/feedback/details/166013/c-compiler-should-optimize-tail-calls,微软的人在上面回答说,实现这个优化有些问题需要处理,并不是想像中那么容易,因此暂时没有实现,但是这个回答是在2007年的时候了,到现在岁月变迁,不知支持了没?我看老赵写的尾递归博客是在2009年,用 c# 作的例子,估计现在 c# 是支持这个优化的了(待考).
尾调用
前面的讨论一直都集中在尾递归上,这其实有些狭隘,尾递归的优化属于尾调用优化这个大范畴,所谓尾调用,形式它与尾递归很像,都是一个函数内最后一个动作是调用下一个函数,不同的只是调用的是谁,显然尾递归只是尾调用的一个特例。
int func1(int a)
{
static int b = 3;
return a + b;
}
int func2(int c)
{
static int b = 2;
return func1(c+b);
}
上面例子中,func2在调用func1之前显然也是可以完全丢掉自己占有的栈空间的,原因与尾递归一样,因此理论上也是可以进行优化的,而事实上这种优化也一直是程序编译优化里的一个常见选项,甚至很多的语言在标准里就直接要求要对尾调用进行优化,原因很明显,尾调用在程序里是经常出现的,优化它不仅能减少栈空间使用,通常也能给程序运行效率带来比较大的提升。
服务器君一共花费了80.846 ms进行了3次数据库查询,努力地为您提供了这个页面。
尾递归(tail recursive),看名字就知道是某种形式的递归。简单的说递归就是函数自己调用自己。那尾递归和递归之间的差别就只能体现在参数上了。
尾递归wiki解释如下:
尾部递归是一种编程技巧。递归函数是指一些会在函数内调用自己的函数,如果在递归函数中,递归调用返回的结果总被直接返回,则称为尾部递归。尾部递归的函数有助将算法转化成函数编程语言,而且从编译器角度来说,亦容易优化成为普通循环。这是因为从电脑的基本面来说,所有的循环都是利用重复移跳到代码的开头来实现的。如果有尾部归递,就只需要叠套一个堆栈,因为电脑只需要将函数的参数改变再重新调用一次。利用尾部递归最主要的目的是要优化,例如在Scheme语言中,明确规定必须针对尾部递归作优化。可见尾部递归的作用,是非常依赖于具体实现的。
我们还是从简单的斐波那契开始了解尾递归吧。
用普通的递归计算Fibonacci数列:
28 | return factorial(n-1) + factorial(n-2); |
程序员运行结果如下:
4 | Process returned 0 (0x0) execution time : 3.502 s |
5 | Press any key to continue . |
在i5的CPU下也要花费 3.502 秒的时间。
下面我们看看如何用尾递归实现斐波那契数。
13 | rs = factorial_tail(n, 1, 1); |
19 | int factorial_tail( int n, int acc1, int acc2) |
27 | return factorial_tail(n-1,acc2,acc1+acc2); |
程序员运行结果如下:
3 | Process returned 0 (0x0) execution time : 1.460 s |
4 | Press any key to continue . |
快了一倍有多。当然这是不完全统计,有兴趣的话可以自行计算大规模的值,这里只是介绍尾递归而已。
我们可以打印一下程序的执行过程,函数加入下面的打印语句:
01 | int factorial_tail( int n, int acc1, int acc2) |
09 | printf ( "factorial_tail(%d, %d, %d) \n" ,n-1,acc2,acc1+acc2); |
10 | return factorial_tail(n-1,acc2,acc1+acc2); |
程序运行结果:
02 | factorial_tail(9, 1, 2) |
03 | factorial_tail(8, 2, 3) |
04 | factorial_tail(7, 3, 5) |
05 | factorial_tail(6, 5, 8) |
06 | factorial_tail(5, 8, 13) |
07 | factorial_tail(4, 13, 21) |
08 | factorial_tail(3, 21, 34) |
09 | factorial_tail(2, 34, 55) |
10 | factorial_tail(1, 55, 89) |
12 | Process returned 0 (0x0) execution time : 1.393 s |
13 | Press any key to continue . |
从上面的调试就可以很清晰地看出尾递归的计算过程了。acc1就是第n个数,而acc2就是第n与第n+1个数的和,这就是我们前面讲到的“迭代”的精髓,计算结果参与到下一次的计算,从而减少很多重复计算量。
fibonacci(n-1,acc2,acc1+acc2)真是神来之笔,原本朴素的递归产生的栈的层次像二叉树一样,以指数级增长,但是现在栈的层次却像是数组,变成线性增长了,实在是奇妙,总结起来也很简单,原本栈是先扩展开,然后边收拢边计算结果,现在却变成在调用自身的同时通过参数来计算。
小结
尾递归的本质是:将单次计算的结果缓存起来,传递给下次调用,相当于自动累积。
在Java等命令式语言中,尾递归使用非常少见,因为我们可以直接用循环解决。而在函数式语言中,尾递归却是一种神器,要实现循环就靠它了。
很多人可能会有疑问,为什么尾递归也是递归,却不会造成栈溢出呢?因为编译器通常都会对尾递归进行优化。编译器会发现根本没有必要存储栈信息了,因而会在函数尾直接清空相关的栈。