KMP算法理解
KMP算法真的是百遇百新,只要隔一段时间再遇见,之前明明已经理解透彻了,结果又全忘光了,最后总得折腾好一阵子才能捡起来。大二数据结构课、考研专业课、去年暑假一刷,到今天再次遇到还是又忘记了。好记性不如烂笔头,为了节省之后遇到捡起来的时间,现在来写了写自己对于KMP算法的理解,分为下面四步:
1. 理解朴素模式匹配的思想
算法的思想是i指向文本串s,j指向模式串t。对于s的每一个和t长度一样的子串,t都去匹配,一旦遇到某一个元素不匹配的话,i就返回下一个需要匹配子串的开始元素,j也返回t的开始元素,以此类推。时间复杂度是O(n^2)。核心思想是如果匹配失败:i回退,j回退。
2. 理解KMP算法的思想
对照着朴素模式匹配来理解,KMP算法的核心思想是如果匹配失败,i保持原位置,j回退到合适的位置。那么j如何回退?合适的位置是哪里?这就引出了下一个概念:前缀、后缀以及最长相同前后缀长度。
以字符串str "aabaaab"为例,
- 前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串,str的前缀是:a, aa, aab, aaba, aabaa, aabaaa
- 后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串,str的后缀是:b, ab, aab, aaab, baaab, abaaab
- 最长相同前后缀长度就是前缀和后缀相同的时候最长的长度,比如str相同的前后缀只有:aab,长度为3。
在KMP算法中引入了一个next数组,next数组的长度跟模式串长度一样,数组的值表示从0到当前索引的模式串的子串的最长相同前后缀长度(有的版本是相对于这个减1,本文以不减1的版本为例),还是以str为例,它的next数组是:
索引 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
字符 | a | a | b | a | a | a | b |
next数组值 | 0 | 1 | 0 | 1 | 2 | 2 | 3 |
比如想求next[4]的值,我们就看从0到4的模式串的子串"aabaa",其前缀为:a, aa, aab, aaba,其后缀为:a, aa, baa, abaa,相同的有:a与aa,最长的长度是2,所以next[4]=2。
3. 理解为什么要按照next数组回退?
结合next数组值的来源------从0到当前索引的模式串的子串的最长相同前后缀长度,来进行思考。举一个例子:
文本串:s
索引 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|---|
字符 | a | a | b | a | a | b | a | a | f | a |
模式串:t
索引 | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
字符 | a | a | b | a | a | f |
next数组值 | 0 | 1 | 0 | 1 | 2 | 0 |
当i=5,j=5时,出现匹配失败,观察next[j - 1] = next[5 - 1] = next[4] = 2,得到:
(a) t.substring(0, 2) == t.substring(3, 5)
结合KMP算法思想我们得到,在i, j从0 - 4的过程中,一直都是匹配成功的,也就是:
(b) s.substring(0, 5) == t.substring(0, 5)
结合(a)与(b), 我们可以得到:
(c) s.substring(0, 2) == t.substring(0, 2) == t.substring(3, 5) == s.substring(3, 5)
简化之后,也就是:
(d) s.substring(3, 5) == t.substring(0, 2)
【注意,substring(start, end)方法截取的是索引start - (end - 1)的子串】
目前,i=5,也就是说,i前面的两个字符与模式串中第一个与第二个字符完全匹配,如果将j按照next[j - 1]移动到第三个字符,这样就可以省去前面两个字符的比较,更加快捷。如图所示:
现在,我们结合朴素匹配匹配理解了KMP算法的基本思想------匹配失败时i不回退,j按照next数组回退,以及为什么要按照next数组回退。现在还剩下最后一个问题?如何构造next数组?虽然我们知道了next数组值的含义,但是怎么用编程语言将其表达出来呢?
4. 理解如何构造next数组。
直接来介绍代码细节:我们假定i代表字符串后缀的结尾索引,j代表字符串前缀的结尾索引,在匹配成功的前提下,此字符串的最长相等前后缀长度可以用(j+1)来表示。假定完成之后,我们来设置初始条件,I = 1, j = 0。然后i与j同时开始移动并进行匹配:
- 如果匹配成功的话,i与j同时移动,还需要判断j是否移动到了模式串的最后一个字符,如果是则匹配成功,直接输出结果。
- 如果匹配失败的话,j需要向后回退(这何尝不也是运用了KMP思想呢?)。如果j能回退,那么就回退到next[j - 1],i保持不变,为了接下来的继续匹配;如果j不能回退,也就是j已经等于0,那么就说明没有相同的前后缀,因此为next[i]设置值。
5. 小结
在这篇文章中,通过四步理解,我们抽丝剥茧,逐渐捋清了KMP算法全貌。这四步原本是我在写LeetCode的时候总结的,目的是帮助我快速地理解与写清代码,现在我将其进行扩充写成了一篇文章,希望在未来的某一天,能帮助我快速地把KMP算法捡起来。最终来总结一下KMP算法的步骤:
-
匹配过程:i指向文本串,j指向模式串。在遍历文本串的过程中,匹配i与j指向的字符。如果匹配成功,i与j同时移动,需要判断j是否移动到了模式串的最后一个字符,如果是表示匹配完成,输出结果。如果匹配失败,如果j能回退,j等于next[j - 1],i不动;如果j不能回退,j不动,i需要前进,继续进行匹配。
-
构造next数组过程:i指向后缀的末尾,j指向前缀的末尾。在i移动的过程中,匹配i与j指向的字符。如果匹配成功,i与j同时移动,并更新next数组。如果匹配失败,如果j能回退,j等于next[j - 1],i保持不动;如果j不能回退,也就是j已经等于0,那么就说明没有相同的前后缀,因此为next[i]设置值。
代码如下:
java
class Solution {
public int strStr(String haystack, String needle) {
// 特殊情况
if (haystack.length() < needle.length()) {
return -1;
}
// 构造next数组
int[] next = new int[needle.length()];
getNext(next, needle);
int i = 0; // 指向haystcak
int j = 0; // 指向needle
for (; i < haystack.length(); i++) {
// 匹配成功:i前进,j前进
if (needle.charAt(j) == haystack.charAt(i)) {
if (j == needle.length() - 1) {
return i - j;
}
j++;
// 匹配失败:j能回退的话i就不动,j不能回退了的话i向前
} else {
// j能回退
if (j > 0) {
j = next[j - 1];
i--;
// j不能回退
} else if (j == 0) {
// do nothing!
}
}
}
return -1;
}
private void getNext(int[] next, String needle) {
next[0] = 0;
int j = 0; // j是前缀末尾,也就是前缀长度 - 1
int i = 1; // i是后缀末尾
for (; i < needle.length(); i++) {
// 匹配失败:j能回退的话,i不动,j回退;j不能回退的话,next[i]赋值
if (needle.charAt(i) != needle.charAt(j)) {
// j能回退
if (j > 0) {
j = next[j - 1];
i--;
// j不能回退
} else if (j == 0) {
next[i] = j;
}
// 匹配成功
} else {
next[i] = ++j;
}
}
}
}