KMP算法是一种字符串匹配算法,这里拿leetcode第28题举例,题目如下:
28. 找出字符串中第一个匹配项的下标
1.暴力算法
最开始看到这个题的时候,我先直接就是一个暴力匹配,突出一个简单无脑。
public static int strStr(String haystack,String needle){
char[] haystackArr = haystack.toCharArray();
char[] needleArr = needle.toCharArray();
int i = 0;
int j = 0;
int k = 0;
while (i < haystackArr.length && j < needleArr.length){
if (haystackArr[i] == needleArr[j]){
i ++;
j ++;
}
else {
k++;
i = k;
j = 0;
}
}
if (j == needleArr.length){
return k;
}
return -1;
}
下面是暴力匹配的流程:
可以看出暴力破解是让主串和子串的字符逐个匹配,直到i=2,j=2。由于该字符匹配失败,所以指针进行了回退,退到了i=1,j=0。
2.KMP 匹配算法
KMP算法的核心思路就是,当出现字符串不匹配时,可以利用之前已经匹配的文本内容,可以利用这些信息来减少回退的步数。
用"sabbutsadc"和"sad"来举例的话,我们可以看出,
1.“sad"可以匹配上"sabbutsadc"开头的"sa”;
2."sad"子串的首字母’s’和后面的字母都不相同;
3.所以当’d’字符匹配失败时,首字母’s’必然不会和前面的’sa’相匹配。
4.此时按照暴力破解的算法,我们应该将子串的指针回到’s’,主串的指针回到’a’来开始重新匹配。
5.但是在KMP算法中,我们充分利用了2和3,因此,我们将子串的指针回到’s’,主串的指针只需要保持在’d’就可以了。
这样一来匹配流程就变成了这样:
可以看出,在匹配的过程中,当匹配不上的时候,i指针不再进行回退了,而j指针回退到了0的位置。其实此时j指针回退的位置是有说法的,那么要如何确定j指针回退的位置呢,这个就要由匹配字符前面的字符串的 「最长前后缀相同长度」来决定了。
2.1最长前后缀相同长度
最长前后缀相同长度指的是一个字符串前缀和后缀相等的最长元素。
举个例子,上文的"sad"中:
前缀有s,sa;
后缀有d,ad;
因此前后缀相同长度为0,所以我们将j指针回退到了0的位置。
再来看一个主串为"ababadefgh",子串为"ababac"的例子:
子串前缀为:a,ab,aba,abab
子串后缀为:a,ba,aba,baba
前后缀相同长度有1和3,那么最长前后缀相同长度就是3。
所以当j=5和i=5匹配不上的时候,我们保持i指针不动,j指针回退到3的位置,重新进行匹配。
最长前后缀相同长度就是我们用来判断主串指针i回退位置的依据。
需要注意的是,前缀后缀不能为同一个元素,比如:在 字符串"a"中,前缀后缀不能同时为"a",所以这个最长前后缀长度为 0。
2.2 next数组
上面我们说最长前后缀相同长度是我们用来判断主串指针i回退位置的依据,next数组就是用来记录子串回退位置的数组。next[j]的公式如下:
1.next[0] = -1;
2.next[j] = 匹配字符前面的字符串的「最长前后缀相同长度」。
我们继续用子串"ababac"作为例子:
- j=0,匹配字符前面的字符串为null,所以next[0] = -1;
- j=1,匹配字符前面的字符串为"a",所以next[1] = 0;
- j=2,匹配字符前面的字符串为"ab",所以next[2] = 0;
- j=3,匹配字符前面的字符串为"aba",所以next[3] = 1;
- j=4,匹配字符前面的字符串为"abab",所以next[4] = 2;
- j=5,匹配字符前面的字符串为"ababa",所以next[5] = 3;
所以next[] = [-1,0,0,1,2,3]。
2.3实现KMP算法
前面理论已经讲得差不多了,接下来我们用代码实现一下KMP算法,leetcode的28题可以写为:
public static int strStrKMP(String haystack, String needle) {
char[] haystackArr = haystack.toCharArray();
char[] needleArr = needle.toCharArray();
int[] next = getNext(needleArr);
int i = 0;
int j = 0;
while (i < haystackArr.length && j < needleArr.length){
if(j == -1 || haystackArr[i] == needleArr[j]){
//继续往后匹配
i++;
j++;
}else{
//回溯j指针到next[j]的位置
j = next[j];
}
}
if(j == needleArr.length){
return i - j;
}
return -1;
}
private static int[] getNext(char[] needle) {
int[] next = new int[needle.length];
int k = -1;
int j = 0;
next[0] = -1;
while (j < needle.length - 1) {
if (k == -1 || needle[j] == needle[k]) {
k++;
j++;
next[j] = k;
} else {
k = next[k];
}
}
return next;
}
3.算法优化
上面的KMP算法在某些特殊情况下仍然不太完善,比如当主串为"aaaaaxxxxx",子串为"aaaaab"时。
- j=0,匹配字符前面的字符串为null,所以next[0] = -1;
- j=1,匹配字符前面的字符串为"a",所以next[1] = 0;
- j=2,匹配字符前面的字符串为"aa",所以next[2] = 1;
- j=3,匹配字符前面的字符串为"aaa",所以next[3] = 2;
- j=4,匹配字符前面的字符串为"aaaa",所以next[4] = 3;
- j=5,匹配字符前面的字符串为"aaaaa",所以next[5] = 4;
所以next[] = [-1,0,1,2,3,4]。
那么匹配流程如下:
从图中我们可以看出,子串的前5个字符都是’a’,所以当j=4匹配失败时,前面4个字符的匹配必然也是失败的。
所以我们要对next数组进行优化,优化的思路就在于:
后面的’a’已经不匹配了,那么前面的’a’必然也不能匹配。
因此next数组我们可以优化为: - j=0,匹配字符前面的字符串为null,所以next[0] = -1;
- j=1,让next[1] = next[0] = -1;
- j=2,让next[2] = next[1] = -1;
- j=3,让next[3] = next[2] = -1;
- j=4,让next[4] = next[3] = -1;
- j=5,该字符和前面不相同,所以next[5] = 4;
这样做的意义在于,当j=4的字符不匹配时,可以直接让指针回退到上一个不相等的字符。
代码的修改非常简单,我们只需要在getNext()方法中加一个判断即可:
private static int[] getNext(char[] needle){
int[] next = new int[needle.length];
int k = -1;
int j = 0;
next[0] = -1;
while (j < needle.length-1){
if(k==-1 || needle[k]==needle[j]){
k++;
j++;
//这里判断前后两个字符是不是相等
if(needle[k]==needle[j]){
//如果两个字符相等,那后一个匹配不上前一个必然也匹配不上
next[j] = next[k];
}else{
next[j] = k;
}
}else{
k = next[k];
}
}
return next;
}