线性动态规划
什么是线性 DP?指的是我们的 递推方程是线性的,例如之前的背包问题。由于递推方程是线性的,因此线性 DP 的状态求解顺序也是线性的,可以从左到右,从上到下依次求解。
数字三角形
数字三角形堪称 DP 第一题。
由于只能朝左下方或者右下方,因此调整成直角三角形,要求只能朝正下方或者右下方即可:
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
- 状态表示:
f[i][j]
表示以坐标为终点的路径上和的最大值 - 状态计算:它只能由左上方或正上方转移过来
- 即
f[i][j] = max(f[i-1][j-1], f[i-1][j]) + a[i][j]
- 初始化:给外部包裹一层
-INF
,这样递推的时候更方便
核心代码:
// ...
for (int i = 1; i <= n; i ++) {
f[i][0] = -INF;
f[i][i + 1] = -INF;
}
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= i; j ++) {
f[i][j] = max(f[i - 1][j - 1], f[i - 1][j]) + a[i][j];
}
}
- 注意这里不仅要让
f[i][0]
为-INF
- 由于
f[i][i]
会用到f[i-1][i]
,因此f[i][i+1]
也都要设置为-INF
- 既然如此,直接把整个
f[][]
设置为-INF
不就好了,时间复杂度也没有增加 - 最后考虑最后一行中的
max
,因此可以设置res
的初始值也为-INF
// ...
for (int i = 0; i <= n; i ++) {
for (int j = 0; j <= n; 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 - 1], f[i - 1][j]) + a[i][j];
}
}
这样就需要设置初始值 f[1][1] = a[1][1];
,以及从第二行开始求。
DP 的时间复杂度:状态数量 * 转移计算时间复杂度,本题为
一维优化:记得倒序
// ...
for (int i = 0; i <= n; i ++) {
f[i] = -INF;
}
f[1] = 0;
for (int i = 1; i <= n; i ++) {
for (int j = 1; j <= i; j ++) {
cin >> a[j];
}
for (int j = i; j > 0; j --) {
f[j] = max(f[j], f[j - 1]) + a[j];
}
}
最长上升子序列 I
给定一个长度为
3 1 2 1 8 5 6
->
1 2 5 6
- 状态表示:
f[i]
表示以a[i]
结尾的上升子序列集合(长度的最大值) - 状态计算:
- 显然,可以分
类,分别为 a[0]...a[i-1]
结尾的上升子序列集合 - 保证单调递增,因此需要
a[j] < a[i]
- 要求长度的最大值,因此是
max(f[j] + 1)
- 显然,可以分
- 初始化:
f[0] = 1
状态数量为
核心代码:
// ...
for (int i = 0; i < n; i ++) {
f[i] = 1;
for (int j = 0; j < i; j ++) {
if (a[j] < a[i]) {
f[i] = max(f[i], f[j] + 1);
}
}
}
还可以用一个 g[]
数组存储转移过程下标:
for (int j = 0; j < i; j ++) {
if (a[j] < a[i]) {
if (f[i] < f[j] + 1) {
f[i] = f[j] + 1;
g[i] = j;
}
}
}
// ...
for (int i = 0, len = f[k]; i < len; i ++) {
cout << a[k] << " ";
k = g[k];
}
最长公共子序列
给定两个长度分别为
- 状态表示:
f[i][j]
表示所有由第一个序列前个字母和第二个序列前 个字母中的公共子序列(长度的最大值) - 状态计算:分为四种子结构
- 不选
a[i]
,不选b[j]
:f[i-1][j-1]
不如第四种情况,可以舍去 - 不选
a[i]
,选b[j]
:f[i-1][j]
包含这种情况 - 选
a[i]
,不选b[j]
:f[i][j-1]
包含这种情况 - 选
a[i]
,选b[j]
:f[i-1][j-1] + 1
- 不选
- 我们注意到这四种子结构可能会互相包含(例如
f[i-1][j]
一定包含了f[i-1][j-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);
}
}
}
注意读取的时候需要从字符串的第 1 位而不是第 0 位开始读取,为 f[i-1][j-1]
留出空间。
如果我们用的是 int a[N];
,然后用 scanf("%s", a + 1);
读入,也就是把 abcd
放到了整型数组的第二位上,因此 a[1] = 0x61626364
。
最长上升子序列 II
上一道题的时间复杂度是
回顾一下之前的做法:
- 对于以
a[i]
结尾的数,根据它倒数第二个数分为类 - 只要满足
a[k] < a[i]
,就将其考虑进入,f[i] = max(f[k] + 1);
思考一下求的过程中有没有冗余?考虑这样的序列 1 3 1 2 5
:
- 对于
5
而言,1 3
和1 2
都是长度为 2 的序列,但是 3 比 2 更大 - 因此对于这之后的数,想要扩展为长度为 3 的上升子序列,则只需要选择
1 2
序列即可 - 因此我们可以对于
a[i]
之前的所有序列按照长度分类,然后只存储每一类中最小的那个序列即可(存储结尾值) - 例如对于
5
而言,长度为1, 2, 3
的单调上升序列结尾值是1, 2, 5
所以结尾值是否一定是随着长度上升而 单调递增 的呢?
显然,如果长度为 4 的结尾值比长度为 3 的结尾值要小的话(或者等于),说明该长度为 4 的序列倒数第二个数比长度为 3 的结尾值也要小,而这与我们仅存储同等长度序列中 结尾值最小 的相矛盾。
- 横坐标为最长上升子序列的长度
- 纵坐标为结尾值最小的那个结尾值(对于长度为 1 的序列,结尾值在
a[0->n-1]
的过程中它是不断降低的) - 我们要找以
a[i]
结尾的最长上升子序列,就是找到纵坐标小于a[i]
的横坐标(二分问题)
考虑时间复杂度:
- 每次更新是
的,二分查找到 a[i]
应该在哪个结尾值q[j]
的后面 - 然后比较
q[j + 1]
和a[i]
大小,如果a[i]
更小就替换 - 一共循环
次,所以时间复杂度为
最短编辑距离
- 状态表示:
- 集合:所有将
A[1~i]
变为B[1~j]
的操作方式 - 属性:操作次数最小值
f[i][j]
- 集合:所有将
- 状态计算:
- 删除:需要使得
A[1~i-1]
已经和B[1~j]
匹配了,f[i-1][j] + 1
- 增加:需要使得
A[1~i]
已经和B[1~j-1]
匹配了,f[i][j-1] + 1
- 修改,需要使得
A[1~i-1]
已经和B[1~j-1]
匹配了,这里需要判断A[i] ?= B[j]
,所以是f[i-1][j-1] + 0
或者f[i-1][j-1] + 1
- 以上三种子状态取最小值
- 删除:需要使得
- 时间复杂度
这里有人会问了?凭什么 f[i][j]
一定只从这三个状态转移过来呢?比如我 ABCDA -> ABEDA
明明就是中间 D -> E
这一步啊,看上去并不是从 [ABCD, ACEDA], [ABCDA, ACED], [ABCD, ABED]
这三种状态通过增加删除修改操作转移过来的啊?
这时候我们就要画一幅状态转移图:
- 红色代表删除:
f[i-1][j] + 1
,每次转移 +1 - 绿色代表增加:
f[i][j-1] + 1
,每次转移 +1 - 蓝色代表修改:
f[i-1][j-1] + 1, f[i-1][j-1] + 0
,每次转移可以 +0
f
矩阵为:
0 1 2 3 4
1 0 1 2 3
2 1 1 2 3
3 2 2 1 2
4 3 3 2 1
可以看出沿着对角线下来的是最快的。而且它确实是由 [ABCD, ABED]
经过 0 步转移得到的。
核心代码:
// ...
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);
if (a[i] == b[j]) {
f[i][j] = min(f[i][j], f[i - 1][j - 1]);
}
else {
f[i][j] = min(f[i][j], f[i - 1][j - 1] + 1);
}
}
}
注意边界:
// ...
for (int i = 0; i <= m; i ++) {
f[0][i] = i;
}
for (int i = 0; i <= n; i ++) {
f[i][0] = i;
}
编辑距离
求每个字符串与给定字符串的编辑距离,判断是否小于给定操作次数即可。