為什麼你學不會遞迴?告別遞迴,談談我的一些經驗

帥地發表於2019-03-15

可能很多人在大一的時候,就已經接觸了遞迴了,不過,我敢保證很多人初學者剛開始接觸遞迴的時候,是一臉懵逼的,我當初也是,給我的感覺就是,遞迴太神奇了!

可能也有一大部分人知道遞迴,也能看的懂遞迴,但在實際做題過程中,卻不知道怎麼使用,有時候還容易被遞迴給搞暈。也有好幾個人來問我有沒有快速掌握遞迴的捷徑啊。說實話,哪來那麼多捷徑啊,不過,我還是想寫一篇文章,談談我的一些經驗,或許,能夠給你帶來一些幫助。

為了兼顧初學者,我會從最簡單的題講起!

遞迴的三大要素

第一要素:明確你這個函式想要幹什麼

對於遞迴,我覺得很重要的一個事就是,這個函式的功能是什麼,他要完成什麼樣的一件事,而這個,是完全由你自己來定義的。也就是說,我們先不管函式裡面的程式碼什麼,而是要先明白,你這個函式是要用來幹什麼。

例如,我定義了一個函式

// 算 n 的階乘(假設n不為0)
int f(int n){
    
}

這個函式的功能是算 n 的階乘。好了,我們已經定義了一個函式,並且定義了它的功能是什麼,接下來我們看第二要素。

第二要素:尋找遞迴結束條件

所謂遞迴,就是會在函式內部程式碼中,呼叫這個函式本身,所以,我們必須要找出遞迴的結束條件,不然的話,會一直呼叫自己,進入無底洞。也就是說,我們需要找出當引數為啥時,遞迴結束,之後直接把結果返回,請注意,這個時候我們必須能根據這個引數的值,能夠直接知道函式的結果是什麼。

例如,上面那個例子,當 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:斐波那契數列

斐波那契數列的是這樣一個數列: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 項的值是多少。

1、第一遞迴函式功能

假設 f(n) 的功能是求第 n 項的值,程式碼如下:

int f(int n){
    
}

2、找出遞迴結束的條件

顯然,當 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);
}

搞定,是不是很簡單?

零基礎的可能還是不大懂,沒關係,之後慢慢按照這個模式練習!好吧,有大佬可能在吐槽太簡單了。

案例2:小青蛙跳臺階

一隻青蛙一次可以跳上1級臺階,也可以跳上2級。求該青蛙跳上一個n級的臺階總共有多少種跳法。

1、第一遞迴函式功能

假設 f(n) 的功能是求青蛙跳上一個n級的臺階總共有多少種跳法,程式碼如下:

int f(int n){
    
}

2、找出遞迴結束的條件

我說了,求遞迴結束的條件,你直接把 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);
}

有人可能會說,我不知道我的結束條件有沒有漏掉怎麼辦?別怕,多練幾道就知道怎麼辦了。

看到這裡有人可能要吐槽了,這兩道題也太容易了吧??能不能被這麼敷衍。少俠,別走啊,下面出道難一點的。

下面其實也不難了,就比上面的題目難一點點而已,特別是第三步等價的尋找。

案例3:反轉單連結串列。

反轉單連結串列。例如連結串列為:1->2->3->4。反轉後為 4->3->2->1

連結串列的節點定義如下:

class Node{
    int date;
    Node next;
}

雖然是 Java語言,但就算你沒學過 Java,我覺得也是影響不大,能看懂。

還是老套路,三要素一步一步來。

1、定義遞迴函式功能

假設函式 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 點贊一波!

最後推廣下我的公眾號:苦逼的碼農戳我即可關注,文章都會首發於我的公眾號,期待各路英雄的關注交流。

相關文章