1. 程式人生 > 其它 >「學習筆記」DP 經典模型

「學習筆記」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)\)

的最大和。邊界即 \(f_{1, 1} = w_{1, 1}\)

順著走,發現 \((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;
}