文章目录


动态规划(二)

今天是讲线性DP和区间DP

线性DP

状态转移方程呈现出一种线性的递推形式的DP,我们将其称为线性DP。

DP问题的时间复杂度怎么算?一般是状态的数量乘以状态转移的计算量

DP问题,是基础算法中比较难的部分,因为它不像其他算法,有个代码模板可以用于记忆。DP问题更偏向于数学问题,它没有一套代码模板,但是有一种思考方式。遇到DP问题,通常我们可以从2个方面进行思考:


  • 状态表示

    • 考虑是一维还是二维(​​f[i]​​​ 或者 ​​f[i][j]​​)
    • 考虑这个状态表示的是哪些集合
    • 考虑​​f[i][j]​​的值,代表的是这个集合的什么属性

  • 状态计算(状态转移方程)
    • DP问题最难的点就在于状态转移(对集合进行划分),即需要自己去想,某个状态,如何从其他的状态转移过来。这个没有固定套路,只能多练,形成经验。DP问题通常都是从实际问题抽象来的,针对某一种DP问题,只要尝试并发现某种状态转移的方式是可行的,是能求出最终解的,那么形成经验后,再遇到该类DP问题,便能更快的解决。

下面通过具体例题,对DP问题的解题过程进行讲解。

数字三角形

​题目链接​

Acwing - 算法基础课 - 笔记(十三)_子序列

题目描述:从顶部出发,在每一结点可以选择移动到其左下方的结点或移动至其右下方的结点,一直走到底层,要求找出一条路径,使路径上的数字的和最大。

分析:

三角形一共有​​n​​​层,在第​​i​​​层,共有​​i​​​个数字,可以用​​a[i][j]​​​来表示三角形中的第​​i​​​行的第​​j​​​列(其中​​j <= i​​​),对于某个位置​​[i,j]​​​,设状态​​f[i][j]​​表示的集合是:从顶点到该点的全部路径;而​​f[i][j]​​的值,表示的是这个集合的什么属性呢?容易想到,自然是表示到达该点的全部路径中,数字和最大的那一条路径的数字和。

状态的表示思考完了,接下来是状态计算。由于每个点,都只能从其左上方的点,或右上方的点走过来。所以,我们可以对​​f[i][j]​​表示的集合进行划分,划分为2个子集合:从左上方的点过来的路径,从右上方的点过来的路径。

那么​​f[i][j] = max(f[i - 1][j - 1], f[i - 1][j]) + a[i][j]​

这就是状态转移方程了(注意对每一层的第一个点和最后一个点,需要做一下特判,或者不用做特判,初始化​​f[i][j]​​时,多初始化一些位置即可)

根据这个思路,写成代码如下

#include <iostream>

const int N = 510;

int a[N][N]; // 存储三角形

int f[N][N]; // 存储状态

int n;

int main() {
scanf("%d", &n);
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= i; j++) scanf("%d", &a[i][j]);
}

f[1][1] = a[1][1];
for(int i = 2; i <= n; i++) {
for(int j = 1; j <= i; j++) {
if(j == 1) f[i][j] = f[i - 1][j] + a[i][j];
else if(j == i) f[i][j] = f[i - 1][j - 1] + a[i][j];
else f[i][j] = std::max(f[i - 1][j - 1], f[i - 1][j]) + a[i][j];
}
}

// 最终的答案, 就是最后一层的所有点的 f[i][j] 中的最大值
int res = f[n][1];
for(int i = 2; i <= n; i++) {
res = std::max(res, f[n][i]);
}

printf("%d", res);
}

其实,可以转换一下思路,从最底层开始遍历,往最顶层做,这样会减少一些迭代次数(并且由于从下往上做时,每个点都由其左下或右下的点转移而来,而每个点一定存在左下的点和右下的点,无需做特判),代码如下

#include <iostream>

const int N = 510;

int a[N][N]; // 存储三角形

int f[N][N];

int n;

int main() {
scanf("%d", &n);
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= i; j++) scanf("%d", &a[i][j]);
}

// 初始化底层的 f[i][j]
for(int i = 1; i <= n; i++) f[n][i] = a[n][i];

// 从下往上走
for(int i = n - 1; i >= 0; i--) {
for(int j = 1; j <= i; j++) {
f[i][j] = std::max(f[i + 1][j], f[i + 1][j + 1]) + a[i][j];
}
}

printf("%d", f[1][1]);
}

最长上升子序列

​题目链接​

给定一个长度为 ​​N​​ 的数列,求数值严格单调递增的子序列的长度最长是多少。

比如对于数列:​​3 1 2 1 8 5 6​​​,严格单调递增的子序列,最长的是:​​1 2 5 6​​,其长度为4。

分析:

同样,先来分析状态表示,数列用​​a​​​表示,某个下标​​i​​​的元素,则用​​a[i]​​表示。

由于数列是一维的,则我们只需要一维的状态,即​​f[i]​​​即可。那么​​f[i]​​表示的集合是什么呢?

我们用​​f[i]​​​表示:所有以​​a[i]​​作为最后一个数的子序列。

​f[i]​​​的值是什么呢?是这些以​​a[i]​​作为最后一个数的子序列中,长度最大的子序列的长度。

接下来状态转移,对所有以​​a[i]​​作为最后一个数的子序列,可以如何进行划分呢?(集合划分)

我们可以考虑子序列的倒数第二个数,我们根据这些子序列中,倒数第二个数是​​a[i - 1]​​​,​​a[i - 2]​​​,​​a[i - 3]​​​,…,​​a[0]​​​,来进行划分。一共划分为​​i​​个子集合。则状态转移方程为:

​f[i] = max(f[j]) + 1​​,其中 j ∈ [ 0 , i − 1 ] j \in [0, i - 1] j∈[0,i−1]

当然,由于子序列需要是严格单调递增,所以并不是​​[0,i - 1]​​​中的所有位置都可以作为倒数第二个位置。必须满足​​a[j] < a[i]​​,才行。

根据这个思路,写成代码如下:

#include <iostream>
const int N = 1010;

int a[N], f[N];

int main() {
int n;
scanf("%d", &n);
for(int i = 0; i < n; i++) scanf("%d", &a[i]);

for(int i = 0; i < n; i++) {
f[i] = 1; // 每个以 a[i] 结尾的子序列, 最少长度为 1, 即其本身
for(int j = 0; j < i; j++) {
if(a[j] < a[i]) f[i] = std::max(f[i], f[j] + 1);
}
}

int res = f[0];
for(int i = 1; i < n; i++) {
res = std::max(res, f[i]);
}

printf("%d", res);
}

进阶版练习题:(最长上升子序列的优化)

最长上升子序列II

​题目链接​

这道题目由于数据范围变得更大了,所以需要对原来的解法进行优化,大概是观察得出一种单调的特性,然后可以用二分来进行优化,将时间复杂度从 O ( n 2 ) O(n^2) O(n2) 降到 O ( n l o g n ) O(nlogn) O(nlogn)

注:这个二分有点难,有很多边界问题需要考虑。数组​​f​​​中存的是,长度为​​i​​​的子序列的末尾的最小值,比如​​f[1]=2​​​表示,长度为1的子序列,结尾的最小值为2,这是一种类似贪心的策略,每次尝试找到长度为​​i​​的子序列的末尾的最小值,这样能保证后面的数字能够尽可能地接到后面,形成更长的上升子序列。

#include <iostream>

const int N = 1e5 + 10;

int n, f[N];

int main() {
scanf("%d", &n);
int len = 0;
for(int i = 0; i < n; i++) {
int a;
scanf("%d", &a);

int l = 0, r = len;
while(l < r) {
int mid = l + r + 1 >> 1;
if(f[mid] < a) l = mid;
else r = mid - 1;
}
f[r + 1] = a;
len = std::max(len, r + 1);
}

printf("%d", len);
return 0;
}

最长公共子序列

​题目链接​

给定两个长度分别为 ​​N​​​ 和 ​​M​​​ 的字符串 ​​A​​​ 和 ​​B​​​,求既是 ​​A​​​ 的子序列又是 ​​B​​ 的子序列的字符串长度最长是多少

比如 ​​acbd​​​,​​abedc​​​,这两个字符串的最长公共子序列是​​abd​​,长度是3。

分析:

同样的,想想一下状态表示,由于是2个序列,则用二维的​​f[i][j]​​来表示,它表示什么集合呢?

​f[i][j]​​​表示,在第一个序列的前​​i​​​个字母中出现,且在第二个序列的前​​j​​ 个字母中出现,的全部子序列(已经是公共子序列了)

​f[i][j]​​的值,是这些子序列中,最长的子序列的长度

这道题最难的点在于状态转移。

下面我们考虑如何对​​f[i][j]​​​表示的集合进行划分。我们用​​a​​​来表示第一个字符串,​​b​​​来表示第二个字符串。我们根据这些子序列是否包含​​a[i]​​​,是否包含​​b[j]​​,来进行集合的划分。则可以分为4种子集合


  • 不包含​​a[i]​​​,不包含​​b[j]​​(用二进制位来表示是否包含,则是00)
  • 包含​​a[i]​​​,不包含​​b[j]​​(10)
  • 不包含​​a[i]​​​,包含​​b[j]​​(01)
  • 包含​​a[i]​​​,包含​​b[j]​​(11)

​f[i, j]​​则是这4个中的最大者。

Acwing - 算法基础课 - 笔记(十三)_动态规划_02

其中00,可以直接用​​f[i - 1, j - 1]​​​ 表示,11可以直接用​​f[i - 1, j - 1] + 1​​​来表示,但注意11需要满足​​a[i] = b[j]​​才行。

比较难的地方在于01和10,01表示,这些子序列中包含​​b[j]​​​,但是不包含​​a[i]​​​,注意是包含​​b[j]​​​,即这些子序列的最后一位是​​b[j]​​​,为了方便叙述,我们将01这个子集合表示为A,而​​f[i - 1, j]​​​表示的集合(暂且称为A’),是所有在字符串​​a​​​的前​​i - 1​​​个字母中出现,且在字符串​​b​​​的前​​j​​​个字母中出现的子序列(​​b[j]​​不一定是子序列的最后一位)。

需要特别注意,A’并不等于A,A’和A是包含关系,A是A’的子集。即​​f[i - 1, j]​​表示的集合,实际是要大于01这个集合的。

但是我们可以用​​f[i - 1, j]​​来代替 01这个集合。因为重复的集合运算并不会影响最终的最大值结果。举例如下:

对于集合​​1 2 3 4 5​​​,我们要求这个集合的最大值,我们先对集合进行划分,先求子集​​1 2 3​​​ 的最大值,为3,再求子集 ​​3 4 5​​的最大值,为5,再求这两个子集的最大者,为5,则整个集合的最大值为5。

注意到,2个子集是有重合部分的(重合了3这个数),但是并不影响求解整个集合的最大值。

即,只要全部子集加起来,能够涵盖掉整个集合(即使子集之间有重合),那么对求整个集合的最大值,是没有影响的。

对于10这个子集,同理,可以用​​f[i, j - 1]​​​来代替它。而观察到,​​f[i - 1, j]​​​和 ​​f[i, j - 1]​​,实际是包含了00这个子集的,所以编写代码时,可以省略00这个子集。

根据思路,写成代码如下

#include <iostream>
#include <cstring>

const int N = 1010;

char a[N], b[N];

int f[N][N];


int main() {

int n, m;
scanf("%d%d", &n, &m);

// 起始坐标从1开始, 不用特判
scanf("%s%s", a + 1, b + 1);

for(int i = 1; i <= n; i++) {
for(int j = 1; j <= m; j++) {
f[i][j] = std::max(f[i - 1][j], f[i][j - 1]);
if(a[i] == b[j]) f[i][j] = std::max(f[i][j], f[i - 1][j - 1] + 1);
}
}
printf("%d", f[n][m]);
}

练习题:

最短编辑距离

​题目链接​

使用​​f[i][j]​​​表示,将字符串​​a​​​的​​1​​​到​​i​​​,变成字符串​​b​​​的​​1​​​到​​j​​​,的所有操作方式。​​f[i][j]​​的值,是所有这些操作方式中,操作次数最小的方式的操作次数。

然后进行集合划分,根据最后一次在​​a[i]​​位置上的操作类型,划分为


  • 最后是删除了​​a[i]​
  • 最后是在​​a[i]​​这个位置后面增加一个字符
  • 最后是把​​a[i]​​改成了另一个字符

所以状态转移方程为:

f[i][j] = min(f[i - 1][j] + 1, f[i][j - 1] + 1, f[i - 1][j - 1] + 1); 

注意最后一种类型,需要先判断一下​​a[i]​​​是否等于​​b[j]​​​,然后需要注意初始化​​f[i][0]​​​ 和 ​​f[0][i]​​,题解如下

#include <iostream>

const int N = 1010;

int n, m;

char a[N], b[N];

int f[N][N];

int main() {
scanf("%d%s", &n, a + 1);
scanf("%d%s", &m, b + 1);

for(int i = 1; i <= n; i++) f[i][0] = i;
for(int j = 1; j <= m; j++) f[0][j] = j;

for(int i = 1; i <= n; i++) {
for(int j = 1; j <= m; j++) {
f[i][j] = std::min(f[i - 1][j] + 1, f[i][j - 1] + 1);
if(a[i] == b[j]) f[i][j] = std::min(f[i][j], f[i - 1][j - 1]);
else f[i][j] = std::min(f[i][j], f[i - 1][j - 1] + 1);
}
}

printf("%d", f[n][m]);
return 0;
}

编辑距离

​题目链接​

是上面题目的变形,核心思路不变,代码题解如下

#include <iostream>
#include <cstring>

const int N = 1010;

int n, m;

char str[N][N];

int f[N][N];

char s[N];

int edit_dis(char a[], char b[]) {
int la = strlen(a + 1), lb = strlen(b + 1);
for(int i = 1; i <= la; i++) f[i][0] = i;
for(int i = 1; i <= lb; i++) f[0][i] = i;

for(int i = 1; i <= la; i++) {
for(int j = 1; j <= lb; j++) {
f[i][j] = std::min(f[i - 1][j] + 1, f[i][j - 1] + 1);
if(a[i] == b[j]) f[i][j] = std::min(f[i][j], f[i - 1][j - 1]);
else f[i][j] = std::min(f[i][j], f[i - 1][j - 1] + 1);
}
}
return f[la][lb];
}

int main() {
scanf("%d%d", &n, &m);
for(int i = 1; i <= n; i++) scanf("%s", str[i] + 1);
while(m--) {
int cnt = 0, limit;
scanf("%s%d", s + 1, &limit);
for(int i = 1; i <= n; i++) {
if(edit_dis(str[i], s) <= limit) cnt++;
}
printf("%d\n", cnt);
}
return 0;
}

区间DP

状态表示是某一个区间,比如​​f[i, j]​​​表示的是​​[i ,j]​​ 这个区间

石子合并

​题目链接​

题目描述:有N堆石子排成一排,编号为1,2,3,…,N

每堆石子有一定质量,用一个整数来描述,现在要将这N堆石子合并成一堆。

每次合并只能合并相邻的两堆,合并的代价是这两堆石子的质量之和,合并后,原先和这2个石堆相邻的石堆,将和新石堆相邻,合并时选择的顺序不同,合并的总代价也不同。

比如:有4堆石子,其质量分别是​​1 3 5 2​​。

如果先合并第1和第2堆,则代价为4,得到​​4 5 2​​​,如果又合并​​4 5​​​,则代价为9,得到​​9 2​​,最后合并代价为11,则总的代价为4 + 9 + 11 = 24。

如果先合并​​1 3​​​,代价为4,得到​​4 5 2​​​,再合并​​5 2​​​,代价为7,得到​​4 7​​,最后合并代价为11,则总的代价为4 + 7 + 11 = 22

问题:找出一种合理的合并顺序,使得总代价最小。

分析:​​f[i, j]​​​表示的集合是:将第​​i​​​堆石子,到第​​j​​堆石子,合并成一堆石子,的所有合并方式。

​f[i, j]​​​的值是,所有合并方式中,代价最小的合并方式的代价。则最终的答案就是​​f[1, n]​

接下来看状态转移,由于将第​​i​​​堆石子,到第​​j​​堆石子,合并成一堆,最后一次操作,一定是将相邻的2堆石子合并。则我们以最后一次合并时,的分界线,来进行集合的分类。

则可以分成(假设​​[i,j]​​​区间内共有​​k​​​堆石子,​​k=j-i+1​​):


  • 左边​​1​​​堆石子,右边​​k-1​​堆
  • 左边​​2​​​堆,右边​​k-2​​堆
  • 左边​​3​​​,右边​​k-3​
  • 左边​​4​​​,右边​​k-4​
  • 左边​​k-1​​​,右边​​1​

一共​​k-1​​个子集,只需要求其中的最小值即可,则状态转移方程为

​f[i,j] = min(f[i,k] + f[k+1,j]) + sum[i,j]​

其中 k ∈ [ i , j − 1 ] k \in [i,j-1] k∈[i,j−1] ,而其中的​​sum[i,j]​​​ 表示第​​i​​​堆到第​​j​​​堆的石子的总质量。因为最后一步的合并代价始终是​​sum[i,j]​​,这个可以用第一章的前缀和来处理。

时间复杂度:状态数量是二维,是 n 2 n^2 n2 的,状态的计算,是枚举​​k​​,是 O ( n ) O(n) O(n) 的计算量,所以一共的时间复杂度是 O ( n 3 ) O(n^3) O(n3) 的

区间DP,需要注意循环时的顺序,我们需要保证在计算​​f[i,j]​​​时,需要的其他全部的​​f​​​的值,都已经被算好了。所以这里我们按区间长度从小到大来枚举,先枚举区间长度为1,所有的​​f[i,j]​​,再枚举区间长度为2,…

所有区间DP类的问题,都可以用这种模式来做,先从小到大循环区间的长度(区间长度1,2,3,…),然后内层循环就循环区间的起点

代码如下

#include <iostream>
#include <cstring>

const int N = 310, INF = 0x3f3f3f3f;

int n, s[N], f[N][N];


int main() {
memset(f, 0x3f, sizeof f);
scanf("%d", &n);
for(int i = 1; i <= n; i++) {
scanf("%d", &s[i]);
s[i] += s[i - 1]; // 直接计算前缀和
}

for(int i = 1; i <= n; i++) f[i][i] = 0;

for(int len = 2; len <= n; len++) {
for(int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1;
for(int k = i; k <= j - 1; k++) {
f[i][j] = std::min(f[i][j], f[i][k] + f[k + 1][j] + s[j] - s[i - 1]);
}
}
}

printf("%d", f[1][n]);

return 0;
}

小结:

对于状态转移的思考,是对状态表示的集合进行划分,根据前面的几道例题,可以得知,我们划分集合时,通常都是根据最后一步的操作,来进行划分的,只要能够划分成功,就能得出状态转移方程。

动态规划为什么快?是因为我们用一个状态来表示了一堆方案的一种属性,即用一个数表示了一堆东西。相比而言,暴力(DFS)会遍历每一种方案,所以它慢。

计数类DP

整数划分

​题目链接​

前面几种DP,求的都是最值,而整数划分,求解的是个数

背包的做法:按照完全背包的思路来想,有​​i​​​属于​​1​​​到​​n​​​,共n种物品,每种物品体积为​​i​​​,每种物品能使用无限次,背包的体积为​​n​​,问恰好能装满背包的物品选法的方案总数。

#include <iostream>

const int N = 1010, MOD = 1e9 + 7;

int n, f[N][N];

int main() {
scanf("%d", &n);

for(int i = 0; i <= n; i++) f[i][0] = 1;

for(int i = 1; i <= n; i++) {
for(int j = 1; j <= n; j++) {
f[i][j] = f[i - 1][j] % MOD;
if(j >= i) f[i][j] = (f[i - 1][j] + f[i][j - i]) % MOD;
}
}

printf("%d", f[n][n]);

return 0;
}

其他动规解法:

用​​f[i][j]​​​表示,所有总和是​​i​​​,并且恰好表示成​​j​​​个数的和的方案,​​f[i][j]​​的值是方案的数量。

集合划分,能够分为如下两类


  • 方案中最小值是1的
  • 方案中最小值大于1的

则状态转移方程为

f[i][j] = f[i - 1][j - 1] + f[i - j][j] 

而最终的答案是​​f[n][1]​​​ + ​​f[n][2]​​​ + ​​f[n][3]​​​ + … + ​​f[n][n]​

不同的看待问题的角度,解法也不一样

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010, mod = 1e9 + 7;

int n;
int f[N][N];

int main()
{
cin >> n;

f[1][1] = 1;
for (int i = 2; i <= n; i ++ )
for (int j = 1; j <= i; j ++ )
f[i][j] = (f[i - 1][j - 1] + f[i - j][j]) % mod;

int res = 0;
for (int i = 1; i <= n; i ++ ) res = (res + f[n][i]) % mod;

cout << res << endl;

return 0;
}