徹底理解遞歸

一:簡單實例

1.階乘的實現

寫個函數實現   N! = N × (N-1) × (N-2) × ... × 2 × 1

  1. public static int factorial(int N) {   
  2.    if (N == 1return 1;   
  3.    return N * factorial(N-1);   
  4. }  
上面的程序雖然簡單,但我們要了解他運行的步驟,以factorial(4)爲例。
  1. factorial(4)    =   4 * factorial(3)  
  2.         =   4 * (3 * factorial(2) )  
  3.         =   4 * (3 * (2 * factorial(1) ) )  
  4.         =   4 * (3 * (2 * (1 * factorial(0) ) ) )  
  5.         =   4 * (3 * (2 * (1 * 1) ) )  
  6.         =   4 * (3 * (2 * 1) )  
  7.         =   4 * (3 * 2)  
  8.         =   4 * 6  
  9.         =   24  
也可以表示爲

  1. factorial(5)   
  2.    factorial(4)   
  3.       factorial(3)   
  4.          factorial(2)   
  5.             factorial(1)   
  6.                return 1   
  7.             return 2*1 = 2   
  8.          return 3*2 = 6   
  9.       return 4*6 = 24   
  10.    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)的公約數是一樣的,其最大公約數也必然相等,得證。


下面的程序用了遞歸和迭代兩種方法。(後面會講到那種類型的遞歸可以改寫成迭代)

  1. public class Euclid {  
  2.   
  3.     // recursive implementation  
  4.     public static int gcd(int p, int q) {  
  5.         if (q == 0return p;  
  6.         else return gcd(q, p % q);  
  7.     }  
  8.   
  9.     // non-recursive implementation  
  10.     public static int gcd2(int p, int q) {  
  11.         while (q != 0) {  
  12.             int temp = q;  
  13.             q = p % q;  
  14.             p = temp;  
  15.         }  
  16.         return p;  
  17.     }  
  18.   
  19.     public static void main(String[] args) {  
  20.         int p = Integer.parseInt(args[0]);  
  21.         int q = Integer.parseInt(args[1]);  
  22.         int d  = gcd(p, q);  
  23.         int d2 = gcd2(p, q);  
  24.         System.out.println("gcd(" + p + ", " + q + ") = " + d);  
  25.         System.out.println("gcd(" + p + ", " + q + ") = " + d2);  
  26.     }  
  27. }  

程序執行的順序如下

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嗎?於是我們就利用遞歸的堆棧結構的特性:先進後出
  1. public class Recursion{  
  2.     public static void main(String args[]){  
  3.         recursion(4267) ;  
  4.     }  
  5.       
  6.     public static void recursion(int value){  
  7.         int quotient ;  
  8.         quotient = value/10 ;  
  9.         if(quotient!=0){ recursion(quotient) ;}  
  10.         System.out.println(value%10) ;  
  11.     }  
  12. }  

遞歸是如何幫助我們以正確的順序打印這些字符呢?下面是這個函數的工作流程。


       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.確定邊界(終了)條件

四:最後

留一個程序給大家去研究研究,看看程序運行的結果。

  1. public class Region{  
  2.     public static void main(String args[]){  
  3.         int[] a = {1,2,3,4} ;  
  4.         System.out.println("final  "+region(a,0,0)) ;  
  5.     }  
  6.       
  7.     public static int region(int[] a,int currentSum,int i){  
  8.         currentSum+=a[i];  
  9.         System.out.println("out  "+ currentSum) ;  
  10.         if(i<3){  
  11.             region(a,currentSum,i+1) ;  
  12.             System.out.println("in  "+ currentSum) ;  
  13.         }  
  14.         System.out.println("hello  ") ;  
  15.         return currentSum ;  
  16.     }  
  17. }  

結果

  1. out  1  
  2. out  3  
  3. out  6  
  4. out  10  
  5. hello  
  6. in  6  
  7. hello  
  8. in  3  
  9. hello  
  10. in  1  
  11. hello  
  12. final  1  

還給大家一道題:《程序員面試100題 In Java》04.二元樹中和爲某一值的所有路徑

如果你想更深入的理解遞歸可以看:Recursion-wiki


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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章