基礎DP

建國中學 游承曦

什麼是DP?

Dynamic Programming, 動態規劃

如何解決一個大問題?

把他拆成小問題再合併求出答案!

  • 最佳解問題
  • 組合計數

一些不重要的名詞(?

  • 最佳子結構

  • 重複子結構

  • 無後效性

可能以後就能滲透了吧

DP的步驟

  • 定義狀態

  • 寫出轉移

  • 打好邊界

時間複雜度通常是 \(O(\) 狀態數 \(\times\) 狀態轉移花費 \()\)

直接看例子吧OAO

ZJ d212 - 東東爬階梯

東東要來爬樓梯,他每次只會跨一階或兩階,假設樓梯共有 \(n\) 階,則他有幾種爬樓梯的做法?

這時候你可以選擇

排列組合 或 DP

顯然排組是還沒教過的東西,吧?

那就來DP吧

2. 對於東東而言,他只能從前兩階或前一階走上來

因此走到第 \(i\) 階的方法數就是走到 \(i-1\) 階和 \(i-2\) 階的方法數和

1. 定義 \(dp_i\) 為走到第 \(i\) 階樓梯的方法數

因此轉移就是 \(dp_i = dp_{i-1} + dp_{i-2}\)

3. 而最初始的狀態,走\(0\)階和走\(1\)階的走法都只有\(1\)種

所以邊界 \(dp_0 = dp_1 = 1\)

int dp(int n){
    if(n == 0 || n == 1) return 1;
    return dp(n-1) + dp(n-2);
}

哪裡出了問題?

這樣每次算走 \(n\) 階的答案都要遞迴 \(n\) 層

似乎效率太差,不滿足重複子結構?

因此我們要把重要的資料留下來!

int dp[maxn]={};
int fib(int n){
    if(dp[n] != 0) return dp[n];
    if(n == 0 || n == 1) return dp[n] = 1;
    return dp[n] = fib(n-1) + fib(n-2);
}

把已經知道走 \(n\) 階的答案存下來

這樣以後要求他時就能直接得出答案

這種遞迴的dp寫法叫做 top-down

int dp[maxn];
dp[0] = dp[1] = 1;
for(int i=2 ; i<=n ; ++i){
    dp[i] = dp[i-1] + dp[i-2];
}

// 直接查表 dp[k];

bottom-up 的作法

複雜度呢?

狀態數 \(\times\) 轉移花費

\(\to N(dp[0...N]) \times 1 (\)每次只要做加法\()\)

\(\to \ O(N)\) !

很棒的線性時間

btw 那題 ZJ d212

int 會爆, long long 也會爆

所以要開 long long unsigned

一大堆的練習題

才是進步的墊腳石

要開始腦力激盪了(?)

ZJ d784 - 連續元素的和

給你一個 \(n\) 個數的序列 \(a\),

找出某一段連續的區間為最大值?

\(<a_n> \ = \ -1, 2, 3, -4, 5\)

\(\max. = 2+3-4+5 = 6\)

1. 定義 \(dp_i\) 為包含 \(a_i\) 所形成的一段最大連續和

如何寫出轉移?

若 \(a_i\) 前一個最大連續和為負,就不要取

反之則加上去

2. \(dp_i = \max(dp_{i-1}, \ 0) + a_i\)

邊界?

3. 只要 \(dp_0 = 0\) 就好惹

4. 最後取 \(\max_{1\leq i \leq n}dp_i\) 就是答案!

NEOJ 141

修改一下剛剛那題

給你一個 \(n\) 個數的序列 \(a\),

可以選一些數字使其為最大,

但任兩個選的數字間至少要隔一個沒選的數?

\(<a_n> \ = \ -1, 2, 3, -4, 5\)

\(\max. = 3+5=8\)

轉換一下定義

1. 定義 \(dp_i\) 為 \(a_1 \) ... \( a_i\) 所形成的最佳解 (總和最大)

要求 \(dp_i\) 時有兩種狀況,選 \(a_i\) 或不選 \(a_i\)

  • 如果選了 \(a_i\),那 \(a_{i-1}\) 就不能選,所以 \(a_i\) 就可以搭配 \(a_1\) ... \(a_{i-2}\) 所形成的最佳解
  • 如果不選 \(a_i\) 那 \(a_1\) ... \(a_{i-1}\) 的最佳解就直接是 \(a_1\) ... \(a_i\) 的最佳解

所以轉移就會是兩者取較大者!

2. \(dp_i =\max(dp_{i-2}+a_i, \ dp_{i-1})\)

邊界!

3. \(dp_0 = 0, \ dp_1 = a_1\)

最後 \(dp_n\) 就會是 \(a_1\) ... \(a_n\) 的最佳解

也就是要求的答案

ZJ d378 - 最小路徑

給一個大小為 \(N\times M\) 的矩陣,

現在要從左上角走到右下角,且每次只能往右或往下走

每經過一個點,總花費就要加上該點的花費

求最小的總花費?

則最小花費為

\(0\to 1\to 5\to 1\to 1\to 0\)

\(=8\)

0 7 8 9
1 5 1 1
2 4 6 0

假設左上角為 \((1, \ 1)\),右下角為 \((n, \ m)\)

\(dp[i][j]\) 為走到 \((i, \ j)\) 時的最小花費

 

如果這一步不在邊界,可以從上方或左方走過來:

則 \(dp[i][j] = \min(dp[i-1][j], \ dp[i][j-1]) + c[i][j]\)

 

如果在邊界,就是以下兩種之一:

\(dp[i][j] = dp[i-1][j] + c[i][j]\)

\(dp[i][j] = dp[i][j-1] + c[i][j]\)

 

初始狀態可以放 \(dp[1][1] = c[1][1]\)

最後答案就是 \(dp[n][m]\)

空間優化

注意到每次轉移都是用前一列的資訊,

那再更前排的不是都不重要了?

所以我們可以把陣列用滾動的方式來使用!

也就是

\(dp[i \% 2][j] = \min\{dp[(i - 1) \% 2][j] + dp[i][j - 1]\} + c[i][j]\)

只需要額外 \(\mathcal{O}(2M)\) 的空間!

TIOJ 1288 - [IOI 1994] 三角旅行

如果上面那題懂了

那你就可以來寫IOI史上最水的題目(有oj的)

只是從長方形變成三角形而已

背包問題

FHVirus : 比 FFT 還難的東西

給你 \(n\) 個物品,每個物品有價值\(v_i\)和重量\(w_i\),

還有一個負重上限為 \(m\) 的背包,

問選一些物品不超過背包重量上限能獲得最大價值?

\(n \times m \leq 10^7\)

直覺

每次選價值最大的物品先放進背包直到放不下

或是先放價值與重量比值最大者

或是...?

反例

\(n = 3, \ m = 10\)

\(v:[ \ 12, 6, 7 \ ]\)

\(w:[ \ 6, 5, 5 \ ]\)

greedy 的想法在這裡是行不通的, 除非...?

0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 12 12 12 12 12
0 0 0 0 6 12 12 12 12 12
0 0 0 0 7 12 12 12 12 13

湊成重量 1~10 的最大價值 \(\rightarrow\)

\(i\)

\(\downarrow\)

\(n = 3, \ m = 10\)

\(v_i : [ \ 12, 6, 7 \ ]\)

\(w_i : [ \ 6, 5, 5 \ ]\)

\(dp[i][j]\) 代表考慮了前 \(i\) 個物品,

重量湊成 \(j\) 的最大價值

要求第 \(i\) 個物品湊成重量 \(j\) 時,

轉移式為

\(dp[i][j] = \max \{dp[i-1][j-w_i]+v_i \mid j-w_i \geq 0 \}\)

邊界呢? 這題不用特別設

答案就是 \(\max\{dp[n][k]\}, k \in [0, m]\)

int main(){
    int n, m;
    int v[MAXN + 1];
    int w[MAXN + 1];
    
    cin >> n >> m;
    for(int i=1 ; i<=n ; ++i)
    	cin >> v[i] >> w[i];
        
    int dp[MAXN + 1][MAXM + 1]{};
    for(int i=1 ; i<=n ; ++i)
        for(int j=0 ; j<=m ; ++j){
            if(j - w[i] >= 0){
                dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i]);
            }
            else{
                dp[i][j] = dp[i-1][j];
            }
        }
        
    int ans = 0;
    for(int i=1 ; i<=n ; ++i)
        for(int j=1 ; j<=m ; ++j)
            ans = max(ans, dp[i][j]);
    cout << ans << '\n';
}

滾動DP

可以發現轉移時都是從 \(dp[i-1]\) 排轉移過來,

 

因此一樣可以只開 \(2 \times M\) 大小的陣列

每次交換兩排來當作轉移來源

比較特別的是對於背包問題可以壓成一個維度

只是要逆序枚舉

逆序枚舉

無限背包

有限背包

區間DP

LCS

Longest Common Subsequence

LIS

Longest Increasing Subsequence

位元DP

e.g. 旅行推銷員問題

基礎DP

By youou

基礎DP

  • 181