最长公共子序列和最长公共子串

442 阅读3分钟

首先区分一下子序列和子串:子串一定要连续,子序列则不一定。比如,对于字符串1AB2345CD,子串可以是1AB,2345,CD等等,子序列可以是12345,ABCD等等; image.png

例如这道题,是求最长公共子串,我们先求一个最长公共子序列

最长公共子序列

思路分析:

对于这种分别用两个指针i,j在两个字符串/数组上移动,得出一个"最长/最大"的问题,一般可以用动态规划。

  • 定义:dp[i][j]表示s1[i..]和s2[j..]的最长公共子序列长度,当i=s1.length()时表示空串,s2同理
  • base case: 空字符跟任何非空字符都不是公共子序列,dp[s1.length()][j..] = 0,dp[i..][s2.length()] = 0;
  • 递推公式:如果s1.charAt(i) = s2.charAt(j),长度+1:dp[i][j] = dp[i-1][j-1]+1;否则就在s1和s2中选择一个字符跳过,选择结果较大的那个

最长公共子序列.drawio.png

// 相等 则为s1[i+1..]和s2[j+1..]的最长公共子序列长度+1
if(s1.charAt(i)==s2.charAt(j)){
    dp[i][j] = dp[i+1][j+1]+1;
// 不相等,在(s1[i+1..]和s2[j..]匹配)和(s1[i..]和s2[j+1..]匹配)两个当中选择一个大的
}else{
    dp[i][j] = Math.max(dp[i+1][j],dp[i][j+1]);
}

代码实现

public  int isMatching(String s1,String s2){
    int m = s1.length();
    int n = s2.length();
    //dp[i][j] 表示s1[i..]和s2[j..]的公共子序列长度
    int[][] dp = new int[m+1][n+1];
    //初始化:空串和非空字符的不构成公共子序列
    for(int i=0;i<m;i++){
        dp[i][n] = 0;
    }
    for(int j=0;j<n;j++){
        dp[m][j] = 0;
    }
    // 空字符是空字符的公共子序列
    dp[m][n] = 0;
    for(int i=m-1;i>=0;i--){
        for(int j=n-1;j>=0;j--){
            if(s1.charAt(i)==s2.charAt(j)){
                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[0][0];
}

最长公共子串

思路分析:

  • 定义:dp[i][j]表示以第i个字符结尾的s1和第j个字符结尾的s2的最长公共子串
  • base case: dp数组初始化为0
  • 递推公式:
  1. 如果两个字符相等,那么在这个位置上(i,j) 的公共子串长度为1,再加上前面一个位置(i-1,j-1) 的公共子串长度构成了(0..i)和(0..j)的公共子串长度
  2. 如果两个字符不相等dp[i][j] 直接记为0就好了
  3. 为什么不记为dp[i-1][j-1]?子串必须是连续的,这里已经不相等了,后面的子串从这里开始公共长度就是0;

代码实现

public String LCS (String str1, String str2) {
    // write code here
    int m = str1.length();
    int n = str2.length();

    int[][] dp = new int[m+1][n+1];
    int maxlength = Integer.MIN_VALUE;
    int maxLastIndex = -1;
    for(int i=1;i<=m;i++){
        for(int j=1;j<=n;j++){
            if(str1.charAt(i-1)==str2.charAt(j-1)){
                dp[i][j] = dp[i-1][j-1]+1;
                // 更新最大长度
                if(dp[i][j]>maxlength){
                    maxlength = dp[i][j];
                    // 记录下标
                    maxLastIndex = j;
                }
            }else{
                // 不相等 公共子串断开 为0
                dp[i][j] = 0;
            }
        }
    }
    // 题目保证最长公共子串存在
    return str2.subSequence(maxLastIndex-maxlength,maxLastIndex).toString();
}

状态压缩

在计算最长公共子串的代码中可以发现,dp[i][j]要么等于0,要么只跟前面一个状态(dp[i-1][j-1])有关,使用一维数组就可以完成状态转移:dp[j] = dp[j-1] + 1;

代码实现

public static String LCS (String str1, String str2) {
    // write code here
    int m = str1.length();
    int n = str2.length();
    int[] dp = new int[n+1];
    int maxlength = Integer.MIN_VALUE;
    int maxLastIndex = -1;
    for(int i = 1;i<=m;i++){
        // 注意这里遍历顺序是相反的
        for(int j = n;j>=1;j--){
            if (str1.charAt(i-1)==str2.charAt(j-1)){
                dp[j] = dp[j-1] + 1;
                if(dp[j]>maxlength){
                    maxlength = dp[j];
                    maxLastIndex =  j;
                }
            }else{
                dp[j] = 0;
            }
        }
    }
    // 题目保证最长公共子串存在
    return str2.subSequence(maxLastIndex-maxlength,maxLastIndex).toString();
}

Question: 为什么要用相反的顺序遍历? 如果用相同的顺序遍历:

最长公共子串状态压缩1.drawio.png

如果用相反的顺序遍历:

最长公共子串状态压缩2.drawio.png

总结

因为子串和子序列的区别,在状态转移时会有不同; 时间复杂度都是O(mn),空间复杂度也是O(mn),状态压缩后空间复杂度可以优化到O(n)