算法学习笔记(44)——线性DP
线性DP
具有线性“阶段”划分的动态规划算法被统称为线性DP。
数字三角形
问题描述
给定一个共有 \(N\) 行的三角矩阵 \(A\),其中第 \(i\) 行有 \(i\) 列。从左上角出发,每次可以向下方或右下方走一步,最终到达底部。求把经过的所有位置上的数加起来,和最大是多少。
状态表示
\(F[i][j]\) 表示从左上角走到第 \(i\) 行第 \(j\) 列,和最大是多少
阶段划分
路径的结尾位置(矩阵中的行、列位置,即一个二维坐标)
转移方程
边界
目标
#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\) 中的位置,从前到后)。最后一个数是确定的,即从小到大枚举倒数第二个数
转移方程
边界
目标
时间复杂度:\(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]\),例如:
此时以 \(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-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;
}
最短编辑距离
问题描述
给定两个字符串 \(A\) 和 \(B\),现在要将 \(A\) 经过若干操作变为 \(B\),可进行的操作有:
- 删除–将字符串 \(A\) 中的某个字符删除。
- 插入–在字符串 \(A\) 的某个位置插入某个字符。
- 替换–将字符串 \(A\) 中的某个字符替换为另一个字符。
现在请你求出,将 \(A\) 变为 \(B\) 至少需要进行多少次操作。
输入格式
第一行包含整数 \(n\),表示字符串 \(A\) 的长度。
第二行包含一个长度为 \(n\) 的字符串 \(A\)。
第三行包含整数 \(m\),表示字符串 \(B\) 的长度。
第四行包含一个长度为 \(m\) 的字符串 \(B\)。
字符串中均只包含大小写字母。
输出格式
输出一个整数,表示最少操作次数。
数据范围
\(1 \le n,m \le 1000\)
输入样例:
10
AGTCTGACGC
11
AGTAAGTAGGC
输出样例:
4
状态表示
\(F[i,j]\) 表示把 \(A[1 \sim i]\) 修改为 \(B[1 \sim j]\) 的所有操作方式对应的操作次数的最小值。
阶段划分
已处理的前缀长度。可以通过三种操作使得 \(A[1 \sim i] = B[1 \sim j]\)。
- 删除 \(A[i]\),这种情况下 \(A[1 \sim i-1] = B[1~j]\)
- 添加,这种情况下 \(A[1 \sim i] = B[1 \sim j - 1]\),在 \(A\) 末尾加上 \(B[j]\)就一样了。
- 修改,这种情况下 \(A[1 \sim i-1] = B[1 \sim j-1]\),所以如果 \(A[i] \neq B[j]\),将 \(A[i]\) 改为 \(B[j]\) 即可,否则不需要操作。
转移方程
边界
当 \(A\) 的长度为 \(0\) 时,最少操作次数是一个一个添加 \(B\) 的所有字符。
当 \(B\) 的长度为 \(0\) 时,最少操作次数是一个一个删除 \(A\) 的所有字符。
目标
时间复杂度:\(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 >> a + 1 >> m >> b + 1;
for (int i = 1; i <= m; i ++ ) f[0][i] = i;
for (int i = 1; i <= n; i ++ ) f[i][0] = i;
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= m; j ++ ) {
f[i][j] = min(f[i - 1][j] + 1, f[i][j - 1] + 1);
f[i][j] = min(f[i][j], f[i - 1][j - 1] + (a[i] != b[j]));
}
cout << f[n][m] << endl;
return 0;
}
编辑距离
问题描述
给定 \(n\) 个长度不超过 \(10\) 的字符串以及 \(m\) 次询问,每次询问给出一个字符串和一个操作次数上限。
对于每次询问,请你求出给定的 \(n\) 个字符串中有多少个字符串可以在上限操作次数内经过操作变成询问给出的字符串。
每个对字符串进行的单个字符的插入、删除或替换算作一次操作。
输入格式
第一行包含两个整数 \(n\) 和 \(m\)。
接下来 \(n\) 行,每行包含一个字符串,表示给定的字符串。
再接下来 \(m\) 行,每行包含一个字符串和一个整数,表示一次询问。
字符串中只包含小写字母,且长度均不超过 \(10\)。
输出格式
输出共 \(m\) 行,每行输出一个整数作为结果,表示一次询问中满足条件的字符串个数。
数据范围
\(1 \le n,m \le 1000\)
输入样例:
3 2
abc
acd
bcd
ab 1
acbd 2
输出样例:
1
3
本题是上一道题的一个简单运用,原理相同,只需要进行多次,不再加以赘述。
#include <iostream>
#include <cstring>
using namespace std;
const int N = 1010;
int n, m;
char str[N][N];
int f[N][N];
int edit_distance(char a[], char b[])
{
int la = strlen(a + 1), lb = strlen(b + 1);
for (int i = 0; i <= lb; i ++ ) f[0][i] = i;
for (int i = 0; i <= la; i ++ ) f[i][0] = i;
for (int i = 1; i <= la; i ++ )
for (int j = 1; j <= lb; j ++ ) {
f[i][j] = min(f[i - 1][j] + 1, f[i][j - 1] + 1);
f[i][j] = min(f[i][j], f[i - 1][j - 1] + (a[i] != b[j]));
}
return f[la][lb];
}
int main()
{
cin >> n >> m;
for (int i = 0; i < n; i ++ ) cin >> str[i] + 1;
while (m -- ) {
char s[N];
int limit;
cin >> s + 1 >> limit;
int cnt = 0;
for (int i = 0; i < n; i ++ )
if (edit_distance(str[i], s) <= limit)
cnt ++;
cout << cnt << endl;
}
return 0;
}