1. 什么是 KMP 算法?
KMP(Knuth-Morris-Pratt)算法是一种用于字符串匹配的算法,它能够在文本串S内查找一个模式串W的出现位置。与普通的字符串匹配算法相比,KMP算法避免了在每次不匹配时都将模式串W移动到文本串S的下一个字符位置,而是利用已经部分匹配的信息来移动模式串W,从而减少不必要的比较次数。
例如我们要在上述文本串中,找到是否包含下面的模式串。最直观的方法就是,两层for循环,外层for循环遍历文本串,内层遍历模式串,从找到模式串第一个位置“a”开始匹配,如果文本串和模式串对应位置的值不相等了,就跳出循环,从文本串的下一个位置开始,重新从模式串第一个值开始匹配。时间复杂度为O(m*n)。
但是可以看出,当第一次循环匹配到如下图箭头所指示的位置时,按照暴力的做法要从文本串的“b”位置重新与模式串的第一个字符“a”去循环匹配,直到循环到正确的位置......这样的话是不是浪费了很多次不必要的循环,因为从图中看,我们的模式串开头有“ab”,第一次匹配失败的前面也是“ab”,在第二次匹配的时候其实可以从绿色框中的“c”开始,也就是说第一次匹配的时候文本串中的第二个“ab”已经跟模式串第二个的“ab”匹配过了,而模式串“abcabd”的子串“abcab”,的前缀有“ab”,后缀也有“ab”,这样的话我们就不用再去匹配“ab”了。这部分没看懂的话没关系,我们先有个概念,继续往下看。
2. 匹配表(π表、前缀表)
要想达到上文提到的目的,就要用到一个关键的匹配表——前缀表也叫π表。前缀表就是记录了模式串W每个位置最长相等前后缀长度的表。在计算前缀表的时候,我们要明确一下,前缀、后缀的概念,首先,前缀是指包含字符串首字母,但不包含字符串最后一个字母的所有子串;然后,后缀是指包含字符串最后一个字母,但是不包含字符串首字母的所有子串。
那么,我们来计算模式串的前缀表,。模式串abcabd,从位置0开始:
位置 | 该位置所表示的字符串 | 前缀 | 后缀 | 最长相等前后缀 | 最长相等前后缀长度 |
0 | a | 无 | 无 | 无 | 0 |
1 | ab | a | b | 无 | 0 |
2 | abc | a/ab | c/bc | 无 | 0 |
3 | abca | a/ab/abc | a/ca/bca | a | 1 |
4 | abcab | a/ab/abc/abca | b/ab/cab/bcab | ab | 2 |
5 | abcabd | a/ab/abc/abca/abcab | d/bd/abd/cabd/bcabd | 无 | 0 |
所以,得出前缀表next=[0,0,0,1,2,0]
3. 获取前缀表的代码
public int[] getNext(String s) {
char[] chars = s.toCharArray();
int[] next = new int[s.length()];
int j = 0;
next[0] = 0;
for(int i = 1; i < next.length; i++) {
while(j > 0 && chars[i] != chars[j]) {
j = next[j - 1];
}
if(chars[i] == chars[j]) {
j++;
}
next[i] = j;
}
return next;
}
首先,我们要明确两个变量的含义,i表示后缀的最后一个字符,j表示前缀的最后一个字符。然后,我们来过一遍这个循环,这次我们以下图为例:
首先,next[0]=0,i从1开始,发现chars[i]==chars[j],j就加1,于是next[1]=1......当i走到如下位置时我们可以获得next数组如下:
以上结果,正常走if就到了。接下来说一下for循环里面的while循环。可见当我们走到i位置时,发现此时chars[i]已经不等于chars[j]了,而此时next数组此时的位置是2(既表示此刻j的下标,又表示在这个位置时的子串的最长相等前后缀的长度为2),于是需要让j往后退,先退到上一次j所在的位置,即next[j - 1]=next[2-1]=1,为什么是j的前一位呢,因为此刻j的位置是未匹配到的位置,而j-1是我们上一次匹配好的,我们要看上一次j的位置的值是否与此刻i位置的值相等,由于此刻next[1]=1,说明此时这个i前面子串的前缀的最长相等前后缀长度为1,与之对应的此时i前面子串的后缀的最长相等前后缀的长度为1,所以说此刻i处前面的“a”与位置“0”处的“a”实际上已经对应好了,我们直接比chars[1]处(即chars[next[j-1]])的值与chars[i]处的值就好了,如果比对不上就继续按照这样的思路循环。
由此,我们便得到了前缀表数组next。
4. 完成模式串与文本串匹配的代码
参考leetcode题目如下:
public int strStr(String haystack, String needle) {
char[] chars = needle.toCharArray();
char[] chars2 = haystack.toCharArray();
int[] next = new int[needle.length()];
getNext(chars, next);
int j = 0;
for(int i = 0; i < haystack.length(); i++) {
while(j > 0 && chars2[i] != chars[j]) {
j = next[j - 1];
}
if(chars2[i] == chars[j]) {
j++;
}
if(j == needle.length()) {
return i - j + 1;
}
}
return -1;
}
private void getNext(char[] chars, int[] next) {
int j = 0;
next[0] = 0;
for(int i = 1; i < next.length; i++) {
while(j > 0 && chars[i] != chars[j]) {
j = next[j - 1];
}
if(chars[i] == chars[j]) {
j++;
}
next[i] = j;
}
}
其实,找到前缀表next后,剩下的匹配思路就和获得前缀表的思路相思了。就是当我们从头开始匹配的时候,当匹配到上图箭头位置,发现不一样了,就是相当于要把模式串的整体向右移动。但是"d"前面的子串最长相等前后缀长度是2,也就是说"c"前"c"后都是"ab",下次循环再匹配的时候,文本串的后面的"ab"就与模式串前面的"ab"匹配了,这个在我们上一次循环中已经匹配好了,后面的循环直接从"c"开始就好了。于是就有了上述代码。
KMP算法还是很绕的,但是有一个地方可以把它当成循环不变量,就是如果chars[i]!=chars[j]的时候,就知道要循环的往前找,每次让j=next[j-1],直到j最终变成0为止,j变为0,也就意味着要从头匹配了。
5. 相关题目
题目:给定一个非空的字符串 s
,检查是否可以通过由它的一个子串重复多次构成。
例1:
输入: s = "abab" 输出: true 解释: 可由子串 "ab" 重复两次构成。
例2:
输入: s = "aba" 输出: false
这道题其实就是找到前缀表后,用你的字符串长度减去前缀表最后一个值得到的长度就是循环体的长度,你再去比较一下这个循环体长度能否被字符串长度整除,如果可以就是由子串循环多次构成的。
public boolean repeatedSubstringPattern(String s) {
char[] c = s.toCharArray();
int[] next = new int[s.length()];
getNext(c, next);
if(next[s.length() - 1] != 0 && s.length() % (s.length() - next[s.length() - 1]) == 0) {
return true;
}
return false;
}
private void getNext(char[] c, int[] next) {
int j = 0;
next[0] = 0;
for(int i = 1; i < next.length; i++) {
while(j > 0 && c[i] != c[j]) {
j = next[j - 1];
}
if(c[i] == c[j]) {
j++;
}
next[i] = j;
}
}