遞歸與尾遞歸
關於遞歸操作,簡單地說,一個函數直接或間接地調用自身,是為直接或間接遞歸。例如,可以使用遞歸來計算一個單向鏈表的長度:
public static int GetLengthRecursively(Node head) { if (head == null) return 0; return GetLengthRecursively(head.Next) + 1; }
在調用時,GetLengthRecursively方法會不斷調用自身,直至滿足遞歸出口。對遞歸有些了解的朋友一定猜得到,如果單向鏈表十分長,那么上面這個方法就可能會遇到棧溢出,也就是拋出StackOverflowException。這是由於每個線程在執行代碼時,都會分配一定尺寸的棧空間(Windows系統中為1M),每次方法調用時都會在棧里儲存一定信息(如參數、局部變量、返回地址等等),這些信息再少也會占用一定空間,成千上萬個此類空間累積起來,自然就超過線程的棧空間了。不過這個問題並非無解,我們只需把遞歸改成如下形式即可(這篇文章里不考慮非遞歸的解法):
public static int GetLengthTailRecursively(Node head, int acc) { if (head == null) return acc; return GetLengthTailRecursively(head.Next, acc + 1); }
GetLengthTailRecursively方法多了一個acc參數,acc的為accumulator(累加器)的縮寫,它的功能是在遞歸調用時“積累”之前調用的結果,並將其傳入下一次遞歸調用中——這就是GetLengthTailRecursively方法與GetLengthRecursively方法相比在遞歸方式上最大的區別:GetLengthRecursive方法在遞歸調用后還需要進行一次“+1”,而GetLengthTailRecursively的遞歸調用屬於方法的最后一個操作。這就是所謂的“尾遞歸”
與普通遞歸相比,由於尾遞歸的調用處於方法的最后,因此方法之前所積累下的各種狀態對於遞歸調用結果已經沒有任何意義,因此完全可以把本次方法中留在堆棧中的數據完全清除,把空間讓給最后的遞歸調用。這樣的(編譯器)優化便使得遞歸不會在調用堆棧上產生堆積,意味着即使是“無限”遞歸也不會讓堆棧溢出。這便是尾遞歸的優勢。
有些朋友可能已經想到了,尾遞歸的本質,其實是將遞歸方法中的需要的“所有狀態”通過方法的參數傳入下一次調用中。對於GetLengthTailRecursively方法,我們在調用時需要給出acc參數的初始值:
GetLengthTailRecursively(head, 0);
為了進一步熟悉尾遞歸的使用方式,我們再用著名的“菲波納鍥”數列作為一個例子。傳統的遞歸方式如下:
public static int FibonacciRecursively(int n) { if (n < 2) return n; return FibonacciRecursively(n - 1) + FibonacciRecursively(n - 2); }
而改造成尾遞歸,我們則需要提供兩個累加器:
public static int FibonacciTailRecursively(int n, int acc1, int acc2) { if (n == 0) return acc1; return FibonacciTailRecursively(n - 1, acc2, acc1 + acc2); }
於是在調用時,需要提供兩個累加器的初始值:
FibonacciTailRecursively(10, 0, 1);
尾遞歸的循環優化
尾遞歸,即是遞歸調用放在方法末尾的遞歸方式,如經典的階乘:
int FactorialTailRecursion(int n, int acc) { if (n == 0) return acc; return FactorialTailRecursion(n - 1, acc * n); }
由於遞歸在方法的末尾,因此方法中的局部變量已經毫無用處,編譯器完全可以將其“復用”,並把尾遞歸優化為“循環”方式:
int FactorialLoopOptimized(int n, int acc) { while (true) { if (n == 0) return acc; acc *= n; n--; } }
c編譯器(gcc)對尾遞歸的優化
在gcc編譯的時候加上-O2會對尾遞歸進行優化。我們可以直接看生成的匯編代碼。(使用gdb, gcc –O2 factorial.c –o factorial; disass factorial)
未加-O2生成的匯編:
加了O2優化的匯編:
去網上稍微搜搜匯編命令,大致就能理解如下:
function factoral(n, sum) { while(n != 0) { sum = n * sum n = n-1 } return sum }
gcc對尾遞歸進行了智能優化。如果還有興趣,可以使用-O3對尾遞歸進行優化,並查看其中的匯編指令。-O3的優化是直接將循環展開。
一些其他網友對尾遞歸的討論
尾遞歸就是從最后開始計算,每遞歸一次就算出相應的結果。而線形遞歸是直到遞歸到一個確定的值后,又從這個具體值向后計算。所以線形遞歸肯定費事。
本文內容截取自以下三篇文章,感謝原文作者,讓我對尾遞歸有了更好的理解。-_-
尾遞歸與Continuation:http://www.cnblogs.com/JeffreyZhao/archive/2009/03/26/tail-recursion-and-continuation.html
淺談尾遞歸的優化方式:
http://www.cnblogs.com/JeffreyZhao/archive/2009/04/01/1424028.html
又見尾遞歸:
http://www.cnblogs.com/yjf512/archive/2012/07/12/2588481.html