尾递归与编译器优化
在计算机中,程序运行中的函数调用是借助栈实现的:每当进入一个新的函数调用,栈就会增加一层栈帧,每当函数返回,栈就会减少一层栈帧。这个栈的大小是有限的(貌似是1M或者2M)。所以在执行递归的过程中递归的次数是有限度的,超过某个不是很大的值就会爆栈(栈溢出)。
以求解Fabonacci问题为例:
使用递归的方式实现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的绝大部分。
这个求解过程大致如此:(以求fabonacci(6)为例)
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
这个求解的过程更像是对一棵以子函数构成的一棵树的后序遍历。向下递归,向上返回。
这样的话,求解的fabonacci数每增加1,需要遍历的树的层数就会增加一层。这是一个指数函数的复杂度增长(貌似没那么夸张,留坑,待研究)。
总之,这种方法对于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再来填坑吧。。