演算法學習筆記(44)——線性DP
線性DP
具有線性“階段”劃分的動態規劃演算法被統稱為線性DP。
數字三角形
問題描述
給定一個共有 \(N\) 行的三角矩陣 \(A\),其中第 \(i\) 行有 \(i\) 列。從左上角出發,每次可以向下方或右下方走一步,最終到達底部。求把經過的所有位置上的數加起來,和最大是多少。
狀態表示
\(F[i][j]\) 表示從左上角走到第 \(i\) 行第 \(j\) 列,和最大是多少
階段劃分
路徑的結尾位置(矩陣中的行、列位置,即一個二維座標)
轉移方程
\[F[i][j] = A[i][j] + max = \begin{cases} F[i-1][j] \\ F[i-1][j-1], \text{if $j$ > 1} \end{cases} \]邊界
\[F[1][1] = A[1][1] \]目標
\[\max_{1\le j \le N}\lbrace F[N][j] \rbrace \]#include <iostream> using namespace std; const int N = 510, INF = 1e9; int n; // 數字三角形的層數 int a[N][N]; // 數字三角形 int f[N][N]; // 狀態集合 int main() { cin >> n; for (int i = 1; i <= n; i ++ ) for (int j = 1; j <= i; j ++ ) cin >> a[i][j]; // 狀態集合初始化為負無窮 for (int i = 0; i <= n; i ++ ) // 每一行最右側元素計算時會用到右上的值,所以多初始化一列 for (int j = 0; j <= i + 1; j ++ ) f[i][j] = -INF; f[1][1] = a[1][1]; for (int i = 2; i <= n; i ++ ) for (int j = 1; j <= i; j ++ ) f[i][j] = max(f[i - 1][j] + a[i][j], f[i - 1][j - 1] + a[i][j]); int res = -INF; for (int i = 1; i <= n; i ++ ) res = max(res, f[n][i]); cout << res << endl; return 0; }
也可以倒序DP,不需要考慮邊界問題,程式碼更加簡潔。
#include <iostream> using namespace std; const int N = 510; int n; int f[N][N]; int main() { cin >> n; for (int i = 1; i <= n; i ++ ) for (int j = 1; j <= i; j ++ ) cin >> f[i][j]; for (int i = n; i >= 1; i -- ) for (int j = i; j >= 1; j -- ) f[i][j] = max(f[i][j] + f[i + 1][j], f[i][j] + f[i + 1][j + 1]); cout << f[1][1] << endl; return 0; }
最長上升子序列(LIS)問題
問題描述
最長上升子序列。給定一個長度為 \(N\) 的數列 \(A\),求數值單調遞增的子序列的長度最長是多少。\(A\) 的任意子序列 \(B\) 可以表示為 \(B = \lbrace A_{k_1}, A_{k_2}, \dots, A_{k_p} \rbrace\),其中 \(k_1 < k_2 < \dots < k_p\)
輸入格式
第一行包含整數 \(N\)。
第二行包含 \(N\) 個整數,表示完整序列。
輸出格式
輸出一個整數,表示最大長度。
資料範圍
\(1 \le N \le 1000\)
\(−10^9 \le \text{數列中的數} \le 10^9\)
輸入樣例:
7
3 1 2 1 8 5 6
輸出樣例:
4
狀態表示
\(F[i]\) 表示以 \(A[i]\) 為結尾的“最長上升子序列”的長度
階段劃分
子序列的結尾位置(數列 \(A\) 中的位置,從前到後)。最後一個數是確定的,即從小到大列舉倒數第二個數
轉移方程
\[F[i] = \max_{0 \le j < i, A[j] < A[i]} \lbrace F[j] + 1 \rbrace \]邊界
\[F[0] = 0 \]目標
\[\max_{1\le i \le N}\lbrace F[i] \rbrace \]時間複雜度:\(O(N^2)\)
#include <iostream>
using namespace std;
const int N = 1010;
int n;
int a[N];
int f[N];
int main()
{
cin >> n;
for (int i = 1; i <= n; i ++ ) cin >> a[i];
// f[0] = 0
for (int i = 1; i <= n; i ++ ) {
// 預設最長上升子序列包含自己,初始化為1
f[i] = 1;
for (int j = 1; j < i; j ++ )
if (a[j] < a[i])
f[i] = max(f[i], f[j] + 1);
}
int res = 0;
for (int i = 1; i <= n; i ++ ) res = max(res, f[i]);
cout << res << endl;
return 0;
}
可以在動態規劃過程中記錄狀態轉移路徑,從而倒推出最長上升子序列
#include <iostream>
using namespace std;
const int N = 1010;
int n;
int a[N];
int f[N];
int g[N]; // 儲存當前狀態是從哪個狀態轉移過來的
int main()
{
cin >> n;
for (int i = 1; i <= n; i ++ ) cin >> a[i];
// f[0] = 0
for (int i = 1; i <= n; i ++ ) {
// 預設最長上升子序列包含自己,初始化為1
f[i] = 1;
for (int j = 1; j < i; j ++ )
if (a[j] < a[i])
if (f[i] < f[j] + 1) {
f[i] = f[j] + 1;
g[i] = j; // 記錄f[i]這一狀態是從j轉移來的
}
}
int k = 1;
for (int i = 1; i <= n; i ++ )
if (f[k] < f[i])
k = i;
cout << f[k] << endl;
while (k) {
cout << a[k] << ' ';
k = g[k];
}
return 0;
}
最長上升子序列(LIS)問題 II
將上題的資料範圍修改為:
\(1 \le N \le 100000\)
其餘條件不變,此時之前的方法 \(O(N^2)\) 的時間複雜度會超時,需要考慮新的辦法進行優化。
在處理之前的最長上升子序列問題時,\(F[i]\) 表示以第 \(i\) 個數字結尾的最長上升子序列的長度,通過列舉最長上升子序列倒數第二個數的所有可能的取值 \(a[j]\),判斷如果 \(a[j] < a[i]\),就更新 \(F[i] = max(F[i], F[j] + 1)\)。在更新過程中,不難發現不同的倒數第二個數可能會有相同的最長上升子序列長度 \(F[j]\),例如:
\[1, 4, 2, 3, 5 \]此時以 \(4\) 結尾的最長上升子序列長度為 \(2\) (\(1, 4\)),而以 \(2\) 結尾的最長上升子序列長度也為 \(2\) (\(1, 2\)),但在更新以 \(5\) 結尾的最長上升子序列長度時,\(4\) 和 \(2\) 均滿足更新條件 \(a[j] < a[i]\),此時便產生了冗餘的計算。我們知道最長上升子序列中能夠接在 \(4\) 之後的數字一定大於 \(4\),同時它也一定能接在 \(2\) 之後,也就是說在最長上升子序列長度相同時,結尾數字越小,之後能夠接上的數字範圍越大,上述例子中,\(3\) 可以接在 \(2\) 後面,但不能接在 \(4\) 後面。所以自然而然地產生了一種優化思路,我們在更新狀態的過程中維護一個數組q[]
代替f[]
,針對每一個最長上升子序列長度,儲存該長度下的所有序列的結尾數字的最小值。在更新狀態時,只需要找到某個長度對應的最小結尾數字用於更新。這裡的查詢我們使用之前學習過的二分查詢,查詢一個數最壞情況下時間複雜度是 \(O(\log N)\),修改q[]
陣列與更新狀態的時間複雜度是 \(O(1)\),總共有 \(N\) 個數,所以總的時間複雜度是 \(O(N\log N)\)。
其實這種解題思路屬於貪心的範疇,但由於我們是通過最長上升子序列這一動態規劃問題的優化而思考出來的,所以將其歸為動態規劃這一類一起學習。
#include <iostream>
using namespace std;
const int N = 100010;
int n; // 輸入數列長度
int a[N]; // 數列
int q[N]; // 儲存不同長度的最長上升子序列結尾數字的最小值
int main()
{
cin >> n;
for (int i = 0; i < n; i ++ ) cin >> a[i];
// 初始化最長上升子序列長度是0,也代表q[]陣列元素個數
int len = 0;
// 列舉數列中的每一個數
for (int i = 0; i < n; i ++ ) {
// 二分查詢q陣列中0~len之間小於a[i]的最大的數
int l = 0, r = len;
while (l < r) {
int mid = l + r + 1 >> 1;
if (q[mid] < a[i]) l = mid;
else r = mid - 1;
}
// 更新len的最大值,r表示可以接在r長度的後面
len = max(len, r + 1);
// r+1長度的最長上升子序列的結尾數字最小值更新為a[i]
q[r + 1] = a[i];
}
cout << len << endl;
return 0;
}
最長公共子序列(LCS)問題
問題描述
最長公共子序列。給定兩個長度分別為 \(N\) 和 \(M\) 的字串 \(A\) 和 \(B\),求既是 \(A\) 的子序列又是 \(B\) 的子序列的字串的最長長度。
狀態表示
\(F[i,j]\) 表示字首子串 \(A[1\sim i]\) 與 \(B[1\sim j]\) 的“最長公共子序列”的長度
階段劃分
已處理的字首長度(兩個字串中的位置,即一個二維座標)
轉移方程
\[F[i] = \max \begin{cases} F[i - 1,j] \\ F[i,j - 1] \\ F[i - 1, j - 1] + 1, \text{if $j$ > 1} \end{cases} \]邊界
\[F[i,0]=F[0,j]=0 \]目標
\[F[N][M] \]注意,\(F[i-1,j]\) 一定包含字首子串 \(A[1\sim i-1]\) 和 \(B[1 \sim j]\) 的最長公共子序列,而且 \(B[j]\) 有可能不在最長公共子序列中,\(F[i-1,j-1]\) 會包含在 \(F[i-1,j]\) 和 \(F[i,j-1]\) 中,所以一般程式碼實現中不需要再對它求max。而只有在 \(A[i]\) 和 \(B[j]\) 相等時才會有 \(F[i-1,j-1]+1\) 這種情況。
狀態數量是 \(N^2\) (二維),狀態轉移是 \(3\) 次運算,\(O(1)\)的時間複雜度,所以總的時間複雜度是 \(O(N^2)\) 的。
#include <iostream>
using namespace std;
const int N = 1010;
int n, m;
char A[N], B[N];
int f[N][N];
int main()
{
cin >> n >> m;
cin >> A + 1 >> B + 1; // 從下標1開始讀入字串
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= m; j ++ ) {
f[i][j] = max(f[i - 1][j], f[i][j - 1]);
if (A[i] == B[j]) f[i][j] = max(f[i][j], f[i - 1][j - 1] + 1);
}
cout << f[n][m] << endl;
return 0;
}