可能不少人在大一的時候,就已經接觸了遞歸了,不過,我敢保證不少人初學者剛開始接觸遞歸的時候,是一臉懵逼的,我當初也是,給個人感受就是,遞歸太神奇了!數組
可能也有一大部分人知道遞歸,也能看的懂遞歸,但在實際作題過程當中,殊不知道怎麼使用,有時候還容易被遞歸給搞暈。也有好幾我的來問我有沒有快速掌握遞歸的捷徑啊。說實話,哪來那麼多捷徑啊,不過,我仍是想寫一篇文章,談談個人一些經驗,或許,可以給你帶來一些幫助。函數
爲了兼顧初學者,我會從最簡單的題講起!學習
第一要素:明確你這個函數想要幹什麼優化
對於遞歸,我以爲很重要的一個事就是,這個函數的功能是什麼,他要完成什麼樣的一件事,而這個,是徹底由你本身來定義的。也就是說,咱們先無論函數裏面的代碼什麼,而是要先明白,你這個函數是要用來幹什麼。.net
例如,我定義了一個函數3d
// 算 n 的階乘(假設n不爲0) int f(int n){ }
這個函數的功能是算 n 的階乘。好了,咱們已經定義了一個函數,而且定義了它的功能是什麼,接下來咱們看第二要素。code
第二要素:尋找遞歸結束條件blog
所謂遞歸,就是會在函數內部代碼中,調用這個函數自己,因此,咱們必需要找出遞歸的結束條件,否則的話,會一直調用本身,進入無底洞。也就是說,咱們須要找出當參數爲啥時,遞歸結束,以後直接把結果返回,請注意,這個時候咱們必須能根據這個參數的值,可以直接知道函數的結果是什麼。排序
例如,上面那個例子,當 n = 1 時,那你應該可以直接知道 f(n) 是啥吧?此時,f(1) = 1。完善咱們函數內部的代碼,把第二要素加進代碼裏面,以下遞歸
// 算 n 的階乘(假設n不爲0) int f(int n){ if(n == 1){ return 1; } }
有人可能會說,當 n = 2 時,那咱們能夠直接知道 f(n) 等於多少啊,那我能夠把 n = 2 做爲遞歸的結束條件嗎?
固然能夠,只要你以爲參數是什麼時,你可以直接知道函數的結果,那麼你就能夠把這個參數做爲結束的條件,因此下面這段代碼也是能夠的。
// 算 n 的階乘(假設n>=2) int f(int n){ if(n == 2){ return 2; } }
注意我代碼裏面寫的註釋,假設 n >= 2,由於若是 n = 1時,會被漏掉,當 n <= 2時,f(n) = n,因此爲了更加嚴謹,咱們能夠寫成這樣:
// 算 n 的階乘(假設n不爲0) int f(int n){ if(n <= 2){ return n; } }
第三要素:找出函數的等價關係式
第三要素就是,咱們要不斷縮小參數的範圍,縮小以後,咱們能夠經過一些輔助的變量或者操做,使原函數的結果不變。
例如,f(n) 這個範圍比較大,咱們可讓 f(n) = n * f(n-1)。這樣,範圍就由 n 變成了 n-1 了,範圍變小了,而且爲了原函數f(n) 不變,咱們須要讓 f(n-1) 乘以 n。
說白了,就是要找到原函數的一個等價關係式,f(n) 的等價關係式爲 n * f(n-1),即
f(n) = n * f(n-1)。
這個等價關係式的尋找,能夠說是最難的一步了,若是你不大懂也不要緊,由於你不是天才,你還須要多接觸幾道題,我會在接下來的文章中,找 10 道遞歸題,讓你慢慢熟悉起來。
找出了這個等價,繼續完善咱們的代碼,咱們把這個等價式寫進函數裏。以下:
// 算 n 的階乘(假設n不爲0) int f(int n){ if(n <= 2){ return n; } // 把 f(n) 的等價操做寫進去 return f(n-1) * n; }
至此,遞歸三要素已經都寫進代碼裏了,因此這個 f(n) 功能的內部代碼咱們已經寫好了。
這就是遞歸最重要的三要素,每次作遞歸的時候,你就強迫本身試着去尋找這三個要素。
仍是不懂?不要緊,我再按照這個模式講一些題。
有些有點小基礎的可能以爲我寫的太簡單了,沒耐心看?少俠,請繼續看,我下面還會講如何優化遞歸。固然,大佬請隨意,能夠直接拉動最下面留言給我一些建議,萬分感謝!
斐波那契數列的是這樣一個數列:一、一、二、三、五、八、1三、2一、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){ // 1.先寫遞歸結束條件 if(n <= 2){ return 1; } // 2.接着寫等價關係式 return f(n-1) + f(n - 2); }
搞定,是否是很簡單?
零基礎的可能仍是不大懂,不要緊,以後慢慢按照這個模式練習!好吧,有大佬可能在吐槽太簡單了。
一隻青蛙一次能夠跳上1級臺階,也能夠跳上2級。求該青蛙跳上一個n級的臺階總共有多少種跳法。
一、第一遞歸函數功能
假設 f(n) 的功能是求青蛙跳上一個n級的臺階總共有多少種跳法,代碼以下:
int f(int n){ }
二、找出遞歸結束的條件
我說了,求遞歸結束的條件,你直接把 n 壓縮到很小很小就好了,由於 n 越小,咱們就越容易直觀着算出 f(n) 的多少,因此當 n = 1時,你知道 f(1) 爲多少吧?夠直觀吧?即 f(1) = 1。代碼以下:
int f(int n){ if(n == 1){ return 1; } }
第三要素:找出函數的等價關係式
每次跳的時候,小青蛙能夠跳一個臺階,也能夠跳兩個臺階,也就是說,每次跳的時候,小青蛙有兩種跳法。
第一種跳法:第一次我跳了一個臺階,那麼還剩下n-1個臺階還沒跳,剩下的n-1個臺階的跳法有f(n-1)種。
第二種跳法:第一次跳了兩個臺階,那麼還剩下n-2個臺階還沒,剩下的n-2個臺階的跳法有f(n-2)種。
因此,小青蛙的所有跳法就是這兩種跳法之和了,即 f(n) = f(n-1) + f(n-2)。至此,等價關係式就求出來了。因而寫出代碼:
int f(int n){ if(n == 1){ return 1; } ruturn f(n-1) + f(n-2); }
你們以爲上面的代碼對不對?
答是不大對,當 n = 2 時,顯然會有 f(2) = f(1) + f(0)。咱們知道,f(0) = 0,按道理是遞歸結束,不用繼續往下調用的,但咱們上面的代碼邏輯中,會繼續調用 f(0) = f(-1) + f(-2)。這會致使無限調用,進入死循環。
這也是我要和大家說的,關於遞歸結束條件是否夠嚴謹問題,有不少人在使用遞歸的時候,因爲結束條件不夠嚴謹,致使出現死循環。也就是說,當咱們在第二步找出了一個遞歸結束條件的時候,能夠把結束條件寫進代碼,而後進行第三步,可是請注意,當咱們第三步找出等價函數以後,還得再返回去第二步,根據第三步函數的調用關係,會不會出現一些漏掉的結束條件。就像上面,f(n-2)這個函數的調用,有可能出現 f(0) 的狀況,致使死循環,因此咱們把它補上。代碼以下:
int f(int n){ //f(0) = 0,f(1) = 1,等價於 n<=1時,f(n) = n。 if(n <= 1){ return n; } ruturn f(n-1) + f(n-2); }
有人可能會說,我不知道個人結束條件有沒有漏掉怎麼辦?別怕,多練幾道就知道怎麼辦了。
看到這裏有人可能要吐槽了,這兩道題也太容易了吧??能不能被這麼敷衍。少俠,別走啊,下面出道難一點的。
下面其實也不難了,就比上面的題目難一點點而已,特別是第三步等價的尋找。
反轉單鏈表。例如鏈表爲:1->2->3->4。反轉後爲 4->3->2->1
鏈表的節點定義以下:
class Node{ int date; Node next; }
雖然是 Java語言,但就算你沒學過 Java,我以爲也是影響不大,能看懂。
仍是老套路,三要素一步一步來。
一、定義遞歸函數功能
假設函數 reverseList(head) 的功能是反轉但鏈表,其中 head 表示鏈表的頭節點。代碼以下:
Node reverseList(Node head){ }
2. 尋找結束條件
當鏈表只有一個節點,或者若是是空表的話,你應該知道結果吧?直接啥也不用幹,直接把 head 返回唄。代碼以下:
Node reverseList(Node head){ if(head == null || head.next == null){ return head; } }
3. 尋找等價關係
這個的等價關係不像 n 是個數值那樣,比較容易尋找。可是我告訴你,它的等價條件中,必定是範圍不斷在縮小,對於鏈表來講,就是鏈表的節點個數不斷在變小,因此,若是你實在找不出,你就先對 reverseList(head.next) 遞歸走一遍,看看結果是咋樣的。例如鏈表節點以下
咱們就縮小範圍,先對 2->3->4遞歸下試試,即代碼以下
Node reverseList(Node head){ if(head == null || head.next == null){ return head; } // 咱們先把遞歸的結果保存起來,先不返回,由於咱們還不清楚這樣遞歸是對仍是錯。, Node newList = reverseList(head.next); }
咱們在第一步的時候,就已經定義了 reverseLis t函數的功能能夠把一個單鏈表反轉,因此,咱們對 2->3->4反轉以後的結果應該是這樣:
咱們把 2->3->4 遞歸成 4->3->2。不過,1 這個節點咱們並無去碰它,因此 1 的 next 節點仍然是鏈接這 2。
接下來呢?該怎麼辦?
其實,接下來就簡單了,咱們接下來只須要把節點 2 的 next 指向 1,而後把 1 的 next 指向 null,不就好了?,即經過改變 newList 鏈表以後的結果以下:
也就是說,reverseList(head) 等價於 ** reverseList(head.next)** + 改變一下1,2兩個節點的指向。好了,等價關係找出來了,代碼以下(有詳細的解釋):
//用遞歸的方法反轉鏈表 public static Node reverseList2(Node head){ // 1.遞歸結束條件 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; }
這道題的第三步看的很懵?正常,由於你作的太少了,可能沒有想到還能夠這樣,多練幾道就能夠了。可是,我但願經過這三道題,給了你之後用遞歸作題時的一些思路,你之後作題能夠按照我這個模式去想。經過一篇文章是不可能掌握遞歸的,還得多練,我相信,只要你認真看個人這篇文章,多看幾回,必定能找到一些思路!!
我已經強調了好屢次,多練幾道了,因此呢,後面我也會找大概 10 道遞歸的練習題供你們學習,不過,我找的可能會有必定的難度。不會像今天這樣,比較簡單,因此呢,初學者還得本身多去找題練練,相信我,掌握了遞歸,你的思惟抽象能力會更強!
接下來我講講有關遞歸的一些優化。
1. 考慮是否重複計算
告訴你吧,若是你使用遞歸的時候不進行優化,是有很是很是很是多的子問題被重複計算的。
啥是子問題? f(n-1),f(n-2)....就是 f(n) 的子問題了。
例如對於案例2那道題,f(n) = f(n-1) + f(n-2)。遞歸調用的狀態圖以下:
看到沒有,遞歸計算的時候,重複計算了兩次 f(5),五次 f(4)。。。。這是很是恐怖的,n 越大,重複計算的就越多,因此咱們必須進行優化。
如何優化?通常咱們能夠把咱們計算的結果保證起來,例如把 f(4) 的計算結果保證起來,當再次要計算 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]; } }
也就是說,使用遞歸的時候,必要
需要考慮有沒有重複計算,若是重複計算了,必定要把計算過的狀態保存起來。
2. 考慮是否能夠自底向上
對於遞歸的問題,咱們通常都是從上往下遞歸的,直到遞歸到最底,再一層一層着把值返回。
不過,有時候當 n 比較大的時候,例如當 n = 10000 時,那麼必需要往下遞歸10000層直到 n <=1 纔將結果慢慢返回,若是n太大的話,可能棧空間會不夠用。
對於這種狀況,其實咱們是能夠考慮自底向上的作法的。例如我知道
f(1) = 1;
f(2) = 2;
那麼咱們就能夠推出 f(3) = f(2) + f(1) = 3。從而能夠推出f(4),f(5)等直到f(n)。所以,咱們能夠考慮使用自底向上的方法來取代遞歸,代碼以下:
public 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,例如一些排序組合。對於這種從下往上的,也是有對應的優化技巧,不過,我就先不寫了,後面再慢慢寫。這篇文章寫了好久了,脖子有點受不了了,,,,頸椎病?懼怕。。。。
說實話,對於遞歸這種比較抽象的思想,要把他講明白,特別是講給初學者聽,仍是挺難的,這也是我這篇文章用了很長時間的緣由,不過,只要能讓大家看完,有所收穫,我以爲值得!有些人可能以爲講的有點簡單,沒事,我後面會找一些不怎麼簡單的題。最後若是以爲不錯,還請給我轉發 or 點贊一波!
最後推廣下個人公衆號:苦逼的碼農:戳我便可關注,文章都會首發於個人公衆號,期待各路英雄的關注交流。