最长公共子串
题目描述
给定两个字符串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描述了一个窗口
- 从
str1
中按照窗口截取窗口子串,检查str2
中是否包含,如果包含就扩展窗口 - 如果
str2
中不包含窗口子串,我们就移动窗口起始位置 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
还有几个优化的点
- 最大公共子串的出现,必然发生在有字符相等的时候。这个应该很好理解,所以算法里只关心出现这个情况的
dp
值,但是同时需要注意,此时dp
里记录的值不再是连续的值了,不能再认为dp[i][j]
描述的是str1
中从0~i
与str2
中从0~j
的最大公共子串,此时的dp
记录值有点类似滑动窗口了,某一段时间内是持续增长的,所以他用maxLen
来记录出现的最大值 - 几个临时变量存储。首先是
maxLen
和x
,这两个变量描述了一个子串,当出现更大的字串时更新这两个值;还有ch1
和ch2
,也有一定性能优化
从上面的分析可以发现,效率优化了,但是理解变困难了,最后我本地统计执行效率如下:
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); } }