最长公共子串

题目描述
给定两个字符串str1和str2,输出两个字符串的最长公共子串,如果最长公共子串为空,输出-1。
示例1
输入
"1AB2345CD","12345EF"
返回值
"2345"

这条题目被归类为动态规划,于是我使用动态规划的思路写了一个算法:

    /**
     * longest common substring
     * 动态规划算法
     * dp[i][j]有两个分支
     * 1. str1[i] = str2[j]  dp[i][j] = dp[i][j] + 1
     * 2. str1[i] != str2[j]  dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1])
     *
     * @param str1 string字符串 the string
     * @param str2 string字符串 the string
     * @return string字符串
     */
    public static String LCS(String str1, String str2) {
        // write code here
        //零界值判断
        if (str1 == null || str2 == null || (str1.isEmpty() && str2.isEmpty())) {
            return "-1";
        }
        int str1L = str1.length();
        int str2L = str2.length();
        int[][] dp = new int[str1L][str2L];
        Map<Integer, String> resultMap = new HashMap<>();
        //初始化索引为0的值 这个是基准值
        for (int i = 0; i < str1L; i++) {
            boolean result = str1.charAt(i) == str2.charAt(0);
            dp[i][0] = result ? 1 : 0;
            resultMap.put(i * str1L, result ? str2.substring(0, 1) : "");
        }
        for (int j = 0; j < str2L; j++) {
            boolean result = str1.charAt(0) == str2.charAt(j);
            dp[0][j] = result ? 1 : 0;
            resultMap.put(j, result ? str1.substring(0, 1) : "");
        }
        //动态规划算法
        for (int i = 1; i < str1L; i++) {
            for (int j = 1; j < str2L; j++) {
                if (str1.charAt(i) == str2.charAt(j)) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                    resultMap.put(i * str1L + j, resultMap.get((i - 1) * str1L + j - 1) + str1.charAt(i));
                } else {
                    if (dp[i][j - 1] > dp[i - 1][j]) {
                        dp[i][j] = dp[i][j - 1];
                        resultMap.put(i * str1L + j, resultMap.get(i * str1L + j - 1));
                    } else {
                        dp[i][j] = dp[i - 1][j];
                        resultMap.put(i * str1L + j, resultMap.get((i - 1) * str1L + j));
                    }
                }
            }
        }
        if(dp[str1L-1][str2L-1] <= 0){
            return "-1";  
        }else{
            return resultMap.get(str1L * (str1L - 1) + str2L - 1);
        }

    }

算法解析不赘述,参见 动态规划经典例题——最长公共子序列和最长公共子串

下面进入正题,当我保存提交代码后,被报错:
运行超时:您的程序未能在规定时间内运行结束,请检查是否循环有错或算法复杂度过大。
case通过率为0.00%

于是我仔细拜读了其他几篇动态规划思路的解法,确认实现没啥问题啊,后面我又做了一点小优化,但是执行结果都大同小异

   /**
     * longest common substring
     * 动态规划算法
     * dp[i][j]有两个分支
     * 1. str1[i] = str2[j]  dp[i][j] = dp[i][j] + 1
     * 2. str1[i] != str2[j]  dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1])
     *
     * @param str1 string字符串 the string
     * @param str2 string字符串 the string
     * @return string字符串
     */
    public static String LCS(String str1, String str2) {
        // write code here
        //零界值判断
        if (str1 == null || str2 == null || (str1.isEmpty() && str2.isEmpty())) {
            return "-1";
        }
        char[] chars1 = str1.toCharArray();
        char[] chars2 = str2.toCharArray();
        int str1L = chars1.length;
        int str2L = chars2.length;
        //动态规划结果
        int[][] dp = new int[str1L][str2L];
        Map<Integer, String> resultMap = new HashMap<>(str1L * (str1L - 1) + str2L - 1);

        //动态规划算法
        for (int i = 0; i < str1L; i++) {
            for (int j = 0; j < str2L; j++) {
                if (chars1[i] == chars2[j]) {
                    if (i == 0 || j == 0) {
                        dp[i][j] = 1;
                        resultMap.put(i * str1L + j, "" + chars1[i]);
                    } else {
                        dp[i][j] = dp[i - 1][j - 1] + 1;
                        resultMap.put(i * str1L + j, resultMap.get((i - 1) * str1L + j - 1) + chars1[i]);
                    }
                } else {
                    if (i == 0 || j == 0) {
                        dp[i][j] = 0;
                        resultMap.put(i * str1L + j, "");
                    } else {
                        if (dp[i][j - 1] > dp[i - 1][j]) {
                            dp[i][j] = dp[i][j - 1];
                            resultMap.put(i * str1L + j, resultMap.get(i * str1L + j - 1));
                        } else {
                            dp[i][j] = dp[i - 1][j];
                            resultMap.put(i * str1L + j, resultMap.get((i - 1) * str1L + j));
                        }
                    }
                }
            }
        }
        if(dp[str1L-1][str2L-1] <= 0){
            return "-1";  
        }else{
            return resultMap.get(str1L * (str1L - 1) + str2L - 1);
        }
    }

最后我把所有无关代码都注释了,只保留了动态规划需要的循环遍历,结果耗时还是很严重:

1AB2345CD
12345EF
动态规划
LCS cost time:258800
滑动窗口
LCS1 cost time:98500

可以看出来执行效率根本不是一个量级的,所以我开始研究执行通过的算法:

    /**
     * 滑动窗口算法
     *
     * @param str1 string字符串 the string
     * @param str2 string字符串 the string
     * @return string字符串
     */
    public static String LCS1(String str1, String str2) {
        // write code here
        StringBuilder sb = new StringBuilder();
        int start = 0, end = 1;
        while (end < str1.length() + 1) {
            if (str2.contains(str1.substring(start, end))) {
                if (sb.length() < end - start) {
                    sb.delete(0, sb.length());
                    sb.append(str1, start, end);
                }
            } else {
                //这个算法我曾经疑惑,假如出现start>end,程序不是会crash么
                //通过debug发现,当start==end时,substring获取的是"",此时contains必然为true
                //所以当start == end时,必然会走end++分支
                start++;
            }
            end++;
        }
        if (sb.length() == 0) {
            return "-1";
        }
        return sb.toString();
    }

这段算法我觉得应该属于滑动窗口的范畴,start和end描述了一个窗口

  1. str1中按照窗口截取窗口子串,检查str2中是否包含,如果包含就扩展窗口
  2. 如果str2中不包含窗口子串,我们就移动窗口起始位置
  3. sb是用来记录最大的窗口子串的,窗口最小为0个单位

这个算法精妙的地方在于窗口大小不会缩小的,我们只需要找最大公共子串,所以一旦有某个窗口子串命中了,往后的窗口子串就不能小于上次命中的长度,所以我们在算法里可以看到当start++时必然伴随着end++,保证窗口不会变小

但是这个算法凭什么比动态规划执行效率高呢?里面用了很多Java封装的函数,很多substring操作,不管是内存还是执行效率(contains内部实现也是循环比对)都没理由比动态规划更优才对。后面我又在通过的代码里找到了一个动态规划的解法:

    public static String LCS2(String str1, String str2) {
        if (str1 == null || str2 == null) return "-1";
        int n1 = str1.length(), n2 = str2.length();
        if (n1 == 0 || n2 == 0) return "-1";
        int[][] dp = new int[n1 + 1][n2 + 1];

        int maxLen = 0, x = 0;
        for (int i = 1; i <= n1; i++) {
            char ch1 = str1.charAt(i - 1);
            for (int j = 1; j <= n2; j++) {
                char ch2 = str2.charAt(j - 1);
                if (ch1 == ch2) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                    if (dp[i][j] > maxLen) {
                        maxLen = dp[i][j];
                        x = i;
                    }
                }
            }
        }

        return maxLen == 0 ? "-1" : str1.substring(x - maxLen, x);
    }

这里dp的索引是从1开始,0位是防止数组越界,并且也有实际意义,可以理解为空子串,结果自然也是0

还有几个优化的点

  1. 最大公共子串的出现,必然发生在有字符相等的时候。这个应该很好理解,所以算法里只关心出现这个情况的dp值,但是同时需要注意,此时dp里记录的值不再是连续的值了,不能再认为dp[i][j]描述的是str1中从0~istr2中从0~j的最大公共子串,此时的dp记录值有点类似滑动窗口了,某一段时间内是持续增长的,所以他用maxLen来记录出现的最大值
  2. 几个临时变量存储。首先是maxLenx,这两个变量描述了一个子串,当出现更大的字串时更新这两个值;还有ch1ch2,也有一定性能优化

从上面的分析可以发现,效率优化了,但是理解变困难了,最后我本地统计执行效率如下:

1AB2345CD
12345EF
我的动态规划结果:2345
LCS cost time:233600
滑动窗口结果:2345
LCS1 cost time:61400
大神的动态规划结果:2345
LCS2 cost time:42800

最后附上完整的代码:

package com.example.myapplication;

import java.util.HashMap;
import java.util.Map;
import java.util.Scanner;

class Test {
    /**
     * 1AB2345CD
     * 12345EF
     */
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        while (sc.hasNext()) {

            String str1 = sc.nextLine();
            String str2 = sc.nextLine();

            long startTime = System.nanoTime();
            String r = LCS(str1, str2);
            System.out.println(r);
            System.out.println("LCS cost time:" + (System.nanoTime() - startTime));

            startTime = System.nanoTime();
            r = LCS1(str1, str2);
            System.out.println(r);
            System.out.println("LCS1 cost time:" + (System.nanoTime() - startTime));

            startTime = System.nanoTime();
            r = LCS2(str1, str2);
            System.out.println(r);
            System.out.println("LCS2 cost time:" + (System.nanoTime() - startTime));

        }
        sc.close();
    }

    /**
     * longest common substring
     * 动态规划算法
     * dp[i][j]有两个分支
     * 1. str1[i] = str2[j]  dp[i][j] = dp[i][j] + 1
     * 2. str1[i] != str2[j]  dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1])
     *
     * @param str1 string字符串 the string
     * @param str2 string字符串 the string
     * @return string字符串
     */
    public static String LCS(String str1, String str2) {
        // write code here
        //零界值判断
        if (str1 == null || str2 == null || (str1.isEmpty() && str2.isEmpty())) {
            return "-1";
        }
        char[] chars1 = str1.toCharArray();
        char[] chars2 = str2.toCharArray();
        int str1L = chars1.length;
        int str2L = chars2.length;
        //动态规划结果
        int[][] dp = new int[str1L][str2L];
        Map<Integer, String> resultMap = new HashMap<>(str1L * (str1L - 1) + str2L - 1);

        //动态规划算法
        for (int i = 0; i < str1L; i++) {
            for (int j = 0; j < str2L; j++) {
                if (chars1[i] == chars2[j]) {
                    if (i == 0 || j == 0) {
                        dp[i][j] = 1;
                        resultMap.put(i * str1L + j, "" + chars1[i]);
                    } else {
                        dp[i][j] = dp[i - 1][j - 1] + 1;
                        resultMap.put(i * str1L + j, resultMap.get((i - 1) * str1L + j - 1) + chars1[i]);
                    }
                } else {
                    if (i == 0 || j == 0) {
                        dp[i][j] = 0;
                        resultMap.put(i * str1L + j, "");
                    } else {
                        if (dp[i][j - 1] > dp[i - 1][j]) {
                            dp[i][j] = dp[i][j - 1];
                            resultMap.put(i * str1L + j, resultMap.get(i * str1L + j - 1));
                        } else {
                            dp[i][j] = dp[i - 1][j];
                            resultMap.put(i * str1L + j, resultMap.get((i - 1) * str1L + j));
                        }
                    }
                }
            }
        }
        return resultMap.get(str1L * (str1L - 1) + str2L - 1);
    }

    /**
     * 滑动窗口算法
     *
     * @param str1 string字符串 the string
     * @param str2 string字符串 the string
     * @return string字符串
     */
    public static String LCS1(String str1, String str2) {
        // write code here
        StringBuilder sb = new StringBuilder();
        int start = 0, end = 1;
        while (end < str1.length() + 1) {
            if (str2.contains(str1.substring(start, end))) {
                if (sb.length() < end - start) {
                    sb.delete(0, sb.length());
                    sb.append(str1, start, end);
                }
            } else {
                //这个算法我曾经疑惑,假如出现start>end,程序不是会crash么
                //通过debug发现,当start==end时,substring获取的是"",此时contains必然为true
                //所以当start == end时,必然会走end++分支
                start++;
            }
            end++;
        }
        if (sb.length() == 0) {
            return "-1";
        }
        return sb.toString();
    }


    public static String LCS2(String str1, String str2) {
        if (str1 == null || str2 == null) return "-1";
        int n1 = str1.length(), n2 = str2.length();
        if (n1 == 0 || n2 == 0) return "-1";
        int[][] dp = new int[n1 + 1][n2 + 1];

        int maxLen = 0, x = 0;
        for (int i = 1; i <= n1; i++) {
            char ch1 = str1.charAt(i - 1);
            for (int j = 1; j <= n2; j++) {
                char ch2 = str2.charAt(j - 1);
                if (ch1 == ch2) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                    if (dp[i][j] > maxLen) {
                        maxLen = dp[i][j];
                        x = i;
                    }
                }
            }
        }

        return maxLen == 0 ? "-1" : str1.substring(x - maxLen, x);
    }

}