遞歸和尾遞歸的區別和實現
基本上大多數C的入門教材里都會說簡單的遞歸,例如求階乘n!,經典的本科入門書籍譚浩強的《C語言程序設計》,但后來看了《代碼大全2》這本書,關於進階和編碼規范的書中提到了,這些計算機教材用愚蠢的例子階乘和斐波那契數列來講解階乘,因為遞歸是強有力的工具,但用階乘去計算階乘之類的,很不明智,除了速度慢,還無法預測運行期間內存的使用情況,而且遞歸比循環更難理解。該書說的這些點,的確是遞歸的一個弊病,但有些時候,遞歸的思想很不錯,二叉樹的很多遍歷問題,或者排序算法,用遞歸實現起來很方便。遞歸的代碼比較簡潔,但使用起來要慎重,有如上所說的一些弊端,但遞歸的思想是解決一些可以迭代的問題的。
1. 遞歸
遞歸的名次解釋(百度百科的):
程序調用自身的編程技巧稱為遞歸( recursion)。遞歸做為一種算法在程序設計語言中廣泛應用。一個過程或函數在其定義或說明中有直接或間接調用自身的一種方法,它通常把一個大型復雜的問題層層轉化為一個與原問題相似的規模較小的問題來求解,遞歸策略只需少量的程序就可描述出解題過程所需要的多次重復計算,大大地減少了程序的代碼量。遞歸的能力在於用有限的語句來定義對象的無限集合。一般來說,遞歸需要有邊界條件、遞歸前進段和遞歸返回段。當邊界條件不滿足時,遞歸前進;當邊界條件滿足時,遞歸返回。
定義如下:
遞歸,就是在運行的過程中調用自己。
構成遞歸需具備的條件:
1. 子問題須與原始問題為同樣的事,且更為簡單;
2. 不能無限制地調用本身,須有個出口,化簡為非遞歸狀況處理。
以遞歸方式實現階乘函數的實現:
int fact(int n) { if (n < 0) return 0; else if(n == 0 || n == 1) return 1; else return n * fact(n - 1); }
再比如求二叉樹的高度就是1+max{height(root->light),height(root->right)},從而有了遞歸算法的求解思路。
遞歸實現的代碼如下:
int height(BTree *p) { int hi = 0,lh = 0,rh = 0; if (p == NULL) hi = 0; else { if (p->lchild ==NULL) lh = 0; else lh = height(p->lchild);//遞歸求解左子樹的高度 if (p->rchild ==NULL) rh = 0; else rh = height(p->rchild);//遞歸求解右子樹的高度 hi = lh>rh ? (lh + 1) : (rh + 1); } return hi; }
下面分析遞歸的工作原理:
先看看C程序在內存中的組織方式: http://blog.csdn.net/zcyzsy/article/details/69788884
(我的這篇博文有詳細寫,這里不做復述):
BSS段,數據段 ,代碼段,堆(heap),棧(stack) ;
而棧又稱堆棧,存放程序的局部變量(不包括靜態局部變量,static變量存在靜態區)。除此以外,在函數被調用時,棧用來傳遞參數和返回值。由於棧的后進先出特點,所以棧特別方便用來保存/恢復調用現場。從這個意義上講,我們可以把堆棧看成一個寄存、交換臨時數據的內存區。
當C程序中調用了一個函數時,棧中會分配一塊空間來保存與這個調用相關的信息,每一個調用都被當作是活躍的。棧上的那塊存儲空間稱為活躍記錄或者棧幀
棧幀由5個區域組成:輸入參數、返回值空間、計算表達式時用到的臨時存儲空間、函數調用時保存的狀態信息以及輸出參數。
棧是用來存儲函數調用信息的絕好方案,然而棧也有一些缺點:
棧維護了每個函數調用的信息直到函數返回后才釋放,這需要占用相當大的空間,尤其是在程序中使用了許多的遞歸調用的情況下。除此之外,因為有大量的信息需要保存和恢復,因此生成和銷毀活躍記錄需要消耗一定的時間。我們需要考慮采用迭代的方案。
簡而言之,遞歸過的壓棧和出棧,時間和空間都有很大的消耗,
2.尾遞歸
幸好可以采用一種稱為尾遞歸的特殊遞歸方式來避免前面提到的這些缺點。
尾遞歸的名次解釋(百科來的,供理解):
如果一個函數中所有遞歸形式的調用都出現在函數的末尾,我們稱這個遞歸函數是尾遞歸的。當遞歸調用是整個函數體中最后執行的語句且它的返回值不屬於表達式的一部分時,這個遞歸調用就是尾遞歸。尾遞歸函數的特點是在回歸過程中不用做任何操作,這個特性很重要,因為大多數現代的編譯器會利用這種特點自動生成優化的代碼。
尾遞歸的原理:
當編譯器檢測到一個函數調用是尾遞歸的時候,它就覆蓋當前的活動記錄而不是在棧中去創建一個新的。編譯器可以做到這點,因為遞歸調用是當前活躍期內最后一條待執行的語句,於是當這個調用返回時棧幀中並沒有其他事情可做,因此也就沒有保存棧幀的必要了。通過覆蓋當前的棧幀而不是在其之上重新添加一個,這樣所使用的棧空間就大大縮減了,這使得實際的運行效率會變得更高。
以尾遞歸方式實現階乘函數的實現:
int facttail(int n, int res) { if (n < 0) return 0; else if(n == 0) return 1; else if(n == 1) return res; else return facttail(n - 1, n *res); }
那么尾遞歸是如何工作的,我們先用遞歸來計算階乘,通過對比,看看前面所定義的遞歸為何不是尾遞歸。
代碼1:在每次函數調用計算n倍的(n-1)!的值,讓n=n-1並持續這個過程直到n=1為止。這種定義不是尾遞歸的,因為每次函數調用的返回值都依賴於用n乘以下一次函數調用的返回值,因此每次調用產生的棧幀將不得不保存在棧上直到下一個子調用的返回值確定。
代碼2:函數比代碼1多個參數res,除此之外並沒有太大區別。res(初始化為1)維護遞歸層次的深度。這就讓我們避免了每次還需要將返回值再乘以n。然而,在每次遞歸調用中,令res=n*res並且n=n-1。繼續遞歸調用,直到n=1,這滿足結束條件,此時直接返回res即可。
可以仔細看看兩個函數的具體實現,看看遞歸和尾遞歸的不同!
示例中的函數是尾遞歸的,因為對facttail的單次遞歸調用是函數返回前最后執行的一條語句。換句話說,在遞歸調用之后還可以有其他的語句執行,只是它們只能在遞歸調用沒有執行時才可以執行。
尾遞歸是極其重要的,不用尾遞歸,函數的堆棧耗用難以估量,需要保存很多中間函數的堆棧。比如sum(n) = f(n) = f(n-1) + value(n) ;會保存n個函數調用堆棧,而使用尾遞歸f(n, sum) = f(n-1, sum+value(n)); 這樣則只保留后一個函數堆棧即可,之前的可優化刪去。
關於尾遞歸,解釋下,其實一開始接觸尾遞歸是實習期間,用erlang函數式編程語言寫項目程序,erlang中沒有循環,只能通過遞歸和列表解析來實現循環的功能。但眾所周知遞歸是代碼好看但效率極低的,於是我看到了erlang的編程之美-尾遞歸。當時帶我的大哥只給了一句,不壓棧的遞歸,放心用,我也實際測了時間,效率之高令人驚艷。在erlang里很好的體驗了一波尾遞歸的強大,多變,實用,靈活。當時沒去想C的尾遞歸,現在覺得要總結一波,C中當然也有高效的尾遞歸啦。
下面貼一段以前寫erlang時的遞歸和尾遞歸代碼:
% 遞歸 loop(0)-> 1; loop(N)-> N * loop(N - 1). % 尾遞歸 tail_loop(N)-> tail_loop(N, 1). tail_loop(0,R)-> R; tail_loop(N,R) -> tail_loop(N - 1, N*R).
貼幾段: %%尾遞歸實現循環和判斷,列表解析實現列表元素的遍歷, %%短小精悍,其實是查找列表中是否有連續3個相同的數(和傳的參相同) get_aj([],H)->false; get_aj([H|[H|[H|D]]],H)->true; get_aj([_|D],H)->get_aj(D,H).
注意哦,這里尾遞歸其實還用了一個重載,然后尾遞歸調用,其實最精髓就是 通過參數傳遞結果,達到不壓棧的目的。C中玩好了尾遞歸,代碼可以很秀。尾遞歸是一種高效解決問題的思想,C和erlang中的尾遞歸都是一樣的。原理相同,效果相同。
————————————————
版權聲明:本文為CSDN博主「Zmyths」的原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/zcyzsy/article/details/77151709