🎯导读:本文档探讨了动态规划在解决数组与字符串匹配问题中的应用,特别是针对最长重复子数组、最长公共子序列及判断子序列这三个经典问题。文中详细介绍了每种问题的背景、核心思路及其对应的Java实现代码,并通过图示和实例解释了动态规划表(DP Table)的构建与优化方法,如使用滚动数组减少空间复杂度。此外,对于判断子序列问题,文档还提供了双指针法的简洁解决方案。这些技术不仅有助于理解动态规划的本质,也为解决类似问题提供了宝贵的编程技巧和优化思路。
最长重复子数组
需要注意,题目要求的是数组,数组在内存中是连续中,默认要求子数组的元素在原来数组中必须连续
【思路】
dpArr[i][j]:只取nums1的前 i 个(即nums1[0,i-1]),nums2的前 j 个(即nums2[0,j-1]),两者的最长重复子数组元素个数。- 当
nums1的第 i 个元素和nums2的第 j 个元素相同时,即nums1[i - 1] == nums2[j - 1]时。数量 = 不取 nums1 的第 i 个元素和不取 nums2 的第 j 个元素的最长重复子数组元素个数 + 1,即dpArr[i][j] = dpArr[i - 1][j - 1] + 1;,此时认为在上一个子数组的基础上,添加当前元素
public int findLength(int[] nums1, int[] nums2) {
int max = 0;
// dpArr[i][j]:只取nums1的前i个,nums2的前j个,两者的最长重复子数组元素个数
int[][] dpArr = new int[nums1.length + 1][nums2.length + 1];
for (int i = 1; i <= nums1.length; i++) {
for (int j = 1; j <= nums2.length; j++) {
// 为啥是 nums1[i - 1] == nums2[j - 1] ,这么想:第一个元素,索引是不是0
if (nums1[i - 1] == nums2[j - 1]) {
// --if-- 如果两个组的当前遍历到的元素相同,说明数量 = 不取 nums1 的第 i 个元素和不取 nums2 的第 j 个元素的最长重复子数组元素个数 + 1
dpArr[i][j] = dpArr[i - 1][j - 1] + 1;
}
// 更新最长重复子数组元素个数
if (dpArr[i][j] > max) max = dpArr[i][j];
}
}
return max;
}
【优化:滚动数组】
- 如果是滚动数组,遍历的时候需要倒序遍历,避免替换了前面的元素
- 当遍历到的元素不同时,需要将 dpArr[j] 置为0。因此当前遍历元素不同时,无法与之前的子数组再连续,因此从此刻开始,重复子数组的元素又变成0了
public int findLength(int[] nums1, int[] nums2) {
int max = 0;
// dpArr[i][j]:只取nums1的前i个,nums2的前j个,两者的最长重复子数组元素个数
int[] dpArr = new int[nums2.length + 1];
for (int i = 1; i <= nums1.length; i++) {
for (int j = nums2.length; j >= 1; j--) {
// 为啥是 nums1[i - 1] == nums2[j - 1] ,这么想:第一个元素,索引是不是0
if (nums1[i - 1] == nums2[j - 1]) {
// --if-- 如果两个组的当前遍历到的元素相同,说明数量 = 不取 nums1 的第 i 个元素和不取 nums2 的第 j 个元素的最长重复子数组元素个数 + 1
dpArr[j] = dpArr[j - 1] + 1;
} else {
// 因为题目要求的是,子数组在原数组中是连续的,当 nums1[i - 1] != nums2[j - 1] 时,将 dpArr[j] 设置为 0
dpArr[j] = 0;
}
// 更新最长重复子数组元素个数
if (dpArr[j] > max) max = dpArr[j];
}
}
return max;
}
[※]最长公共子序列
【思路】
- 该题和上题是有很大的共同点的,只是一个是数字数组,一个相当于是字符数组
- 但是有一个明显的区别是:上一题的重复子数组在原来的数组中是连续的,但在该题中,子序列中的各个字符在原来的字符串中不需要连续
- 因此递推公式在遍历到两个字符不同时有区别,即:如果两个字符串的当前遍历到的字符不相同,则数量=max(不取 text1 的第 i 个字符的最长公共子序列长度, 不取 text2 的第 j 个字符的最长公共子序列长度),
dpArr[i][j] = Math.max(dpArr[i - 1][j], dpArr[i][j - 1]); - 而在遍历到两个字符相同时,处理方法和上题是相同的,即
dpArr[i][j] = dpArr[i - 1][j - 1] + 1; - 因为该题一定是
dpArr[text1.length()][text2.length()]最大,因此没有必要每次更新max,直接返回dpArr[text1.length()][text2.length()]即可
public int longestCommonSubsequence(String text1, String text2) {
// dpArr[i][j]:只取 text1 的前i个字符,text2 的前j个字符,两者的最长公共子序列长度
int[][] dpArr = new int[text1.length() + 1][text2.length() + 1];
for (int i = 1; i <= text1.length(); i++) {
char c1 = text1.charAt(i - 1);
for (int j = 1; j <= text2.length(); j++) {
if (c1 == text2.charAt(j - 1)) {
// --if-- 如果两个字符串的当前遍历到的字符相同,说明数量 = 不取 text1 的第 i 个字符和不取 text2 的第 j 个字符的最长公共子序列长度 + 1
dpArr[i][j] = dpArr[i - 1][j - 1] + 1;
} else {
// --if-- 如果两个字符串的当前遍历到的字符不相同,则数量=max(不取 text1 的第 i 个字符的最长公共子序列长度, 不取 text2 的第 j 个字符的最长公共子序列长度)
dpArr[i][j] = Math.max(dpArr[i - 1][j], dpArr[i][j - 1]);
}
}
}
// 返回最大数量
return dpArr[text1.length()][text2.length()];
}
【滚动数组】
该题的降维和背包问题还是有区别的,即不能简单将内层循环从正序遍历改成倒序遍历
- 背包问题递推:
dpArr[i][j] = Math.max(dpArr[i - 1][j], dpArr[i - 1][j - spaceArr[i]] + valueArr[i]);,第dpArr[i][j]只依赖于第i-1行来递推 - 该题:
dpArr[i][j] = Math.max(dpArr[i - 1][j], dpArr[i][j - 1]);,即dpArr[i][j]不只依赖于第i-1行来递推,还依赖dpArr[i][j]只依赖于第i行的j - 1列来递推。这样就不能倒序遍历了,因为这里要求是要先更新dpArr[i][j - 1],再更新dpArr[i][j],所以只能是正序遍历,即先更新dpArr[j - 1],再更新dpArr[j]。但你想一下dpArr[j - 1]是啥,是不是原来的dpArr[i - 1][j - 1],因此如果正序遍历的话,dpArr[i - 1][j - 1]就会被覆盖了,因此在遍历的过程中,需要用一个变量pre来记住dpArr[i - 1][j - 1]
public int longestCommonSubsequence(String text1, String text2) {
// dpArr[i][j]:只取 text1 的前i个字符,text2 的前j个字符,两者的最长公共子序列长度
int[] dpArr = new int[text2.length() + 1];
for (int i = 1; i <= text1.length(); i++) {
char c1 = text1.charAt(i - 1);
int pre = 0;
for (int j = 1; j <= text2.length(); j++) {
// 将 dpArr[i][j] 记录一下
int cur = dpArr[j];
if (c1 == text2.charAt(j - 1)) {
// --if-- 如果两个字符串的当前遍历到的字符相同,说明数量 = 不取 text1 的第 i 个字符和不取 text2 的第 j 个字符的最长公共子序列长度 + 1
dpArr[j] = pre + 1;
} else {
// --if-- 如果两个字符串的当前遍历到的字符不相同,则数量=max(不取 text1 的第 i 个字符的最长公共子序列长度, 不取 text2 的第 j 个字符的最长公共子序列长度)
dpArr[j] = Math.max(dpArr[j], dpArr[j - 1]);
}
pre = cur;
}
}
// 返回最大数量
return dpArr[text2.length()];
}
判断子序列
动态规划
思路一
直接用最长公共子序列的代码,如果说最终的最长公共子序列长度等于s的长度,就返回true,否则返回false
public boolean isSubsequence(String s, String t) {
// dpArr[i][j]:只取 s 的前i个字符,t 的前j个字符,两者的最长公共子序列长度
int[] dpArr = new int[t.length() + 1];
for (int i = 1; i <= s.length(); i++) {
char c1 = s.charAt(i - 1);
int pre = 0;
for (int j = 1; j <= t.length(); j++) {
// 将 dpArr[i][j] 记录一下
int cur = dpArr[j];
if (c1 == t.charAt(j - 1)) {
// --if-- 如果两个字符串的当前遍历到的字符相同,说明数量 = 不取 s 的第 i 个字符和不取 t 的第 j 个字符的最长公共子序列长度 + 1
dpArr[j] = pre + 1;
} else {
// --if-- 如果两个字符串的当前遍历到的字符不相同,则数量=max(不取 s 的第 i 个字符的最长公共子序列长度, 不取 t 的第 j 个字符的最长公共子序列长度)
dpArr[j] = Math.max(dpArr[j], dpArr[j - 1]);
}
pre = cur;
}
}
// 返回最大数量
return dpArr[t.length()] == s.length();
}
思路二
在最长公共子序列的代码的基础上,递推公式从
dpArr[i][j] = Math.max(dpArr[i - 1][j], dpArr[i][j - 1]);简化为dpArr[i][j] = dpArr[i][j - 1];,为什么可以这样?
难道在这道题里面,dpArr[i - 1][j]一直小于等于dpArr[i][j - 1]?
想要验证很简单。如果说dpArr[i - 1][j] > dpArr[i][j - 1],报错就可以
public boolean isSubsequence(String s, String t) {
// dpArr[i][j]:只取 s 的前i个字符,t 的前j个字符,两者的最长公共子序列长度
int[][] dpArr = new int[s.length() + 1][t.length() + 1];
for (int i = 1; i <= s.length(); i++) {
char c1 = s.charAt(i - 1);
for (int j = 1; j <= t.length(); j++) {
if (c1 == t.charAt(j - 1)) {
// --if-- 如果两个字符串的当前遍历到的字符相同,说明数量 = 不取 s 的第 i 个字符和不取 t 的第 j 个字符的最长公共子序列长度 + 1
dpArr[i][j] = dpArr[i - 1][j - 1] + 1;
} else {
dpArr[i][j] = Math.max(dpArr[i - 1][j], dpArr[i][j - 1]);
if (dpArr[i - 1][j] > dpArr[i][j - 1]) {
System.out.println(1 / 0);
}
}
}
}
// 返回最大数量
return dpArr[s.length()][t.length()] == s.length();
}
进行实验,发现报错了,也就是说dpArr[i - 1][j]并非一直小于等于dpArr[i][j - 1],说明dpArr[i][j]此时根本就不是s[0,i-1]和t[0,j-1]的最长公共子序列长度
那为什么不用求
s[0,i-1]和t[0,j-1]的最长公共子序列长度呢?这里的dpArr[i][j]又应该如何理解?
假如测试数据为:s="abc",t="ahbgdc",两个算法的DP数组如下:
【最长公共子序列】
【判断子序列】
从上面的DP数组可知:dpArr[i][j]可以理解为s[0,i-1],t[0,j-1]两个子串的最长公共子序列的长度,但要求子序列必须以s[i-1]结尾。
因此递推公式为:dpArr[i][j] = dpArr[i][j - 1];。如果dpArr[i][j] 可以等于 dpArr[i-1][j];,说明子序列不以s[i-1]结尾了。如在上述例子中,dpArr[0][0]=1,如果dpArr[1][0]=dpArr[0][0]=1的话,那ab和a的子序列为a,并未以b为结尾,这样就没有满足dpArr[i][j]的定义了
那为啥可以将
dpArr[i][j]定义为s[0,i-1],t[0,j-1]两个子串的最长公共子序列的长度,但要求子序列必须以s[i-1]结尾呢?
其实很容易理解,题目要判断s是否为t的子串。如果说s是t子串的话,子序列即s本身,即子序列的每个元素必须属于s,所以在迭代过程中,要求子序列必须始终以s的元素结尾。
public boolean isSubsequence(String s, String t) {
// dpArr[i][j]:只取 s 的前i个字符,t 的前j个字符,两者的最长公共子序列长度
int[][] dpArr = new int[s.length() + 1][t.length() + 1];
for (int i = 1; i <= s.length(); i++) {
char c1 = s.charAt(i - 1);
for (int j = 1; j <= t.length(); j++) {
if (c1 == t.charAt(j - 1)) {
// --if-- 如果两个字符串的当前遍历到的字符相同,说明数量 = 不取 s 的第 i 个字符和不取 t 的第 j 个字符的最长公共子序列长度 + 1
dpArr[i][j] = dpArr[i - 1][j - 1] + 1;
} else {
// --if-- 如果两个字符串的当前遍历到的字符不相同,则数量=max(不取 s 的第 i 个字符的最长公共子序列长度, 不取 t 的第 j 个字符的最长公共子序列长度)
dpArr[i][j] = dpArr[i][j - 1];
}
}
}
// 返回最大数量
return dpArr[s.length()][t.length()] == s.length();
}
【一维数组】
public boolean isSubsequence(String s, String t) {
// dpArr[i][j]:只取 s 的前i个字符,t 的前j个字符,两者的最长公共子序列长度
int[] dpArr = new int[t.length() + 1];
for (int i = 1; i <= s.length(); i++) {
char c1 = s.charAt(i - 1);
int pre = 0;
for (int j = 1; j <= t.length(); j++) {
// 将 dpArr[i][j] 记录一下
int cur = dpArr[j];
if (c1 == t.charAt(j - 1)) {
// --if-- 如果两个字符串的当前遍历到的字符相同,说明数量 = 不取 s 的第 i 个字符和不取 t 的第 j 个字符的最长公共子序列长度 + 1
dpArr[j] = pre + 1;
} else {
// --if-- 如果两个字符串的当前遍历到的字符不相同,则数量=max(不取 s 的第 i 个字符的最长公共子序列长度, 不取 t 的第 j 个字符的最长公共子序列长度)
dpArr[j] = dpArr[j - 1];
}
pre = cur;
}
}
// 返回最大数量
return dpArr[t.length()] == s.length();
}
双指针
这道题还可以使用双指针来求解,方法比较简单,就不解释了
public boolean isSubsequence(String s, String t) {
int i = 0, j = 0;
if (s.length() == 0) return true;
while (j < t.length()) {
if (s.charAt(i) == t.charAt(j)) {
// --if-- s[i] = t[j]
// 指针1 前进一步
i++;
// s的元素全部遍历完了,说明 s 是 t 的子串
if (i == s.length()) return true;
}
// 指针2 前进一步
j++;
}
return false;
}