計算機科學的新學生通常難以理解遞歸程序設計的概念。遞歸思想之所以困難,原因在于它非常像是循環(huán)推理(circular reasoning)。它也不是一個直觀的過程;當我們指揮別人做事的時候,我們極少會遞歸地指揮他們。
Introduction
遞歸算法是一種直接或者間接調用自身函數(shù)或者方法的算法。
遞歸算法的實質是把問題分解成規(guī)模縮小的同類問題的子問題,然后遞歸調用方法來表示問題的解。遞歸算法對解決一大類問題很有效,它可以使算法簡潔和易于理解。
遞歸算法,其實說白了,就是程序的自身調用。它表現(xiàn)在一段程序中往往會遇到調用自身的那樣一種coding策略,這樣我們就可以利用大道至簡的思想,把一個大的復雜的問題層層轉換為一個小的和原問題相似的問題來求解的這樣一種策略。
遞歸往往能給我們帶來非常簡潔非常直觀的代碼形勢,從而使我們的編碼大大簡化,然而遞歸的思維確實跟我們的常規(guī)思維相逆的,我們通常都是從上而下的思維問題, 而遞歸趨勢從下往上的進行思維。這樣我們就能看到我們會用很少的語句解決了非常大的問題,所以遞歸策略的最主要體現(xiàn)就是小的代碼量解決了非常復雜的問題。
遞歸算法解決問題的特點:
? 遞歸就是方法里調用自身。
? 在使用遞增歸策略時,必須有一個明確的遞歸結束條件,稱為遞歸出口。
? 遞歸算法解題通常顯得很簡潔,但遞歸算法解題的運行效率較低。所以一般不提倡用遞歸算法設計程序。
? 在遞歸調用的過程當中系統(tǒng)為每一層的返回點、局部量等開辟了棧來存儲。遞歸次數(shù)過多容易造成棧溢出等,所以一般不提倡用遞歸算法設計程序。
遞歸算法要求。遞歸算法所體現(xiàn)的“重復”一般有三個要求:
(1) 是每次調用在規(guī)模上都有所縮小(通常是減半);
(2) 是相鄰兩次重復之間有緊密的聯(lián)系,前一次要為后一次做準備(通常前一次的輸出就作為后一次的輸入);
(3) 是在問題的規(guī)模極小時必須用直接給出解答而不再進行遞歸調用,因而每次遞歸調用都是有條件的(以規(guī)模未達到直接解答的大小為條件),無條件遞歸調用將會成為死循環(huán)而不能正常結束。
從遞歸的經(jīng)典示例開始
計算階乘
計算階乘是遞歸程序設計的一個經(jīng)典示例。計算某個數(shù)的階乘就是用那個數(shù)去乘包括 1 在內的所有比它小的數(shù)。例如,factorial(5) 等價于 5*4*3*2*1,而 factorial(3) 等價于 3*2*1。
階乘的一個有趣特性是,某個數(shù)的階乘等于起始數(shù)(starting number)乘以比它小1的數(shù)的階乘。例如,factorial(5) 與5 * factorial(4) 相同。您很可能會像這樣編寫階乘函數(shù):
(注:本文的程序示例用C語言編寫)
不過,這個函數(shù)的問題是,它會永遠運行下去,因為它沒有終止的地方。函數(shù)會連續(xù)不斷地調用 factorial。 當計算到零時,沒有條件來停止它,所以它會繼續(xù)調用零和負數(shù)的階乘。因此,我們的函數(shù)需要一個條件,告訴它何時停止。
由于小于 1 的數(shù)的階乘沒有任何意義,所以我們在計算到數(shù)字 1 的時候停止,并返回 1 的階乘(即 1)。因此,真正的遞歸函數(shù)類似于:
精密詳解c/c++遞歸算法,感受遞歸算法的獨特用處
可見,只要初始值大于零,這個函數(shù)就能夠終止。停止的位置稱為 基線條件(base case)。基線條件是遞歸程序的最底層位置,在此位置時沒有必要再進行操作,可以直接返回一個結果。所有遞歸程序都必須至少擁有一個基線條件,而且 必須確保它們最終會達到某個基線條件;否則,程序將永遠運行下去,直到程序缺少內存或者棧空間。
斐波納契數(shù)列
斐波納契數(shù)列(Fibonacci Sequence),最開始用于描述兔子生長的數(shù)目時用上了這數(shù)列。從數(shù)學上,費波那契數(shù)列是以遞歸的方法來定義:
這樣斐波納契數(shù)列的遞歸程序就可以非常清晰的寫出來了:
精密詳解c/c++遞歸算法,感受遞歸算法的獨特用處
遞歸程序的基本步驟
每一個遞歸程序都遵循相同的基本步驟:
(1) 初始化算法。遞歸程序通常需要一個開始時使用的種子值(seed value)。要完成此任務,可以向函數(shù)傳遞參數(shù),或者提供一個入口函數(shù), 這個函數(shù)是非遞歸的,但可以為遞歸計算設置種子值。
(2) 檢查要處理的當前值是否已經(jīng)與基線條件相匹配。如果匹配,則進行處理并返回值。
(3) 使用更小的或更簡單的子問題(或多個子問題)來重新定義答案。
(4) 對子問題運行算法。
(5) 將結果合并入答案的表達式。
(6) 返回結果。
使用歸納定義
有時候,編寫遞歸程序時難以獲得更簡單的子問題。不過,使用歸納定義的(inductively-defined)數(shù)據(jù)集 可以令子問題的獲得更為簡單。歸納定義的數(shù)據(jù)集是根據(jù)自身定義的數(shù)據(jù)結構 —— 這叫做歸納定義(inductive definition)。
例如,鏈表就是根據(jù)其本身定義出來的。鏈表所包含的節(jié)點結構體由兩部分構成:它所持有的數(shù)據(jù),以及指向另一個節(jié)點結構體(或者是 NULL,結束鏈表)的指針。 由于節(jié)點結構體內部包含有一個指向節(jié)點結構體的指針,所以稱之為是歸納定義的。
使用歸納數(shù)據(jù)編寫遞歸過程非常簡單。注意,與我們的遞歸程序非常類似,鏈表的定義也包括一個基線條件 —— 在這里是 NULL 指針。 由于 NULL 指針會結束一個鏈表,所以我們也可以使用 NULL 指針條件作為基于鏈表的很多遞歸程序的基線條件。
下面看兩個例子。
鏈表求和示例
讓我們來看一些基于鏈表的遞歸函數(shù)示例。假定我們有一個數(shù)字列表,并且要將它們加起來。履行遞歸過程序列的每一個步驟,以確定它如何應用于我們的求和函數(shù):
(1) 初始化算法。這個算法的種子值是要處理的第一個節(jié)點,將它作為參數(shù)傳遞給函數(shù)。
(2) 檢查基線條件。程序需要檢查確認當前節(jié)點是否為 NULL 列表。如果是,則返回零,因為一個空列表的所有成員的和為零。
(3) 使用更簡單的子問題重新定義答案。我們可以將答案定義為當前節(jié)點的內容加上列表中其余部分的和。為了確定列表其余部分的和, 我們針對下一個節(jié)點來調用這個函數(shù)。
(4) 合并結果。遞歸調用之后,我們將當前節(jié)點的值加到遞歸調用的結果上。
這樣我們就可以很簡單的寫出鏈表求和的遞歸程序,實例如下:
精密詳解c/c++遞歸算法,感受遞歸算法的獨特用處
漢諾塔問題
漢諾塔(Hanoi Tower)問題也是一個經(jīng)典的遞歸問題,該問題描述如下:
漢諾塔問題:古代有一個梵塔,塔內有三個座A、B、C,A座上有64個盤子,盤子大小不等,大的在下,小的在上(如圖)。有一個和尚想把這64個盤子從A座移到B座,但每次只能允許移動一個盤子,并且在移動過程中,3個座上的盤子始終保持大盤在下,小盤在上。
精密詳解c/c++遞歸算法,感受遞歸算法的獨特用處
Hanoi Tower Solving
? 如果只有 1 個盤子,則不需要利用B塔,直接將盤子從A移動到C。
? 如果有 2 個盤子,可以先將盤子1上的盤子2移動到B;將盤子1移動到C;將盤子2移動到C。這說明了:可以借助B將2個盤子從A移動到C,當然,也可以借助C將2個盤子從A移動到B。
? 如果有3個盤子,那么根據(jù)2個盤子的結論,可以借助c將盤子1上的兩個盤子從A移動到B;將盤子1從A移動到C,A變成空座;借助A座,將B上的兩個盤子移動到C。
以此類推,上述的思路可以一直擴展到 n 個盤子的情況,將將較小的 n-1個盤子看做一個整體,也就是我們要求的子問題,以借助B塔為例,可以借助空塔B將盤子A上面的 n-1 個盤子從A移動到B;將A最大的盤子移動到C,A變成空塔;借助空塔A,將B塔上的 n-2 個盤子移動到A,將C最大的盤子移動到C,B變成空塔…
根據(jù)以上的分析,不難寫出程序:
精密詳解c/c++遞歸算法,感受遞歸算法的獨特用處
將循環(huán)轉化為遞歸
在下表中了解循環(huán)的特性,看它們可以如何與遞歸函數(shù)的特性相對比。
精密詳解c/c++遞歸算法,感受遞歸算法的獨特用處
可見,遞歸函數(shù)與循環(huán)有很多類似之處。實際上,可以認為循環(huán)和遞歸函數(shù)是能夠相互轉換的。 區(qū)別在于,使用遞歸函數(shù)極少被迫修改任何一個變量 —— 只需要將新值作為參數(shù)傳遞給下一次函數(shù)調用。 這就使得您可以獲得避免使用可更新變量的所有益處,同時能夠進行重復的、有狀態(tài)的行為。
下面還是以階乘為例子,循環(huán)寫法為:
精密詳解c/c++遞歸算法,感受遞歸算法的獨特用處
遞歸寫法在第二節(jié)中已經(jīng)介紹過了,這里就不重復了,可以比較一下。
尾遞歸介紹
對于遞歸函數(shù)的使用,人們所關心的一個問題是棧空間的增長。確實,隨著被調用次數(shù)的增加,某些種類的遞歸函數(shù)會線性地增加棧空間的使用 —— 不過,有一類函數(shù),即尾部遞歸函數(shù),不管遞歸有多深,棧的大小都保持不變。尾遞歸屬于線性遞歸,更準確的說是線性遞歸的子集。
函數(shù)所做的最后一件事情是一個函數(shù)調用(遞歸的或者非遞歸的),這被稱為 尾部調用(tail-call)。使用尾部調用的遞歸稱為 尾部遞歸。當編譯器檢測到一個函數(shù)調用是尾遞歸的時候,它就覆蓋當前的活動記錄而不是在棧中去創(chuàng)建一個新的。編譯器可以做到這點,因為遞歸調用是當前活躍期內最后一條待執(zhí)行的語句,于是當這個調用返回時棧幀中并沒有其他事情可做,因此也就沒有保存棧幀的必要了。通過覆蓋當前的棧幀而不是在其之上重新添加一個,這樣所使用的棧空間就大大縮減了,這使得實際的運行效率會變得更高。
讓我們來看一些尾部調用和非尾部調用函數(shù)示例,以了解尾部調用的含義到底是什么:
精密詳解c/c++遞歸算法,感受遞歸算法的獨特用處
可見,要使調用成為真正的尾部調用,在尾部調用函數(shù)返回之前,對其結果 不能執(zhí)行任何其他操作。
注意,由于在函數(shù)中不再做任何事情,那個函數(shù)的實際的棧結構也就不需要了。惟一的問題是,很多程序設計語言和編譯器不知道如何除去沒有用的棧結構。如果我們能找到一個除去這些不需要的棧結構的方法,那么我們的尾部遞歸函數(shù)就可以在固定大小的棧中運行。
在尾部調用之后除去棧結構的方法稱為 尾部調用優(yōu)化 。
那么這種優(yōu)化是什么?我們可以通過詢問其他問題來回答那個問題:
(1) 函數(shù)在尾部被調用之后,還需要使用哪個本地變量?哪個也不需要。
(2) 會對返回的值進行什么處理?什么處理也沒有。
(3) 傳遞到函數(shù)的哪個參數(shù)將會被使用?哪個都沒有。
好像一旦控制權傳遞給了尾部調用的函數(shù),棧中就再也沒有有用的內容了。雖然還占據(jù)著空間,但函數(shù)的棧結構此時實際上已經(jīng)沒有用了,因此,尾部調用優(yōu)化就是要在尾部進行函數(shù)調用時使用下一個棧結構 覆蓋 當前的棧結構,同時保持原來的返回地址。
我們所做的本質上是對棧進行處理。再也不需要活動記錄(activation record),所以我們將刪掉它,并將尾部調用的函數(shù)重定向返回到調用我們的函數(shù)。 這意味著我們必須手工重新編寫棧來仿造一個返回地址,以使得尾部調用的函數(shù)能直接返回到調用它的函數(shù)。
Conclusion
遞歸是一門偉大的藝術,使得程序的正確性更容易確認,而不需要犧牲性能,但這需要程序員以一種新的眼光來研究程序設計。對新程序員來說,命令式程序設計通常是一個更為自然和直觀的起點,這就是為什么大部分程序設計說明都集中關注命令式語言和方法的原因。 不過,隨著程序越來越復雜,遞歸程序設計能夠讓程序員以可維護且邏輯一致的方式更好地組織代碼。