「學習筆記」DP 經典模型
數字三角形
原題目連結:Link。
首先我們可以發現,貪心是行不通的。見 Hack:
1
10 1
10 1 1
0 0 0 100
假如每次都選可以走的點中最大的,那麼走出來的是 \(1 + 10 + 10 + 0 = 20\),但實際上,我們可以走 \(1 + 10 + 1 + 100 = 112\),這也是最優路徑。
所以,我們考慮動態規劃(DP)。
逆推
設 \(f_{i, j}\) 表示從第 \(i\) 行第 \(j\) 列走到最後一行的最大的和。邊界根據定義(術語叫做狀態),就是 \(f_{n, i} = w_{n, i}\)(\(w\) 是點的權值)。這樣,我們只需要從最後一層逆著往上走,推到 \(f_{1, 1}\)
考慮 \((i, j)\) 是如何被走到的,發現可以被 \((i + 1, j)\) 走到,也可以被 \((i + 1, j + 1)\) 走到(這裡是逆著走的)。那麼我們得到一個柿子(術語叫做狀態轉移方程,也就是 \(f_{i, j}\) 這個狀態是怎麼從別的狀態轉移過來的),\(f_{i, j} = \max(f_{i + 1, j}, f_{i + 1, j + 1}) + w_{i, j}\)(別忘了加上本身的權值)。
順推
上面我們介紹了逆推,那麼,可不可以順推呢?
既然要順推,我們定義 \(f_{i, j}\) 表示從 \((1, 1)\) 走到 \((i, j)\)
順著走,發現 \((i, j)\) 可以被 \((i - 1, j)\) 和 \((i - 1, j - 1)\) 走到。從而有 \(f_{i, j} = \max(f_{i - 1, j}, f_{i - 1, j - 1}) + w_{i, j}\)。
由於不能保證最後在哪裡結束,所以答案為 \(\max_{1 \leq i \leq n}\{f_{n, i}\}\)(走到第 \(n\) 行的所有路徑的最大值)。
最長上升子序列
原題目連結:Link。
最長上升子序列即 Longest Increasing Subsequence(LIS)。這裡我們介紹簡單的 \(O(n ^ 2)\)
設 \(f_i\) 表示前 \(i\) 個數中以 \(a_i\) 結尾(經常這麼設計狀態)的 LIS 長度。初始 \(f_i = 1\)(\(a_i\) 自己就是一個上升子序列)。
考慮如何轉移。注意到前 \(i\) 個數中以 \(a_i\) 結尾的 LIS 一定是一個不以 \(a_i\) 結尾的 LIS「拼上」\(a_i\)。那麼我們可以列舉這個不以 \(a_i\) 結尾的 LIS 的結尾 \(j\)。從而有狀態轉移方程 \(f_i = \max_{1 \leq j < i, a_j < a_i}\{f_j + 1\}\)(這個狀態轉移方程很重要,請細細體會)。
程式碼如下:
#include <bits/stdc++.h>
using namespace std;
const int N = 1005;
int n, a[N], f[N], ans;
int main() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
f[i] = 1;
}
for (int i = 2; i <= n; i++)
for (int j = 1; j < i; j++)
if (a[j] < a[i] && f[j] + 1 > f[i])
f[i] = f[j] + 1;
for (int i = 1; i <= n; i++)
if (f[i] > f[ans]) ans = i;
cout << f[ans] << endl;
return 0;
}
那如果我們要輸出 LIS 呢?其實也非常簡單。我們用一個 \(pre\) 陣列來記錄,表示 \(i\) 是從 \(pre_i\) 「轉移」過來的(\(pre_i\) 下一個就是 \(i\))。這樣,我們只需要在轉移的時候,加上 pre[i] = j
即可。
輸出時,我們可以使用遞迴。程式碼如下:
#include <bits/stdc++.h>
using namespace std;
const int N = 5005;
int n, a[N], pre[N], f[N], ans;
void print(int x) { // print(x) 表示輸出以 x 結尾的 LIS
if (pre[x] != x) print(pre[x]); // 遞迴,先輸出 x 前面的所有序列
cout << a[x] << " "; // 再輸出它本身
}
int main() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
pre[i] = i; // 初始每一個數都是自己轉移的,定義為 i
f[i] = 1;
}
for (int i = 2; i <= n; i++)
for (int j = 1; j < i; j++)
if (a[j] < a[i] && f[j] + 1 > f[i]) {
f[i] = f[j] + 1;
pre[i] = j;
// f[i] 被 f[j] + 1 更新,則 i 是從 j 轉移過來的
}
for (int i = 1; i <= n; i++)
if (f[i] > f[ans]) ans = i;
cout << f[ans] << endl;
print(ans);
return 0;
}
這種記錄前驅 \(pre\) 或者字尾 \(nxt\) 的輸出方法在 DP、搜尋中很常見,請讀者一定掌握。
PS:還有 \(O(n \log n)\) 的 LIS 方法,使用二分,可以自行上網搜尋。
最長公共子序列
最長公共子序列,即 Longest Common Subsequence(LCS)。
原題目連結:Link。
給出兩個長度為 \(n\) 和 \(m\) 的字串 \(A\) 和 \(B\),求即是 \(A\) 的子序列又是 \(B\) 的子序列的最大長度。
設 \(f_{i, j}\) 表示 \(A_{1 \sim i}\) 與 \(B_{1 \sim j}\) 的 LCS 長度。當 \(A_i = B_j\),即它們是公共時,我們可以把它們作為一個公共子序列,從而有 \(f_{i, j} = f_{i - 1, j - 1} + 1\);否則它們不公共,那麼「丟掉」\(A_i\) 或者 \(B_j\) 對 LCS 是沒有影響的,有 \(f_{i, j} = \max(f_{i - 1, j}, f_{i,j - 1})\)。
綜上,得到狀態轉移方程
\[ f_{i, j} =\left\{ \begin{aligned} & f_{i - 1, j - 1} + 1 \ (A_i = B_j)\\ & \max(f_{i - 1, j}, f_{i,j - 1}) \ \text{else} \end{aligned} \right. \]程式碼如下:
#include <bits/stdc++.h>
using namespace std;
const int N = 1005;
int n, m;
char a[N], b[N];
int dp[N][N];
int main() {
scanf("%s", a + 1);
scanf("%s", b + 1);
n = strlen(a + 1);
m = strlen(b + 1); // 從 1 開始避免 -1 越界
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
if (a[i] == b[j]) dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
printf("%d\n", dp[n][m]);
return 0;
}