7.7-DPday1

七龙猪發表於2024-07-07

動態規劃理論基礎

img

什麼是動態規劃

動態規劃,英文:Dynamic Programming,簡稱DP,如果某一問題有很多重疊子問題,使用動態規劃是最有效的。

所以動態規劃中每一個狀態一定是由上一個狀態推匯出來的這一點就區分於貪心,貪心沒有狀態推導,而是從區域性直接選最優的

關於貪心演算法,你該瞭解這些! (opens new window)中我舉了一個揹包問題的例子。

例如:有N件物品和一個最多能背重量為W 的揹包。第i件物品的重量是weight[i],得到的價值是value[i] 每件物品只能用一次,求解將哪些物品裝入揹包裡物品價值總和最大。

動態規劃中dp[j]是由dp[j-weight[i]]推匯出來的,然後取max(dp[j], dp[j - weight[i]] + value[i])

但如果是貪心呢,每次拿物品選一個最大的或者最小的就完事了,和上一個狀態沒有關係。

所以貪心解決不了動態規劃的問題。

其實大家也不用死扣動規和貪心的理論區別,後面做做題目自然就知道了

而且很多講解動態規劃的文章都會講最優子結構啊和重疊子問題啊這些,這些東西都是教科書的上定義,晦澀難懂而且不實用。

大家知道動規是由前一個狀態推匯出來的,而貪心是區域性直接選最優的,對於刷題來說就夠用了。

上述提到的揹包問題,後序會詳細講解。

動態規劃的解題步驟

做動規題目的時候,很多同學會陷入一個誤區,就是以為把狀態轉移公式背下來,照葫蘆畫瓢改改,就開始寫程式碼,甚至把題目AC之後,都不太清楚dp[i]表示的是什麼。

這就是一種朦朧的狀態,然後就把題給過了,遇到稍稍難一點的,可能直接就不會了,然後看題解,然後繼續照葫蘆畫瓢陷入這種惡性迴圈中

狀態轉移公式(遞推公式)是很重要,但動規不僅僅只有遞推公式。

對於動態規劃問題,我將拆解為如下五步曲,這五步都搞清楚了,才能說把動態規劃真的掌握了!

[!TIP]

  1. 確定dp陣列(dp table)以及下標的含義
  2. 確定遞推公式
  3. dp陣列如何初始化
  4. 確定遍歷順序
  5. 舉例推導dp陣列

一些同學可能想為什麼要先確定遞推公式,然後在考慮初始化呢?

因為一些情況是遞推公式決定了dp陣列要如何初始化!

後面的講解中我都是圍繞著這五點來進行講解。

可能刷過動態規劃題目的同學可能都知道遞推公式的重要性,感覺確定了遞推公式這道題目就解出來了。

其實 確定遞推公式 僅僅是解題裡的一步而已!

一些同學知道遞推公式,但搞不清楚dp陣列應該如何初始化,或者正確的遍歷順序,以至於記下來公式,但寫的程式怎麼改都透過不了。

後序的講解的大家就會慢慢感受到這五步的重要性了。

動態規劃應該如何debug

相信動規的題目,很大部分同學都是這樣做的。

看一下題解,感覺看懂了,然後照葫蘆畫瓢,如果能正好畫對了,萬事大吉,一旦要是沒透過,就怎麼改都透過不了,對 dp陣列的初始化,遞推公式,遍歷順序,處於一種黑盒的理解狀態。

寫動規題目,程式碼出問題很正常!

找問題的最好方式就是把dp陣列列印出來,看看究竟是不是按照自己思路推導的!

一些同學對於dp的學習是黑盒的狀態,就是不清楚dp陣列的含義,不懂為什麼這麼初始化,遞推公式背下來了,遍歷順序靠習慣就是這麼寫的,然後一鼓作氣寫出程式碼,如果程式碼能透過萬事大吉,透過不了的話就憑感覺改一改。

這是一個很不好的習慣!

做動規的題目,寫程式碼之前一定要把狀態轉移在dp陣列的上具體情況模擬一遍,心中有數,確定最後推出的是想要的結果

然後再寫程式碼,如果程式碼沒透過就列印dp陣列,看看是不是和自己預先推導的哪裡不一樣。

如果列印出來和自己預先模擬推導是一樣的,那麼就是自己的遞迴公式、初始化或者遍歷順序有問題了。

如果和自己預先模擬推導的不一樣,那麼就是程式碼實現細節有問題。

這樣才是一個完整的思考過程,而不是一旦程式碼出問題,就毫無頭緒的東改改西改改,最後過不了,或者說是稀裡糊塗的過了

這也是我為什麼在動規五步曲裡強調推導dp陣列的重要性。

舉個例子哈:在「程式碼隨想錄」刷題小分隊微信群裡,一些錄友可能程式碼透過不了,會把程式碼拋到討論群裡問:我這裡程式碼都已經和題解一模一樣了,為什麼透過不了呢?

發出這樣的問題之前,其實可以自己先思考這三個問題:

  • 這道題目我舉例推導狀態轉移公式了麼?
  • 我列印dp陣列的日誌了麼?
  • 列印出來了dp陣列和我想的一樣麼?

如果這靈魂三問自己都做到了,基本上這道題目也就解決了,或者更清晰的知道自己究竟是哪一點不明白,是狀態轉移不明白,還是實現程式碼不知道該怎麼寫,還是不理解遍歷dp陣列的順序。

然後在問問題,目的性就很強了,群裡的小夥伴也可以快速知道提問者的疑惑了。

注意這裡不是說不讓大家問問題哈, 而是說問問題之前要有自己的思考,問題要問到點子上!

大家工作之後就會發現,特別是大廠,問問題是一個專業活,是的,問問題也要體現出專業!

如果問同事很不專業的問題,同事們會懶的回答,領導也會認為你缺乏思考能力,這對職場發展是很不利的。

所以大家在刷題的時候,就鍛鍊自己養成專業提問的好習慣。

總結

這一篇是動態規劃的整體概述,講解了什麼是動態規劃,動態規劃的解題步驟,以及如何debug。

動態規劃是一個很大的領域,今天這一篇講解的內容是整個動態規劃系列中都會使用到的一些理論基礎。

在後序講解中針對某一具體問題,還會講解其對應的理論基礎,例如揹包問題中的01揹包,leetcode上的題目都是01揹包的應用,而沒有純01揹包的問題,那麼就需要在把對應的理論知識講解一下。

大家會發現,我講解的理論基礎並不是教科書上各種動態規劃的定義,錯綜複雜的公式。

這裡理論基礎篇已經是非常偏實用的了,每個知識點都是在解題實戰中非常有用的內容,大家要重視起來哈。


509. 斐波那契數

題意描述:

[!NOTE]

斐波那契數 (通常用 F(n) 表示)形成的序列稱為 斐波那契數列 。該數列由 01 開始,後面的每一項數字都是前面兩項數字的和。也就是:

F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1

給定 n ,請計算 F(n)

示例 1:

輸入:n = 2
輸出:1
解釋:F(2) = F(1) + F(0) = 1 + 0 = 1

示例 2:

輸入:n = 3
輸出:2
解釋:F(3) = F(2) + F(1) = 1 + 1 = 2

示例 3:

輸入:n = 4
輸出:3
解釋:F(4) = F(3) + F(2) = 2 + 1 = 3

提示:

  • 0 <= n <= 30

思路:

[!TIP]

斐波那契數列大家應該非常熟悉不過了,非常適合作為動規第一道題目來練練手。

因為這道題目比較簡單,可能一些同學並不需要做什麼分析,直接順手一寫就過了。

但「程式碼隨想錄」的風格是:簡單題目是用來加深對解題方法論的理解的

透過這道題目讓大家可以初步認識到,按照動規五部曲是如何解題的。

對於動規,如果沒有方法論的話,可能簡單題目可以順手一寫就過,難一點就不知道如何下手了。

所以我總結的動規五部曲,是要用來貫穿整個動態規劃系列的,就像之前講過二叉樹系列的遞迴三部曲 (opens new window)回溯法系列的回溯三部曲 (opens new window)一樣。後面慢慢大家就會體會到,動規五部曲方法的重要性。

動態規劃

動規五部曲:

這裡我們要用一個一維dp陣列來儲存遞迴的結果

  1. 確定dp陣列以及下標的含義

dp[i]的定義為:第i個數的斐波那契數值是dp[i]

  1. 確定遞推公式

為什麼這是一道非常簡單的入門題目呢?

因為題目已經把遞推公式直接給我們了:狀態轉移方程 dp[i] = dp[i - 1] + dp[i - 2];

  1. dp陣列如何初始化

題目中把如何初始化也直接給我們了,如下:

dp[0] = 0;
dp[1] = 1;
  1. 確定遍歷順序

從遞迴公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依賴 dp[i - 1] 和 dp[i - 2],那麼遍歷的順序一定是從前到後遍歷的

  1. 舉例推導dp陣列

按照這個遞推公式dp[i] = dp[i - 1] + dp[i - 2],我們來推導一下,當N為10的時候,dp陣列應該是如下的數列:

0 1 1 2 3 5 8 13 21 34 55

如果程式碼寫出來,發現結果不對,就把dp陣列列印出來看看和我們推導的數列是不是一致的。

以上我們用動規的方法分析完了,C++程式碼如下:

class Solution {
public:
    int fib(int N) {
        if (N <= 1) return N;
        vector<int> dp(N + 1);
        dp[0] = 0;
        dp[1] = 1;
        for (int i = 2; i <= N; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[N];
    }
};
  • 時間複雜度:O(n)
  • 空間複雜度:O(n)

當然可以發現,我們只需要維護兩個數值就可以了,不需要記錄整個序列。

程式碼如下:

class Solution {
public:
    int fib(int N) {
        if (N <= 1) return N;
        int dp[2];
        dp[0] = 0;
        dp[1] = 1;
        for (int i = 2; i <= N; i++) {
            int sum = dp[0] + dp[1];
            dp[0] = dp[1];
            dp[1] = sum;
        }
        return dp[1];
    }
};
  • 時間複雜度:O(n)
  • 空間複雜度:O(1)

遞迴解法(代價大不推薦)

本題還可以使用遞迴解法來做

程式碼如下:

class Solution {
public:
    int fib(int N) {
        if (N < 2) return N;
        return fib(N - 1) + fib(N - 2);
    }
};
  • 時間複雜度:O(2^n)
  • 空間複雜度:O(n),算上了程式語言中實現遞迴的系統棧所佔空間

這個遞迴的時間複雜度大家畫一下樹形圖就知道了,如果不清晰的同學,可以看這篇:透過一道面試題目,講一講遞迴演算法的時間複雜度!(opens new window)

總結

斐波那契數列這道題目是非常基礎的題目,我在後面的動態規劃的講解中將會多次提到斐波那契數列!

這裡我嚴格按照關於動態規劃,你該瞭解這些! (opens new window)中的動規五部曲來分析了這道題目,一些分析步驟可能同學感覺沒有必要搞的這麼複雜,程式碼其實上來就可以擼出來。

但我還是強調一下,簡單題是用來掌握方法論的,動規五部曲將在接下來的動態規劃講解中發揮重要作用,敬請期待!


70. 爬樓梯

題意描述:

[!NOTE]

假設你正在爬樓梯。需要 n 階你才能到達樓頂。

每次你可以爬 12 個臺階。你有多少種不同的方法可以爬到樓頂呢?

示例 1:

輸入:n = 2
輸出:2
解釋:有兩種方法可以爬到樓頂。
1. 1 階 + 1 階
2. 2 階

示例 2:

輸入:n = 3
輸出:3
解釋:有三種方法可以爬到樓頂。
1. 1 階 + 1 階 + 1 階
2. 1 階 + 2 階
3. 2 階 + 1 階

提示:

  • 1 <= n <= 45

思路:

[!TIP]

本題大家如果沒有接觸過的話,會感覺比較難,多舉幾個例子,就可以發現其規律。

爬到第一層樓梯有一種方法,爬到二層樓梯有兩種方法。

那麼第一層樓梯再跨兩步就到第三層 ,第二層樓梯再跨一步就到第三層。

所以到第三層樓梯的狀態可以由第二層樓梯 和 到第一層樓梯狀態推匯出來,那麼就可以想到動態規劃了。

我們來分析一下,動規五部曲:

定義一個一維陣列來記錄不同樓層的狀態

  1. 確定dp陣列以及下標的含義

dp[i]: 爬到第i層樓梯,有dp[i]種方法

  1. 確定遞推公式

如何可以推出dp[i]呢?

從dp[i]的定義可以看出,dp[i] 可以有兩個方向推出來。

首先是dp[i - 1],上i-1層樓梯,有dp[i - 1]種方法,那麼再一步跳一個臺階不就是dp[i]了麼。

還有就是dp[i - 2],上i-2層樓梯,有dp[i - 2]種方法,那麼再一步跳兩個臺階不就是dp[i]了麼。

那麼dp[i]就是 dp[i - 1]與dp[i - 2]之和!

所以dp[i] = dp[i - 1] + dp[i - 2]

在推導dp[i]的時候,一定要時刻想著dp[i]的定義,否則容易跑偏。

這體現出確定dp陣列以及下標的含義的重要性!

  1. dp陣列如何初始化

再回顧一下dp[i]的定義:爬到第i層樓梯,有dp[i]種方法。

那麼i為0,dp[i]應該是多少呢,這個可以有很多解釋,但基本都是直接奔著答案去解釋的。

例如強行安慰自己爬到第0層,也有一種方法,什麼都不做也就是一種方法即:dp[0] = 1,相當於直接站在樓頂。

但總有點牽強的成分。

那還這麼理解呢:我就認為跑到第0層,方法就是0啊,一步只能走一個臺階或者兩個臺階,然而樓層是0,直接站樓頂上了,就是不用方法,dp[0]就應該是0.

其實這麼爭論下去沒有意義,大部分解釋說dp[0]應該為1的理由其實是因為dp[0]=1的話在遞推的過程中i從2開始遍歷本題就能過,然後就往結果上靠去解釋dp[0] = 1

從dp陣列定義的角度上來說,dp[0] = 0 也能說得通。

需要注意的是:題目中說了n是一個正整數,題目根本就沒說n有為0的情況。

所以本題其實就不應該討論dp[0]的初始化!

我相信dp[1] = 1,dp[2] = 2,這個初始化大家應該都沒有爭議的。

所以我的原則是:不考慮dp[0]如何初始化,只初始化dp[1] = 1,dp[2] = 2,然後從i = 3開始遞推,這樣才符合dp[i]的定義。

  1. 確定遍歷順序

從遞推公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,遍歷順序一定是從前向後遍歷的

  1. 舉例推導dp陣列

舉例當n為5的時候,dp table(dp陣列)應該是這樣的

70.爬樓梯

如果程式碼出問題了,就把dp table 列印出來,看看究竟是不是和自己推導的一樣。

此時大家應該發現了,這不就是斐波那契數列麼!

唯一的區別是,沒有討論dp[0]應該是什麼,因為dp[0]在本題沒有意義!

以上五部分析完之後,C++程式碼如下:

// 版本一
class Solution {
public:
    int climbStairs(int n) {
        if (n <= 1) return n; // 因為下面直接對dp[2]操作了,防止空指標
        vector<int> dp(n + 1);
        dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i <= n; i++) { // 注意i是從3開始的
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
};
  • 時間複雜度:\(O(n)\)
  • 空間複雜度:\(O(n)\)

當然依然也可以,最佳化一下空間複雜度,程式碼如下:

// 版本二
class Solution {
public:
    int climbStairs(int n) {
        if (n <= 1) return n;
        int dp[3];
        dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i <= n; i++) {
            int sum = dp[1] + dp[2];
            dp[1] = dp[2];
            dp[2] = sum;
        }
        return dp[2];
    }
};
  • 時間複雜度:\(O(n)\)
  • 空間複雜度:\(O(1)\)

後面將講解的很多動規的題目其實都是當前狀態依賴前兩個,或者前三個狀態,都可以做空間上的最佳化,但我個人認為面試中能寫出版本一就夠了哈,清晰明瞭,如果面試官要求進一步最佳化空間的話,我們再去最佳化

因為版本一才能體現出動規的思想精髓,遞推的狀態變化。

擴充

這道題目還可以繼續深化,就是一步一個臺階,兩個臺階,三個臺階,直到 m個臺階,有多少種方法爬到n階樓頂。

這又有難度了,這其實是一個完全揹包問題,但力扣上沒有這種題目,大家可以去卡碼網去做一下 57. 爬樓梯(opens new window)

所以後續我在講解揹包問題的時候,今天這道題還會從揹包問題的角度上來再講一遍。 如果想提前看一下,可以看這篇:70.爬樓梯完全揹包版本(opens new window)

這裡我先給出本題的程式碼:

class Solution {
public:
    int climbStairs(int n) {
        vector<int> dp(n + 1, 0);
        dp[0] = 1;
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) { // 把m換成2,就可以AC爬樓梯這道題
                if (i - j >= 0) dp[i] += dp[i - j];
            }
        }
        return dp[n];
    }
};

程式碼中m表示最多可以爬m個臺階。

以上程式碼不能執行哈,我主要是為了體現只要把m換成2,粘過去,就可以AC爬樓梯這道題,不信你就粘一下試試

此時我就發現一個絕佳的大廠面試題,第一道題就是單純的爬樓梯,然後看候選人的程式碼實現,如果把dp[0]的定義成1了,就可以發難了,為什麼dp[0]一定要初始化為1,此時可能候選人就要強行給dp[0]應該是1找各種理由。那這就是一個考察點了,對dp[i]的定義理解的不深入。(答:dp[0]沒有意義。)

然後可以繼續發難,如果一步一個臺階,兩個臺階,三個臺階,直到 m個臺階,有多少種方法爬到n階樓頂。這道題目leetcode上並沒有原題,絕對是考察候選人演算法能力的絕佳好題。

這一連套問下來,候選人演算法能力如何,面試官心裡就有數了。

其實大廠面試最喜歡的問題就是這種簡單題,然後慢慢變化,在小細節上考察候選人

總結

這道題目和動態規劃:斐波那契數 (opens new window)題目基本是一樣的,但是會發現本題相比動態規劃:斐波那契數 (opens new window)難多了,為什麼呢?

關鍵是 動態規劃:斐波那契數 (opens new window)題目描述就已經把動規五部曲裡的遞迴公式和如何初始化都給出來了,剩下幾部曲也自然而然的推出來了。

而本題,就需要逐個分析了,大家現在應該初步感受出關於動態規劃,你該瞭解這些! (opens new window)裡給出的動規五部曲了。

簡單題是用來掌握方法論的,例如昨天斐波那契的題目夠簡單了吧,但昨天和今天可以使用一套方法分析出來的,這就是方法論!

所以不要輕視簡單題,那種憑感覺就刷過去了,其實和沒掌握區別不大,只有掌握方法論並說清一二三,才能觸類旁通,舉一反三哈!


746. 使用最小花費爬樓梯

題意描述:

[!NOTE]

給你一個整數陣列 cost ,其中 cost[i] 是從樓梯第 i 個臺階向上爬需要支付的費用。一旦你支付此費用,即可選擇向上爬一個或者兩個臺階。

你可以選擇從下標為 0 或下標為 1 的臺階開始爬樓梯。

請你計算並返回達到樓梯頂部的最低花費。

示例 1:

輸入:cost = [10,15,20]
輸出:15
解釋:你將從下標為 1 的臺階開始。
- 支付 15 ,向上爬兩個臺階,到達樓梯頂部。
總花費為 15 。

示例 2:

輸入:cost = [1,100,1,1,1,100,1,1,100,1]
輸出:6
解釋:你將從下標為 0 的臺階開始。
- 支付 1 ,向上爬兩個臺階,到達下標為 2 的臺階。
- 支付 1 ,向上爬兩個臺階,到達下標為 4 的臺階。
- 支付 1 ,向上爬兩個臺階,到達下標為 6 的臺階。
- 支付 1 ,向上爬一個臺階,到達下標為 7 的臺階。
- 支付 1 ,向上爬兩個臺階,到達下標為 9 的臺階。
- 支付 1 ,向上爬一個臺階,到達樓梯頂部。
總花費為 6 。

提示:

  • 2 <= cost.length <= 1000
  • 0 <= cost[i] <= 999

思路:

[!TIP]

在力扣修改了題目描述下,我又重新修改了題解

修改之後的題意就比較明確了,題目中說 “你可以選擇從下標為 0 或下標為 1 的臺階開始爬樓梯” 也就是相當於 跳到 下標 0 或者 下標 1 是不花費體力的, 從 下標 0 下標1 開始跳就要花費體力了。

  1. 確定dp陣列以及下標的含義

使用動態規劃,就要有一個陣列來記錄狀態,本題只需要一個一維陣列dp[i]就可以了。

dp[i]的定義:到達第i臺階所花費的最少體力為dp[i]

對於dp陣列的定義,大家一定要清晰!

  1. 確定遞推公式

可以有兩個途徑得到dp[i],一個是dp[i-1] 一個是dp[i-2]

dp[i - 1] 跳到 dp[i] 需要花費 dp[i - 1] + cost[i - 1]

dp[i - 2] 跳到 dp[i] 需要花費 dp[i - 2] + cost[i - 2]

那麼究竟是選從dp[i - 1]跳還是從dp[i - 2]跳呢?

一定是選最小的,所以dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);

  1. dp陣列如何初始化

看一下遞迴公式,dp[i]dp[i - 1],dp[i - 2]推出,既然初始化所有的dp[i]是不可能的,那麼只初始化dp[0]dp[1]就夠了,其他的最終都是dp[0]、dp[1]推出。

那麼 dp[0] 應該是多少呢? 根據dp陣列的定義,到達第0臺階所花費的最小體力為dp[0],那麼有同學可能想,那dp[0] 應該是 cost[0],例如 cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] 的話,dp[0] 就是 cost[0] 應該是1。

這裡就要說明本題力扣為什麼改題意,而且修改題意之後 就清晰很多的原因了。

新題目描述中明確說了 “你可以選擇從下標為 0 或下標為 1 的臺階開始爬樓梯。” 也就是說 到達 第 0 個臺階是不花費的,但從 第0 個臺階 往上跳的話,需要花費 cost[0]。

所以初始化 dp[0] = 0dp[1] = 0;

  1. 確定遍歷順序

最後一步,遞迴公式有了,初始化有了,如何遍歷呢?

本題的遍歷順序其實比較簡單,簡單到很多同學都忽略了思考這一步直接就把程式碼寫出來了。

因為是模擬臺階,而且dp[i]由dp[i-1]、dp[i-2]推出,所以是從前到後遍歷cost陣列就可以了。

但是稍稍有點難度的動態規劃,其遍歷順序並不容易確定下來。 例如:01揹包,都知道兩個for迴圈,一個for遍歷物品巢狀一個for遍歷揹包容量,那麼為什麼不是一個for遍歷揹包容量巢狀一個for遍歷物品呢? 以及在使用一維dp陣列的時候遍歷揹包容量為什麼要倒序呢?

這些都與遍歷順序息息相關。當然揹包問題後續「程式碼隨想錄」都會重點講解的!

  1. 舉例推導dp陣列

拿示例2:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] ,來模擬一下dp陣列的狀態變化,如下:

img

如果大家程式碼寫出來有問題,就把dp陣列列印出來,看看和如上推導的是不是一樣的。

以上分析完畢,整體C++程式碼如下:

class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost) {
        vector<int> dp(cost.size() + 1);
        dp[0] = 0; // 預設第一步都是不花費體力的
        dp[1] = 0;
        for (int i = 2; i <= cost.size(); i++) {
            dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
        }
        return dp[cost.size()];
    }
};
  • 時間複雜度:O(n)
  • 空間複雜度:O(n)

還可以最佳化空間複雜度,因為dp[i]就是由前兩位推出來的,那麼也不用dp陣列了,C++程式碼如下:

// 版本二
class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost) {
        int dp0 = 0;
        int dp1 = 0;
        for (int i = 2; i <= cost.size(); i++) {
            int dpi = min(dp1 + cost[i - 1], dp0 + cost[i - 2]);
            dp0 = dp1; // 記錄一下前兩位
            dp1 = dpi;
        }
        return dp1;
    }
};
  • 時間複雜度:O(n)
  • 空間複雜度:O(1)

當然如果在面試中,能寫出版本一就行,除非面試官額外要求 空間複雜度,那麼再去思考版本二,因為版本二還是有點繞。版本一才是正常思路。

擴充

舊力扣描述,如果按照 第一步是花費的,最後一步不花費,那麼程式碼是這麼寫的,提交也可以透過

// 版本一
class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost) {
        vector<int> dp(cost.size());
        dp[0] = cost[0]; // 第一步有花費
        dp[1] = cost[1];
        for (int i = 2; i < cost.size(); i++) {
            dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];
        }
        // 注意最後一步可以理解為不用花費,所以取倒數第一步,第二步的最少值
        return min(dp[cost.size() - 1], dp[cost.size() - 2]);
    }
};

當然如果對 動態規劃 理解不夠深入的話,擴充內容就別看了,容易越看越懵。

總結

大家可以發現這道題目相對於 昨天的動態規劃:爬樓梯 (opens new window)又難了一點,但整體思路是一樣的。

動態規劃:斐波那契數 (opens new window)動態規劃:爬樓梯 (opens new window)再到今天這道題目,錄友們感受到循序漸進的梯度了嘛。

每個系列開始的時候,都有錄友和我反饋說題目太簡單了,趕緊上難度,但也有錄友和我說有點難了,快跟不上了。

其實我選的題目都是有目的性的,就算是簡單題,也是為了練習方法論,然後難度都是梯度上來的,一環扣一環。

但我也可以隨便選來一道難題講唄,這其實是最省事的,不用管什麼題目順序,看心情找一道就講。

難的是把題目按梯度排好,循序漸進,再按照統一方法論把這些都串起來,所以大家不要催我哈,按照我的節奏一步一步來就行了。


62.不同路徑

題意描述:

[!WARNING]

一個機器人位於一個 m x n 網格的左上角 (起始點在下圖中標記為 “Start” )。

機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角(在下圖中標記為 “Finish” )。

問總共有多少條不同的路徑?

示例 1:

img

輸入:m = 3, n = 7
輸出:28

示例 2:

輸入:m = 3, n = 2
輸出:3
解釋:
從左上角開始,總共有 3 條路徑可以到達右下角。
1. 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右
3. 向下 -> 向右 -> 向下

示例 3:

輸入:m = 7, n = 3
輸出:28

示例 4:

輸入:m = 3, n = 3
輸出:6

提示:

  • 1 <= m, n <= 100
  • 題目資料保證答案小於等於(2 * 109)

思路:

[!TIP]

深搜

這道題目,剛一看最直觀的想法就是用圖論裡的深搜,來列舉出來有多少種路徑。

注意題目中說機器人每次只能向下或者向右移動一步,那麼其實機器人走過的路徑可以抽象為一棵二叉樹,而葉子節點就是終點!

如圖舉例:

62.不同路徑

此時問題就可以轉化為求二叉樹葉子節點的個數,程式碼如下:

class Solution {
private:
    int dfs(int i, int j, int m, int n) {
        if (i > m || j > n) return 0; // 越界了
        if (i == m && j == n) return 1; // 找到一種方法,相當於找到了葉子節點
        return dfs(i + 1, j, m, n) + dfs(i, j + 1, m, n);
    }
public:
    int uniquePaths(int m, int n) {
        return dfs(1, 1, m, n);
    }
};

大家如果提交了程式碼就會發現超時了!

來分析一下時間複雜度,這個深搜的演算法,其實就是要遍歷整個二叉樹。

這棵樹的深度其實就是m+n-1(深度按從1開始計算)。

那二叉樹的節點個數就是 2^(m + n - 1) - 1。可以理解深搜的演算法就是遍歷了整個滿二叉樹(其實沒有遍歷整個滿二叉樹,只是近似而已)

所以上面深搜程式碼的時間複雜度為O(2^(m + n - 1) - 1),可以看出,這是指數級別的時間複雜度,是非常大的。

)動態規劃

機器人從(0 , 0) 位置出發,到(m - 1, n - 1)終點。

按照動規五部曲來分析:

  1. 確定dp陣列(dp table)以及下標的含義

dp[i] [j] :表示從(0 ,0)出發,到(i, j) 有dp[i] [j]條不同的路徑。

  1. 確定遞推公式

想要求dp[i] [j],只能有兩個方向來推匯出來,即dp[i - 1] [j] 和 dp[i] [j - 1]。

此時在回顧一下 dp[i - 1] [j] 表示啥,是從(0, 0)的位置到(i - 1, j)有幾條路徑,dp[i] [j - 1]同理。

那麼很自然,dp[i] [j] = dp[i - 1] [j] + dp[i] [j - 1],因為dp[i] [j]只有這兩個方向過來。

  1. dp陣列的初始化

如何初始化呢,首先dp[i] [0]一定都是1,因為從(0, 0)的位置到(i, 0)的路徑只有一條,那麼dp[0] [j]也同理。

所以初始化程式碼為:

for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;
  1. 確定遍歷順序

這裡要看一下遞推公式dp[i] [j] = dp[i - 1] [j] + dp[i] [j - 1],dp[i] [j]都是從其上方和左方推導而來,那麼從左到右一層一層遍歷就可以了。

這樣就可以保證推導dp[i] [j]的時候,dp[i - 1] [j] 和 dp[i] [j - 1]一定是有數值的。

  1. 舉例推導dp陣列

如圖所示:

62.不同路徑1

以上動規五部曲分析完畢,C++程式碼如下:

class Solution {
public:
    int uniquePaths(int m, int n) {
        vector<vector<int>> dp(m, vector<int>(n, 0));
        for (int i = 0; i < m; i++) dp[i][0] = 1;
        for (int j = 0; j < n; j++) dp[0][j] = 1;
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }
        return dp[m - 1][n - 1];
    }
};
  • 時間複雜度:O(m × n)
  • 空間複雜度:O(m × n)

其實用一個一維陣列(也可以理解是滾動陣列)就可以了,但是不利於理解,可以最佳化點空間,建議先理解了二維,在理解一維,C++程式碼如下:

class Solution {
public:
    int uniquePaths(int m, int n) {
        vector<int> dp(n);
        for (int i = 0; i < n; i++) dp[i] = 1;
        for (int j = 1; j < m; j++) {
            for (int i = 1; i < n; i++) {
                dp[i] += dp[i - 1];
            }
        }
        return dp[n - 1];
    }
};
  • 時間複雜度:O(m × n)
  • 空間複雜度:O(n)

數論方法

在這個圖中,可以看出一共m,n的話,無論怎麼走,走到終點都需要 m + n - 2 步。

62.不同路徑

在這m + n - 2 步中,一定有 m - 1 步是要向下走的,不用管什麼時候向下走。

那麼有幾種走法呢? 可以轉化為,給你m + n - 2個不同的數,隨便取m - 1個數,有幾種取法。

那麼這就是一個組合問題了。

那麼答案,如圖所示:

62.不同路徑2

求組合的時候,要防止兩個int相乘溢位! 所以不能把算式的分子都算出來,分母都算出來再做除法。

例如如下程式碼是不行的。

class Solution {
public:
    int uniquePaths(int m, int n) {
        int numerator = 1, denominator = 1;
        int count = m - 1;
        int t = m + n - 2;
        while (count--) numerator *= (t--); // 計算分子,此時分子就會溢位
        for (int i = 1; i <= m - 1; i++) denominator *= i; // 計算分母
        return numerator / denominator;
    }
};

需要在計算分子的時候,不斷除以分母,程式碼如下:

class Solution {
public:
    int uniquePaths(int m, int n) {
        long long numerator = 1; // 分子
        int denominator = m - 1; // 分母
        int count = m - 1;
        int t = m + n - 2;
        while (count--) {
            numerator *= (t--);
            while (denominator != 0 && numerator % denominator == 0) {
                numerator /= denominator;
                denominator--;
            }
        }
        return numerator;
    }
};
  • 時間複雜度:O(m)
  • 空間複雜度:O(1)

計算組合問題的程式碼還是有難度的,特別是處理溢位的情況!

總結

本文分別給出了深搜,動規,數論三種方法。

深搜當然是超時了,順便分析了一下使用深搜的時間複雜度,就可以看出為什麼超時了。

然後在給出動規的方法,依然是使用動規五部曲,這次我們就要考慮如何正確的初始化了,初始化和遍歷順序其實也很重要!


63. 不同路徑 II

題意描述:

[!WARNING]

一個機器人位於一個 m x n 網格的左上角 (起始點在下圖中標記為 “Start” )。

機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角(在下圖中標記為 “Finish”)。

現在考慮網格中有障礙物。那麼從左上角到右下角將會有多少條不同的路徑?

網格中的障礙物和空位置分別用 10 來表示。

示例 1:

img

輸入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
輸出:2
解釋:3x3 網格的正中間有一個障礙物。
從左上角到右下角一共有 2 條不同的路徑:
1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右

示例 2:

img

輸入:obstacleGrid = [[0,1],[0,0]]
輸出:1

提示:

  • m == obstacleGrid.length
  • n == obstacleGrid[i].length
  • 1 <= m, n <= 100
  • obstacleGrid[i][j]01

思路:

[!TIP]

這道題相對於62.不同路徑 (opens new window)就是有了障礙。

第一次接觸這種題目的同學可能會有點懵,這有障礙了,應該怎麼算呢?

62.不同路徑 (opens new window)中我們已經詳細分析了沒有障礙的情況,有障礙的話,其實就是標記對應的dp table(dp陣列)保持初始值(0)就可以了。

動規五部曲:

  1. 確定dp陣列(dp table)以及下標的含義

dp[i] [j] :表示從(0 ,0)出發,到(i, j) 有dp[i][j]條不同的路徑。

  1. 確定遞推公式

遞推公式和62.不同路徑一樣,dp[i] [j] = dp[i - 1] [j] + dp[i] [j - 1]

但這裡需要注意一點,因為有了障礙,(i, j)如果就是障礙的話應該就保持初始狀態(初始狀態為0)。

所以程式碼為:

if (obstacleGrid[i][j] == 0) { // 當(i, j)沒有障礙的時候,再推導dp[i][j]
    dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
  1. dp陣列如何初始化

62.不同路徑 (opens new window)不同路徑中我們給出如下的初始化:

vector<vector<int>> dp(m, vector<int>(n, 0)); // 初始值為0
for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;

因為從(0, 0)的位置到(i, 0)的路徑只有一條,所以dp[i] [0]一定為1,dp[0] [j]也同理。

但如果(i, 0) 這條邊有了障礙之後,障礙之後(包括障礙)都是走不到的位置了,所以障礙之後的dp[i] [0]應該還是初始值0。

如圖:

63.不同路徑II

下標(0, j)的初始化情況同理。

所以本題初始化程式碼為:

vector<vector<int>> dp(m, vector<int>(n, 0));
for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1;
for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1;

注意程式碼裡for迴圈的終止條件,一旦遇到obstacleGrid[i] [0] == 1的情況就停止dp[i] [0]的賦值1的操作,dp[0] [j]同理

  1. 確定遍歷順序

從遞迴公式dp[i] [j] = dp[i - 1] [j] + dp[i] [j - 1] 中可以看出,一定是從左到右一層一層遍歷,這樣保證推導dp[i] [j]的時候,dp[i - 1] [j] dp[i] [j - 1]一定是有數值。

程式碼如下:

for (int i = 1; i < m; i++) {
    for (int j = 1; j < n; j++) {
        if (obstacleGrid[i][j] == 1) continue;
        dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
    }
}
  1. 舉例推導dp陣列

拿示例1來舉例如題:

63.不同路徑II1

對應的dp table 如圖:

63.不同路徑II2

如果這個圖看不懂,建議再理解一下遞迴公式,然後照著文章中說的遍歷順序,自己推導一下!

動規五部分分析完畢,對應C++程式碼如下:

class Solution {
public:
    int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
        int m = obstacleGrid.size();
        int n = obstacleGrid[0].size();
        if (obstacleGrid[m - 1][n - 1] == 1 || obstacleGrid[0][0] == 1) //如果在起點或終點出現了障礙,直接返回0
            return 0;
        vector<vector<int>> dp(m, vector<int>(n, 0));
        for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1;
        for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1;
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                if (obstacleGrid[i][j] == 1) continue;
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }
        return dp[m - 1][n - 1];
    }
};
  • 時間複雜度:O(n × m),n、m 分別為obstacleGrid 長度和寬度
  • 空間複雜度:O(n × m)

同樣我們給出空間最佳化版本:

class Solution {
public:
    int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
        if (obstacleGrid[0][0] == 1)
            return 0;
        vector<int> dp(obstacleGrid[0].size());
        for (int j = 0; j < dp.size(); ++j)
            if (obstacleGrid[0][j] == 1)
                dp[j] = 0;
            else if (j == 0)
                dp[j] = 1;
            else
                dp[j] = dp[j-1];

        for (int i = 1; i < obstacleGrid.size(); ++i)
            for (int j = 0; j < dp.size(); ++j){
                if (obstacleGrid[i][j] == 1)
                    dp[j] = 0;
                else if (j != 0)
                    dp[j] = dp[j] + dp[j-1];
            }
        return dp.back();
    }
};
  • 時間複雜度:O(n × m),n、m 分別為obstacleGrid 長度和寬度
  • 空間複雜度:O(m)

總結

本題是62.不同路徑 (opens new window)的障礙版,整體思路大體一致。

但就算是做過62.不同路徑,在做本題也會有感覺遇到障礙無從下手。

其實只要考慮到,遇到障礙dp[i] [j]保持0就可以了

也有一些小細節,例如:初始化的部分,很容易忽略了障礙之後應該都是0的情況。


343. 整數拆分

題意描述:

[!NOTE]

給定一個正整數 n ,將其拆分為 k正整數 的和( k >= 2 ),並使這些整數的乘積最大化。

返回 你可以獲得的最大乘積

示例 1:

輸入: n = 2
輸出: 1
解釋: 2 = 1 + 1, 1 × 1 = 1。

示例 2:

輸入: n = 10
輸出: 36
解釋: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。

提示:

  • 2 <= n <= 58

思路:

[!TIP]

看到這道題目,都會想拆成兩個呢,還是三個呢,還是四個....我們來看一下如何使用動規來解決。

動態規劃

動規五部曲,分析如下:

  1. 確定dp陣列(dp table)以及下標的含義

dp[i]:分拆數字i,可以得到的最大乘積為dp[i]

dp[i]的定義將貫徹整個解題過程,下面哪一步想不懂了,就想想dp[i]究竟表示的是啥!

  1. 確定遞推公式

可以想 dp[i]最大乘積是怎麼得到的呢?其實可以從1遍歷j,然後有兩種渠道得到dp[i].

一個是j * (i - j) 直接相乘。

一個是j * dp[i - j]相當於是拆分(i - j),對這個拆分不理解的話,可以回想dp陣列的定義。

那有同學問了,j怎麼就不拆分呢?

j是從1開始遍歷,拆分j的情況,在遍歷j的過程中其實都計算過了。那麼從1遍歷j,比較(i - j) * jdp[i - j] * j 取最大的。遞推公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));

也可以這麼理解,j * (i - j) 是單純的把整數拆分為兩個數相乘,而j * dp[i - j]是拆分成兩個以及兩個以上的個數相乘。

如果定義dp[i - j] * dp[j] 也是預設將一個數強制拆成4份以及4份以上了。

所以遞推公式:dp[i] = max({dp[i], (i - j) * j, dp[i - j] * j});

那麼在取最大值的時候,為什麼還要比較dp[i]呢?因為在遞推公式推導的過程中,每次計算dp[i],取最大的而已。

  1. dp的初始化

不少同學應該疑惑,dp[0] dp[1]應該初始化多少呢?

有的題解裡會給出dp[0] = 1,dp[1] = 1的初始化,但解釋比較牽強,主要還是因為這麼初始化可以把題目過了。

嚴格從dp[i]的定義來說,dp[0] dp[1] 就不應該初始化,也就是沒有意義的數值。

拆分0和拆分1的最大乘積是多少?

這是無解的。

這裡我只初始化dp[2] = 1,從dp[i]的定義來說,拆分數字2,得到的最大乘積是1,這個沒有任何異議!

  1. 確定遍歷順序

確定遍歷順序,先來看看遞迴公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));

dp[i] 是依靠 dp[i - j]的狀態,所以遍歷i一定是從前向後遍歷,先有dp[i - j]再有dp[i]。

所以遍歷順序為:

for (int i = 3; i <= n ; i++) {
    for (int j = 1; j < i - 1; j++) {
        dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
    }
}

注意 列舉j的時候,是從1開始的。從0開始的話,那麼讓拆分一個數拆個0,求最大乘積就沒有意義了。

j的結束條件是j < i - 1,其實j < i也是可以的,不過可以節省一步,例如讓j = i - 1,的話,其實在 j = 1的時候,這一步就已經拆出來了,重複計算,所以 j < i - 1

至於 i是從3開始,這樣dp[i - j]就是dp[2]正好可以透過我們初始化的數值求出來。

更最佳化一步,可以這樣:

for (int i = 3; i <= n ; i++) {
    for (int j = 1; j <= i / 2; j++) {
        dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
    }
}

因為拆分一個數n 使之乘積最大,那麼一定是拆分成m個近似相同的子數相乘才是最大的

例如 6 拆成 3 * 3, 10 拆成 3 * 3 * 4。 100的話 也是拆成m個近似陣列的子數 相乘才是最大的。

只不過我們不知道m究竟是多少而已,但可以明確的是m一定大於等於2,既然m大於等於2,也就是 最差也應該是拆成兩個相同的 可能是最大值。那麼 j 遍歷,只需要遍歷到 n/2 就可以,後面就沒有必要遍歷了,一定不是最大值。

至於 “拆分一個數n 使之乘積最大,那麼一定是拆分成m個近似相同的子數相乘才是最大的” 這個我就不去做數學證明了,感興趣的同學,可以自己證明。

  1. 舉例推導dp陣列

舉例當n為10 的時候,dp陣列裡的數值,如下:

343.整數拆分

以上動規五部曲分析完畢,C++程式碼如下:

class Solution {
public:
    int integerBreak(int n) {
        vector<int> dp(n + 1);
        dp[2] = 1;
        for (int i = 3; i <= n ; i++) {
            for (int j = 1; j <= i / 2; j++) {
                dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
            }
        }
        return dp[n];
    }
};
  • 時間複雜度:O(n^2)
  • 空間複雜度:O(n)

貪心

本題也可以用貪心,每次拆成n個3,如果剩下是4,則保留4,然後相乘,但是這個結論需要數學證明其合理性!

我沒有證明,而是直接用了結論。感興趣的同學可以自己再去研究研究數學證明哈。

給出我的C++程式碼如下:

class Solution {
public:
    int integerBreak(int n) {
        if (n == 2) return 1;
        if (n == 3) return 2;
        if (n == 4) return 4;
        int result = 1;
        while (n > 4) {
            result *= 3;
            n -= 3;
        }
        result *= n;
        return result;
    }
};
  • 時間複雜度:O(n)
  • 空間複雜度:O(1)

總結

本題掌握其動規的方法,就可以了,貪心的解法確實簡單,但需要有數學證明,如果能自圓其說也是可以的。

其實這道題目的遞推公式並不好想,而且初始化的地方也很有講究,我在寫本題的時候一開始寫的程式碼是這樣的:

class Solution {
public:
    int integerBreak(int n) {
        if (n <= 3) return 1 * (n - 1);
        vector<int> dp(n + 1, 0);
        dp[1] = 1;
        dp[2] = 2;
        dp[3] = 3;
        for (int i = 4; i <= n ; i++) {
            for (int j = 1; j <= i / 2; j++) {
                dp[i] = max(dp[i], dp[i - j] * dp[j]);
            }
        }
        return dp[n];
    }
};

這個程式碼也是可以過的!

在解釋遞推公式的時候,也可以解釋通,dp[i] 就等於 拆解i - j的最大乘積 * 拆解j的最大乘積。 看起來沒毛病!

但是在解釋初始化的時候,就發現自相矛盾了,dp[1]為什麼一定是1呢?根據dp[i]的定義,dp[2]也不應該是2啊。

但如果遞迴公式是 dp[i] = max(dp[i], dp[i - j] * dp[j]);,就一定要這麼初始化。遞推公式沒毛病,但初始化解釋不通!

雖然程式碼在初始位置有一個判斷if (n <= 3) return 1 * (n - 1);,保證n<=3 結果是正確的,但程式碼後面又要給dp[1]賦值1 和 dp[2] 賦值 2,這其實就是自相矛盾的程式碼,違背了dp[i]的定義!

我舉這個例子,其實就說做題的嚴謹性,上面這個程式碼也可以AC,大體上一看好像也沒有毛病,遞推公式也說得過去,但是僅僅是恰巧過了而已。


96.不同的二叉搜尋樹

題意描述:

[!WARNING]

給你一個整數 n ,求恰由 n 個節點組成且節點值從 1n 互不相同的 二叉搜尋樹 有多少種?返回滿足題意的二叉搜尋樹的種數。

示例 1:

img

輸入:n = 3
輸出:5

示例 2:

輸入:n = 1
輸出:1

提示:

  • 1 <= n <= 19

思路:

[!TIP]

這道題目描述很簡短,但估計大部分同學看完都是懵懵的狀態,這得怎麼統計呢?

關於什麼是二叉搜尋樹,我們之前在講解二叉樹專題的時候已經詳細講解過了,也可以看看這篇二叉樹:二叉搜尋樹登場! (opens new window)再回顧一波。

瞭解了二叉搜尋樹之後,我們應該先舉幾個例子,畫畫圖,看看有沒有什麼規律,如圖:

96.不同的二叉搜尋樹

n為1的時候有一棵樹,n為2有兩棵樹,這個是很直觀的。

96.不同的二叉搜尋樹1

來看看n為3的時候,有哪幾種情況。

當1為頭結點的時候,其右子樹有兩個節點,看這兩個節點的佈局,是不是和 n 為2的時候兩棵樹的佈局是一樣的啊!

(可能有同學問了,這佈局不一樣啊,節點數值都不一樣。別忘了我們就是求不同樹的數量,並不用把搜尋樹都列出來,所以不用關心其具體數值的差異)

當3為頭結點的時候,其左子樹有兩個節點,看這兩個節點的佈局,是不是和n為2的時候兩棵樹的佈局也是一樣的啊!

當2為頭結點的時候,其左右子樹都只有一個節點,佈局是不是和n為1的時候只有一棵樹的佈局也是一樣的啊!

發現到這裡,其實我們就找到了重疊子問題了,其實也就是發現可以透過dp[1] 和 dp[2] 來推匯出來dp[3]的某種方式。

思考到這裡,這道題目就有眉目了。

dp[3] = 元素1為頭結點搜尋樹的數量 + 元素2為頭結點搜尋樹的數量 + 元素3為頭結點搜尋樹的數量

元素1為頭結點搜尋樹的數量 = 右子樹有2個元素的搜尋樹數量 * 左子樹有0個元素的搜尋樹數量

元素2為頭結點搜尋樹的數量 = 右子樹有1個元素的搜尋樹數量 * 左子樹有1個元素的搜尋樹數量

元素3為頭結點搜尋樹的數量 = 右子樹有0個元素的搜尋樹數量 * 左子樹有2個元素的搜尋樹數量

有2個元素的搜尋樹數量就是dp[2]。

有1個元素的搜尋樹數量就是dp[1]。

有0個元素的搜尋樹數量就是dp[0]。

所以dp[3] = dp[2] * dp[0] + dp[1] * dp[1] + dp[0] * dp[2]

如圖所示:

96.不同的二叉搜尋樹2

此時我們已經找到遞推關係了,那麼可以用動規五部曲再系統分析一遍。

  1. 確定dp陣列(dp table)以及下標的含義

dp[i] : 1到i為節點組成的二叉搜尋樹的個數為dp[i]

也可以理解是i個不同元素節點組成的二叉搜尋樹的個數為dp[i] ,都是一樣的。

以下分析如果想不清楚,就來回想一下dp[i]的定義

  1. 確定遞推公式

在上面的分析中,其實已經看出其遞推關係, dp[i] += dp[以j為頭結點左子樹節點數量] * dp[以j為頭結點右子樹節點數量]

j相當於是頭結點的元素,從1遍歷到i為止。

所以遞推公式:dp[i] += dp[j - 1] * dp[i - j]; ,j-1 為j為頭結點左子樹節點數量,i-j 為以j為頭結點右子樹節點數量

  1. dp陣列如何初始化

初始化,只需要初始化dp[0]就可以了,推導的基礎,都是dp[0]。

那麼dp[0]應該是多少呢?

從定義上來講,空節點也是一棵二叉樹,也是一棵二叉搜尋樹,這是可以說得通的。

從遞迴公式上來講,dp[以j為頭結點左子樹節點數量] * dp[以j為頭結點右子樹節點數量] 中以j為頭結點左子樹節點數量為0,也需要dp[以j為頭結點左子樹節點數量] = 1, 否則乘法的結果就都變成0了。所以初始化dp[0] = 1

  1. 確定遍歷順序

首先一定是遍歷節點數,從遞迴公式:dp[i] += dp[j - 1] * dp[i - j]可以看出,節點數為i的狀態是依靠 i之前節點數的狀態。

那麼遍歷i裡面每一個數作為頭結點的狀態,用j來遍歷。

程式碼如下:

for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= i; j++) {
        dp[i] += dp[j - 1] * dp[i - j];
    }
}
  1. 舉例推導dp陣列

n為5時候的dp陣列狀態如圖:

96.不同的二叉搜尋樹3

當然如果自己畫圖舉例的話,基本舉例到n為3就可以了,n為4的時候,畫圖已經比較麻煩了。

我這裡列到了n為5的情況,是為了方便大家 debug程式碼的時候,把dp陣列打出來,看看哪裡有問題

綜上分析完畢,C++程式碼如下:

class Solution {
public:
    int numTrees(int n) {
        vector<int> dp(n + 1);
        dp[0] = 1;
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= i; j++) {
                dp[i] += dp[j - 1] * dp[i - j];
            }
        }
        return dp[n];
    }
};
  • 時間複雜度:\(O(n^2)\)
  • 空間複雜度:\(O(n)\)

大家應該發現了,我們分析了這麼多,最後程式碼卻如此簡單!

總結

這道題目雖然在力扣上標記是中等難度,但可以算是困難了!

首先這道題想到用動規的方法來解決,就不太好想,需要舉例,畫圖,分析,才能找到遞推的關係。

然後難點就是確定遞推公式了,如果把遞推公式想清楚了,遍歷順序和初始化,就是自然而然的事情了。

可以看出我依然還是用動規五部曲來進行分析,會把題目的方方面面都覆蓋到!

而且具體這五部分析是我自己平時總結的經驗,找不出來第二個的,可能過一陣子 其他題解也會有動規五部曲了

當時我在用動規五部曲講解斐波那契的時候,一些錄友和我反應,感覺講複雜了。

其實當時我一直強調簡單題是用來練習方法論的,並不能因為簡單我就程式碼一甩,簡單解釋一下就完事了。

可能當時一些同學不理解,現在大家應該感受方法論的重要性了,加油💪


[動態規劃:01揹包理論基礎]

題意描述:

[!NOTE]

有 𝑁 件物品和一個容量是 𝑉 的揹包。每件物品只能使用一次。

第 𝑖 件物品的體積是 𝑣𝑖,價值是 𝑤𝑖。

求解將哪些物品裝入揹包,可使這些物品的總體積不超過揹包容量,且總價值最大。
輸出最大價值。

輸入格式

第一行兩個整數,𝑁,𝑉,用空格隔開,分別表示物品數量和揹包容積。

接下來有 𝑁 行,每行兩個整數𝑣𝑖,𝑤𝑖,用空格隔開,分別表示第 𝑖 件物品的體積和價值。

輸出格式

輸出一個整數,表示最大價值。

資料範圍

0<𝑁,𝑉≤1000
0<𝑣𝑖,𝑤𝑖≤1000

輸入樣例

4 5
1 2
2 4
3 4
4 5

輸出樣例:

8

思路:

[!TIP]