一文带你掌握最长重复子数组、最长公共子序列、判断子序列的区别

57 阅读11分钟

🎯导读:本文档探讨了动态规划在解决数组与字符串匹配问题中的应用,特别是针对最长重复子数组、最长公共子序列及判断子序列这三个经典问题。文中详细介绍了每种问题的背景、核心思路及其对应的Java实现代码,并通过图示和实例解释了动态规划表(DP Table)的构建与优化方法,如使用滚动数组减少空间复杂度。此外,对于判断子序列问题,文档还提供了双指针法的简洁解决方案。这些技术不仅有助于理解动态规划的本质,也为解决类似问题提供了宝贵的编程技巧和优化思路。

最长重复子数组

leetcode.cn/problems/ma…

在这里插入图片描述

需要注意,题目要求的是数组,数组在内存中是连续中,默认要求子数组的元素在原来数组中必须连续

在这里插入图片描述

【思路】

  • 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;
}

在这里插入图片描述

[※]最长公共子序列

leetcode.cn/problems/lo…

在这里插入图片描述

【思路】

  • 该题和上题是有很大的共同点的,只是一个是数字数组,一个相当于是字符数组
  • 但是有一个明显的区别是:上一题的重复子数组在原来的数组中是连续的,但在该题中,子序列中的各个字符在原来的字符串中不需要连续
  • 因此递推公式在遍历到两个字符不同时有区别,即:如果两个字符串的当前遍历到的字符不相同,则数量=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()];
}

在这里插入图片描述

判断子序列

leetcode.cn/problems/is…

在这里插入图片描述

动态规划

思路一

直接用最长公共子序列的代码,如果说最终的最长公共子序列长度等于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的话,那aba的子序列为a,并未以b为结尾,这样就没有满足dpArr[i][j]的定义了

那为啥可以将dpArr[i][j]定义为s[0,i-1]t[0,j-1]两个子串的最长公共子序列的长度,但要求子序列必须以s[i-1]结尾呢?

其实很容易理解,题目要判断s是否为t的子串。如果说st子串的话,子序列即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;
}

在这里插入图片描述