標籤:
電腦科學的新學生通常難以理解遞迴程式設計的概念。遞迴思想之所以困難,原因在於它非常像是迴圈推理(circular reasoning)。它也不是一個直觀的過程;當我們指揮別人做事的時候,我們極少會遞迴地指揮他們。
Introduction
遞迴演算法是一種直接或者間接調用自身函數或者方法的演算法。遞迴演算法的實質是把問題分解成規模縮小的同類問題的子問題,然後遞迴調用方法來表示問題的解。遞迴演算法對解決一大類問題很有效,它可以使演算法簡潔和易於理解。遞迴演算法,其實說白了,就是程式的自身調用。它表現在一段程式中往往會遇到調用自身的那樣一種coding策略,這樣我們就可以利用大道至簡的思想,把一個大的複雜的問題層層轉換為一個小的和原問題相似的問題來求解的這樣一種策略。遞迴往往能給我們帶來非常簡潔非常直觀的代碼形勢,從而使我們的編碼大大簡化,然而遞迴的思維確實很我們的常規思維相逆的,我們通常都是從上而下的思維問題, 而遞迴趨勢從下往上的進行思維。這樣我們就能看到我們會用很少的語句解決了非常大的問題,所以遞迴策略的最主要體現就是小的代碼量解決了非常複雜的問題。
遞迴演算法解決問題的特點:
- 遞迴就是方法裡調用自身。
- 在使用遞增歸策略時,必須有一個明確的遞迴結束條件,稱為遞迴出口。
- 遞迴演算法解題通常顯得很簡潔,但遞迴演算法解題的運行效率較低。所以一般不提倡用遞迴演算法設計程式。
- 在遞迴調用的過程當中系統為每一層的返回點、局部量等開闢了棧來儲存。遞迴次數過多容易造成棧溢出等,所以一般不提倡用遞迴演算法設計程式。
遞迴演算法要求。遞迴演算法所體現的“重複”一般有三個要求:
(1) 是每次調用在規模上都有所縮小(通常是減半);
(2) 是相鄰兩次重複之間有緊密的聯絡,前一次要為後一次做準備(通常前一次的輸出就作為後一次的輸入);
(3) 是在問題的規模極小時必須用直接給出解答而不再進行遞迴調用,因而每次遞迴調用都是有條件的(以規模未達到直接解答的大小為條件),無條件遞迴調用將會成為死迴圈而不能正常結束。
從遞迴的經典樣本開始計算階乘
計算階乘是遞迴程式設計的一個經典樣本。計算某個數的階乘就是用那個數去乘包括 1 在內的所有比它小的數。例如,factorial(5) 等價於5*4*3*2*1,而 factorial(3) 等價於 3*2*1。
階乘的一個有趣特性是,某個數的階乘等於起始數(starting number)乘以比它小一的數的階乘。例如,factorial(5) 與 5 * factorial(4) 相同。您很可能會像這樣編寫階乘函數:
| 123 |
int factorial(int n){ return n * factorial(n - 1);} |
(註:本文的程式樣本用C語言編寫)
不過,這個函數的問題是,它會永遠運行下去,因為它沒有終止的地方。函數會連續不斷地調用 factorial。 當計算到零時,沒有條件來停止它,所以它會繼續調用零和負數的階乘。因此,我們的函數需要一個條件,告訴它何時停止。
由於小於 1 的數的階乘沒有任何意義,所以我們在計算到數字 1 的時候停止,並返回 1 的階乘(即 1)。因此,真正的遞迴函式類似於:
| 123456 |
int factorial(int n){ if(n == 1) return 1; else return n * factorial(n - 1);} |
可見,只要初始值大於零,這個函數就能夠終止。停止的位置稱為 基準條件(base case)。基準條件是遞迴程式的 最底層位置,在此位置時沒有必要再進行操作,可以直接返回一個結果。所有遞迴程式都必須至少擁有一個基準條件,而且 必須確保它們最終會達到某個基準條件;否則,程式將永遠運行下去,直到程式缺少記憶體或者棧空間。
費伯納西數列
費伯納西數列(Fibonacci Sequence),最開始用於描述兔子生長的數目時用上了這數列。從數學上,費波那契數列是以遞迴的方法來定義:
這樣費伯納西數列的遞迴程式就可以非常清晰的寫出來了:
| 123456 |
int Fibonacci(int n){ if (n <= 1) return n; else return Fibonacci(n-1) + Fibonacci(n-2); } |
遞迴程式的基本步驟
每一個遞迴程式都遵循相同的基本步驟:
(1) 初始化演算法。遞迴程式通常需要一個開始時使用的種子值(seed value)。要完成此任務,可以向函數傳遞參數,或者提供一個入口函數, 這個函數是非遞迴的,但可以為遞迴計算設定種子值。(2) 檢查要處理的當前值是否已經與基準條件相匹配。如果匹配,則進行處理並傳回值。(3) 使用更小的或更簡單的子問題(或多個子問題)來重新定義答案。(4) 對子問題運行演算法。(5) 將結果合并入答案的運算式。(6) 返回結果。
使用歸納定義
有時候,編寫遞迴程式時難以獲得更簡單的子問題。 不過,使用 歸納定義的(inductively-defined)資料集 可以令子問題的獲得更為簡單。歸納定義的資料集是根據自身定義的資料結構 —— 這叫做 歸納定義(inductive definition)。
例如,鏈表就是根據其本身定義出來的。鏈表所包含的節點結構體由兩部分構成:它所持有的資料,以及指向另一個節點結構體(或者是 NULL,結束鏈表)的指標。 由於節點結構體內部包含有一個指向節點結構體的指標,所以稱之為是歸納定義的。
使用歸納資料編寫遞迴過程非常簡單。注意,與我們的遞迴程式非常類似,鏈表的定義也包括一個基準條件 —— 在這裡是 NULL 指標。 由於 NULL 指標會結束一個鏈表,所以我們也可以使用 NULL 指標條件作為基於鏈表的很多遞迴程式的基準條件。
下面看兩個例子。
鏈表求和樣本
讓我們來看一些基於鏈表的遞迴函式樣本。假定我們有一個數字列表,並且要將它們加起來。履行遞迴過程式列的每一個步驟,以確定它如何應用於我們的求和函數:
(1) 初始化演算法。這個演算法的種子值是要處理的第一個節點,將它作為參數傳遞給函數。(2) 檢查基準條件。程式需要檢查確認當前節點是否為 NULL 列表。如果是,則返回零,因為一個空列表的所有成員的和為零。(3) 使用更簡單的子問題重新定義答案。我們可以將答案定義為當前節點的內容加上列表中其餘部分的和。為了確定列表其餘部分的和, 我們針對下一個節點來調用這個函數。(4) 合并結果。遞迴調用之後,我們將當前節點的值加到遞迴調用的結果上。
這樣我們就可以很簡單的寫出鏈表求和的遞迴程式,執行個體如下:
| 12345 |
int sum_list(struct list_node *l){if(l == NULL)return 0;return l.data + sum_list(l.next);} |
漢諾塔問題
漢諾塔(Hanoi Tower)問題也是一個經典的遞迴問題,該問題描述如下:
漢諾塔問題:古代有一個梵塔,塔內有三個座A、B、C,A座上有64個盤子,盤子大小不等,大的在下,小的在上()。有一個和尚想把這64個盤子從A座移到B座,但每次只能允許移動一個盤子,並且在移動過程中,3個座上的盤子始終保持大盤在下,小盤在上。
Hanoi Tower Solving
- 如果只有 1 個盤子,則不需要利用B塔,直接將盤子從A移動到C。
- 如果有 2 個盤子,可以先將盤子1上的盤子2移動到B;將盤子1移動到C;將盤子2移動到C。這說明了:可以藉助B將2個盤子從A移動到C,當然,也可以藉助C將2個盤子從A移動到B。
- 如果有3個盤子,那麼根據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變成空塔…
根據以上的分析,不難寫出程式:
| 12345678910 |
void Hanoi (int n, char A, char B, char C){ if (n==1){ //end condition move(A,B);//‘move’ can be defined to be a print function } else{ Hanoi(n-1,A,C,B);//move sub [n-1] pans from A to B move(A,C);//move the bottom(max) pan to C Hanoi(n-1,B,A,C);//move sub [n-1] pans from B to C }} |
將迴圈轉化為遞迴
在下表中瞭解迴圈的特性,看它們可以如何與遞迴函式的特性相對比。
| Properties |
Loops |
Recursive functions |
| 重複 |
為了獲得結果,反覆執行同一代碼塊;以完成代碼塊或者執行 continue 命令訊號而實現重複執行。 |
為了獲得結果,反覆執行同一代碼塊;以反覆調用自己為訊號而實現重複執行。 |
| 終止條件 |
為了確保能夠終止,迴圈必須要有一個或多個能夠使其終止的條件,而且必須保證它能在某種情況下滿足這些條件的其中之一。 |
為了確保能夠終止,遞迴函式需要有一個基準條件,令函數停止遞迴。 |
| 狀態 |
迴圈進行時更新目前狀態。 |
目前狀態作為參數傳遞。 |
可見,遞迴函式與迴圈有很多類似之處。實際上,可以認為迴圈和遞迴函式是能夠相互轉換的。 區別在於,使用遞迴函式極少被迫修改任何一個變數 —— 只需要將新值作為參數傳遞給下一次函數調用。 這就使得您可以獲得避免使用可更新變數的所有益處,同時能夠進行重複的、有狀態的行為。
下面還是以階乘為例子,迴圈寫法為:
| 12345678 |
int factorial(int n){ int product = 0; while(n>0){ product *= n; n--; } return product;} |
遞迴寫法在第二節中已經介紹過了,這裡就不重複了,可以比較一下。
尾遞迴介紹
對於遞迴函式的使用,人們所關心的一個問題是棧空間的增長。確實,隨著被調用次數的增加,某些種類的遞迴函式會線性地增加棧空間的使用 —— 不過,有一類函數,即尾部遞迴函式,不管遞迴有多深,棧的大小都保持不變。尾遞迴屬於線性遞迴,更準確的說是線性遞迴的子集。
函數所做的最後一件事情是一個函數調用(遞迴的或者非遞迴的),這被稱為 尾部調用(tail-call)。使用尾部調用的遞迴稱為 尾部遞迴。當編譯器檢測到一個函數調用是尾遞迴的時候,它就覆蓋當前的活動記錄而不是在棧中去建立一個新的。編譯器可以做到這點,因為遞迴調用是當前活躍期內最後一條待執行的語句,於是當這個調用返回時棧幀中並沒有其他事情可做,因此也就沒有儲存棧幀的必要了。通過覆蓋當前的棧幀而不是在其之上重新添加一個,這樣所使用的棧空間就大大縮減了,這使得實際的運行效率會變得更高。
讓我們來看一些尾部調用和非尾部調用函數樣本,以瞭解尾部調用的含義到底是什麼:
| 123456789101112131415161718192021222324252627 |
int test1(){ int a = 3; test1(); /* recursive, but not a tail call. We continue */ /* processing in the function after it returns. */ a = a + 4; return a;}int test2(){ int q = 4; q = q + 5; return q + test1(); /* test1() is not in tail position. * There is still more work to be * done after test1() returns (like * adding q to the result*/}int test3(){ int b = 5; b = b + 2; return test1(); /* This is a tail-call. The return value * of test1() is used as the return value * for this function.*/ }int test4(){ test3(); /* not in tail position */ test3(); /* not in tail position */ return test3(); /* in tail position */} |
可見,要使調用成為真正的尾部調用,在尾部調用函數返回之前,對其結果 不能執行任何其他動作。
注意,由於在函數中不再做任何事情,那個函數的實際的棧結構也就不需要了。惟一的問題是,很多程式設計語言和編譯器不知道 如何除去沒有用的棧結構。如果我們能找到一個除去這些不需要的棧結構的方法,那麼我們的尾部遞迴函式就可以在固定大小的棧中運行。
在尾部調用之後除去棧結構的方法稱為 尾部調用最佳化 。
那麼這種最佳化是什嗎?我們可以通過詢問其他問題來回答那個問題:
(1) 函數在尾部被調用之後,還需要使用哪個本地變數?哪個也不需要。(2) 會對返回的值進行什麼處理?什麼處理也沒有。(3) 傳遞到函數的哪個參數將會被使用?哪個都沒有。
好像一旦控制權傳遞給了尾部調用的函數,棧中就再也沒有有用的內容了。雖然還佔據著空間,但函數的棧結構此時實際上已經沒有用了,因此,尾部調用最佳化就是要在尾部進行函數調用時使用下一個棧結構 覆蓋 當前的棧結構,同時保持原來的返回地址。
我們所做的本質上是對棧進行處理。再也不需要活動記錄(activation record),所以我們將刪掉它,並將尾部調用的函數重新導向返回到調用我們的函數。 這意味著我們必須手工重新編寫棧來仿造一個返回地址,以使得尾部調用的函數能直接返回到調用它的函數。
Conclusion
遞迴是一門偉大的藝術,使得程式的正確性更容易確認,而不需要犧牲效能,但這需要程式員以一種新的眼光來研究程式設計。對新程式員 來說,命令式程式設計通常是一個更為自然和直觀的起點,這就是為什麼大部分程式設計說明都集中關注命令式語言和方法的原因。 不過,隨著程式越來越複雜,遞迴程式設計能夠讓程式員以可維護且邏輯一致的方式更好地組織代碼。
轉載於 遞迴演算法詳解
[轉]遞迴演算法詳解