文章目录
- 最长公共子序列(LCS)
- 编辑距离(Edit Distance)
- 总结
- 相关题目练习
- 583. 两个字符串的删除操作 https://leetcode.cn/problems/delete-operation-for-two-strings/
- 712. 两个字符串的最小ASCII删除和 https://leetcode.cn/problems/minimum-ascii-delete-sum-for-two-strings/
- 解法1:求编辑距离
- 解法2:求最长子序列
- 1458. 两个子序列的最大点积 https://leetcode.cn/problems/max-dot-product-of-two-subsequences/
- 97. 交错字符串 https://leetcode.cn/problems/interleaving-string/
本文记录这种两个序列之间相互比较的 dp 题目的通用模板。
最长公共子序列(LCS)
1143. 最长公共子序列
递推公式如下
为了避免出现负数下标,因此定义 dp 数组时定义成 dp[m + 1][n + 1]。
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length(), n = text2.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[m][n];
}
}
可以优化成一维 dp 数组。
因为 dp[i][j] 的取值只和 左上角,上面 和 左边 这三个位置的数字有关,只需要引入一个额外的变量 pre 记录一下遍历过程中被覆盖的 dp[j]作为下一次左上角的值 就好了。
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length(), n = text2.length();
int[] dp = new int[n + 1];
for (int i = 1; i <= m; ++i) {
int pre = dp[0];
for (int j = 1; j <= n; ++j) {
int tmp = dp[j];
dp[j] = text1.charAt(i - 1) == text2.charAt(j - 1)? pre + 1: Math.max(dp[j], dp[j - 1]);
pre = tmp;
}
}
return dp[n];
}
}
编辑距离(Edit Distance)
72. 编辑距离
先定义 dp[i][j] 表示 word1 从0~i,word2 从0~j 需要的最少操作数。
递推公式如下:
min() 中的三个 分别对应 删除、添加、替换 这三种操作。
class Solution {
public int minDistance(String word1, String word2) {
int m = word1.length(), n = word2.length();
int[][] dp = new int[m + 1][n + 1];
// dp数组初始化
for (int i = 0; i <= m; ++i) dp[i][0] = i;
for (int j = 0; j <= n; ++j) dp[0][j] = j;
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (word1.charAt(i - 1) == word2.charAt(j - 1)) dp[i][j] = dp[i - 1][j - 1];
else dp[i][j] = Math.min(dp[i - 1][j - 1], Math.min(dp[i - 1][j], dp[i][j - 1])) + 1;
}
}
return dp[m][n];
}
}
继续优化成一维 dp 数组
class Solution {
public int minDistance(String word1, String word2) {
int m = word1.length(), n = word2.length();
int[] dp = new int[n + 1];
for (int j = 0; j <= n; ++j) dp[j] = j;
for (int i = 1; i <= m; ++i) {
dp[0] = i; // 注意这里的dp[0]要随着i的变化更新
int pre = dp[0] - 1; // 引入额外的pre记录被覆盖的dp[i-1][j-1]
for (int j = 1; j <= n; ++j) {
int tmp = dp[j];
if (word1.charAt(i - 1) == word2.charAt(j - 1)) dp[j] = pre;
else dp[j] = Math.min(pre, Math.min(dp[j], dp[j - 1])) + 1;
pre = tmp;
}
}
return dp[n];
}
}
总结
这两道题目的类似点在于都是比较两个序列之间的内容,这种情况下通常定义 dp 数组为:
dp[m + 1][n + 1] (之所以 + 1 是为了方式遍历的过程中出现负数下标)
与之对应,遍历元素的时候,使用的下标分别是 i - 1 和 j - 1。(因为原始数据的数据范围还是 0 ~ m 和 0~ n)
由于无后效性,即 dp[i][j] 的数值只与和它接近的几个数字有关,因此可以优化 dp 数组的空间。
相关题目练习
583. 两个字符串的删除操作 https://leetcode.cn/problems/delete-operation-for-two-strings/
https://leetcode.cn/problems/delete-operation-for-two-strings/
和 72. 编辑距离 这道题目很像,唯一的区别在于,可以使用的操作只有删除任意一个字符串中的一个元素。
class Solution {
public int minDistance(String word1, String word2) {
int m = word1.length(), n = word2.length();
int[][] dp = new int[m + 1][n + 1];
// dp数组初始化
for (int i = 0; i <= m; ++i) dp[i][0] = i;
for (int j = 0; j <= n; ++j) dp[0][j] = j;
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (word1.charAt(i - 1) == word2.charAt(j - 1)) dp[i][j] = dp[i - 1][j - 1];
else dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + 1;
}
}
return dp[m][n];
}
}
直接把 72. 编辑距离 这道题目的答案复制过来,删除 dp[i - 1][j - 1] 到 dp[i][j] 的转移即可(对应着这道题没有替换元素的操作)
712. 两个字符串的最小ASCII删除和 https://leetcode.cn/problems/minimum-ascii-delete-sum-for-two-strings/
https://leetcode.cn/problems/minimum-ascii-delete-sum-for-two-strings/
解法1:求编辑距离
同样是只有删除操作的编辑距离,除此之外,每次操作需要的花费变成了字符的 ASCII 码。
class Solution {
public int minimumDeleteSum(String s1, String s2) {
int m = s1.length(), n = s2.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 1; i <= m; ++i) dp[i][0] = dp[i - 1][0] + s1.charAt(i - 1);
for (int j = 1; j <= n; ++j) dp[0][j] = dp[0][j - 1] + s2.charAt(j - 1);
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
char a = s1.charAt(i - 1), b = s2.charAt(j - 1);
if (a == b) dp[i][j] = dp[i - 1][j - 1];
else dp[i][j] = Math.min(dp[i - 1][j] + a, dp[i][j - 1] + b);
}
}
return dp[m][n];
}
}
解法2:求最长子序列
这一题乍一看和上一题很像! 但其实应该反过来想,这道题求的也可以是最长公共子序列,这样剩下的就是需要被删除的字符了。
class Solution {
public int minimumDeleteSum(String s1, String s2) {
int m = s1.length(), n = s2.length(), sum = 0;
int[][] dp = new int[m + 1][n + 1];
for (char ch: s1.toCharArray()) sum += ch;
for (char ch: s2.toCharArray()) sum += ch;
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
char a = s1.charAt(i - 1), b = s2.charAt(j - 1);
if (a == b) dp[i][j] = dp[i - 1][j - 1] + a + b;
else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
return sum - dp[m][n];
}
}
1458. 两个子序列的最大点积 https://leetcode.cn/problems/max-dot-product-of-two-subsequences/
https://leetcode.cn/problems/max-dot-product-of-two-subsequences/
这道题目有两点需要注意,子序列要求:1.长度相同 2.非空。
这道题目一开始写成了:
class Solution {
public int maxDotProduct(int[] nums1, int[] nums2) {
int m = nums1.length, n = nums2.length;
int[][] dp = new int[m + 1][n + 1];
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
int v = nums1[i - 1] * nums2[j - 1];
dp[i][j] = Math.max(v + dp[i - 1][j - 1], dp[i][j]);
}
}
return dp[m][n];
}
}
结果发现答案要求是非空的,也就是如果只有负数的话,那没办法,负数也得选进去。
正解如下:
class Solution {
public int maxDotProduct(int[] nums1, int[] nums2) {
int m = nums1.length, n = nums2.length;
int[][] dp = new int[m + 1][n + 1];
for (int i = 0; i <= m; ++i) Arrays.fill(dp[i], Integer.MIN_VALUE / 2); // 因为负数也得选,就先都设成一个比较小的数字
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
dp[i][j] = nums1[i - 1] * nums2[j - 1]; // 至少也得选一个
dp[i][j] = Math.max(Math.max(dp[i - 1][j], dp[i][j - 1]), dp[i][j]);
dp[i][j] = Math.max(nums1[i - 1] * nums2[j - 1] + dp[i - 1][j - 1], dp[i][j]);
}
}
return dp[m][n];
}
}
这道题目做起来手感怪怪的。
97. 交错字符串 https://leetcode.cn/problems/interleaving-string/
https://leetcode.cn/problems/interleaving-string/
dp[i][j] 表示 s1 的前 i 个元素 和 s2 的前 j 个元素 能否组成 s3 的前 i + j 个元素。
class Solution {
public boolean isInterleave(String s1, String s2, String s3) {
int m = s1.length(), n = s2.length(), t = s3.length();
if (m + n != t) return false;
boolean[][] dp = new boolean[m + 1][n + 1]; // 表示能组合成的序列长度
dp[0][0] = true;
for (int i = 0; i <= m; ++i) {
for (int j = 0; j <= n; ++j) {
if (i > 0 && s1.charAt(i - 1) == s3.charAt(i + j - 1)) dp[i][j] |= dp[i - 1][j];
if (j > 0 && s2.charAt(j - 1) == s3.charAt(i + j - 1)) dp[i][j] |= dp[i][j - 1];
}
}
return dp[m][n];
}
}
注意这个时候 i 和 j 的 for 循环的起始点都是 0 而不是 1 了。
关于 s3 的下标为什么是 i + j - 1。举个例子,当 i = 1, j = 1,此时对应的两个下标都是0,那么这两个合起来之后对应 s3 的下标是 i + j - 2 和 i + j - 1。