【动态规划篇】:动态规划中的“双线叙述”--如何用状态转移解决双序列难题
✨感谢您阅读本篇文章,文章内容是个人学习笔记的整理,如果哪里有误的话还请您指正噢✨
✨ 个人主页:余辉zmh–CSDN博客
✨ 文章所属专栏:动态规划篇–CSDN博客
文章目录
- 一.双序列类DP
- 解题步骤
- 二.例题
- 1.最长公共子序列
- 2.不相交的线
- 3.不同的子序列
- 4.通配符匹配
- 5.正则表达式
- 6.交错字符串
- 7.两个字符串的最小ASCLL码删除和
- 8.最长重复子数组
一.双序列类DP
双序列通常是两个字符串或者两个数组,处理这类的DP,通常涉及到比较,匹配或转换操作。
解题步骤
-
1.状态表示:
一般是二维数组,表示处理到两个序列的某个位置时的状态。
-
2.状态转移方程:
从某段区间分析,根据当前字符是否相等以及可能的操作(如替换,插入,删除)来决定状态如何转换。
-
3.初始化:
对于两个序列,都存在为空的情况,需要单独处理为空的状态值。
-
4.填表顺序:
根据状态转移方程用到前状态的位置来决定填表顺序,按顺序填充状态表。
-
5.返回值:
根据题意要求和状态表示确定返回值,可能是返回最后一个状态值也可能是找到状态表中的最大值等。
二.例题
1.最长公共子序列
题目:
算法原理:
本道题属于该类型的模板题,最经典的二维状态表表示双序列的状态,后面的题都是在此基础上进行变形。
对于双序列的分析,都是根据某段区间的情况,推出状态转移方程。
代码实现:
int longestCommonSubsequence(string s1, string s2){
int m = s1.size(), n = s2.size();
//状态表示 dp[i][j]表示s1[0,i]区间和s2[0,j]区间的所有公共子序列中,最长的公共子序列长度
//添加第0行和第0列,表示空字符串
//初始化为0
vector<vector<int>> dp(m + 1, vector<int>(n + 1));
//使下标映射正确
s1 = ' ' + s1;
s2 = ' ' + s2;
//填表
for (int i = 1; i <= m; i++){
for (int j = 1; j <= n; j++){
//状态转移方程 分情况讨论
if (s1[i] == s2[j]){
dp[i][j] = dp[i - 1][j - 1] + 1;
}
else{
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
//返回值
return dp[m][n];
}
2.不相交的线
题目:
算法原理:
根据题意,将两个数组中相同的数字两线,但是不能存在相交的连线,要求找到最多的连线。转换一下其实就是找两个数组的最长公共子序列的长度,这样的连线一定不会存在相交。所以本道题和上面一道题可以说是完全相同,连状态表示和状态转移的推导都一样,只不过上一题是两个字符串,本道题是两个数组,但是处理方式还是一样的,这里就不再重复讲解,可以根据上一题的讲解来看。
代码实现:
int maxUncrossedLines(vector<int>& nums1, vector<int>& nums2){
int m = nums1.size(), n = nums2.size();
//状态表示 dp[i][j]表示nums1区间[0,i]和nums2区间[0,j]可以连线的个数
//初始化 初始值设置为0
vector<vector<int>> dp(m + 1, vector<int>(n + 1));
//填表
for (int i = 1; i <= m; i++){
for (int j = 1; j <= n; j++){
//状态转移方程 分情况讨论
if (nums1[i - 1] == nums2[j - 1]){
dp[i][j] = dp[i - 1][j - 1] + 1;
}
else{
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
//返回值
return dp[m][n];
}
3.不同的子序列
题目:
算法原理:
本道题是判断其中一个字符串中有多少个子串和另一个字符串相等,还是从某段区间分析。
状态表示: dp[i][j]
表示s区间[0,j]内的所有子序列中有多少个和t区间[0,i]的子串相等。
初始化: 添加第0行和第0列 第0行表示t字符串为空,则s字符串中一定存在一个子序列空串与t字符串相等,第0行初始值设置为1;第0列表示s字符串为空,则s字符串一定不存在子序列与t字符串相等,第0列初始值设置为0。
状态转移方程:根据当前字符s[j]
包含还是不包含分情况讨论,如果不包含,找剩余区间中的相等个数(dp[i][j-1]
);如果包含,需要先判断当前位置字符是否相等(s[j-1]==t[i-1]
),然后找剩余区间中的相等个数(dp[i-1][j-1]
);两种情况的个数相加就是当前位置的状态值。
填表顺序:从第一行到最后一行,其中每一行从左往右。
返回值:dp[n][m]
,其中m是s字符串的长度,n是t字符串的长度。
代码实现:
const int num = 1000000007;
int numDistinct(string s, string t){
int m = s.size(), n = t.size();
//状态表示 dp[i][j]表示s区间[0,j]内的所有子序列中有多少个和t区间[0,i]的子串相等
//初始化 添加第0行和第0列 第0行表示t字符串为空,则s字符串中一定存在一个子序列空串与t字符串相等,第0行初始值设置为1
//第0列表示s字符串为空,则s字符串一定不存在子序列与t字符串相等,第0列初始值设置为0
vector<vector<int>> dp(n + 1, vector<int>(m + 1));
for (int i = 0; i <= m; i++){
dp[0][i] = 1;
}
//填表
for (int i = 1; i <= n; i++){
for (int j = 1; j <= m; j++){
//如果不包含当前s[j]字符
dp[i][j] = dp[i][j - 1];
//如果包含当前s[j]字符 需要先判断是否相等
if (s[j - 1] == t[i - 1]){
dp[i][j] += dp[i - 1][j - 1];
}
dp[i][j] %= num;
}
}
//返回值
return dp[n][m];
}
4.通配符匹配
题目:
算法原理:
本道题属于两个字符串的匹配问题,先从某段区间考虑,分析某段区间的匹配情况,从而推导出状态转移方程。因为算法原理较复杂,这里用画图讲解,如图所示:
代码实现:
bool isMatch(string s, string p){
int m = s.size(), n = p.size();
//状态表示 dp[i][j]表示p[0,j]区间的子串能否匹配s[0,i]区间的子串
vector<vector<bool>> dp(m + 1, vector<bool>(n + 1,false));
//使下标映射正确
s = " " + s;
p = " " + p;
//初始化
dp[0][0] = true;
for (int j = 1; j <= n; j++){
if(p[j]=='*'){
dp[0][j] = true;
}
else{
break;
}
}
//填表
for (int i = 1; i <= m; i++){
for (int j = 1; j <= n; j++){
//状态转移方程 分情况讨论
if (p[j] == '*'){
dp[i][j] = dp[i - 1][j] || dp[i][j - 1];
}
else{
dp[i][j] = (p[j] == '?' || p[j] == s[i]) && dp[i - 1][j - 1];
}
}
}
//返回值
return dp[m][n];
}
5.正则表达式
题目:
算法原理:
本道题和上一题属于一类两个字符串的匹配问题,和上一题其实还是有点相似的,状态表示其实还是相同,根据某段区间的匹配情况来分析,只是状态转移方程中某些情况的分析不同,这里还是用画图讲解,如图所示:
代码实现:
bool isMatch(string s, string p){
int m = s.size(), n = p.size();
//状态表示 dp[i][j]表示p[0,j]区间的子串能否匹配s[0,i]区间的子串
//添加第0行和第0列表示两个字符串为空的情况
vector<vector<bool>> dp(m + 1, vector<bool>(n + 1));
//使下标映射正确
s = " " + s;
p = " " + p;
//初始化
dp[0][0] = true;
for (int j = 2; j <= n; j += 2){
if (p[j] == '*'){
dp[0][j] = true;
}
//遇到非星号直接结束,剩余全为false
else{
break;
}
}
//填表
for (int i = 1; i <= m; i++){
for (int j = 1; j <= n; j++){
//状态转移方程 分情况讨论
if (p[j] == '*'){
dp[i][j] = dp[i][j - 2] || (p[j - 1] == '.' || p[j - 1] == s[i]) && dp[i - 1][j];
}
else{
dp[i][j] = (p[j] == '.' || p[j] == s[i]) && dp[i - 1][j - 1];
}
}
}
//返回值
return dp[m][n];
}
6.交错字符串
题目:
算法原理:
本道题虽然是三个字符串,但是第三个字符串是由前两个字符串拼接而成的,所以还是属于两个字符串类的DP问题,这里还是从某段区间考虑,分析前两个字符串的某段区间的子串能否拼接成第三个字符串的某段区间的子串。这里用画图讲解,如图所示:
代码实现:
bool isInterleave(string s1, string s2, string s3){
int m = s1.size(), n = s2.size();
//预处理 下标0表示空串的情况
s1 = " " + s1;
s2 = " " + s2;
//状态表示 dp[i][j]表示s1[1,i]区间的子串和s2[1,j]区间的子串能否拼成s3[1,i+j]区间的子串
vector<vector<bool>> dp(m + 1, vector<bool>(n + 1,false));
//初始化
dp[0][0] = true;
for (int i = 1; i <= m; i++){
if (s1[i] == s3[i]){
dp[i][0] == true;
}
else{
break;
}
}
for (int j = 1; j <= n; j++){
if (s2[j] == s3[j]){
dp[0][j] = true;
}
else{
break;
}
}
//填表
for (int i = 1; i <= m; i++){
for (int j = 1; j <= n; j++){
//状态表示 根据s3最后一个字符分两种情况,满足其中一种即可
//不能用两个if语句判断
dp[i][j] = (s1[i] == s3[i + j] && dp[i - 1][j]) || (s2[j] == s3[i + j] && dp[i][j - 1]);
}
}
//返回值
return dp[m][n];
}
7.两个字符串的最小ASCLL码删除和
题目:
算法原理:
本道题第一眼可能没什么思路,但是反着来看就会比较简单,这里用到了正难则反的思想。如果反着来看就是找两个字符串的公共子序列最大ASCLL码和,然后用两个字符串总的ASCLL码和减去二倍的公共子序列中的最大ASCLL码和(这里减去二倍是因为存在两个字符串,需要减去两次公共子序列的最大ASCLL码和),因此本道题就转换成了找两个字符串的公共子序列的最大ASCLL码和。还是从某段区间来分析。
状态表示: dp[i][j]
表示s1区间[0,i]和s2区间[0,j]的所有子序列中,最大ASCLL码和的公共子序列。
初始化:添加第一行,表示s1为空串的情况,添加第一列,表示s2为空串的情况,当其中一个为空串时,公共子序列也为空,最大ASCLL码和就为0,所以初始值全部设置为0。
状态转移方程:根据两个字符串最后一个位置的字符s1[i]
和s2[j]
选还是不选分情况讨论,第一种情况:如果两个相等选一个即可,就找前一个位置的状态然后加上当前字符的ASCLL码;第二种情况:如果不相等,选择s1[i]
字符,找剩余区间内的状态值(dp[i-1][j]
);第三种情况:如果不相等,选择s2[j]
字符,找剩余区间内的状态值(dp[i][j-1]
);第四种情况:如果不相等,两个都不选,找剩余区间内的状态值(dp[i-1][j-1]
),但是这种情况在第二种和第三种情况中都已经包括该区间,所以可以省去。最后取三种情况中的最大值。
填表顺序:从第一行到最后一行,其中每一行从左往右。
返回值:两个字符串总的ASCLL码和减去二倍的公共子序列中的最大ASCLL码和(sum-2*dp[m][n]
)。
代码实现:
int minimumDeleteSum(string s1, string s2){
//正难则反思想 找两个字符串的公共子序列最大ASCLL码和
int m = s1.size(), n = s2.size();
//状态表示 dp[i][j]表示s1区间[0,i]和s2区间[0,j]的所有子序列中,最大ASCLL码和的公共子序列
//添加第一行,表示s1为空串的情况,添加第一列,表示s2为空串的情况
vector<vector<int>> dp(m + 1, vector<int>(n + 1));
//填表
for (int i = 1; i <= m; i++){
for (int j = 1; j <= n; j++){
//状态转移方程
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
if (s1[i - 1] == s2[j - 1]){
dp[i][j] = max(dp[i - 1][j - 1] + s1[i-1], dp[i][j]);
}
}
}
//统计两个字符串总的ASCLL码和
int sum = 0;
for(auto ch : s1){
sum += ch;
}
for(auto ch : s2){
sum += ch;
}
//返回值,总的和减去两个最大和公共子序列就是需要最小删除的和
return sum - 2 * dp[m][n];
}
8.最长重复子数组
题目:
算法原理:
本道题第一眼看可能会觉得和最长公共子序列差不多,都是要找两个数组或字符公共的,但是这里有一个不同点,对于最长公共子序列那道题要找的是子序列,而本道题要找到是子数组,这两个的区别就是,子数组以nums[i]为结尾时,前一个必须是nums[i-1],相邻值才是子数组;而子序列以nums[i]为结尾时,前一个不一定是nums[i-1],可以是nums[i-2]也可以是nums[i-3]等等许多情况。因此本道题的状态表示就不能和前面的几道题一样,从某段区间中的子序列分析,而是必须以当前位置元素为结尾来分析。
状态表示 dp[i][j]
表示nums1以i位置元素为结尾和nums2以j位置元素为结尾的公共子数组中的,最长的公共子数组长度
初始化 添加第0行和第0列,表示两个数组为空数组的情况,初始值设置为0
状态转移方程:根据两个数组当前位置元素来分析,如果nums[i-1]==nums[j-1]
(因为添加了第0行和第0列,映射到原数组中下标要减一),只需找到以前一个位置元素为结尾时的最长公共子数组长度(dp[i-1][j-1]
)然后加一即可。如果nums[i-1]!=nums[j-1]
,因为状态表示是以当前位置元素为结尾的公共子数组,既然结尾的两个元素都不想等,所以就不存在公共子数组,长度就为0。
填表顺序:从第一行到最后一行,其中每一行从左往后。
返回值:因为不知道最长的公共子数组是以哪个位置元素为结尾的,所以需要遍历整个状态表,找到最大值返回。
代码实现:
int findLength(vector<int>& nums1, vector<int>& nums2){
int m = nums1.size(), n = nums2.size();
//状态表示 dp[i][j]表示nums1以i位置为结尾和nums2以j位置为结尾的公共子数组中的,最长的公共子数组长度
//初始化 添加第0行和第0列,表示两个数组为空数组的情况,初始值设置为0
vector<vector<int>> dp(m + 1, vector<int>(n + 1));
//返回值 因为不确定最长的公共子数组以那两个位置为结尾,所以需要找到状态表中的最大值
int ret = 0;
//填表
for (int i = 1; i <= m; i++){
for (int j = 1; j <= n; j++){
if (nums1[i - 1] == nums2[j - 1]){
dp[i][j] = dp[i - 1][j - 1] + 1;
}
ret = max(ret, dp[i][j]);
}
}
return ret;
}
以上就是关于双序列类DP例题的讲解,如果哪里有错的话,可以在评论区指正,也欢迎大家一起讨论学习,如果对你的学习有帮助的话,点点赞关注支持一下吧!!!