遞歸的本質

特別聲明,以下大部分內容摘自李煜東的《算法競賽進階指南》

   理解遞歸,首先要從函數調用說起。實際上,一臺32臺的計算機採用“堆棧結構”來實現函數調用,它在彙編語言中,按照函數參數從右至左的順序依次入棧(大部分編譯器貌似都是從右至左),然後指向call(Address)指令。該指令把返回地址(當前語句的下一條語句)入棧,然後跳轉到address位置的語句。在函數返回時,它指行ret指令。該指令會把返回地址出棧,並跳轉到該地址執行。

   對於C++中函數定義非static的局部變量,每次執行call與ret指令時,會在棧中相應的保存與復原,而作用範圍超過該函數的變量,以及動態申請的堆區空間(使用new關鍵字或者malloc函數且注意此處的堆並非數據結構中的堆)。棧指針、返回值、局部的運算會藉助CPU的寄存器完成。爲什麼此處強調的是非static變量,因爲C++中的所有static變量都是保存在內存四區中的靜態區。全局static變量和局部static變量的唯一區別就在於二者的作用域不用以及初始化時機不同,具體初始化時機詳見百度,此處不再贅述,但是二者的生命週期都和main函數是一致的。

  函數調用棧描述的是函數之間的調用關係。它由多個棧幀組成,每個棧幀對應着一個還未運行完的函數。棧幀中保存了該函數的返回地址和局部變量,因而不僅能在函數調用完畢之後找到正確的返回地址,還能保證不同函數之間的局部變量互相不會干擾--因爲不同函數對應着不同的棧幀。

  有了上述的理論作爲支撐,很容易得出下面的結論:遞歸時每一次函數調用都一個棧幀保存調用信息,遞歸實質上就是一條道走到黑,走到遞歸出口的時候,不能再繼續往下走了,於是又只能原路返回。在從函數第一次調用到遞歸出口以及再從遞歸出口原路返回的過程中,只需要適當的插入一些其他操作(如二叉樹的遍歷中對節點的訪問操作),就可以實現我們的目的。(二叉樹的遍歷例子見我的另一篇博客:https://blog.csdn.net/qq_15054345/article/details/84066830

  遞歸在自己調用自己的過程中,操作系統會爲每一次調用都建立一個棧幀,但是這個操縱對程序員是透明(透明即不可見,計算機世界的透明都是對外提供一個功能接口,但是內部實現一般都不可見)的。所以這就造成了下面兩個現象:1、遞歸對於初學者很難理解,2,遞歸次數過多或者沒有遞歸出口的遞歸函數會造成棧溢出(C++的遞歸一般支持上萬次的遞歸調用而不會造成棧溢出)。

  在數據量比較大的情況下,遞歸的效率是比較低的。這是因爲遞歸做了很多重複的計算,這就又引出了另外一種算法思想:動態規劃。動態規劃其實就是把遞歸中的每一次計算結果用一個數組保存下來,下次再需要用到這個值的時候,直接從數組取值,而不是再重新算一遍,這就大幅度的提升了算法效率。實質上在算法界,目前我遇到的只有兩種情況:要麼是以更高的時間開銷換取更低的空間開銷,要麼以更高的空間開銷換取更低的時間開銷(當然可能有其他的,歡迎補充和指教),不過實際中都是後者居多。上面這段話比較抽象,拿斐波那契數列爲例吧,:

                                                 F(12) = F(11) + F(10)

                                                 F(11) = F(10) + F(9)

在計算F(12)的時候,需要計算一次F(11)和F(10),而在計算F(11)的時候還要計算一次F(10),並且F(12)和F(11)這兩個計算中的F(10)可是算了兩次的,並非共用的,這就是我說的重複計算。如果把F(i)的計算結果保存在一個數組中一個元素A[i]中,下次再次用到F(i)的計算結果時不再是傻乎乎的去算,而是直接從A[i]中取值,就能省去一大筆的時間開銷,這就是動態規劃的牛逼之處。

其實遞歸的優化方式還有一種叫做剪枝,有興趣的可以搜一搜。我目前見識的剪枝還比較少,無法做出總結。

每個人在學算法的時候都是菜鳥,我當初學的時候還不知道如何下手,我並非計算機本專業的,也沒有人指導,完全靠自己學以及上網看博客搜資料,中間走了很多彎路。現在我儘量把自己對算法的理解寫下來,期望能幫助一些初學者。當然,若我的理解有錯誤之處,望指出來,算法路上與君共勉!

發佈了73 篇原創文章 · 獲贊 5 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章