---
# System prepended metadata

title: 【C++】競程筆記（多維 DP、LCS 最長共同子序列 習題練習）
tags: [資料結構, 程式設計, C++, DP, 競程, 演算法, 程式語言, CP]

---

# 【C++】競程筆記（多維 DP、LCS 最長共同子序列 習題練習）

[TOC]

練習題目參考自：NTUCPC Guide，此筆記僅為個人學習用途。

## 823 . RGB

Problem Source：https://oj.ntucpc.org/problems/823

先定義三種顏色的代號以及對分數的貢獻（權重）：

- 紅色（$j=0$）：權重為 $-v_i$（題目要求扣掉紅色數字總和）。
- 綠色（$j=1$）：權重為 $+v_i$（題目要求加上綠色數字總和）。
- 藍色（$j=2$）：權重為 $0$（藍色不影響分數）。

最後再來定義狀態：定義 $dp[i][j]$ 為考慮到第 $i$ 個數字，且將第 $i$ 個數字塗上顏色 $j$ 時，目前所能獲得的最大價值。

- $i : 1 \le i \le N$
- $j : 0 \le j \le 2$ （分別代表紅、綠、藍）

求 DP 轉移式：

題目限制「相鄰的數字必須標上不同的顏色」，要計算第 $i$ 個數字塗某個顏色時，第 $i-1$ 個數字必須是另外兩種顏色之一，最終解答是從中選取較大者，再加上當前顏色的權重。

對第 $i$ 個數字 ($i > 1$)：

- 若第 $i$ 個塗紅色（$j=0$）：前一個必須是綠色或藍色。$$dp[i][0] = \max(dp[i-1][1], dp[i-1][2]) - v_i$$
- 若第 $i$ 個塗綠色（$j=1$）：前一個必須是紅色或藍色。$$dp[i][1] = \max(dp[i-1][0], dp[i-1][2]) + v_i$$
- 若第 $i$ 個塗藍色（$j=2$）：前一個必須是紅色或綠色。$$dp[i][2] = \max(dp[i-1][0], dp[i-1][1])$$

初始狀態定義：

當考慮序列中第 1 個數字（$i=1$）時，沒有前一個數字的限制，直接根據顏色計算價值。

- $dp[1][0] = -v_1$
- $dp[1][1] = +v_1$
- $dp[1][2] = 0$

最終答案：在塗完最後一個數字 $N$ 後，最後一個數字可能是紅、綠、藍任一種，取三者中的最大值即為答案：$$Answer = \max(dp[N][0], dp[N][1], dp[N][2])$$

時間複雜度： $O(N)$

空間複雜度： $O(N)$

記得 DP 陣列要是 long long，題目測資有點大。

範例程式碼：

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

using namespace std;

const int MAXN = 1000005;

long long dp[MAXN][3];

int main(){
    ios::sync_with_stdio(false), cin.tie(nullptr);
    int N;
    cin >> N;
    int a[N + 5];
    for (int i = 1; i <= N; ++i) cin >> a[i];
    dp[1][0] = -a[1];
    dp[1][1] = a[1];
    dp[1][2] = 0;
    for (int i = 1; i <= N; ++i){
        dp[i][0] = max(dp[i - 1][1], dp[i - 1][2]) - a[i];
        dp[i][1] = max(dp[i - 1][0], dp[i - 1][2]) + a[i];
        dp[i][2] = max(dp[i - 1][0], dp[i - 1][1]);
    }
    cout << max({dp[N][0], dp[N][1], dp[N][2]}) << '\n';
}
```

## Grid 1

Problem Source：https://atcoder.jp/contests/dp/tasks/dp_h

1. 狀態定義：$dp[i][j]$ 為從起點 $(1, 1)$ 走到座標 $(i, j)$ 的路徑總數。

- $i$（row），範圍 $1 \sim H$。
- $j$（column），範圍 $1 \sim W$。

2. 轉移式定義：

題目要求只能「向右」或「向下」移動。

要到達 $(i, j)$，只可能從上方 $(i-1, j)$ 或左方 $(i, j-1)$ 走過來。

對於每格 $(i, j)$，判斷規則如下：

- $(i, j)$ 是障礙物（`#`）：無法到達該格，路徑數為 0。$$dp[i][j] = 0$$
- $(i, j)$ 是空地（`.`）：路徑數等於「從上方來的路徑數」加上「從左方來的路徑數」。$$dp[i][j] = dp[i-1][j] + dp[i][j-1]$$

3. 初始狀態定義： $dp[1][1] = 1$ ，因為一開始就在 $(1, 1)$，算 1 種方法（停在原地）。

需要注意的是，當跑雙層迴圈，i == 1 且 j == 1 的情況要被跳過，否則後續計算會錯誤，結果會是 0。

另外在讀取 m 的字元時，當 j = W，會讀到字串結尾 `\0`，透過 j - 1 可以避開。

或是可以在輸入的時候做 padding（填充），就可以直接寫 `m[i][j]`：

```cpp=
for (int i = 1; i <= H; ++i) {
    string temp;
    cin >> temp;
    m[i] = " " + temp; // 手動加一個空白在前面
}
```

範例程式碼：

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

using namespace std;

const int MOD = 1000000007;

int main(){
    ios::sync_with_stdio(false), cin.tie(nullptr);
    int H, W;
    cin >> H >> W;
    vector <string> m(H + 1);
    for (int i = 1; i <= H; ++i) cin >> m[i];
    vector <vector <long long>> dp(H + 1, vector<long long>(W + 1, 0));
    dp[1][1] = 1;
    for (int i = 1; i <= H; ++i){
        for (int j = 1; j <= W; ++j){
            if (i == 1 && j == 1) continue;
            if (m[i][j - 1] == '#'){
                dp[i][j] = 0;
            }
            else{
                dp[i][j] = (dp[i - 1][j] + dp[i][j - 1]) % MOD;
            }
        }
    }
    cout << dp[H][W] << '\n';
}
```

## P2386 放蘋果

Problem Source：https://www.luogu.com.cn/problem/P2386

1. 狀態定義： $dp[i][j]$ 是將 $i$ 顆蘋果放入 $j$ 個盤子的方法數。

2. 轉移式定義：
    - 至少一個盤子為空：既然盤子是相同的，空哪個都沒差。可先將一個盤子移走，不用它。將 $i$ 個蘋果放入 $j-1$ 個盤子。 $$dp[i][j - 1]$$
    - 所有盤子都有放蘋果（無空盤）：
        - 每個盤子至少要有一個蘋果。
        - 可先在每個盤子上各放 $1$ 個蘋果（消耗掉 $j$ 個蘋果）。
        - 剩下的蘋果數量為 $i - j$ 個。
        - 問題轉化為「將剩下的 $i-j$ 個蘋果，放入 $j$ 個盤子（可隨意放）」。$$dp[i-j][j], i \ge j$$

最終得到 $$dp[i][j] = dp[i][j-1] + dp[i-j][j]$$

當 $i < j$ 時，因為不可能每個盤子都放蘋果，第二項為 0，即 $dp[i][j] = dp[i][j-1]$

3. 初始狀態定義：
    - 沒有蘋果（$i=0$）：$$dp[0][j] = 1$$
    - 只有 1 個盤子（$j=1$）：$$dp[i][1] = 1$$

範例程式碼：

```cpp=
#include <iostream>

using namespace std;

int main(){
    ios::sync_with_stdio(false), cin.tie(nullptr);
    
    int t;
    cin >> t;
    
    while (t--){
        int m, n;
        cin >> m >> n;
        
        int dp[15][15] = {0};
        
        for (int j = 0; j <= n; ++j) dp[0][j] = 1;
        
        for (int i = 0; i <= m; ++i) dp[i][1] = 1;
        
        for (int i = 1; i <= m; ++i){
            for (int j = 1; j <= n; ++j){
                if (i < j){
                    dp[i][j] = dp[i][j - 1];
                }
                else{
                    dp[i][j] = dp[i][j - 1] + dp[i - j][j];
                }
            }
        }
        cout << dp[m][n] << '\n';
    }
}
```

## N 箱 M 球

Problem Source：https://tioj.ck.tp.edu.tw/problems/1291

1. 狀態定義：$dp[i][j]$ 為將 $i$ 個不同的球，放入 $j$ 個相同的箱子，且沒有空箱的方法數。
    - $i$ 代表當前處理到的球數（$1 \le i \le m$）
    - $j$ 代表當前使用的箱子數（$1 \le j \le n$）
2. 轉移式定義：
    - 考慮第 $i$ 顆球放入時的情況，此時已用 $j$ 個箱子，第 $i$ 顆球有兩種選擇：
        1. 自己獨佔一個新箱子：代表前 $i-1$ 顆球已佔用了 $j-1$ 個箱子，第 $i$ 顆球放入第 $j$ 個箱子。方法數為：$$dp[i-1][j-1]$$
        2. 放入已經有球的箱子中：代表前 $i-1$ 顆球已佔用了 $j$ 個箱子，第 $i$ 顆球可以放入這 $j$ 個箱子中的任一個。因為這 $j$ 個箱子裡面已有不同的球了，所以這 $j$ 個箱子被視為不同的箱子。方法數為：$$j \times dp[i-1][j]$$

最終得到：$$dp[i][j] = (dp[i-1][j-1] + j \times dp[i-1][j]) \pmod{1000000}$$

3. 初始狀態定義：
    - $dp[0][0] = 1$：0 個球放入 0 個箱子，視為一種方法（空集合）。

4. 最終答案：$$ans = \sum_{k=1}^{n} dp[m][k] \pmod{1000000}$$

因為題目這邊要求的是使用 1 個箱子到使用 $n$ 個箱子的所有情況總和。

範例程式碼：

```cpp=
#include <iostream>
#include <algorithm>

using namespace std;

const int MAXN = 205;

const int MOD = 1000000;

long long dp[MAXN][MAXN];

int main(){
    ios::sync_with_stdio(false), cin.tie(nullptr);
    dp[0][0] = 1;
    for (int i = 1; i < MAXN; ++i){
        for (int j = 1; j <= i; ++j){
            dp[i][j] = (dp[i - 1][j - 1] + j * dp[i - 1][j]) % MOD;
        }
    }
    int n, m;
    while (cin >> n >> m && (n != 0 || m != 0)){
        long long ans = 0;
        for (int k = 1; k <= n && k <= m; ++k) ans = (ans + dp[m][k]) % MOD;
        cout << ans << '\n';
    }
}
```

## 幸運表格

Problem Source：https://oj.ntucpc.org/problems/790

1. 狀態定義：$dp[i][j]$ 為從座標 $(i, j)$ 出發，依照規則走到離開表格為止，所能獲得的最大幸運指數總和。
2. 轉移式定義：為了讓總和最大，選「右」、「下」兩方向中值較大的那一個。
    - 向右走：
        - 如果右邊還有格子（$j+1 < M$），得到$$dp[i][j+1]$$
        - 如果右邊是邊界（離開表格），得到$$0$$
    - 向下走：
        - 如果下面還有格子（$i+1 < N$），得到 $$dp[i+1][j]$$
        - 如果下面就是邊界（離開表格），得到 $$0$$

最終得到：$$dp[i][j] = A[i][j] + \max(\text{向右獲利}, \text{向下獲利})$$

當中 $A[i][j]$ 是幸運表格的數字。

3. 初始狀態定義：最右下角的格子 $(N-1, M-1)$ 因為右邊和下面都是界外，所以 $dp[N-1][M-1] = A[N-1][M-1] + \max(0, 0)$。

在程式設計上，由於計算 $(i, j)$ 需要用到 $(i+1, j)$ 和 $(i, j+1)$ 的值，須由右下角往左上角計算。

範例程式碼：

```cpp=
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

const int INF = -1e9;

int main(){
    ios::sync_with_stdio(false), cin.tie(nullptr);
  
    int n, m;
    cin >> n >> m;
    
    vector <vector<int>> a(n, vector<int>(m, 0));
    vector <vector<int>> dp(n, vector<int>(m, 0));
    
    for (int i = 0; i < n; ++i)
      for (int j = 0; j < m; ++j)
        cin >> a[i][j];
    
    int ans = INF;
    
    for (int i = n - 1; i >= 0; --i){
      for (int j = m - 1; j >= 0; --j){
        int right_gain = 0;
        if (j + 1 < m){
          right_gain = dp[i][j + 1];
        }
        
        int down_gain = 0;
        if (i + 1 < n){
          down_gain = dp[i + 1][j];
        }
        
        dp[i][j] = a[i][j] + max(right_gain, down_gain);
        
        if (dp[i][j] > ans){
          ans = dp[i][j];
        }
      }
    }
    
    cout << ans << '\n';
}
```

## APCS 2020/10 P3 勇者修煉

Problem Source：https://oj.ntucpc.org/problems/789

1. 狀態定義：

定義兩個輔助狀態陣列，及一個最終狀態陣列（假設目前處理到第 $i$ 列）：

- $L[j]$ ：表示到達位置 $(i, j)$，且最後一步是由上方 $(i-1, j)$ 或是左方 $(i, j-1)$ 走過來的最大經驗值。（向右掃描）
- $R[j]$ ：表示到達位置 $(i, j)$，且最後一步是由上方 $(i-1, j)$ 或是右方 $(i, j+1)$ 走過來的最大經驗值。（向左掃描）
- $dp[i][j]$：表示從起點到達 $(i, j)$ 的最終最大經驗值。

在同一列中，一條路徑不可能同時「先向左再向右」或「先向右再向左」，這樣會經過同一格兩次，所以到達 $(i, j)$ 的最佳路徑，必定是 $L[j]$ 和 $R[j]$ 兩者取其大。

2. 轉移式定義：

假設已算完第 $i-1$ 列的結果，記為 $prev\_dp$，現在要計算第 $i$ 列的值，該格的經驗值為 $cost[i][j]$。

對於第 $i$ 列的每一個位置 $j$：

- 向右掃描 $L[j]$ ：考慮從「上一列直接下來」比較好，還是從「這一列的左邊走過來」比較好。$$L[j] = cost[i][j] + \max(prev\_dp[j], L[j-1])$$
    - 邊界條件：若是第一欄 $j=0$，則只能從上面下來，不能從左邊來。
- 向左掃描 $R[j]$ ：考慮從「上一列直接下來」比較好，還是從「這一列的右邊走過來」比較好。$$R[j] = cost[i][j] + \max(prev\_dp[j], R[j+1])$$
    - 邊界條件：若是最後一欄 $j=m-1$，則只能從上面下來，不能從右邊來。
- 最後 $dp[i][j]$ 取兩者為最大值：$$dp[i][j] = \max(L[j], R[j])$$

3. 初始狀態定義：所有上面要用的陣列大小為 m，都設 0。

這邊採用的是滾動 DP 的方式解題，能節省空間。

範例程式碼：

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

using namespace std;

using ll = long long;

int main() {
    ios_base::sync_with_stdio(false), cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    vector<vector<int>> grid(n, vector<int>(m));
    for (int i = 0; i < n; ++i)
        for (int j = 0; j < m; ++j)
            cin >> grid[i][j];

    vector<ll> prev_dp(m, 0);
    
    vector<ll> L(m), R(m);

    vector<ll> dp(m);

    for (int i = 0; i < n; ++i) {
        // 向右掃描
        // L[j] = grid[i][j] + max(從上面來, 從左邊來)
        for (int j = 0; j < m; ++j) {
            ll from_up = prev_dp[j];
            ll from_left = (j == 0) ? -1e18 : L[j - 1];
            
            if (j == 0) L[j] = grid[i][j] + from_up;
            else L[j] = grid[i][j] + max(from_up, from_left);
        }
        // 向左掃描
        // R[j] = grid[i][j] + max(從上面來, 從右邊來)
        for (int j = m - 1; j >= 0; --j) {
            ll from_up = prev_dp[j];
            ll from_right = (j == m - 1) ? -1e18 : R[j + 1];

            if (j == m - 1) R[j] = grid[i][j] + from_up;
            else R[j] = grid[i][j] + max(from_up, from_right);
        }

        for (int j = 0; j < m; ++j) dp[j] = max(L[j], R[j]);
        
        prev_dp = dp;
    }

    ll ans = -2e18;
    for (ll val : prev_dp) 
      if (val > ans) ans = val;

    cout << ans << '\n';

    return 0;
}
```

## 連鎖店 (Chains)

Problem Source：https://tioj.ck.tp.edu.tw/problems/1938

題目中所謂每家都要開在前一家的東北方，意思就是$N$ 個點的 $X$ 與 $Y$ 座標都必須是嚴格遞增。

1. 狀態定義：$dp[k][y]$ 為在考慮當前的 $X$ 座標時，已選取了 $k$ 家連鎖店，且第 $k$ 家店位於 $Y$ 座標為 $y$ 的位置時，所有連鎖店累積的最大顧客數總和。
2. 轉移式定義：

當遍歷到第 $x$ 列時，嘗試在此列放置第 $k$ 家店。

假設要在 $(x, y)$ 放置第 $k$ 家店，根據題目要求，第 $k-1$ 家店必須位於 $(x', y')$，其中 $x' < x$ 且 $y' < y$。

因為是由 $x = 0$ 到 $M-1$ 依序計算，當處理到 $x$ 時，$dp$ 表中儲存的正是 $x' < x$ 的最佳解，因此只要注意 $y$ 的限制（$y' < y$）即可。

可得轉移式：$$dp[k][y] = \max(dp[k][y], \quad \text{profit}(k, x, y) + \max_{0 \le y' < y} \{ dp[k-1][y'] \})$$

- $\text{profit}(k, x, y) = (a \cdot (k-1) + b \cdot x + c \cdot y) \mod d$
- $\max_{0 \le y' < y} \{ dp[k-1][y'] \}$ 代表在前幾列中，選了 $k-1$ 家店且 $Y$ 座標小於目前 $y$ 的最大值。

為了避免用到同一列 $x$ 更新過的資料（$x$ 不能相同），在實作時，內層迴圈計算 $k$ 時應由大到小（$N$ 到 $1$），或者使用暫存變數，確保所讀取的 $dp[k-1]$ 是來自之前的 $x$ 列。

3. 初始狀態定義：所有 $dp[k][y]$ 初始化為一個極小值。

範例程式碼：

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

using namespace std;

using ll = long long;

int main(){
  ios::sync_with_stdio(false), cin.tie(nullptr);
  
  int M, N;
  ll a, b, c, d;
  cin >> M >> N >> a >> b >> c >> d;
  
  vector <vector<ll>> dp(N + 1, vector<ll>(M + 1, -1));
  
  for (int x = 0; x < M; ++x){
    for (int k = N; k >= 1; --k){
      ll max_prev = -1;
      
      for (int y = 0; y < M; ++y){
        ll profit = (a * (k - 1) + b * x + c * y) % d;
        
        // 如果是第 1 家店
        if (k == 1) dp[k][y] = max(dp[k][y], profit);
        // 如果是第 2 家以上的店，需要看前一家店 (k-1) 的最大值
        else if (max_prev != -1) dp[k][y] = max(dp[k][y], max_prev + profit);
        
        if (k > 1) max_prev = max(max_prev, dp[k - 1][y]);
      }
    }
  }
  
  ll ans = 0;
  for (int y = 0; y < M; ++y)
      if (dp[N][y] != -1)
          ans = max(ans, dp[N][y]);
  cout << ans << '\n';
}
```

## Edit Distance

Problem Source：https://cses.fi/problemset/task/1639

1. 狀態定義：

令輸入的兩個字串分別為 $S$（長度 $n$）和 $T$（長度 $m$）。

$dp[i][j]$ 為將字串 $S$ 的前 $i$ 個字元轉換成字串 $T$ 的前 $j$ 個字元，所需的最少操作次數。

- $S[1 \dots i]$ 表示 $S$ 的前 $i$ 個字元（index 1 based）。
- $T[1 \dots j]$ 表示 $T$ 的前 $j$ 個字元。

2. 轉移式定義：

對於 $dp[i][j]$，考慮 $S$ 的第 $i$ 個字元（$S[i]$）和 $T$ 的第 $j$ 個字元（$T[j]$）的關係。

可從三個方向轉移，分別對應三種操作：

1. 插入：假設 $S[1 \dots i]$ 已轉成 $T[1 \dots j-1]$，只要在 $S$ 的尾端插入一個字元 $T[j]$ 即可變成 $T[1 \dots j]$。$$dp[i][j-1] + 1$$
2. 刪除：假設 $S[1 \dots i-1]$ 已轉成 $T[1 \dots j]$，只要把 $S$ 原本多出來的第 $i$ 個字元刪除。$$dp[i-1][j] + 1$$
3. 替換：
    - 如果 $S[i] == T[j]$ ，則不需要任何操作，直接繼承 $dp[i-1][j-1]$ 的結果。$$dp[i-1][j-1]$$
    - 如果 $S[i] \neq T[j]$ ，把 $S$ 的第 $i$ 個字元替換成 $T$ 的第 $j$ 個字元。$$dp[i-1][j-1] + 1$$

最後得到轉移式：$$dp[i][j] = \min \begin{cases}
dp[i][j-1] + 1 & \text{(插入)} \\
dp[i-1][j] + 1 & \text{(刪除)} \\
dp[i-1][j-1] + cost & \text{(替換)}
\end{cases}$$

其中 $cost$ 的定義為若 $S[i] == T[j]$ 則 $cost = 0$，否則 $cost = 1$。

3. 初始狀態定義
    - $dp[i][0] = i$：將 $S$ 的前 $i$ 個字元轉換成空字串，需要刪除 $i$ 次。
    - $dp[0][j] = j$：將空字串轉換成 $T$ 的前 $j$ 個字元，需要插入 $j$ 次。
    
範例程式碼：

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

using namespace std;

int main(){
  ios::sync_with_stdio(false), cin.tie(nullptr);
  
  string s, t;
  cin >> s >> t;
  
  int n = s.length(), m = t.length();
  
  vector <vector <int>> dp(n + 1, vector<int>(m + 1));
  
  for (int i = 0; i <= n; ++i) dp[i][0] = i;
  for (int j = 0; j <= m; ++j) dp[0][j] = j;
  
  for (int i = 1; i <= n; ++i){
    for (int j = 1; j <= m; ++j){
      int cost = (s[i - 1] == t[j - 1]) ? 0 : 1;
      dp[i][j] = min({
        dp[i - 1][j] + 1,
        dp[i][j - 1] + 1,
        dp[i - 1][j - 1] + cost
      });
    }
  }
  
  cout << dp[n][m] << '\n';
}
```