Acwing - 算法基础课 - 笔记(动态规划 · 二)

文章目录

    • 动态规划(二)
      • 线性DP
        • 数字三角形
        • 最长上升子序列
        • 最长上升子序列II
        • 最长公共子序列
        • 最短编辑距离
        • 编辑距离
      • 区间DP
        • 石子合并
      • 计数类DP
        • 整数划分

动态规划(二)

今天是讲线性DP和区间DP

线性DP

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

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

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

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

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

数字三角形

题目链接

Acwing - 算法基础课 - 笔记(动态规划 · 二)_第1张图片

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

分析:

三角形一共有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 

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 

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,i1]

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

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

#include 
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 

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;
}
最长公共子序列

题目链接

给定两个长度分别为 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个中的最大者。

Acwing - 算法基础课 - 笔记(动态规划 · 二)_第2张图片

其中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 
#include 

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]);
}

----2022/06/01 更新, 滚动数组优化
观察上述的状态转移方程可以发现,状态f[i][j]只和f[i-1][j]f[i][j - 1]f[i - 1][j - 1] 这三个状态有关。即f[i][j]只和上一行当前列当前行左侧一列,以及上一行左侧一列,这三个状态有关。故我们可以考虑用滚动数组的思想来优化空间。由于需要依赖当前行左侧的状态,则我们需要从左往右更新状态,而依赖上一行当前列,则我们从上往下更新状态,对于左上角的状态,我们用一个临时变量进行存储即可。

#include 
#include 
using namespace std;
const int N = 1010;
int f[N];

int main() {
    int n, m;
    string a, b;
    cin >> n >> m;
    cin >> a >> b;

    for (int i = 1; i <= n; i++) {
        int old = 0, temp = 0; // 存储左上角的状态, 注意要定义在这里, 注意每一轮更新时, 需要重置为0
        for (int j = 1; j <= m; j++) {
            temp = f[j];// 暂存当前这一列, 作为左上角
            f[j] = max(f[j - 1], f[j]); // 左侧, 上侧
            if (a[i - 1] == b[j - 1]) f[j] = max(f[j], old + 1); // 左上角
            // 将当前列作为下一次迭代的左上角
            old = temp;
        }
    }

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

练习题:

最短编辑距离

题目链接

使用f[i][j]表示,将字符串a1i,变成字符串b1j,的所有操作方式。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 

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 
#include 

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,j1] ,而其中的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 
#include 

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属于1n,共n种物品,每种物品体积为i,每种物品能使用无限次,背包的体积为n,问恰好能装满背包的物品选法的方案总数。

#include 

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

int n, f[N][N];

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

	// 选出体积恰好为0的方案数,为1,所有数都不选也是一种方案
	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;
}

同样的,可以采用一维数组优化

#include 
using namespace std;
const int N = 1010, MOD = 1e9 + 7;

int f[N];

int main() {

	int n;
	cin >> n;
	
	f[0] = 1;

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

	printf("%d\n", f[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]

可以理解为,在最小值是1的方案中,只需要去掉1这个数,数的个数变成了j - 1,和变成了i - 1;在最小值大于1的方案中,只需要把每个数都减掉1,则和变成了i - j,数的个数仍然是j

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

至于边界条件f[0][0] = 1,可以直接列举最简单的状态,然后根据状态转移方程来反推。比如列举状态f[1][1],它的值毫无疑问是1,而根据状态转移方程,f[1][1] = f[0][0] + f[0][1]。能够知道f[0][0]或者f[0][1]的值应该是1。
再列举f[1][j] = 0j > 1),列举f[i][1] = 1i >1),容易得出只需要将f[0][0]设为1就行了。

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

#include 
using namespace std;
const int N = 1010, MOD = 1e9 + 7;
int f[N][N];
int n;

int main() {
    cin >> n;

    f[0][0] = 1;

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

    int ans = 0;

    for (int i = 1; i <= n; i++) {
        ans = (ans + f[n][i]) % MOD;
    }
    printf("%d\n", ans);
    return 0;
}

你可能感兴趣的:(算法,Acwing算法基础课,算法,动态规划)