几道常见的字符串算法题
1.KMP算法
文本串 text = "caniwaitforyourheart"
模式串 pattern = "sorry"
- 暴力解法 O(mn)
- KMP算法 O(n+m)
-
next数组(后面KMP算法要用到):
-
字符串s(下标从0开始): 那么它以i号位作为结尾的子串就是s[0...i]。对该子串来说,长度为k + 1的前缀和后缀分别是s[0...k]与s[i-k...i]
-
定义一个int型数组next,其中next[i]表示使子串s[0...i]的前缀s[0...k]等于后缀s[i-k...i]的最大的k(注意前缀跟后缀可以部分重叠,但不能是s[0...i]本身);如果找不到相等的前后缀,那么就令next[i] = -1。显然,next[i]就是所求最长相等前后缀中前缀最后一位的下标。
-
next数组的计算过程举例
-
尝试一下自己计算字符串"abababc"的next数组,可以得到[-1, -1, 0, 1, 2, 3, -1]
-
那么怎么求解next数组呢?暴力的做法可行,但不够高效。下面用“递推”的方式来高效求解next数组,即假设已经求出了next[0]~next[i-1],现在要用它们来推出next[i]
-
递推之前再强调一遍,next[i]表示的是子串[0...i]的最长相等前后缀的前缀的最后一位的下标。 递推过程中一定要记得这个next[i]的意义
-
next数组的递推:
-
假设字符串s为: ababaa, 目前已有next[0] = -1、 next[1] = -1, next[2] = 0, next[3] = 1, 现在需要递推出next[4] 与 next[5]
-
next[4]:
求解过程图:
-
next[5]:
求解过程图:
-
根据next[4]与next[5]的推导,可以总结next数组的求解过程
|| 1. 初始化next数组,令j = next[0] = -1
|| 2. 让i在1~len-1范围遍历,对每个i,执行3.、4.,以求解next[i]
|| 3. 不断令j = next[j], 直到j回退为-1 或者是出现s[i] == s[j+1]
|| 4. 如果s[i] == s[j+1], 则next[i] = j+1; 否则next[i] = j (= -1)
-
代码如下:
public void getNext(char[] s, int len) { int j = -1; next[0] = -1; // 初始化 j = next[0] = -1 for (int i = 1; i< len; i++) { // 求解next[1] ~ next[len -1] while (j != -1 && s[i] != s[j+1]) { j = next[j]; // 反复令j = next[j] } // 直到j回退到-1,或是s[i] = s[j+1] if (s[i] == s[j+1]) { // 如果s[i] == s[j+1] j++; // 则next[i] = j + 1, 先令j指向这个位置 } next[i] = j; } } -
-
-
KMP算法
-
给定一个文本串text和一个模式串pattern,然后判断模式串pattern是否是文本串text的子串
-
以text = "abababaabc", pattern = "ababaab"为例子。令i指向text的当前欲比较位(还没有比),令j指向pattern中当前已被匹配的最后位,这样只要text[i] == pattern[j+1]成立,就说明pattern[j+1]也被成功匹配,此时让i、j加1以继续比较,直到j达到m-1时说明pattern是text的子串(m为模式串pattern的长度)。
-
匹配过程中,无疑会出现text[i] != pattern[j+1]的情况,这个时候为了不完全放弃之前匹配成功的结果,需要合理地对j进行回溯。回溯的选择与求递推求next数组时一样,令j = next[j]
-
KMP算法的一般思路为:
|| 1. 初始化j = -1,表示pattern当前已被匹配的最后位 || 2. 让i遍历文本串text,对每个i,执行3.、4.、来试图匹配text[i]和pattern[j+1] || 3. 不断令j = next[j], 直到j回退为-1,或是text[i] == pattern[j+1]成立。 || 4. 如果text[i] == pattern[j+1],则令j++。如果j达到m-1,说明pattern是text的子串,返回true
-
KMP算法的代码如下:
public boolean KMP(char[] text, char[] pattern) { // 字符串的长度 int n = text.length; int m = pattern.length; getNext(pattern, m); // 计算pattern的next数组 int j = -1; for (int i = 0; i < n; i++) { // 试图匹配text[i] while (j != -1 && text[i] != pattern[j+1]) { j = next[j]; // 不断回退,直到j回到-1或text[i] == pattern[j+1] } if (next[i] == pattern[j+1]) { j++; // text[i] 与 pattern[j+1]匹配成功,令j加1 } if (j == m-1) { // pattern完全匹配,说明pattern是text的子串 return true; } } return false; // 执行完text还没匹配成功,说明pattern不是text的子串 } -
-
接着考虑如何统计文本串text中模式串pattern出现的次数
- 当j == m-1时表示pattern的一次完全成功匹配,此时可以令记录成功匹配次数的变量加1,但问题在于,这之后应该从模式串pattern的哪个位置开始进行下一次匹配。由于模式串pattern在文本串中的多次出现可能是有部分重叠情况的,因此不能什么都不做就直接让i加1继续比较,而是必须先让j回退一定距离,那么回退到哪里可以不漏解且有效率呢?有了前面的经验,应该可以想到是next[j],因为此时next[j]代表着整个模式串pattern的最长相等前后缀,从这个位置开始可以让j最大,即让已经成功匹配的部分最长,这样既能够保证不漏解,又使下一次的匹配省去许多无意义的比较
- 统计模式串pattern出现次数的KMP算法代码如下:
public int KMP(char[] text, char[] pattern) { // 字符串的长度 int n = text.length; int m = pattern.length; getNext(pattern, m); // 计算pattern的next数组 int ans = 0; // 表示成功匹配的次数 int j = -1; for (int i = 0; i < n; i++) { // 试图匹配text[i] while (j != -1 && text[i] != pattern[j+1]) { j = next[j]; // 不断回退,直到j回到-1或text[i] == pattern[j+1] } if (next[i] == pattern[j+1]) { j++; // text[i] 与 pattern[j+1]匹配成功,令j加1 } if (j == m-1) { // pattern完全匹配,说明pattern是text的子串 ans++; // 成功匹配次数加1 j = next[j]; // 让j回退到next[j]继续匹配 } } return ans; // 返回成功匹配的次数 } -
关于KMP算法的时间复杂度O(m+n)的分析(十分有趣):
2. 最长回文子串(dp or 中心扩散法)
给你一个字符串 s,找到 s 中最长的回文子串。
如果字符串的反序与原始字符串相同,则该字符串称为回文字符串。
暴力模拟:
会超时
dp:
public String longestPalindrome(String s) {
if (s == null || s.length() < 2) {
return s;
}
int strLen = s.length();
int maxStart = 0; // 最长回文子串的起点
int maxEnd = 0; // 最长回文子串的终点
int maxLen = 1; // 最长回文串的长度
boolean[][] dp = new boolean[strLen][strLen]; // boolean[i][j]表示字符串从i到j这段是否为回文(记录状态)
for (int r = 1; r < strLen; r++) {
for (int l = 0; l < r; l++) {
if (s.charAt(l) == s.charAt(r) && (r - l <= 2 || dp[l + 1][r - 1] )) { // r - l <= 2: 可以看作是当子串长度为3时的特殊情况(是dp的边界)
dp[l][r] = true;
if (r - l + 1 > maxLen) {
maxLen = r - l + 1;
maxStart = l;
maxEnd = r;
}
}
}
}
return s.substring(maxStart, maxEnd + 1);
}
中心扩散法:
public String longestPalindrome(String s) {
if (s == null || s.length() == 0) {
return s;
}
int strLen = s.length();
int left = 0;
int right = 0;
int len = 1;
int maxStart = 0;
int maxLen = 0;
for (int i = 0; i < strLen; i++) {
left = i - 1;
right = i + 1;
while (left >= 0 && s.charAt(left) == s.charAt(i)) {
len++;
left--;
}
while (right < strLen && s.charAt(right) == s.charAt(i)) {
len++;
right++;
}
while (left >= 0 && right < strLen && s.charAt(right) == s.charAt(left)) {
len = len + 2;
left--;
right++;
}
if (len > maxLen) {
maxLen = len;
maxStart = left;
}
len = 1;
}
// 为什么maxStart要加1呢?
// 比如 acdbbdaa,left会停在第一个c处,也就是位置1,而最长回文子串是从第一个d开始的,也就是位置2。而最后maxStart的值是left赋予的
return s.substring(maxStart + 1, maxStart + maxLen + 1);
}
3. 最长回文子序列(dp)
给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。
子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。
子序列与上面子串区别就是:可以删除字符
dp:
public int longestPalindromeSubseq(String s) {
int n = s.length();
int[][] f = new int[n][n]; // s[i][j]表示s的第i个字符到第j个字符组成的子串中,最长的回文序列长度是多少
for (int i = n-1; i >= 0; i--) { // 注意遍历顺序,这样可以保证每个子问题都已经算好了 (由于状态转移方程方程都是从长度较短的子序列向长度较长的子序列转移,因此需要注意动态规划的循环顺序)
f[i][i] = 1;
for (int j = i + 1; j < n; j++) {
if (s.charAt(i) == s.charAt(j)) {
f[i][j] = f[i+1][j-1] + 2;
} else {
f[i][j] = Math.max(f[i+1][j], f[i][j-1]);
}
}
}
return f[0][n-1];
}