---
# System prepended metadata

title: 動態規劃(DP)

---

# 動態規劃(DP)

---

動態規劃（簡稱 DP）是一種運用遞迴解決問題的技巧。
它的應用廣泛，不像既有的演算法擁有固定流程，因此題目靈活多變，題型也千奇百怪

DP的核心概念就是避免對相同的問題再重複計算，而通常我們通常會使用陣列去存取已知的答案

## 實作

例題：
> 有一個 $N$ 階的樓梯，你站在第 $0$ 階上，每一步只能爬 $1$ 階或 $2$ 階，
且只能向上爬，不能往下走，求有多少種不同方法可以抵達第 $N$ 階？

國小數學告訴我們，不知道答案是多少，就把答案假設為未知數！

因此我們把到第$i$階的方法數設為$dp(i)$

考慮站在第 $i$ 階上，會有兩種可能：
->爬$1$階
->爬$2$階
同理到達第 $i$ 階的前一步，就只能是從第 $i - 1$ 階或第 $i - 2$ 階走上來的，
所以就把兩種的方法數加總就會得到第$i$階的方法數！

因此我們可以得到：
$$dp(i)=dp(i-1)+dp(i-2)$$
這其實就是費式數列

而如果把計算的過程畫成樹狀圖
就會發現，為了知道$dp(i)$，我們需要先知道$dp(i-1)$和$dp(i-2)$，
因此就相當於在樹上開分支。
而幾乎每個節點都會有兩個分支，因此會有約$2^n$個節點，
總時間複雜度就是$O(2^n)$！
![](https://i.imgur.com/P9Gk3pT.png)

這個複雜度當$n$很小時可能沒什麼影響，但$n$只要太大(大概到$30$就會爆了)保證吃TLE！


```cpp=
int f(int n){
    if(n == 1 || n == 0){
        return 1;
    }
    return f(n-1) + f(n-2);
}
```
---
### Top-down

透過觀察可以發現，我們重複計算了不少相同的值。

例如上圖中，$dp(2)$的值被計算了三次，

有沒有辦法把它壓到只有一次？

<br> <br/>
<br> <br/>
<br> <br/>
<br> <br/>

可以！

每次呼叫遞迴時，先檢查是否已計算過；如果有算過，直接回傳之前算好的結果。如果沒算過，就先計算它的值，然後在回傳之前把值填入表中，並標記這問題已算過。
(會優先往左子結點)
![](https://i.imgur.com/KYnx7Ce.png)

但須特別注意，如果**遞迴深度過深**，很有可能會因為Stack Overflow而吃到RE
```cpp=
//初始化
int dp[105] = {0};
dp[1] = 1;
dp[2] = 2;
int mod = 1e9+7;
int f(int n){
    if(dp[n] != 0){
        //查表並回傳結果
        return dp[n];
    }
    //標記並建表
    dp[n]=( f(n-1) + f(n-2) ) % mod;
    return dp[n];
}
```
時間複雜度:$O(N)$

---
### Bottom-up

如果每次都要遞迴到最底層，再依序回傳它的值，不如一開始就從底層開始算！
<!-- 通常使用迴圈實作，在求出關係式後，將子問題根據前面的計算結果進行結算 -->

<br><br/>

![](https://i.imgur.com/fRU2tFX.gif)

```cpp=
//初始化
int dp[105] = {0};
int mod = 1e9+7;
dp[0] = 1;
dp[1] = 1;
for(int i = 2; i <= 100; i++){
    dp[i] = (dp[i-1] + dp[i-2]) % mod;
}
```

### 狀態轉移

透過「狀態」來定義要計算的問題，用來尋找和子問題之間的關係

那我們要如何定義狀態呢?

我們以剛剛的例題做為例子

通常會先將題目中所提及的變數套進DP式中，並且去定義該陣列所代表的涵義
->：$dp[i]$：代表走到第$i$階的方法數

接下來我們會通常會先考慮最後一格的情況，把所有可能的情況考慮過後，再把答案合併出來
-> $dp[i]=dp[i-1]+dp[i-2]$：代表第$i$階的前一格有可能是$i-1$或$i-2$階

最後記得要去定義初始狀態
->$dp[0]=1,dp[1]=1$ ： 走0步和1步都是一種可能
(該兩格狀態都無法用上述關係式找出答案)

### 增維

有時候會發現不管怎麼定義狀態都沒辦法描述完整，或著把不同情況視為相同狀態
很有可能是目前的維度不夠，導致無法表達出所有情況

而「增維」就是追加描述來區別這些不該被視為相同的狀況

----

而到目前為止講的概念單獨看時會太過抽象，也難以理解其用意
以下會搭配題目的實際應用，來解說每個概念

---

## 例題


換零錢問題$-1$
> 有大小為$N$的$A$序列，其中的每個數字代表的是硬幣面額，問有幾種方法可湊出S元
(前後順序不同視為不一樣，如1,2，2,1)

這題跟上一題樓梯很類似，只是現在有n種方式可以去挑選
```cpp=
#include <bits/stdc++.h>
using namespace std;
int n,s,a[105];
vector<int> dp(105, -1);

int f(int m){
    int ret = 0;
    if(m<0) return 0;
    for(int i=0;i<n;i++){
        if(m-a[i]>=0){//注意金錢不得為負
            if(dp[m-a[i]]!=-1){//如果表格被標記就直接使用
                ret+=dp[m-a[i]];
            }
            else{
                dp[m-a[i]]=f(m-a[i]);//標記並建表
                ret+=dp[m-a[i]];
            }
        }
    }
    return ret;
}
int main(){
    dp[0]=1;//初始化
    cin>>n>>s;
    for(int i=0;i<n;i++){
        cin>>a[i];
    }
    cout<<f(s)<<"\n";
return 0;
}
```

換零錢問題$-2$
> 有大小為$N$的$A$序列，每個數字代表的是硬幣面額，並且不重複挑選，問有幾種方法可湊出$S$元
(前後順序不同視為一樣)

這時候你會發現原來的一維DP不太好去定義
這時候就可以用到剛剛提到的增維
多增加一維來表示最後可挑選硬幣的狀態
定義$dp[i][j]$為前$i$個硬幣，總和為$j$元的方法數
而每次我們都去考慮他上一個可挑選硬幣($i-1$)枚的數字


再把以上答案加總即是子問題答案
轉移式:$dp[i][j+a[i]]+=dp[i-1][j]$

時間複雜度:$O(NS)$

```cpp=
#include <bits/stdc++.h>
using namespace std;
int main(){
    int n,s;
    cin>>n>>s;
    vector <int> a(105);
    vector <vector <int> >  dp(1005,vector <int>(1005));
    for(int i=0;i<n;i++){
        cin>>a[i];
    }
    for(int i=0;i<n;i++){//將0元初始化為1種可能
        dp[i][0]=1;
    }
    dp[0][a[0]]=1;
    for(int i=1;i<n;i++){
        for(int j=0;j<=s;j++){
            if(j+a[i]<=s){//需在範圍內轉移
                dp[i][j+a[i]]+=dp[i-1][j];
            }
        }
    }
    cout<<dp[n-1][s]<<"\n";
    return 0;
}
```

有沒有辦法用更小的空間呢

可以發現，每次轉移$i$時只跟$i-1$有關
所以其實根本不需要到$NS$的空間
每次都只需將原本存$i$的位置去往右平移該陣列

但如果單存往右轉移會發生捨麼事
例如說:假設硬幣為$2$元，因此會先在$dp[0]$的時候更新$dp[2]$，而到$dp[2]$的時候又會更新$dp[4]$，但$dp[2]$已經有考慮過$2$元了，不應再考慮一次

所以我們可以試著倒著去遍歷(第2個for迴圈)
如此一來就不會因為前面的狀態轉移去影響後面的轉移


我們把上述做法稱為空間優化


```cpp=
#include <bits/stdc++.h>
using namespace std;
int main(){
    int n,s;
    cin>>n>>s;
    int a[105],dp[1005]={0};
    for(int i=0;i<n;i++){
        cin>>a[i];
    }
    dp[0]=1;
    for(int j=0;j<n;j++){
        for(int i=s;i>=0;i--){//由大到小轉移
            if(i+a[j]<=s){//需在範圍內轉移
                dp[i+a[j]]+=dp[i];
            }
        }
    }
    cout<<dp[s]<<"\n";
return 0;
}
```

---

<!-- 換零錢問題$-3$

今天有大小為N的A序列，每個數字代表的是硬幣面額，最少需要幾枚硬幣可湊出S元
之前我們有在greedy做過一樣的題目，但只有在特定的面額才會正確
如s=8,a={1,4,5}
如果你先貪心的拿了最高硬幣5元，這樣總需4枚硬幣，但如果拿2枚4元硬幣，反而總硬幣數還比較少。-->
## 區間dp

> 給你一串長度為$n$的數列，你可以對他做以下操作：
選擇連續的三個數，刪除中間的數
>
> 只是每次操作都會造成負擔，負擔的量為三數的乘積
如何在最少的負擔下使得數列剩下兩數

作法：
```cpp=
#include<bits/stdc++.h>
using namespace std;

int main(){
    int n;
    int a[105] , dp[105][105];
    cin >> n;
    for(int i = 1 ; i <= n ; i++)
        cin >> a[i];
    for(int i = 1 ; i <= n ; ++i)
        for(int j = 1 ; j <= n ;++j)
            dp[i][j] = 10000;
    for(int i = 1 ; i < n ; i++)
         dp[i][i+1] = 0;
    int ans = INT_MAX;
    for(int i = 1 ; i <= n ; i++)
        for(int j = i - 1 ; j >= 1 ; j--)
            for(int k = j + 1 ; k <= i - 1 ; k++){
                dp[j][i] = min(dp[j][k] + dp[k][i] + a[j]*a[k]*a[i] , dp[j][i]);
            }
    cout << dp[1][n] << "\n";
    return 0;
}
```
## 經典問題 

1.背包問題

(1)0/1背包問題
> 給你$n$種物品，每種物品都會有它的價值$v_i$和他的重量$w_i$，你有一個背包，總共可以放入總重為$W$的物品，每一種物品只可以放1個，請求出能夠放入最高價值是多少。($1\leq W\leq1000$ , $1\leq n\leq100$)

這個問題如果用greedy先放價值最高的物品，很容易就可以找出錯誤的例子，因此要使用dp來從所有情況中找出最好的。

我們以$dp[i][j]$表示只看前$i$個物品，且剛好取重量為$j$的最大價值。
則$dp[i][j]=\left\{
\begin{aligned}
dp[i-1][j]\quad \quad \quad \quad \small{(不取第i個物品的情況)}\\
dp[i-1][j-w[i]] + v[i] \quad\small{(取了第i個物品的情況)}
\end{aligned}
\right.$

```cpp=
#include<bits/stdc++.h>
using namespace std;

int main(){
    int v[105], w[105], dp[105][1005] = {0};
    int n, W;
    cin >> n >> W;
    for(int i = 1; i <= n; i++)
        cin >> v[i] >> w[i];
    int ans = 0;
    for(int i = 1; i <= n; i++){
        for(int j = 0; j <= W; j++){
            dp[i][j] = dp[i-1][j]; // 不取的情況
        }
        for(int j = w[i]; j <= W; j++){
            dp[i][j] = max(dp[i-1][j-w[i]] + v[i], dp[i-1][j]); // 取的情況
            ans = max(ans, dp[i][j]);
        }
    }
    cout << ans << "\n";
    return 0;
}
```

<a></a>
:::spoiler 小測驗
以下是一個爛掉的code，請問它的bug在哪裡？

```cpp=
#include<bits/stdc++.h>
using namespace std;

int main(){
    int v[105],w[105], dp[105][1005];
    int n,W;
    cin >> n >> W;
    for(int i = 1 ; i <= n ; i++)
        cin >> v[i] >> w[i];
    for(int i = 1 ; i <= W ; i++)
        dp[0][i] = -10000;
    dp[0][0] = 0;
    int ans = 0;
    for(int i = 1 ; i <= n; i++){
        for(int j = w[i] ; j <= W ; ++j){
            dp[i][j] = max(dp[i-1][j-w[i]] + v[i], dp[i-1][j]));//轉移狀態
            ans = max(ans , dp[i][j]);
        }
    }
    cout << ans << "\n";
    return 0;
}
```
:::
<a></a>
::: spoiler 提示1
以下的測資可以讓這個code WA
```
3 3
1 1
10 2
100 2
```
:::
::: spoiler 解答
觀察程式碼的轉移式，可以發現它的轉移式只有
$dp[i][j]=dp[i-1][j-w[i]] + v[i]$
<a></a>
也就是說這個code無法處理跳過某一項不取的情況！
:::
<a></a>
當然我們也可以空間壓縮，而且寫法更簡潔
```cpp=
#include<bits/stdc++.h>
using namespace std;

int main(){
    int v[105], w[105], dp[1005] = {0};
    int n, W;
    cin >> n >> W;
    for(int i = 1; i <= n; i++)
        cin >> v[i] >> w[i];
    int ans = 0;
    for(int i = 1; i <= n; i++){
        for(int j = W; j >= w[i]; j--){ // 這邊倒過來跑很重要
            dp[j] = max(dp[j - w[i]] + v[i], dp[j]);
            ans = max(ans, dp[j]);
        }
    }
    cout << ans << "\n";
    return 0;
}
```

(2)無限背包問題

> 給你$n$種物品，每種物品都會有它的價值$val_i$和他的重量$w_i$，你有一個背包，總共可以放入總重為$W$的物品，每一種物品可以放很多個，請求出能夠放入最高價值是多少。($1\leq W\leq 1000$ , $1\leq n\leq 1000$)

作法和0/1背包差不多

```cpp=
#include<bits/stdc++.h>
using namespace std;

int main(){
    int v[105], w[105], dp[1005] = {0};
    int n, W;
    cin >> n >> W;
    for(int i = 1; i <= n; i++)
        cin >> v[i] >> w[i];
    int ans = 0;
    for(int i = 1; i <= n; i++){
        for(int j = w[i]; j >= W; j--){ // 這邊不用倒過來跑
            dp[j] = max(dp[j - w[i]] + v[i], dp[j]);
            ans = max(ans , dp[j]);
        }
    }
    cout << ans << "\n";
    return 0;
}

```
