遞歸是一個重要的算法,希望你也能學得會。
遞歸的三大步驟
編寫遞歸函數的步驟,可以分解為三個。
遞歸第一個步驟:明確函數要做什么
對於遞歸,一個最重要的事情就是要明確這個函數的功能。這個函數要完成一樣什么樣的事情,是完全由程序員來定義的,當寫一個遞歸函數的時候,先不要管函數里面的代碼是什么,而要先明確這個函數是實現什么功能的。
比如,我定義了一個函數,這個函數是用來計算n的階乘的。
// 計算n的階乘(假設n不為0) int f(int n) { // 先不管內部實現邏輯 }
這樣,就完成了第一個步驟:明確遞歸函數的功能。
遞歸第二個步驟:明確遞歸的結束(退出遞歸)條件
所謂遞歸,就是會在函數的內部邏輯代碼中,調用這個函數本身。因此必須在函數內部明確遞歸的結束(退出)遞歸條件,否則函數會一直調用自己形成死循環。意思就是說,需要有一個條件(標識符參數)去引導遞歸結束,直接將結果返回。要注意的是,這個標識符參數需要是可以預見的,對於函數的執行返回結果也是可以預見的。
比如在上面的計算n的階乘的函數中,當n=1的時候,肯定能知道f(n)對應的結果是1,因為1的階乘就是1,那么我們就可以接着完善函數內部的邏輯代碼,即將第二元素(遞歸結束條件)加進代碼里面。
// 計算n的階乘(假設n不為0) int f(int n) { if (n == 1) { return 1; } }
當然了,當n=2的時候,也可以知道n的階乘是2,那么也可以把n=2作為遞歸的結束條件。
// 計算n的階乘(假設n>=2) int f(int n) { if (n == 2) { return 2; } }
這里就可以看出,遞歸的結束條件並不局限,只要遞歸能正常結束,任何結束條件都是允許的,但是要注意一些邏輯上的細節。比如說上面的n==2的條件就需要n>2,否則當n=1的時候就會被漏掉,可能導致遞歸不能正常結束。完善一下就是當n<=2的時候,f(n)都會等於n。
// 計算n的階乘(假設n不為0) int f(int n) { if (n <= 2) { return n; } }
這樣,就完成了第二步驟:明確遞歸的退出條件。
遞歸的第三個步驟:找到函數的等價關系式
遞歸的第三個步驟就是要不斷地縮小參數的范圍,縮小之后就可以通過一些輔助的變量或操作使原函數的結果不變。比如在上面的計算n的階乘的函數中,要縮小f(n)的范圍,就可以讓f(n)=n* f(n-1),這樣范圍就從n變成了n-1,范圍變小了,直到范圍抵達n<=2退出遞歸。並且為了維持原函數不變,我們需要讓f(n-1)乘上n。說白了,就是要找到一個原函數的等價關系式。在這里,f(n)的等價關系式為n*f(n-1),即f(n)=n*f(n-1)。
// 計算n的階乘(假設n不為0) int f(int n) { if (n <= 2) { return n; } // 把n打出來看一下,你就能明白遞歸的原理了 System.out.println(n); // 加入f(n)的等價操作邏輯 return n * f(n - 1); }
到這里f(n)的功能就基本實現了。每次寫遞歸函數的時候,強迫自己跟着這三個步驟走,能達到事半功倍的效果。另外也可以看出,第三個步驟幾乎是最難的一個步驟。
遞歸案例1:斐波那契數列
斐波那契數列的是這樣一個數列:1、1、2、3、5、8、13、21、34、….,即第一項 f(1) = 1、第二項 f(2) = 1、…..、第 n 項目為 f(n)=f(n-1)+f(n-2),求第 n 項的值是多少。
遞歸第一個步驟:明確函數要做什么
假設f(n)的功能是求第n項的值,代碼如下:
int f(int n) { }
遞歸第二個步驟:明確遞歸的結束(退出遞歸)條件
顯然,當n=1或者n=2的時候,我們可以輕易得知結果是f(1)=f(2)=1。所以遞歸結束的條件可以是n<=2,代碼如下:
int f(int n) { if (n <= 2) { return 1; } }
遞歸的第三個步驟:找到函數的等價關系式
在題目中已經有等價關系式了,即f(n) = f(n-1) + f(n-2)。
int f(int n) { // 先寫遞歸結束條件 if (n <= 2) { return 1; } // 寫等價關系式 return f(n - 1) + f(n - 2); }
這個案例非常簡單。
遞歸案例2:小青蛙跳台階
一只青蛙一次可以跳上1級台階,也可以跳上2級台階。求該青蛙跳上一個n級的台階總共有多少種跳法。
遞歸第一個步驟:明確函數要做什么
假設f(n)的功能是求青蛙跳上一個n級台階總共有多少種跳法,代碼如下:
int f(int n) { }
遞歸第二個步驟:明確遞歸的結束(退出遞歸)條件
上面說了,求遞歸結束的條件,直接把n壓縮到很小很小就行了,因為n越小我們就能越直觀地算出f(n)的多少。這里,當n=1的時候,f(1)=1,因此可以將n=1作為遞歸結束條件。
int f(int n) { if (n == 1) { return 1; } }
遞歸的第三個步驟:找到函數的等價關系式
接下來找到函數的等價關系式就是這個函數的難點了,下面來分析一下。
1.假設台階只有一級,那么顯然只有一種跳法。
2.要是有兩級台階,那么就有兩種跳法:一種是一次跳一級台階,一種是一次跳兩級台階。
3.要是有三級台階,青蛙的第一步就有兩種跳法:當青蛙第一步跳了一級台階,那么就只剩下了兩級台階,將問題轉化成為兩級台階的跳法,當青蛙第一步跳了兩級台階,那么就只剩下了一級台階,就將問題轉化為了一級台階的跳法。
4.n階台階與三階台階的分析是一樣的。
我們把跳n級台階時的跳法看成是n的函數,記為f(n)。當n=1時,f(1)=1;當n=2時,f(2)=2;當n=3時,f(3)=f(2)+f(1);當n=4時,f(4)=f(3)+f(2)......當n=n的時候,f(n)=f(n-2)+f(n-1),顯然這是一個斐波那契數列。
int f(int n) { // 先寫遞歸結束條件 if (n == 1) { return 1; } // 寫等價關系式 return f(n - 1) + f(n - 2); }
要注意的是,上面的遞歸結束條件顯然不夠嚴謹,因為當n=2的時候,這里的遞歸退出條件就不能夠限制遞歸的正常退出了,需要稍微完善一下。
int f(int n) { // 先寫遞歸結束條件 if (n < 1) { return 0; } if (n == 1) { return 1; } if (n == 2) { return 2; } // 寫等價關系式 return f(n - 1) + f(n - 2); }
因此建議在寫完遞歸函數之后要回頭去校驗遞歸退出條件。
遞歸案例3:反轉單鏈表
反轉單鏈表是一個常見的算法。例如鏈表為1->2->3->4。反轉后為 4->3->2->1。
鏈表的節點定義如下:
class Node { int date; Node next; // 存儲下一個節點 }
遞歸第一個步驟:明確函數要做什么
假設函數reverseList(head)的功能是反轉單鏈表,其中head表示鏈表的頭節點。代碼如下:
Node reverseList(Node head) {
}
遞歸第二個步驟:明確遞歸的結束(退出遞歸)條件
當鏈表只有一個節點,或者如果是空鏈表的話,就直接返回head就行了。
Node reverseList(Node head) { if (head == null || head.next == null){ return head; } }
遞歸的第三個步驟:找到函數的等價關系式
// 用遞歸的方法反轉鏈表 public static Node reverseList2(Node head) { // 遞歸結束條件 if (head == null || head.next == null) { return head; } // 遞歸反轉子鏈表 Node newList = reverseList2(head.next); // 改變1,2節點的指向 // 通過head.next獲取節點2 Node t1 = head.next; // 讓2的next指向 2 t1.next = head; // 1的next指向null head.next = null; // 把調整之后的鏈表返回 return newList; }
遞歸的一些優化思路
遞歸的優化也是一門學問,這里列出兩個優化思路。
考慮是否重復計算
遞歸的時候很可能會出現子運算重復計算的問題。什么是子運算?f(n-1),f(n-2)等就是子運算。
例如,在上面的案例中,等價表達式是f(n)=f(n-1)+f(n-2),遞歸調用的狀態圖如下:
可以看出,在遞歸調用的時候,重復計算了兩次f(5),五次f(4)等......這時非常恐怖的,因為n越大,重復計算的就越多,因此必須想辦法優化。
如何優化呢,一般的做法是把計算的結果保存起來,例如把f(4)的結果保存起來,當再次要計算f(4)的時候,先判斷一下之前是否計算過,計算過就可以直接取結果了。用什么保存呢,可以用數組或者HashMap保存,這里用數組保存好了。把n作為數組下標,f(n)作為值,例如arr[n]=f(n)這樣子。當f(n)還沒有計算過的時候,我們讓arr[n]等於一個特殊值,例如arr[n]=-1。當我們要判斷的時候,如果arr[n]=-1,則證明f(n)沒有計算過,否則,f(n)就已經計算過了,且f(n)=arr[n]。
// 假定arr數組已經初始化好 int f(int n) { if (n <= 1) { return n; } // 先判斷有沒計算過 if (arr[n] != -1) { // 計算過,直接返回 return arr[n]; } else { // 沒有計算過,遞歸計算,並且把結果保存到 arr數組里 arr[n] = f(n - 1) + f(n - 1); reutrn arr[n]; } }
也就是說,使用遞歸的時候,必須要考慮有沒有重復計算,如果重復計算了,一定要把計算過的狀態值保存起來。
考慮是否可以自底向上
對於遞歸,一般的思路是從上往下遞歸,直到遞歸到達最底層,再一層一層地把值返回。
這樣的話,在n比較大的情況下,比如當n=10000的時候,就必須要往下遞歸10000層直到n<=1的時候才開始將結果逐層返回,可能會導致棧空間不夠用而報出StackOverflowException的異常。
對於這種情況就可以考慮自底向上遞歸的做法。
int f (int n) { if (n <= 2) { return n; } int f1 = 1; int f2 = 2; int sum = 0; for (int i = 3; i <= n; i++) { sum = f1 + f2; f1 = f2; f2 = sum; } return sum; }
另外的,這種方法也可以被稱為遞推。
總結
其實遞歸並不一定總是從上往下,也有很多從下往上的寫法。比如可以從n=1開始,一直遞歸到n=1000,常用在一些排序組合的場景。而對於這種從下往上的寫法,也是有相應的優化技巧。
最后要說的是,對於遞歸這種比較抽象的思想,需要自己多思考和多寫才能對較好地掌握遞歸,可能要禿頭才敢說對遞歸熟練吧(手動滑稽)。
"就這樣吧,再壞的也不要走了,再好的也不要來了。"