[动态规划-练习] 5 最长公共子序列问题详解

参考:

  1. 详解最长公共子序列问题,秒杀三道动态规划题目
  2. 两个字符串的最小 ASCII 删除和

原作者总结出来的解决算法的一个技巧:把大的问题细化到一个点,先研究在这个小的点上如何解决问题,然后再通过递归/迭代的方式扩展到整个问题

本文从「最长公共子序列问题」展开,总结三道子序列问题,解这道题仔细讲讲这种子序列问题的套路,你就能感受到这种思维方式了。

1 最长公共子序列

计算1143. 最长公共子序列(Longest Common Subsequence,简称 LCS)是一道经典的动态规划题目:

image-20210713152231518

力扣第 1143 题就是这道题,函数签名如下:

int longestCommonSubsequence(String s1, String s2);

首先,我们先考虑暴力算法:把s1s2的所有子序列都穷举出来,然后看看有没有公共的,然后在所有公共子序列里面再寻找一个长度最大的。

然后,我们在暴力算法的基础上进行改进:我们可以不要考虑整个字符串,而是细化到s1s2的每个字符

在“子序列解题模板”中有一个规律:

对于两个字符串求子序列的问题,都是用两个指针ij分别在两个字符串上移动,大概率是动态规划思路

思路:具体到每一个字符,思考每个字符该做什么

接下来,咱不要看s1s2两个字符串,而是要具体到每一个字符,思考每个字符该做什么

image-20210713160735508

我们只看s1[i]s2[j]

  1. 如果s1[i] == s2[j],说明这个字符一定在lcsimage-20210713160804821

  2. 如果s1[i] != s2[j],应该怎么办呢?s1[i] != s2[j]意味着,s1[i]s2[j]中至少有一个字符不在lcs图片

    如上图,总共可能有三种情况,我怎么知道具体是那种情况呢?

    答:其实我们也不知道,那就把这三种情况的答案都算出来,取其中结果最大的那个呗,因为题目让我们算「最长」公共子序列的长度嘛。

    这里我们可以进行一个优化,我们可以观察情况3:

    情况三在计算s1[i+1..]s2[j+1..]lcs长度,这个长度肯定是小于等于情况二s1[i..]s2[j+1..]中的lcs长度的,因为s1[i+1..]s1[i..]短嘛,那从这里面算出的lcs当然也不可能更长嘛。

    同理,情况三的结果肯定也小于等于情况一。

    也就是说,情况三被情况一和情况二包含了,所以我们可以直接忽略掉情况三。

现在我们大致思路已经有了,就是:注意对比每一个字符,它们有两种可能:

  • s1[i] == s2[j]时,LCS加1;
  • s1[i] != s2[j]时,分三种情况讨论。(实际上,情况3包含在情况1和2之中。)

代码实现

1、不使用「dp数组」,也不使用「备忘录」的动态规划:

 class Solution {
 public:
 	int longestCommonSubsequence(string text1, string text2) {
 		if (text1.size() == 0 || text2.size() == 0)	return 0;

 		return dp(text1, 0, text2, 0);

 	}

 	int dp(string text1, int i, string text2, int j) {
 		// base case
 		if (i == text1.size() || j == text2.size())
 			return 0;

 		// 状态转移
 		if (text1[i] == text2[j])
 			return dp(text1, i + 1, text2, j + 1) + 1;
 		// else if (text1[i] != text2[j]) 
         return max(
             dp(text1, i, text2, j + 1),
             max(dp(text1, i + 1, text2, j), 
                 dp(text1, i + 1, text2, j + 1)) // 这里可以优化
         );

 	}
 };

说明:

  1. 我们的dp函数含义为:

    // 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
    int dp(String s1, int i, String s2, int j)
    
  2. 情况3我们其实可以不写,但这里为了代码的直观性,还是加上了。

2、使用「dp数组」(迭代实现):

class Solution {
public:
	int longestCommonSubsequence(string text1, string text2) {
		int m = text1.size();
		int n = text2.size();
		// dp数组:dp[i][j]表示s1[i..] 和 s2[j..] 的最长公共子序列长度
		vector<vector<int>> dp(m+1, vector<int>(n+1, -1));

		// 初始情况
		for (int j = 0; j < n; j++) {
			dp[0][j] = 0;
		}
		for (int i = 0; i < m; i++) {
			dp[i][0] = 0;
		}

		// 状态转移
		// 注意i、j的值,因为我们多给dp数组增加了一行、一列,此时text的下标需要注意
		for (int i = 1; i <= m; i++) {
			int ii = i - 1;
			for (int j = 1; j <= n; j++) {
				int jj = j - 1;
				if (text1[ii] == text2[jj]) // 相等时
					dp[i][j] = dp[i - 1][j - 1] + 1;
				else if (text1[ii] != text2[jj]) { // 不相等时,分三种情况讨论
					int a = dp[i-1][j];		// 情况1:text1[i] 不在 lcs 中
					int b = dp[i][j - 1];	// 情况2:text2[j] 不在 lcs 中
					int c = dp[i-1][j - 1]; // 情况3:text1[i]、text2[j] 均不在 lcs 中

					dp[i][j] = max(a, max(b, c)); // 找出这三种情况最大的
				}
			}
		}

		return dp[m][n];

	}
};

说明:

  1. 注意我们这里的text[]dp[][]的下标。

    image-20210713164037703

    可以看到,我们声明的dp数组是dp[m+1][n+1]大小的,这是因为,我们需要考虑空字符串的情况,所以需要多一行、多一列来存储。

    text1[]的下表范围在0~ m-1text2[]的下表范围在0~ n-1。所以,在带入下标值时,需要区分dp和text的下标。

3、使用「备忘录」(递归实现):

 //添加备忘录
 class Solution {
 public:
 	// memo 备忘录:text1[i,...] 、text2[j,...]的最长公共子序列
 	vector<vector<int>> memo;

 	int longestCommonSubsequence(string text1, string text2) {
 		for (int i = 0; i < text1.size(); i++) {			
 			memo.push_back(vector<int>(text2.size(), -1));
 		}

 		if (text1.size() == 0 || text2.size() == 0)	return 0;

 		return dp(text1, 0, text2, 0);

 	}

 	int dp(string text1, int i, string text2, int j) {
 		// base case
 		if (i == text1.size() || j == text2.size())
 			return 0;

 		// 状态转移
 		if (memo[i][j] != -1)// 如果之前计算过,则直接返回备忘录中的答案
 			return memo[i][j];

 		if (text1[i] == text2[j])
 			memo[i][j] = dp(text1, i + 1, text2, j + 1) + 1;
 			// return dp(text1, i + 1, text2, j + 1) + 1;
 		else if (text1[i] != text2[j]) {
 			memo[i][j] =  max(
 				dp(text1, i, text2, j + 1),
 				max(dp(text1, i + 1, text2, j),
 					dp(text1, i + 1, text2, j + 1))
 			);
 		}

 		return memo[i][j];
 	}
 };

进阶:空间优化

这里我们可以来练习空间优化,详细的可见前文的 状态压缩 一文。

首先,看上一小节中的使用「dp数组」实现的代码。发现在求dp[i][j]时需要[i-1][j]、[i][j-1]、[i-1][j-1]位置的值

class Solution {
public:
	int longestCommonSubsequence(string text1, string text2) {
		int m = text1.size();
		int n = text2.size();
		// dp数组:dp[i][j]表示s1[i..] 和 s2[j..] 的最长公共子序列长度
		//vector<vector<int>> dp(m + 1, vector<int>(n + 1, -1));
		vector<int> dp(n + 1, -1);

		// 初始情况:相当于dp[0][j]
		for (int j = 0; j <= n; j++) {
			dp[j] = 0;
		}

		// 状态转移
		// 注意i、j的值,因为我们多给dp数组增加了一行、一列,此时text的下标需要注意
		for (int i = 1; i <= m; i++) {
			int ii = i - 1;
			int temp = dp[0];
			dp[0] = 0; // 更新,相当于每一行的第0列
			for (int j = 1; j <= n; j++) {
				int pre = temp; // pre 实际上对应dp[i-1][j-1]
				temp = dp[j];
				int jj = j - 1;
				if (text1[ii] == text2[jj]) // 相等时
					//dp[i][j] = dp[i - 1][j - 1] + 1;
					dp[j] = pre;
				else if (text1[ii] != text2[jj]) { // 不相等时,分三种情况讨论
					//int a = dp[i - 1][j];		// 情况1:text1[i] 不在 lcs 中
					int a = dp[j];
					//int b = dp[i][j - 1];	// 情况2:text2[j] 不在 lcs 中
					int b = dp[j - 1];
					//int c = dp[i - 1][j - 1]; // 情况3:text1[i]、text2[j] 均不在 lcs 中
					int c = pre;

					dp[i][j] = max(a, max(b, c)); // 找出这三种情况最大的
				}
			}
		}

		return dp[n];

	}
};

2 字符串删除操作

力扣第 583 题「两个字符串的删除操作」,看下题目:

image-20210713170630536

函数签名如下:

int minDistance(String s1, String s2);

题目让我们计算将两个字符串变得相同的最少删除次数,那我们可以思考一下,最后这两个字符串会被删成什么样子?

答:删除的结果就是它俩的最长公共子序列嘛!

所以,我们这一题可以直接套用上一题的代码,只需要加一点点即可:

class Solution {
public:
	int longestCommonSubsequence(string text1, string text2) {
		int m = text1.size();
		int n = text2.size();
		// dp数组:dp[i][j]表示s1[i..] 和 s2[j..] 的最长公共子序列长度
		vector<vector<int>> dp(m+1, vector<int>(n+1, -1));

		// 初始情况
		for (int j = 0; j < n; j++) {
			dp[0][j] = 0;
		}
		for (int i = 0; i < m; i++) {
			dp[i][0] = 0;
		}

		// 状态转移
		// 注意i、j的值,因为我们多给dp数组增加了一行、一列,此时text的下标需要注意
		for (int i = 1; i <= m; i++) {
			int ii = i - 1;
			for (int j = 1; j <= n; j++) {
				int jj = j - 1;
				if (text1[ii] == text2[jj]) // 相等时
					dp[i][j] = dp[i - 1][j - 1] + 1;
				else if (text1[ii] != text2[jj]) { // 不相等时,分三种情况讨论
					int a = dp[i-1][j];		// 情况1:text1[i] 不在 lcs 中
					int b = dp[i][j - 1];	// 情况2:text2[j] 不在 lcs 中
					int c = dp[i-1][j - 1]; // 情况3:text1[i]、text2[j] 均不在 lcs 中

					dp[i][j] = max(a, max(b, c)); // 找出这三种情况最大的
				}
			}
		}

		// return dp[m][n];
        int LCS = dp[m][n];
        return m - LCS + n - LCS;

	}
};

3 最小 ASCII 删除和

LeetCode的712. 两个字符串的最小ASCII删除和

image-20210713172258488

这道题,和上一道题非常类似,这回不问我们删除的字符个数了,问我们删除的字符的 ASCII 码加起来是多少。

那就不能直接复用计算最长公共子序列的函数了,但是可以依照之前的思路,稍微修改 base case 和状态转移部分即可直接写出解法代码

  • 我们的dp数组含义需要修改为:将 s1[i…] 和 s2[j…] 删除成相同字符串,最小的 ASCII 码之和为 dp(s1, i, s2, j)。
class Solution {
public:
	int longestCommonSubsequence(string text1, string text2) {
		int res = 0; // 存储ASCII的值

		int m = text1.size();
		int n = text2.size();
		// dp数组:dp[i][j]表示s1[i..] 和 s2[j..] 的最长公共子序列长度
		vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));

		// 初始情况
		for (int j = 1; j <= n; j++) {
			dp[0][j] = text2[j-1] + dp[0][j - 1];
		}
		for (int i = 1; i <= m; i++) {
			dp[i][0] = text1[i-1] + dp[i - 1][0];
		}

		// 状态转移
		// 注意i、j的值,因为我们多给dp数组增加了一行、一列,此时text的下标需要注意
		for (int i = 1; i <= m; i++) {
			int ii = i - 1;
			for (int j = 1; j <= n; j++) {
				int jj = j - 1;
				if (text1[ii] == text2[jj]) // 相等时
					dp[i][j] = dp[i - 1][j - 1];
				else if (text1[ii] != text2[jj]) { // 不相等时,分三种情况讨论
					int a = dp[i - 1][j] + text1[ii];		// 情况1:text1[i] 不在 lcs 中
					int b = dp[i][j - 1] + text2[jj];	// 情况2:text2[j] 不在 lcs 中
					int c = dp[i - 1][j - 1] + text1[ii] + text2[jj]; // 情况3:text1[i]、text2[j] 均不在 lcs 中

					dp[i][j] = min(a, min(b, c)); // 找出这三种情况最大的
				}
			}
		}

		return dp[m][n];
	}
};

4 小结

至此,三道子序列问题就解决完了,关键在于将问题细化到字符,根据每两个字符是否相同来判断他们是否在结果子序列中,从而避免了对所有子序列进行穷举。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值