尾递归与编译器优化

在计算机中,程序运行中的函数调用是借助栈实现的:每当进入一个新的函数调用,栈就会增长一层栈帧,每当函数返回,栈就会减小一层栈帧。这个栈的大小是有限的(貌似是1M或者2M)。因此在执行递归的过程当中递归的次数是有限度的,超过某个不是很大的值就会爆栈(栈溢出)。html

以求解Fabonacci问题为例:ios

使用递归的方式实现Fabonacci问题的代码以下:函数

 1 #include <iostream>
 2 #include <cstdio>
 3 #include <time.h>
 4 using namespace std;
 5 
 6 unsigned long long fabonacci( int n )
 7 {
 8     if( n==1 || n==2 )
 9         return 1;
10     else
11         return fabonacci( n-1 )+fabonacci( n-2 );
12 }
13 
14 int main()
15 {
16     int n;
17     cin>>n;
18     clock_t start,finish;
19     double times;
20     start=clock();
21     unsigned long long s=fabonacci(n);
22     finish=clock();
23     times=(double)(finish-start)/CLOCKS_PER_SEC;
24     printf( "%d\n",s );
25     printf( "%f",times );
26     return 0;
27 }

在代码中加入了计时函数,记录递归所用的时间。测试

当输入n=46是程序输出:优化

计算的结果为1836311903,整个递归用时12.04s.占整个execute time的绝大部分。spa

这个求解过程大体如此:(以求fabonacci(6)为例)code

f(6)
=f(5)    + f(4)
=(f(4)   + f(3)) + f(4)
=((f(3)  + f(2)) + f(3)) + f(4)
=(((f(2) + f(1)) + f(2)) + f(3)) + f(4)
=(((1    + 1)    + 1)    + f(3)) + f(4)
=(3      +(f(2)  + f(1)) + f(4)
=(3      +(1     +1)+f(4)
=5       +(f(3)  + f(2))
=5       +((f(2) + f(1)) + f(2))
=5       +(( 1    +1)      +1)        
=5       +3
=5

这个求解的过程更像是对一棵以子函数构成的一棵树的后序遍历。向下递归,向上返回。htm

这样的话,求解的fabonacci数每增长1,须要遍历的树的层数就会增长一层。这是一个指数函数的复杂度增加(貌似没那么夸张,留坑,待研究)。blog

总之,这种方法对于n>50的状况是很难快速的到解的。递归

而若是使用尾递归,则会大大地避免这种状况。

“在计算机科学里,尾调用是指一个函数里的最后一个动做是一个函数调用的情形:即这个调用的返回值直接被当前函数返回的情形。这种情形下称该调用位置为尾位置。若这个函数在尾位置调用自己(或是一个尾调用自己的其余函数等等),则称这种状况为尾递归,是递归的一种特殊情形。”

尾调用的重要性在于它能够不在调用栈上面添加一个新的堆栈帧——而是更新它,如同迭代通常。尾递归于是具备两个特征:

调用自身函数(Self-called);
计算仅占用常量栈空间(Stack Space)。

“形式上只要是最后一个return语句返回的是一个完整函数,它就是尾递归

以上来自维基百科对尾调用(递归)的定义。

由于尾递归是当前函数最后一个动做,因此当前函数帧上的局部变量(全局变量保存在堆中)等大部分的东西都不须要了保存了,因此当前的函数帧通过适当的更动之后能够直接看成被尾调用的函数的帧使用。所以整个过程只要使用一个栈帧,在函数栈中不用新开辟栈空间。省去了向上返回->计算所用的时间,这样的话整个的计算过程就变成了线性的时间复杂度。

那么求解fabonacci数列尾递归版的写法是:

 1 #include <iostream>
 2 #include <cstdio>
 3 #include <time.h>
 4 using namespace std;
 5 
 6 unsigned long long fabonacci( int i,int num, unsigned long long pre1, unsigned long long pre2 )
 7 {
 8     if( i==num )
 9         return pre1+pre2;
10     else
11         return fabonacci( i+1,num,pre1+pre2,pre1 );
12 }
13 
14 int main()
15 {
16     int n;
17     cin>>n;
18     clock_t start,finish;
19     double times;
20     start=clock();
21     unsigned long long s=fabonacci(3,n,1,1);
22     finish=clock();
23     times=(double)(finish-start)/CLOCKS_PER_SEC;
24     cout<<s<<endl;
25     cout<<times<<endl;
26     return 0;
27 }

注意在fabonacci函数返回的时候将本层函数和上一层的计算结果传递给了下一层,用于下一层的计算,而且设置一个计数器来判断是否到达了底部。判断计算结束后,直接返回结果,而不用一层一层向上返回。

这样的话,求解的过程就变成了:

f(3,6,1,1)
=f(4,6,2,1)
=f(5,6,3,2)
=f(6,6,5,3)

 最终返回5+3=8

这样,即便是fabonacci(1000),也能够很快求出答案

这里计时函数尚未抓取到就计算完成了。。。

 

尾递归的另外一个优化显然就是栈空间上的优化。开头说到程序的函数栈空间是有限的,使用尾递归显然能够避免因为递归层数过多而产生的爆栈。

然而,不幸的是。不一样的编译器(解释器)会有不一样的选择。对于Python这种语言的解释器不会进行尾递归优化,即便你写成了尾递归形式的代码,他依然为你分配相应的栈空间。

C则比较奇怪,我对这两个程序进行了测试。普通递归版的程序能够计算到fabonacci(65141)(固然不可能等到其输出结果,可是在输入后至关长的一段时间里程序都没有爆栈,输入65142则会直接爆栈),而尾递归版的程序只能够计算到fabonacci(32572),反而不如普通递归版的计算的多。

查了查书,才知道原来G++编译器是有编译选项的,默认的O1编译选项是不会进行尾递归优化的,而O2编译选项就能够优化。这又涉及到编译原理的知识了。。等我看看SICP和CSAPP再来填坑吧。。

相关文章
相关标签/搜索