尾遞歸與編譯器優化

在計算機中,程序運行中的函數調用是藉助棧實現的:每當進入一個新的函數調用,棧就會增長一層棧幀,每當函數返回,棧就會減小一層棧幀。這個棧的大小是有限的(貌似是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再來填坑吧。。

相關文章
相關標籤/搜索