Acwing - 算法基础课 - 笔记(十三)

·  阅读 66

动态规划(二)

今天是讲线性DP和区间DP

线性DP

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

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

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

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

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

数字三角形

题目链接

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

分析:

三角形一共有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,i1]j \in [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

最长公共子序列

题目链接

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

比如 acbdabedc,这两个字符串的最长公共子序列是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个中的最大者。

其中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]);
}
复制代码

练习题:编辑距离

区间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,j1]k \in [i,j-1] ,而其中的sum[i,j] 表示第i堆到第j堆的石子的总质量。因为最后一步的合并代价始终是sum[i,j],这个可以用第一章的前缀和来处理。

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

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

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

代码如下

#include <iostream>

const int N = 310, INF = 0x3f3f3f3f;

int s[N]; // s 用于计算前缀和

int f[N][N];

int n;

int main() {
	scanf("%d", &n);
    // 直接计算前缀和
	for(int i = 1; i <= n; i++) {
		scanf("%d", &s[i]);
		s[i] += s[i - 1];
	}
	
    // 枚举区间长度, 直接从2开始, 对于区间长度为1, 所有的 f 都为 0, 所以直接从2开始枚举即可
	for(int len = 2; len <= n; len++) {
		for(int i = 1, j = i + len - 1; j <= n; i++, j++) {
			f[i][j] = INF;
			for(int k = i; k < j; 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]);
}
复制代码
分类:
后端
标签:
分类:
后端
标签: