動態規劃初步

Weekoder發表於2024-06-09

動態規劃(DP)教程

大家好,我是Weekoder!

Week_team 的同學們都來聽課啦!

如果你還沒有加入 Week_team,點選這裡即可加入我們!

動態規劃的概念

動態規劃(DP)聽起來是個非常嚇人的東西,實際上……確實是個嚇人的東西。但是我想告訴大家,動態規劃並沒有那麼難,主要有兩個關鍵點:

  • 多練。

  • 多思考。

就是這麼簡單。

但實際做起來卻不簡單。

那麼,我們偉大旅程的第一步就是——搞懂動態規劃是什麼!


首先,動態規劃也稱 DP,他適合解決的問題型別有兩種:

  • 最值問題。(最大最小值)

  • 計數問題。(請問一共有多少種方案數?)

只要知道這些就夠了。

那 DP 又分為哪幾個步驟呢?

  1. 找到決策。

  2. 設計狀態。

  3. 考慮轉移。

沒有看懂沒關係,我們一個個來。

B3636為例子,展開我們的講解。

1. 找到決策

這一步比較簡單,但也是最重要的一步。

題目描述裡有這樣一段話:


機器貓有兩種操作可以做。假設現在已經有 \(x\) 個字,機器貓可以選擇:

  • 往文件最後加一個字。字數變成 \(x+1\)
  • 把文件複製貼上一遍。字數變成 \(2x\)

這給了我們一個啟發。假設現在你是機器貓,你坐在電腦前開始打字。噠噠噠噠噠噠噠噠噠噠噠(超真實打字音效)。你的螢幕上有若干個字,這時候就到了最讓人犯難的時候了:到底該加一個字還是貼上一遍?答案是不需要知道。唯一需要知道的是當前有兩個選擇:加字和貼上一遍。這就是我們的決策。

恭喜你找到決策了!啪啪啪啪啪(鼓掌)!

簡單總結:找到決策就是模擬一遍題目的過程,找到需要進行抉擇的地方。

2. 設計狀態

設計狀態對初學者來說往往是很讓他們困擾的。那該怎麼辦呢?最好的方法還是多練。但這裡我也會提供一種設計狀態的思路。

狀態是什麼?我們在求解動態規劃問題時,往往需要陣列,如 \(dp[10010]\)。你可以把狀態理解為你對陣列元素的定義。如對於這道題,我們可以把 \(dp_i\) 定義為打了 \(i\) 個字所需要的最少步驟,那麼答案就是 \(dp_n\)(打 \(n\) 個字所需要的最少步驟)。這就像你對函式意義的定義。

那我們拿到一道題之後該怎麼去定義呢?其實對於我,一開始的時候可以定義的比較隨便。但一旦發現很難求解,就要及時考慮加入狀態。比如定義的狀態是 \(dp_i\),那麼就加入一個 \(j\),變為 \(dp_{i,j}\)。在各種各樣的題目中,甚至會出現四維陣列 \(dp_{i,j,k,l}\)

那麼這道題的狀態就設計為 \(dp_i\) 表示打 \(i\) 個字所需要的最少步驟。

注意:最好的方法還是多練,多刷題!

3. 考慮轉移

這是很多人眼中最難的一步。其實我覺得這一步並沒有設計狀態難,因為他和第一步是緊密相關的。

我們第一步說到了決策,那我們就要想一想怎麼利用這個決策把狀態一一填滿。

這個時候其實是要設計一個狀態轉移方程。就像這樣:

\[dp_i=? \]

現在我們要把這個 \(?\) 填上。我們想一想 \(dp_i\) 能從哪個地方做決策而來?就像已經打了 \(i\) 個字的機器貓,想知道他有可能是打了幾個字的機器貓做了一步決策而來的。顯然,我們之前說過,機器貓要麼只能打一個字(\(x+1\)),要麼只能貼上一遍文字(\(2x\))。現在反過來看,不就是打了 \(i-1\)\(i\div2\) 個字的機器貓嗎?一個打一個字就變成了 \(i\),另一個貼上一遍文字也變成了 \(i\)。那我們既然要求最小步驟,肯定要在兩種情況裡取最小值再加上做出決策的步驟(\(1\) 步)啦。看看狀態轉移方程吧:

\[dp_i=\min(dp_{i-1},dp_{i\div2})+1 \]

這樣就設計成功了!

其實我認為,狀態轉移方程就是決策的逆運算。


這樣就結束了。

還有一點比較重要的是,設定初始狀態。像剛才的例子,初始狀態就是 \(dp_1=0\),因為一開始就有一個字,不需要進行任何操作。當然,全域性陣列預設是 \(0\),所以不需要單獨加上這樣一條指令。

我們只需要迴圈從小到大擴充套件,套用狀態轉移方程就行了。

\(\text{Code:}\)

#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 5;
int n, dp[N];
int main() {
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);
    cin >> n;
    for (int i = 2; i <= n; i++) {
        if (i % 2 == 0)
            dp[i] = min(dp[i - 1], dp[i / 2]) + 1;
        else
            dp[i] = dp[i - 1] + 1;
    }
    cout << dp[n];
    return 0;
}

這裡有一個細節,就是在迴圈裡並沒有直接套用公式,而是判斷了字數的奇偶。因為當字數為奇數時,是不可能從 \(i\div2\) 轉移過來的。而奇數的情況一定不能寫成這樣:

\[dp_i=\min(dp_i,dp_{i-1})+1 \]

這裡不能取 min,因為只有 \(i-1\) 這一種情況,上面的是在兩種情況:\(i-1\)\(i\div2\) 中取 min。在求最值問題時才會有上面這種寫法。

總結

我相信如果你是初學者,光看程式碼看定是不能完全看懂的。可以私信我 @Weekoder 或者在評論區發表意見和疑問。

作業:動態規劃初步題單

再見!

相關文章