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

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

823 . RGB

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

先定義三種顏色的代號以及對分數的貢獻(權重):

  • 紅色(j=0j=0):權重為 vi-v_i(題目要求扣掉紅色數字總和)。
  • 綠色(j=1j=1):權重為 +vi+v_i(題目要求加上綠色數字總和)。
  • 藍色(j=2j=2):權重為 00(藍色不影響分數)。

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

  • i:1iNi : 1 \le i \le N
  • j:0j2j : 0 \le j \le 2 (分別代表紅、綠、藍)

求 DP 轉移式:

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

對第 ii 個數字 (i>1i > 1):

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

初始狀態定義:

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

  • dp[1][0]=v1dp[1][0] = -v_1
  • dp[1][1]=+v1dp[1][1] = +v_1
  • dp[1][2]=0dp[1][2] = 0

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

時間複雜度: O(N)O(N)

空間複雜度: 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]dp[i][j] 為從起點 (1,1)(1, 1) 走到座標 (i,j)(i, j) 的路徑總數。
  • ii(row),範圍 1H1 \sim H
  • jj(column),範圍 1W1 \sim W
  1. 轉移式定義:

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

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

對於每格 (i,j)(i, j),判斷規則如下:

  • (i,j)(i, j) 是障礙物(#):無法到達該格,路徑數為 0。$$dp[i][j] = 0$$
  • (i,j)(i, j) 是空地(.):路徑數等於「從上方來的路徑數」加上「從左方來的路徑數」。$$dp[i][j] = dp[i-1][j] + dp[i][j-1]$$
  1. 初始狀態定義: dp[1][1]=1dp[1][1] = 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]dp[i][j] 是將 ii 顆蘋果放入 jj 個盤子的方法數。

  2. 轉移式定義:

    • 至少一個盤子為空:既然盤子是相同的,空哪個都沒差。可先將一個盤子移走,不用它。將 ii 個蘋果放入 j1j-1 個盤子。 $$dp[i][j - 1]$$
    • 所有盤子都有放蘋果(無空盤):
      • 每個盤子至少要有一個蘋果。
      • 可先在每個盤子上各放 11 個蘋果(消耗掉 jj 個蘋果)。
      • 剩下的蘋果數量為 iji - j 個。
      • 問題轉化為「將剩下的 iji-j 個蘋果,放入 jj 個盤子(可隨意放)」。$$dp[i-j][j], i \ge j$$

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

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

  1. 初始狀態定義:
    • 沒有蘋果(i=0i=0):$$dp[0][j] = 1$$
    • 只有 1 個盤子(j=1j=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]dp[i][j] 為將 ii 個不同的球,放入 jj 個相同的箱子,且沒有空箱的方法數。
    • ii 代表當前處理到的球數(1im1 \le i \le m
    • jj 代表當前使用的箱子數(1jn1 \le j \le n
  2. 轉移式定義:
    • 考慮第 ii 顆球放入時的情況,此時已用 jj 個箱子,第 ii 顆球有兩種選擇:
      1. 自己獨佔一個新箱子:代表前 i1i-1 顆球已佔用了 j1j-1 個箱子,第 ii 顆球放入第 jj 個箱子。方法數為:$$dp[i-1][j-1]$$
      2. 放入已經有球的箱子中:代表前 i1i-1 顆球已佔用了 jj 個箱子,第 ii 顆球可以放入這 jj 個箱子中的任一個。因為這 jj 個箱子裡面已有不同的球了,所以這 jj 個箱子被視為不同的箱子。方法數為:$$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]=1dp[0][0] = 1:0 個球放入 0 個箱子,視為一種方法(空集合)。
  2. 最終答案:$$ans = \sum_{k=1}^{n} dp[m][k] \pmod{1000000}$$

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

範例程式碼:

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

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

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

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

在程式設計上,由於計算 (i,j)(i, j) 需要用到 (i+1,j)(i+1, j)(i,j+1)(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. 狀態定義:

定義兩個輔助狀態陣列,及一個最終狀態陣列(假設目前處理到第 ii 列):

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

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

  1. 轉移式定義:

假設已算完第 i1i-1 列的結果,記為 prev_dpprev\_dp,現在要計算第 ii 列的值,該格的經驗值為 cost[i][j]cost[i][j]

對於第 ii 列的每一個位置 jj

  • 向右掃描 L[j]L[j] :考慮從「上一列直接下來」比較好,還是從「這一列的左邊走過來」比較好。$$L[j] = cost[i][j] + \max(prev_dp[j], L[j-1])$$
    • 邊界條件:若是第一欄 j=0j=0,則只能從上面下來,不能從左邊來。
  • 向左掃描 R[j]R[j] :考慮從「上一列直接下來」比較好,還是從「這一列的右邊走過來」比較好。$$R[j] = cost[i][j] + \max(prev_dp[j], R[j+1])$$
    • 邊界條件:若是最後一欄 j=m1j=m-1,則只能從上面下來,不能從右邊來。
  • 最後 dp[i][j]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

題目中所謂每家都要開在前一家的東北方,意思就是NN 個點的 XXYY 座標都必須是嚴格遞增。

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

當遍歷到第 xx 列時,嘗試在此列放置第 kk 家店。

假設要在 (x,y)(x, y) 放置第 kk 家店,根據題目要求,第 k1k-1 家店必須位於 (x,y)(x', y'),其中 x<xx' < xy<yy' < y

因為是由 x=0x = 0M1M-1 依序計算,當處理到 xx 時,dpdp 表中儲存的正是 x<xx' < x 的最佳解,因此只要注意 yy 的限制(y<yy' < y)即可。

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

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

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

  1. 初始狀態定義:所有 dp[k][y]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. 狀態定義:

令輸入的兩個字串分別為 SS(長度 nn)和 TT(長度 mm)。

dp[i][j]dp[i][j] 為將字串 SS 的前 ii 個字元轉換成字串 TT 的前 jj 個字元,所需的最少操作次數。

  • S[1i]S[1 \dots i] 表示 SS 的前 ii 個字元(index 1 based)。
  • T[1j]T[1 \dots j] 表示 TT 的前 jj 個字元。
  1. 轉移式定義:

對於 dp[i][j]dp[i][j],考慮 SS 的第 ii 個字元(S[i]S[i])和 TT 的第 jj 個字元(T[j]T[j])的關係。

可從三個方向轉移,分別對應三種操作:

  1. 插入:假設 S[1i]S[1 \dots i] 已轉成 T[1j1]T[1 \dots j-1],只要在 SS 的尾端插入一個字元 T[j]T[j] 即可變成 T[1j]T[1 \dots j]。$$dp[i][j-1] + 1$$
  2. 刪除:假設 S[1i1]S[1 \dots i-1] 已轉成 T[1j]T[1 \dots j],只要把 SS 原本多出來的第 ii 個字元刪除。$$dp[i-1][j] + 1$$
  3. 替換:
    • 如果 S[i]==T[j]S[i] == T[j] ,則不需要任何操作,直接繼承 dp[i1][j1]dp[i-1][j-1] 的結果。$$dp[i-1][j-1]$$
    • 如果 S[i]T[j]S[i] \neq T[j] ,把 SS 的第 ii 個字元替換成 TT 的第 jj 個字元。$$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}$$

其中 costcost 的定義為若 S[i]==T[j]S[i] == T[j]cost=0cost = 0,否則 cost=1cost = 1

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

範例程式碼:

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';
}