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

練習題目參考自: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,題目測資有點大。

範例程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#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$。
  1. 轉移式定義:

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

要到達 $(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]$$
  1. 初始狀態定義: $dp[1][1] = 1$ ,因為一開始就在 $(1, 1)$,算 1 種方法(停在原地)。

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

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

或是可以在輸入的時候做 padding(填充),就可以直接寫 m[i][j]

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

範例程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#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]$

  1. 初始狀態定義:
    • 沒有蘋果($i=0$):$$dp[0][j] = 1$$
    • 只有 1 個盤子($j=1$):$$dp[i][1] = 1$$

範例程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#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}$$

  1. 初始狀態定義:

    • $dp[0][0] = 1$:0 個球放入 0 個箱子,視為一種方法(空集合)。
  2. 最終答案:$$ans = \sum_{k=1}^{n} dp[m][k] \pmod{1000000}$$

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

範例程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#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]$ 是幸運表格的數字。

  1. 初始狀態定義:最右下角的格子 $(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)$ 的值,須由右下角往左上角計算。

範例程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#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]$ 兩者取其大。

  1. 轉移式定義:

假設已算完第 $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])$$
  1. 初始狀態定義:所有上面要用的陣列大小為 m,都設 0。

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

範例程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#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$ 列。

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

範例程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#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$ 個字元。
  1. 轉移式定義:

對於 $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$。

  1. 初始狀態定義
    • $dp[i][0] = i$:將 $S$ 的前 $i$ 個字元轉換成空字串,需要刪除 $i$ 次。
    • $dp[0][j] = j$:將空字串轉換成 $T$ 的前 $j$ 個字元,需要插入 $j$ 次。

範例程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#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';
}