《Thinking In Algorithm》09.徹底理解遞迴
遞迴真的非常非常重要!!!
我們直接從例子開始吧!
一:簡單例項
1.階乘的實現
寫個函式實現 N! = N × (N-1) × (N-2) × ... × 2 × 1
public static int factorial(int N) {
if (N == 1) return 1;
return N * factorial(N-1);
}
上面的程式雖然簡單,但我們要了解他執行的步驟,以factorial(4)為例。
也可以表示為factorial(4) = 4 * factorial(3) = 4 * (3 * factorial(2) ) = 4 * (3 * (2 * factorial(1) ) ) = 4 * (3 * (2 * (1 * factorial(0) ) ) ) = 4 * (3 * (2 * (1 * 1) ) ) = 4 * (3 * (2 * 1) ) = 4 * (3 * 2) = 4 * 6 = 24
factorial(5)
factorial(4)
factorial(3)
factorial(2)
factorial(1)
return 1
return 2*1 = 2
return 3*2 = 6
return 4*6 = 24
return 5*24 = 120
2.歐幾里得函式的實現
求p和q的最大公約數
首先我們複習一下歐幾里得演算法
定理:gcd(a,b) = gcd(b,a mod b)
證明:a可以表示成a = kb + r,則r = a mod b
假設d是a,b的一個公約數,則有
d|a, d|b,而r = a - kb,因此d|r
因此d是(b,a mod b)的公約數
假設d 是(b,a mod b)的公約數,則
d | b , d |r ,但是a = kb +r
因此d也是(a,b)的公約數
因此(a,b)和(b,a mod b)的公約數是一樣的,其最大公約數也必然相等,得證。
下面的程式用了遞迴和迭代兩種方法。(後面會講到那種型別的遞迴可以改寫成迭代)
public class Euclid { // recursive implementation public static int gcd(int p, int q) { if (q == 0) return p; else return gcd(q, p % q); } // non-recursive implementation public static int gcd2(int p, int q) { while (q != 0) { int temp = q; q = p % q; p = temp; } return p; } public static void main(String[] args) { int p = Integer.parseInt(args[0]); int q = Integer.parseInt(args[1]); int d = gcd(p, q); int d2 = gcd2(p, q); System.out.println("gcd(" + p + ", " + q + ") = " + d); System.out.println("gcd(" + p + ", " + q + ") = " + d2); } }
程式執行的順序如下
Computing the recurrence relation for x = 27 and y = 9: |
---|
gcd(27, 9) = gcd(9, 27% 9) = gcd(9, 0) = 9 |
Computing the recurrence relation for x = 259 and y = 111: |
gcd(259, 111) = gcd(111, 259% 111) = gcd(111, 37) = gcd(111, 111% 37) = gcd(37, 0) = 37 |
二:遞迴的本質
從上面兩個簡單的例子我們隊遞迴的執行順序有了一點了解,我們知道遞迴的本質和棧資料的存取很相似了,都是先進去,但是往往最後處理!再者對於遞迴函式的區域性變數的儲存是按照棧的方式去存的,對於每一層的遞迴函式在棧中都儲存了本層函式的區域性變數,一邊該層遞迴函式結束時能夠儲存原來該層的資料!如圖:可能你看到這裡還是一頭霧水,遞迴的本質怎麼就和堆疊一樣了呢,ok,我們舉個例子來詳細說明這點,因為上面兩個簡單的例子不能很清楚說明他的操作順序。 1.給出一個值4267,我們需要依次產生字元‘4’,‘2’,‘6’,和‘7’。就如在printf函式中使用了%d格式碼,它就會執行類似處理。 分析:首先我們會想到用4267取餘,然後除以10再區域,如此迴圈。但這樣輸出的順序不會是7,6,2,4嗎?於是我們就利用遞迴的堆疊結構的特性:先進後出
public class Recursion{
public static void main(String args[]){
recursion(4267) ;
}
public static void recursion(int value){
int quotient ;
quotient = value/10 ;
if(quotient!=0){ recursion(quotient) ;}
System.out.println(value%10) ;
}
}
遞迴是如何幫助我們以正確的順序列印這些字元呢?下面是這個函式的工作流程。
1. 將引數值除以10
2. 如果quotient的值為非零,呼叫binary-to-ascii列印quotient當前值的各位數字
3. 接著,列印步驟1中除法運算的餘數
注意在第2個步驟中,我們需要列印的是quotient當前值的各位數字。我們所面臨的問題和最初的問題完全相同,只是變數quotient的 值變小了。我們用剛剛編寫的函式(把整數轉換為各個數字字元並打印出來)來解決這個問題。由於quotient的值越來越小,所以遞迴最終會終止。
一旦你理解了遞迴,閱讀遞迴函式最容易的方法不是糾纏於它的執行過程,而是相信遞迴函式會順利完成它的任務。如果你的每個步驟正確無誤,你的限制條件設定正確,並且每次呼叫之後更接近限制條件,遞迴函式總是能正確的完成任務。
但是,為了理解遞迴的工作原理,你需要追蹤遞迴呼叫的執行過程,所以讓我們來進行這項工作。追蹤一個遞迴函式的執行過程的關鍵是理解函式中所聲 明的變數是如何儲存的。當函式被呼叫時,它的變數的空間是創建於執行時堆疊上的。以前呼叫的函式的變數扔保留在堆疊上,但他們被新函式的變數所掩蓋,因此 是不能被訪問的。
當遞迴函式呼叫自身時,情況於是如此。每進行一次新的呼叫,都將建立一批變數,他們將掩蓋遞迴函式前一次呼叫所建立的變數。當我追蹤一個遞迴函式的執行過程時,必須把分數不同次呼叫的變數區分開來,以避免混淆。
程式中的函式有兩個變數:引數value和區域性變數quotient。下面的一些圖顯示了堆疊的狀態,當前可以訪問的變數位於棧頂。所有其他呼叫的變數飾以灰色的陰影,表示他們不能被當前正在執行的函式訪問。
假定我們以4267這個值呼叫遞迴函式。當函式剛開始執行時,堆疊的內容如下圖所示:
執行除法之後,堆疊的內容如下:
接著,if語句判斷出quotient的值非零,所以對該函式執行遞迴呼叫。當這個函式第二次被呼叫之初,堆疊的內容如下:
堆疊上建立了一批新的變數,隱藏了前面的那批變數,除非當前這次遞迴呼叫返回,否則他們是不能被訪問的。再次執行除法運算之後,堆疊的內容如下:
quotient的值現在為42,仍然非零,所以需要繼續執行遞迴呼叫,並再建立一批變數。在執行完這次呼叫的出發運算之後,堆疊的內容如下:
此時,quotient的值還是非零,仍然需要執行遞迴呼叫。在執行除法運算之後,堆疊的內容如下:
不算遞迴呼叫語句本身,到目前為止所執行的語句只是除法運算以及對quotient的值進行測試。由於遞迴呼叫這些語句重複執行,所以它的效果 類似迴圈:當quotient的值非零時,把它的值作為初始值重新開始迴圈。但是,遞迴呼叫將會儲存一些資訊(這點與迴圈不同),也就好是儲存在堆疊中的 變數值。這些資訊很快就會變得非常重要。
現在quotient的值變成了零,遞迴函式便不再呼叫自身,而是開始列印輸出。然後函式返回,並開始銷燬堆疊上的變數值。
每次呼叫putchar得到變數value的最後一個數字,方法是對value進行模10取餘運算,其結果是一個0到9之間的整數。把它與字元常量‘0’相加,其結果便是對應於這個數字的ASCII字元,然後把這個字元打印出來。
輸出4:
接著函式返回,它的變數從堆疊中銷燬。接著,遞迴函式的前一次呼叫重新繼續執行,她所使用的是自己的變數,他們現在位於堆疊的頂部。因為它的value值是42,所以呼叫putchar後打印出來的數字是2。
輸出42:
接著遞迴函式的這次呼叫也返回,它的變數也被銷燬,此時位於堆疊頂部的是遞迴函式再前一次呼叫的變數。遞迴呼叫從這個位置繼續執行,這次列印的數字是6。在這次呼叫返回之前,堆疊的內容如下:
輸出426:
現在我們已經展開了整個遞迴過程,並回到該函式最初的呼叫。這次呼叫打印出數字7,也就是它的value引數除10的餘數。
輸出4267:
然後,這個遞迴函式就徹底返回到其他函式呼叫它的地點。
如果你把打印出來的字元一個接一個排在一起,出現在印表機或螢幕上,你將看到正確的值:4267
三:另外
遞迴的使用條件:存在一個遞迴呼叫的終止條件;
每次遞迴的呼叫必須越來越靠近這個條件;只有這樣遞迴才會終止,否則是不能使用遞迴的!
總之,在你使用遞迴來處理問題之前必須首先考慮使用遞迴帶來的好處是否能補償
他所帶來的代價!否則,使用迭代演算法會比遞迴演算法要高效。
遞迴的基本原理:
1 每一次函式呼叫都會有一次返回.當程式流執行到某一級遞迴的結尾處時,它會轉移到前一級遞迴繼續執行.
2 遞迴函式中,位於遞迴呼叫前的語句和各級被調函式具有相同的順序.如列印語句 #1 位於遞迴呼叫語句前,它按照遞迴呼叫的順序被執行了 4 次.
3 每一級的函式呼叫都有自己的區域性變數.
4 遞迴函式中,位於遞迴呼叫語句後的語句的執行順序和各個被呼叫函式的順序相反.
即位於遞迴函式入口前的語句,右外往裡執行;位於遞迴函式入口後面的語句,由裡往外執行。
5 雖然每一級遞迴有自己的變數,但是函式程式碼並不會得到複製.
6 遞迴函式中必須包含可以終止遞迴呼叫的語句.
一旦你理解了遞迴(理解遞迴,關鍵是腦中有一幅程式碼的圖片,函式執行到遞迴函式入口時,就擴充一段完全一樣的程式碼,執行完擴充的程式碼並return後,繼續執行前一次遞迴函式中遞迴函式入口後面的程式碼),閱讀遞迴函式最容易的方法不是糾纏於它的執行過程,而是相信遞迴函式會順利完成它的任務。如果你的每個步驟正確無誤,你的限制條件設定正確,並且每次呼叫之後更接近限制條件,遞迴函式總是能正確的完成任務。
不算遞迴呼叫語句本身,到目前為止所執行的語句只是除法運算以及對quotient的值進行測試。由於遞迴呼叫這些語句重複執行,所以它的效果類似迴圈:當quotient的值非零時,把它的值作為初始值重新開始迴圈。但是,遞迴呼叫將會儲存一些資訊(這點與迴圈不同),也就好是儲存在堆疊中的變數值。這些資訊很快就會變得非常重要。
斐波那契數是典型的遞迴案例:
Fib(0) = 0 [基本情況] Fib(1) = 1 [基本情況]
對所有n > 1的整數:Fib(n) = (Fib(n-1) + Fib(n-2)) [遞迴定義]
遞迴演算法一般用於解決三類問題:
(1)資料的定義是按遞迴定義的。(Fibonacci函式)
(2)問題解法按遞迴演算法實現。(回溯)
(3)資料的結構形式是按遞迴定義的。(樹的遍歷,圖的搜尋)
如:
procedure a;
begin
a;
end;
這種方式是直接呼叫.
又如:
procedure b;
begin
c;
end;
procedure c;
begin
b;
end;
這種方式是間接呼叫.
如何設計遞迴演算法
1.確定遞迴公式
2.確定邊界(終了)條件
四:最後
留一個程式給大家去研究研究,看看程式執行的結果。
public class Region{
public static void main(String args[]){
int[] a = {1,2,3,4} ;
System.out.println("final "+region(a,0,0)) ;
}
public static int region(int[] a,int currentSum,int i){
currentSum+=a[i];
System.out.println("out "+ currentSum) ; //按順序輸出:遞迴式前面
if(i<3){
region(a,currentSum,i+1) ;
System.out.println("in "+ currentSum) ; //先進後出:遞迴式後面
}
System.out.println("hello ") ;
return currentSum ;
}
}
結果
out 1
out 3
out 6
out 10
hello
in 6
hello
in 3
hello
in 1
hello
final 1
這個例子我希望大家務必去研究研究,遞迴的先進後出的思想體現的淋漓精緻,雖然是沒有意義的程式。
由上面的例子我們可以知道:遞迴式前面的是按順序執行,遞迴是後面的則是先進後出的執行。如上面程式中,有標明
Reference:
http://introcs.cs.princeton.edu/java/23recursion/
http://en.wikipedia.org/wiki/Recursion_(computer_science)
http://blog.csdn.net/fightforyourdream/article/details/8671276
http://beckshanling.iteye.com/blog/378483