1. 程式人生 > >【演算法】遞迴

【演算法】遞迴

遞迴

  • 遞迴實現的原理:
    一個遞迴函式的呼叫過程類似於多個函式的巢狀的呼叫,只不過呼叫函式和被呼叫函式是同一個函式。為了保證遞迴函式的正確執行,系統需設立一個工作棧。具體地說,遞迴呼叫的內部執行過程如下:
    1. 運動開始時,首先為遞迴呼叫建立一個工作棧,其結構包括值參、區域性變數和返回地址;
    2. 每次執行遞迴呼叫之前,把遞迴函式的值參、區域性變數的當前值以及呼叫後的返回地址壓棧;
    3. 每次遞迴呼叫結束後,將棧頂元素出棧,使相應的值參和區域性變數恢復為呼叫前的值,然後轉向返回地址指定的位置繼續執行。
  • 注意:在我們瞭解了遞迴的基本思想及其數學模型之後,我們如何才能寫出一個漂亮的遞迴程式呢?我認為主要是把握好如下三個方面:
    1. 明確遞迴函式的作用;
    2. 明確遞迴終止條件與對應的解決辦法;
    3. 找出函式的等價關係式,提取重複的邏輯縮小問題規模。
  • 遞迴三步走:
    • 明確函式功能:要清楚你寫這個函式是想要做什麼;
    • 尋找遞迴出口:遞迴一定要有結束條件,不然會永遠遞迴下去,禁止套娃;
    • 找出遞推關係:開始實現遞迴,一步一步遞推出最終結果。

明確函式功能

第一步,明確這個函式的功能是什麼,它要完成什麼樣的一件事。
而這個功能,是完全由你自己來定義的。也就是說,我們先不管函式裡面的程式碼是什麼、怎麼寫,而首先要明白,你這個函式是要用來幹什麼的。

例如,求解任意一個數的階乘:
要做出這個題,
第一步,要明確即將要寫出的這個函式的功能為:算n的階乘。

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

尋找遞迴出口(初始條件)

遞迴:就是在函式實現的內部程式碼中,呼叫這個函式本身。所以,我們必須要找出遞迴的結束條件,不然的話,會一直呼叫自己,一直套娃,直到記憶體充滿。

  • 必須有一個明確的結束條件。因為遞迴就是有“遞”有“歸”,所以必須又有一個明確的點,到了這個點,就不用“遞下去”,而是開始“歸來”。

第二步,我們需要找出當引數為何值時,遞迴結束,之後直接把結果返回。
(一般為初始條件,然後從初始條件一步一步擴充到最終結果)

注意:這個時候我們必須能根據這個引數的值,能夠直接知道函式的結果是什麼。

讓我們繼續完善上面那個階乘函式。
第二步,尋找遞迴出口:
當n=1時,我們能夠直接知道f(1)=1;

那麼遞迴出口就是n=1時函式返回1。
如下:

//算n的階乘(假設n不為0)
int f(int n) {
    if(n == 1) {
        return 1;
    }
}

當然,當n=2時,我們也是知道f(2)等於多少的,n=2也可以作為遞迴出口。遞迴出口可能並不唯一的。

找出遞推關係

第三步,我們要從初始條件一步一步遞推到最終結果。(可以類比數學歸納法,多米諾骨牌)

  • 初始條件:f(1) = 1
  • 遞推關係式:f(n) = f(n-1)*n

這樣就可以從n=1,一步一步推到n=2,n=3...

// 算n的階乘(假設n不為0)
int f(int n) {
    if(n = 1) {
        return n;
    }
    // 把f(n)的遞推關係寫進去
    return f(n-1) * n;
}

到這裡,遞迴三步走就完成了,那麼這個遞迴函式的功能我們也就實現了。
可能初學的讀者會感覺很奇妙,這就能算出階乘了?

那麼,我們來一步一步推一下。
f(1)=1
f(2)=f(1)*2=2
f(3)=f(2)*3=2*3=6
...
你看看是不是解決了,n都能遞推出來!

例題

斐波那契數列

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

    // 1.f(n)為求第n項的值
    int f(int n) {
    
    }
  • 尋找遞迴出口:f(1)=1,f(2)=1

    // 1.f(n)為求第n項的值
    int f(int n) {
        // 2.遞迴出口
        if(n <= 2) {
            return 1;
        }
    }
  • 找出遞推關係:f(n) = f(n-1)+f(n-2)

    // 1.f(n)為求第n項的值
    int f(int n) {
        // 2.遞迴出口
        if(n <= 2) {
            return 1;
        }
        // 3.遞推關係
        return f(n-1) + f(n-2);
    }

小青蛙跳臺階

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

  • 明確函式功能:f(n)為青蛙跳上一個n級的臺階總共有多少種跳法

    int f(int n) {
    
    }
  • 尋找遞迴出口:f(0)=0,f(1)=1

    int f(int n) {
        // 遞迴出口
        if(n <= 1) {
            return n;
        }
    }
  • 找出遞推關係:f(n) = f(n-1)+f(n-2)

    int f(int n) {
        // 遞迴出口
        if(n <= 2) {
            return 1;
        }
        // 遞推關係
        return f(n-1) + f(n-2);
    }

遞迴優化思路

自頂向下

上面說了那麼多,都是自底向上的
(我比較習慣自底向上,因為比較符合數學歸納法,順著推)

例如,階乘可以理解為f(n)一步一步分解為f(n-1)...直到f(1),一步步化小,這樣也是可以的。

重複計算

其實遞迴當中有很多子問題被重複計算。

對於斐波那契數列,f(n) = f(n-1)+f(n-2)。
遞迴呼叫的狀態圖如下:

其中,遞迴計算時f(6)、f(5)...都被重複了很多次,這是極大的浪費,當n越大,因重複計算浪費的就越多,所以我們必須要進行優化。

  • 優化思路:
    • 建立一個數組,將子問題的計算結果儲存起來。
    • 判斷之前是否計算過:
      • 計算過,取出來用
      • 沒有計算過,再遞迴計算
  • 例項:
    • 把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];
    }
}