淺談尾遞歸

在《數據結構與算法分析:C描述》(Data Structures and Algorithm Analysis In C)的第三章中,以打印鏈表爲例,提到了尾遞歸(tail recursion)並指出了尾遞歸是使用遞歸極其不當的例子,它指出雖然編譯器會對尾遞歸自動優化,但即便如此最好還是不要去寫尾遞歸。而我在《算法精解:C語言描述》(Mastering Algorithms with C)中也看到書中提到編譯器會對尾遞歸進行優化,但是此書貌似看起來很提倡使用。

這裏對於不瞭解尾遞歸爲何物的童鞋們,我想探討幾個基本問題。
【1】什麼是尾遞歸?
【2】編譯器是怎樣優化尾遞歸的?
【3】優化工作交給編譯器還是交給自己?

第一個問題,什麼是尾遞歸?
直接上代碼:

int fact(int n) {
    if(n<0) return 0;
    else if (n == 0) return 1;
    else if(n ==1) return 1;
    else return n * fact(n - 1);
}

int factTail(int n, int a) {
    if (n < 0) return 0;
    else if (n == 0) return 1;
    else if (n == 1) return a;
    else return factTail(n - 1, n * a);
}

遞歸與尾遞歸

這兩個函數都是在計算n的階乘,結果一樣的,但只有下面的facttail函數纔是尾遞歸。
所以可以看出,尾遞歸的概念就是函數返回之前的最後一個操作若是遞歸調用,則該函數進行了尾遞歸,而上面的fact函數,最後一個操作是乘法,所以顯然不是尾遞歸。

第二個問題,編譯器是怎樣優化尾遞歸的?
我們知道遞歸調用是通過棧來實現的,每調用一次函數,系統都將函數當前的變量、返回地址等信息保存爲一個棧幀壓入到棧中,那麼一旦要處理的運算很大或者數據很多,有可能會導致很多函數調用或者很大的棧幀,這樣不斷的壓棧,很容易導致棧的溢出。

我們回過頭看一下尾遞歸的特性,函數在遞歸調用之前已經把所有的計算任務已經完畢了,他只要把得到的結果全交給子函數就可以了,無需保存什麼,子函數其實可以不需要再去創建一個棧幀,直接把就着當前棧幀,把原先的數據覆蓋即可。相對的,如果是普通的遞歸,函數在遞歸調用之前並沒有完成全部計算,還需要調用遞歸函數完成後才能完成運算任務,比如return n * fact(n - 1);這句話,這個fact(n)在算完fact(n-1)之後才能得到n * fact(n - 1)的運算結果然後才能返回。

綜上所述,編譯器對尾遞歸的優化實際上就是當他發現你丫在做尾遞歸的時候,就不會去不斷創建新的棧幀,而是就着當前的棧幀不斷的去覆蓋,一來防止棧溢出,二來節省了調用函數時創建棧幀的開銷,用《算法精解》裏面的原話就是:“When a compiler detects a call that is tail recursive, it overwrites the current activation record instead of pushing a new one onto the stack.”

第三個問題,優化工作交給編譯器還是交給自己?
這個怎麼說呢,據網上查閱,java,C#和python都不支持編譯環境自動優化尾遞歸,這種情況下,當然是別用遞歸效率最高,可以看下這裏http://www.cnblogs.com/Alexander-Lee/archive/2010/09/16/1827587.html。但是對於C語言來說,編譯器白提供的服務,用了也不差,畢竟遞歸代碼會好理解一點,但換句話說,如果寫到尾遞歸這份上了,變成非遞歸已經很好實現了,完全可以用循環來搞定,所以呢,這個時候,就看個人喜好了。

注:
老趙 大神也寫過一篇關於尾遞歸的文章,不過是用C#描述的,我沒怎麼看,感興趣可以瞭解下。http://www.cnblogs.com/JeffreyZhao/archive/2009/03/26/tail-recursion-and-continuation.html

轉載自
https://site.douban.com/196781/widget/notes/12161495/note/262014367/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章