---
# System prepended metadata

title: 動態規劃

---

# 動態規劃

Dynamic Programming(簡稱DP)，基本上是分治法+Memoization

## 如何學好DP

DP只是一種觀念，用途極廣，因此要熟練DP，最重要的是多刷題。

DP最重要的就是如何定狀態，沒有狀態就不用DP了，複雜度跟暴力枚舉沒什麼兩樣。

刷題建議不要找DP題單來刷，這樣會完全無法訓練「如何看出一題是不是DP」的能力。

## 分治法

把一個問題分割成更小的問題，小到答案非常明顯，之後合併。
因為大問題跟小問題本質上是同一種問題，所以常常用遞迴實現

### merge sort
分治法的經典應用。要排序一個數列，可以把原數列切成兩半，之後排序兩個小數列，最後再把排序完的兩個小數列合併。

首先思考，什麼時候可以停止切下去呢?如果數列只剩一項，那麼它顯然就是一個已排序數列

```cpp!
vector<int> Divide(vector<int> v){
    if(v.size()>1){
        int M=v.size()/2;
        vector<int> a=Divide(vector<int>(v.begin(),v.begin()+M));
        vector<int> b=Divide(vector<int>(v.begin()+M,v.end()));
        v=Merge(a,b);
    }
    return v;
}
```

接著，要怎麼快速的合併兩個已經排序(由小到大)的數列呢?
只要每次比較兩個小數列的第一項取出最小值即可，複雜度$O(n)$

```cpp
vector<int> Merge(vector<int> a,vector<int> b){
    a.push_back(1e9);
    b.push_back(1e9);
    int a_index=0,b_index=0;
    vector<int> v;
    while(a_index!=a.size()-1 || b_index!=b.size()-1){
        if(a[a_index]<b[b_index]){
            v.push_back(a[a_index]);
            a_index++;
        }
        else{
            v.push_back(b[b_index]);
            b_index++;
        }
    }
    return v;
}
```

示意圖:

![1_wj5YiSbqq-KNCBUGJixWoQ](https://hackmd.io/_uploads/rk-klRo7a.png)
[圖源](https://levelup.gitconnected.com/merge-sort-656f8ee59d83)
總複雜度$O(nlogn)$，是非常快的排序法
## Memoization
在做DP的時候，分割出來的子問題可能一再出現，所以用陣列把答案存下來，下次再遇到可以直接$O(1)$回答。在[遞迴](https://hackmd.io/KHyaIm1eT6uDS_XKFFptDw)裡也有講過

## 背包問題
[cses:book shop](https://cses.fi/problemset/task/1158/)
DP的經典問題。你有一個背包，以及$n$個重量為$w_i$，價值為$v_i$的物品。已知背包能裝的總重為$W$，求背包能裝的最大價值?

如果我們定義$dp[i]=背包重量不超過i時的最大價值$，那麼所求的答案就是$dp[w]$
假設我們要在現有的背包加入一個物品$k$，那麼$$dp[i+w_k]=max(dp[i+w_k],dp[i]+v_k)$$也就是說，在重量為$i$的時候，如果放入物品$k$後的價值比較高，那就更新放入後的最大價值。這條式子稱為狀態轉移方程，也就是用比較小的狀態($dp[i]$)來得出大的狀態($dp[i+w_k]$)

```cpp
#include <bits/stdc++.h>

using namespace std;

int main()
{
    int n,x;
    cin>>n>>x;
    vector<int> v(n),w(n),dp(x+1);
    for(int i=0;i<n;i++) cin>>w[i];
    for(int i=0;i<n;i++) cin>>v[i];
    for(int i=0;i<n;i++){
        for(int j=x-w[i];j>=0;j--){  //倒著跑
            dp[j+w[i]]=max(dp[j+w[i]],dp[j]+v[i]);
        }
    }
    cout<<dp[x];
    return 0;
}
```
注意第二層迴圈要倒著跑，不然一個物品可能會更新很多次。例如:$在j=0時，w_i被更新過；迴圈跑到j=w_i時，又用更新過的j去更新j+w_i$

### 練習題
[cses:Dice Combinations](https://cses.fi/problemset/task/1633/)
給一個數字$n$，用1~6組合有幾種方法?
ex: 3有四種:{ 1+1+1 , 1+2 , 2+1 , 3 }

題解:對於一個數字$n$，只有可能是$(n-1)+1,(n-2)+2...$六種方法。定義$dp[i]=數字i$的方法數，那麼$$dp[i]= \sum_{k=1}^{6}dp[i-k]$$

```cpp
int dp(int n){
    if(n<0) return 0;
    if(v[n]) return v[n];
    int sum=0;
    for(int i=n-6;i<n;i++){
        sum+=dp(i);
    }
    v[n]=sum;
    return v[n];
}
```
因為$dp(n)$可能被呼叫多次，所以用陣列v把算過的答案存起來
## 優化

### 矩陣優化
當轉移式可以用前幾項表達時，通常可以配成矩陣
例如剛剛那題可以配成:
$$
\begin{bmatrix} 1&1&1&1&1&1 \\ 1&0&0&0&0&0 \\ 0&1&0&0&0&0 \\ 0&0&1&0&0&0 \\ 0&0&0&1&0&0 \\ 0&0&0&0&1&0\end{bmatrix} \times \begin{bmatrix} dp(i-1) \\ dp(i-2) \\ dp(i-3) \\ dp(i-4) \\ dp(i-5) \\ dp(i-6) \end{bmatrix}=
\begin{bmatrix}dp(i) \\ dp(i-1) \\ dp(i-2) \\ dp(i-3) \\ dp(i-4) \\ dp(i-5)\end{bmatrix}$$

推成一般式:
$$
\begin{bmatrix} 1&1&1&1&1&1 \\ 1&0&0&0&0&0 \\ 0&1&0&0&0&0 \\ 0&0&1&0&0&0 \\ 0&0&0&1&0&0 \\ 0&0&0&0&1&0\end{bmatrix}^n \times \begin{bmatrix} 1 \\ 0 \\ 0 \\ 0 \\ 0 \\ 0 \end{bmatrix}=
\begin{bmatrix}dp(i) \\ dp(i-1) \\ dp(i-2) \\ dp(i-3) \\ dp(i-4) \\ dp(i-5)\end{bmatrix}$$

複雜度$O(k^3logn)$，其中$k$為矩陣行數 
具體見[遞迴](https://hackmd.io/KHyaIm1eT6uDS_XKFFptDw)

### 單調隊列優化
有時對於一個狀態，可能從前面任一個狀態轉移過來，例如：
    $$dp[i]=\displaystyle\min_{j<i}\{dp[j]+f(i,j)\}$$
    
這個時候，使 $dp[i]$ 最小的 $j$ 稱為決策點。如果所有決策點具有單調性(遞增或遞減)的話，例如:

| $i$ (狀態) | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| - | - | - |- | - |- | - |- | - |
| $j$ (決策) | 0 | 1 | 1 | 1 | 3 | 3 | 6 | 6 |

如上表，$[2,4]$ 就稱為決策1的區間。

那麼我們可以用一個單調隊列(monotonous queue)來儲存決策區間。要加入一個決策時，如果當前決策的左端點比尾部的左端點小時，可以刪除尾部；反之，如果當前決策的右端點比尾部的右端點大時，可以刪除當前決策。接著，只要找出第一個當前決策比尾部小的點即可，又因為決策有單調性，所以可以使用二分搜。
如此一來，本來是$O(n^2)$的DP被優化成$O(nlogn)$

### 例題
[CSES Maximum Subarray Sum II](https://cses.fi/problemset/task/1644)

(其實這只有單調隊列，不太能算DP)

定義 $dp[j] = \displaystyle\max_{a\leq j-i\leq b}\{pref_j-pref_i\}$ ，其中$pref_i$表示陣列前 $i$ 項的總和，特別定義 $pref_0=0$。

則答案就是$\max\left(dp[1],dp[2],\dots,dp[n]\right)$

對於每個 $j$ ， $pref_j$都是定值，因此我們只需要找到使得 $pref_i$ 最小的 $i$ 即可，而透過觀察可以發現一個重要性質：

**「對於每個 $j$ ，最佳的 $i$ 必定呈非嚴格遞增」**

因此可以套用單調隊列算法：

用deque維護「對於目前的 $j$ ，最好的 $pref_i$」，我們用for迴圈由小到大遞增的跑過所有的 $j$ ，每圈的開始，我們先把所有「已經過期的 $i~(j-i > b)$ 」 從deque前端刪掉，然後讓 $pref_{j-a}$ 不斷的跟deque尾端的值比較，如果 $pref_{j-a}$ 比deque尾端的值還小，那目前的deque尾端的值就是「沒用的」，因為這個值不但比 $pref_{j-a}$ 還要早過期，拿它的值算出來的 $pref_j-pref_i$ 還更小，我們必須把它pop掉，反之則比較結束，在deque尾端此時的值過期之前，拿 $pref_{j-a}$ 算出來的 $pref_j-pref_i$ 都不會是最好的，的把 $pref_{j-a}$ 加進deque的後端之後， $dp[j]$ 就會是 $pref_j-deque.front()$ 了。

本題因為決策覆蓋區間的特性$（i的區間一定從i+1開始）$，所以不需要二分搜，複雜度$O(n)$

有二分搜的版本見[玩具裝箱](https://www.luogu.com.cn/problem/P3195)(這就有DP了)

單調隊列性質：

可以發現，上述算法中的deque的值滿足「deque越前端的值，越早過期，但值越好」，知道這個性質可以讓實作單調隊列更加簡單，更不容易把大小於弄反。

### 斜率優化
一個常見的DP式:
$$dp[i]=\displaystyle\min_{j<i}\{a[j]*b[i]+c[j]\}$$
這時，如果我們定義直線$f_j(x)=a[j]*x+c[j]$，那麼:
$$dp[i]=\displaystyle\min_{j<i}\{f_j(b[i])\}$$也就是說，$dp[i]$現在是從$j$條直線中選出$x=b[i]$的最小值
![協優](https://hackmd.io/_uploads/HyiJKiJVT.png)
如圖，$b_i$的最小值在$L_2$上，代表狀態$i$的決策為$2$。另外，我們把$b_i$稱為$i$的查詢
![協優](https://hackmd.io/_uploads/Hk7zCskV6.png)
如圖，藍色斜線部分稱為$L_2$的查詢區間，也就是說，當$b_i$在查詢區間中的時候，最小值會發生在$L_2$

#### 斜率單調
因為斜率是遞減的，所以如果$f_a(x_i)>f_b(x_i),a>b$，那麼當$x>x_i$時，$f_a(x)$一定也會大於$f_b(x)$，也就是說，查詢區間是遞增的!要維護遞增的區間，又輪到單調隊列上場了。

#### 查詢單調
如果查詢是遞增的，那麼每次插入前，可以把查詢區間小於目前查詢的線從前面刪掉，因為不可能再查回去了。這樣一來，隊頭的直線就會是當前的決策。想法很像上面單調列隊的例題。
插入的時候，要把尾端不可能成為最小值的線刪掉。思考兩條線的情形:![協優](https://hackmd.io/_uploads/BykzV3yVT.png)
如圖，$L_2$的查詢區間會是$x$($L_1和L_2的交點$x$座標$)以後

這時插入$L_3$，會有兩種情況:![協優](https://hackmd.io/_uploads/HypTV21V6.png)
這種情況下，$L_1L_2L_3$都有查詢區間，直接插入沒有問題

![協優](https://hackmd.io/_uploads/BkAGBnkV6.png)
這個時候，會發現$L_2$的查詢區間整個不見，所以可以把$L_2$刪掉

要判斷能不能刪掉$L_2$，只需要判斷它的查詢區間有沒有被覆蓋，觀察圖會發現，一條線的查詢範圍就是跟前一條線的交點到後一條線的交點。設$x_{ab}$為$a和b$的交點$x$，那麼$L_2$的區間為$[x_{12},x_{23}]$。如果$x_{12}>x_{23}$的話，$L_2$顯然沒有查詢區間。

整個算法基本上就像上面單調列隊的例題，複雜度一樣$O(n)$

#### 查詢不單調
查詢不單調的差別就是因為查詢可能查回去比較小的值，所以隊頭的線不能亂刪，以後還有可能用到。這樣我們就不能直接拿隊頭的線當決策了。

設$i$為我們要找的線(最小值會發生的線)，當前查詢是$s$，因為查詢區間遞增，所以:$$f_1(s)...f_{i-2}(s)>f_{i-1}(s)>f_i(s)<f_{i+1}(s)<f_{i+2}(s)...$$
其實看圖也會發現。這時候我們要找的就是單峰函數的極值。![協優](https://hackmd.io/_uploads/rkKf0hkVp.png)
在中間切兩刀，如果$f(a+1)<f(a)$的話，極值在$a$右邊；反過來則在左邊。基本上就是二分搜。總共要搜$n$次，複雜度$O(nlogn)$

這種求極值的搜尋有一種特化的二分搜，叫黃金切割搜，有興趣可以查查看。

#### 斜率不單調
需要使用李超線段樹。有一點難，之後時間夠的話有可能會教。
<!-- 你確定你要教李超線段樹？ 我不確定-->

### 例題
[cses:Monster Game](https://cses.fi/problemset/task/2084/)
