遞推演算法與遞推套路(演算法基礎篇)

有道技術團隊發表於2021-10-13

聯絡我們有道技術團隊助手:ydtech01 / 郵箱:[ydtech@rd.netease.com]

相信瞭解演算法同學經常會說動態規劃太難了,看到題目完全不知從何下手,或者是說“一看題解就會,一看題目就廢”這樣的一個狀態。本質上是由於學習動態規劃的時候,學習方法不對,最終導致南轅北轍,沒有掌握其中精髓。而動態規劃與遞推演算法又有著曖昧不清的關係,我們選擇先從遞推演算法入手,一步一步揭開動態規劃的神祕面紗。

一、遞推公式

每一個遞推演算法,都有一個遞推公式,通過遞推公式我們可以更加明確的瞭解遞推演算法。

1.1 斐波那契數列的遞推公式

f(n) = f(n-1) + f(n-2) ,這是我們斐波那契數列的遞推公式,有很多同學可能會問,這個公式實際有什麼用呢?接下來,我們來直接看一個演算法題:爬樓梯

LeetCode 70. 爬樓梯

這道題我們要怎麼理解呢?我們如果想要爬到第 n 階樓梯,那麼上一步有可能是在 n-1 ,也有可能是在 n-2

因此,這道題的解法就一目瞭然了:

function climbStairs(n: number): number {
    const res: number[] = [];
    // 爬到第0層的方法就是一動不動,我們可以認為他只有一種
    res[0] = 1;
    // 爬上第一層階梯的可能性只有1個,就是走一步
    res[1] = 1;
    // 因此,後面的爬樓方式,我們就可以通過地推方式計算出來
    for(let i=2;i<=n;i++) {
        res[i] = res[i-1] + res[i-2];
    }
    return res[n];
};

二、數學歸納法

上面帶著大家一起學習了一下斐波那契數列遞推公式的實際應用。那麼,為什麼上面這個公式就能夠描述這一類問題的特性呢?這就要再聊一下數學歸納法了。

數學歸納法在整個電腦科學當中是非常重要的,主要分為以下幾步:

  1. 驗證k0成立(邊界條件);
  2. 證明如果k(i)成立,那麼k(i+1)也成立;
  3. 聯合步驟1和步驟2,證明由k0~k(n)成立。

不知道大家是否還記得,之前我們學習二叉樹時,有擴充套件學習過遞迴程式的設計,而遞迴程式的設計要點就是數學歸納法在廣義方面的應用,又稱為結構歸納法

那麼,我們再來看一下上面的爬樓梯問題,怎麼使用數學歸納法分析。

  1. 驗證k0成立:在爬樓梯問題中,我們的邊界條件就是當n為0和n為1。
  2. 證明如果k(i)成立,那麼k(i+1)也成立:我們假設 res[i-1] 和 res[i-2] 是正確的,那麼,res[i]也是成立的。
  3. 聯合步驟1和步驟2,證明由k0~k(n)成立:由於步驟1和步驟2聯立,必然能夠的出res[n]是成立的。

三、如何求解遞推問題(遞推問題的求解套路)

論求解套路的重要性:求解套路就是遞推演算法的學習方式,如果學習方式錯了,很可能南轅北轍,花了比別人更多的時間,反而掌握的更少。就像健身的時候,如果你掌握了一些動作要領,可能1~2個月肌肉就出來了,但是你要是沒有掌握動作要領,練錯了,不僅長得肌肉變成肥膘,還可能傷到自己。
  1. 確定遞推狀態(重點)

    • 一種函式符號 f(x) 以及賦予函式符號一個含義

      • 例如上面的斐波那契數的求解問題,我們要賦予 f(x) 一個含義: 爬上第x階樓梯的方法總數
    • 函式所對應的值就是我們要求解的答案

      • 如:f(x) = y 中, x是自變數, y 是因變數。而在上面爬樓梯的問題當中,自變數x就是要爬的樓梯數,而因變數 y 就是爬到 x 階樓梯的方法總數。因此,我們再求解問題的時候,最終要的是確定哪個是自變數,哪個是因變數。通常,因變數的值就是我們要求解的值。
      • 那麼,我們要如何分析題目中的自變數是什麼呢?我們要確定,會影響因變數的因素有哪些。如爬樓梯問題中,影響方法總數的就只有我們當前要爬的樓梯數,因此,自變數就是樓梯數 x。
    • 思維練習

  • 首先來分析一下遞推狀態是什麼。

首先第一個會影響我們方法總數的自變數就是 n ,即房間被劃分成了幾個區塊,其次,由於房間是環形的,為了不讓首尾顏色專案,我們還需要將首尾顏色也記錄到我們的遞推狀態當中,那麼,我們就得到了如下的公式:

f(n, i, j) = y,其中,n代表一個房間被分成幾個區塊,i 和 j 分別代表首尾顏色, y 代表方法總數。這個公式的意思是:總共有n個區塊的房間,第一個區塊塗第i種顏色,最後一個區塊塗第j種顏色並且相鄰顏色不同的方法總數為y

  • 遞推公式

    上面分析得出了 f(n, i, j) = y 這樣一個簡易版的公式,現在,我們就需要確定,通過怎樣的運算能夠算出f(n, i, j)

注意,上面三個遞推公式都是正確的,只是在不斷的優化我們的遞推公式,提升程式效率,但三種方式都可以求解出正確答案

  1. 確定遞推公式

    • 確定 f(n) 依賴於哪些 f(i) 的值
  2. 分析邊界條件(k0)
  3. 程式實現

    • 遞迴
    • 迴圈

四、遞推與動態規劃

動態規劃問題其實就是求解最優化的遞推問題,動態規劃問題相較於普通的遞推問題,多出了一個決策的過程

空講概念有點抽象,我們來結合具體問題來分析。依舊還是爬樓梯問題,不過比之前的爬樓梯多了一個體力花費。

LeetCode 746. 使用最小花費爬樓梯

這道題與上面簡單的爬樓梯問題類似,差別就在於每上一層樓梯,我們需要花費一定的體力,要求我們求出花費最小的體力。

通過上面的分析,我們可以得出以下公式:dp[n] = min(dp[n-2] + cost[n-2], dp[n-1] + cost[n-1])

翻譯一下上面的公式:爬上第n層樓梯的總體力花費應該等於最後一步從第n-2層爬上來的體力花費與最後一步是從n-1層爬上來的體力花費的最小值

function minCostClimbingStairs(cost: number[]): number {
    const n = cost.length;
    const dp: number[] = [];
    // 由於題目給定可以從第0層或第1層開始爬,因此,我們初始化第0層和第1層的體力花費為0
    dp[0] = 0;
    dp[1] = 0;
    // 我們從第二層的體力花費開始遞推
    for(let i=2;i<=n;i++) {
        // 第i層的體力花費是我最後一步從i-1層爬上來的體力花費與從i-2層趴上來的體力花費的最小值
        dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
    }
    return dp[n];
};

五、結語

大家都覺得動態規劃學起來很難,主要是因為我們要真正學好動態規劃,需要從:遞推狀態定義、狀態轉移方程推導、程式實現等三個大方向上入手並學習,並且這三個方向都是不好學的。今天通過遞推演算法與遞推公式的相關學習,以及初步的瞭解了遞推演算法與動態規劃的關係。這些都是我們後續學習動態規劃的基礎,其中尤為重要的是數學歸納法的理解與應用。“光說不練假把式”,今天說的大部分都是理論,下一篇文章《遞推演算法與遞推套路(手撕演算法篇)》將會直接從一些遞推或動態規劃的題目入手,學習遞推程式或動態規劃程式的求解套路,讓你看到遞推和動規不再茫然。敬請期待!

相關文章