前言
在学习KMP算法的过程中,当了解了什么是最长公共前后缀,了解了什么是next数组之后,我们自然想要将KMP算法具体实现出来,那么如何将KMP算法的代码写出来呢,或者说如何去理解KMP算法的代码呢?
一、next数组
首先我们要知道什么是模式串的next数组:
通俗来讲,next数组是一个长度为模式串长度的整型数组在next[i]存放的数值大小即为对应的模式串s从s[0]至s[i-1]这一子串的最长公共前后缀,举个例子:模式串s为:a c t a c b
(前缀用粗体表示,后缀用斜体表示)
下标 | 子串 | 最长公共前后缀长度 |
---|---|---|
0 | a | 0 |
1 | ac | 0 |
2 | act | 0 |
3 | a ct a | 1 |
4 | ac t ac | 2 |
5 | actacb | 0 |
故,该例子的next数组为:{0,0,0,1,2,0}
那么,next数组该怎么用呢?
我们给出文本串 k:a c t a c t a c b
此时来进行匹配:
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
文本串 | a | c | t | a | c | t | a | c | b |
模式串 | a | c | t | a | c | b | |||
next | 0 | 0 | 0 | 1 | 2 | 0 | |||
模式串下标 | 0 | 1 | 2 | 3 | 4 | 5 |
在匹配到最后一个字符的时候,失配了,这个时候我们要进行模式串的向后滑动,那么滑动到哪里呢?
如果不计较算法的高效,我们只需要向后滑动一位,再重新一位一位比较匹配即可
而我们在得到了模式串next数组的值以后,假如在模式串的第i位失配,则我们先看next[i-1],并将其值所对应的下标“滑动”到失配的位置,话不多说,上图
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
文本串 | a | c | t | a | c | t | a | c | b |
模式串 | a | c | t | a | c | b | |||
next | 0 | 0 | 0 | 1 | 2 | 0 | |||
模式串下标 | 0 | 1 | 2 | 3 | 4 | 5 |
如图,在i=5时失配,next[4]所对应的值为2,则将模式串下标为2的位置“滑动”到当前的失配位,也就是i=5的位置,如图,模式串其他的元素也会随着一起“滑动”,随后直接从该位置继续匹配。
这个时候,匹配成功,继续匹配,直到模式串的最后一个元素也匹配成功,得出结果,如图
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
文本串 | a | c | t | a | c | t | a | c | b |
模式串 | a | c | t | a | c | b | |||
next | 0 | 0 | 0 | 1 | 2 | 0 | |||
模式串下标 | 0 | 1 | 2 | 3 | 4 | 5 |
此时,匹配结束,这就是通过next数组进行匹配的流程
原因
next数组为什么会这么神奇呢?为什么按照这样来“滑动”,可以把之前的一部分模式串直接跳过呢?相比这是大多数人刚学KMP算法的时候所思考的。我们再回到刚刚失配时的操作:
下标i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
文本串 | a | c | t | a | c | t | a | c | b |
模式串 | a | c | t | a | c | b | |||
next | 0 | 0 | 0 | 1 | 2 | 0 | |||
模式串下标j | 0 | 1 | 2 | 3 | 4 | 5 |
如图,不难看出,在失配的时候,k[i]与s[j]自然是不相等的,这个时候我们就需要将模式串索引为next[j-1]的元素移动到i这个位置。
我们可以得到以下等式:
k
[
i
−
m
]
=
s
[
j
−
m
]
=
,
m
=
1
,
2
,
…
n
e
x
t
[
j
−
1
]
k[i-m] =s[j-m]=,m=1,2,…next[j-1]
k[i−m]=s[j−m]=,m=1,2,…next[j−1]
s
[
m
]
=
s
[
l
e
n
−
2
×
n
e
x
t
[
j
−
1
]
+
m
]
,
m
=
1
,
2
,
…
n
e
x
t
[
j
−
1
]
,
l
e
n
=
s
.
s
i
z
e
(
)
s[m] = s[len-2\times next[j-1]+m],m = 1,2,…next[j-1],len = s.size()
s[m]=s[len−2×next[j−1]+m],m=1,2,…next[j−1],len=s.size()
看起来有点头晕,我们就拿上面那个来举例子
next[j-1]是2
则第一个等式的意思是,在失配位之前有2个字符,模式串与文本串是匹配的(即图中绿色的两组子串)
下标i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
文本串 | a | c | t | a | c | t | a | c | b |
模式串 | a | c | t | a | c | b | |||
next | 0 | 0 | 0 | 1 | 2 | 0 | |||
模式串下标j | 0 | 1 | 2 | 3 | 4 | 5 |
而第二个等式的意思是,在失配位之前,存在长度为2的相同的前后缀,也就是最大公共前后缀(即图中绿色的两组子串)
下标i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
文本串 | a | c | t | a | c | t | a | c | b |
模式串 | a | c | t | a | c | b | |||
next | 0 | 0 | 0 | 1 | 2 | 0 | |||
模式串下标j | 0 | 1 | 2 | 3 | 4 | 5 |
由这两个等式就可以得出一个结论:在出现失配之前,会有长度为next[j-1]的最大公共前后缀已经在这次比较时匹配过,在模式串“滑动”的时候,我们就可以跳过这些已经匹配过的前缀,而前缀的长度正好是next[j-1],所以我们只需要将s[next[j-1]这个位置移动到失配位,这个时候,模式串的前缀正好和刚刚匹配过的文本串的后缀重合,不需要再次匹配。
求next数组
在明白了原理以后,我们就需要根据模板串来获得其next数组,现在让我们一起试试吧。
由于这个很难从零讲清楚,我先放上代码,随后通过解析代码来分析
//获取模式串的next数组
void getNext(int *next, string needle){
int j = 0;
next[0] = 0;
for(int i = 1;i<needle.size();i++){
while(j>0 && needle[i]!=needle[j]){
j = next[j-1];
}
if(needle[i]==needle[j]) j++;
next[i] = j;
}
}
首先,我们传入一个空的next数组,并将模式串needle传入。
int j = 0; next[0] = 0;
这里j有两个含义,一个是当前比较的游标,一个是最大公共前后缀的长度。
由于模式串的第一个子串是不会存在前后缀的,最大公共前后缀长度为0,故设next[0]为0
for循环
从模式串的第二位遍历到模式串最后一位
next[i]的确定分两步,我们可以想象一下,此时j为最大公共前缀的长度,而needle[j-1]代表了最大公共前缀的最后一位。
如果needle[i]==needle[j]:则needle[j]也是是当前子串的最大公共前缀的一部分,则j应该自增1,在自增的同时,将j下一次比较的游标向后挪一位。
在j自增以后,由于needle[i]所在的位置已经是当前子串的最后一位,则不会再有更长的公共前后缀,所以,将j的值赋给next[i]即可。则可以把代码补全如下:
//获取模式串的next数组
void getNext(int *next, string needle){
int j = 0;
next[0] = 0;
//处理前后缀相等的情况
if(needle[i]==needle[j]) j++;
next[i] = j;
}
}
如果needle[i]!=needle[j]:在前后缀不相等时,说明最大公共前后缀长度不可以再增加,我们需要往回退,而往回退的时候,我们希望在之前得到的最大公共前后缀中,获得其最大公共前后缀,并将游标j定位到上一次出现该前缀的位置。
举个例子:在求模式串:a c a a c c 的next数组的过程中
已经得到了这些元素:
模式串 | a | c | a | a | c | c |
---|---|---|---|---|---|---|
next | 0 | 0 | 1 | |||
i,j的位置 | j | i | ||||
index | 0 | 1 | 2 | 3 | 4 | 5 |
此时,needle[I]!=neelde[j],j=1,我们看到 a 这个字符不能加入到最大公共前后缀,也就是说 ac不会是这个子串的最大公共前后缀,原最大公共前后缀为a,那么,在原最大公共前后缀这个子串中,也存在着最大公共前后缀,这个例子中最大公共前后缀的最大公共前后缀长度为next[0]=0,不会出现a是这个当前最大公共前后缀的前缀的下一位的可能性,所以回到模式串开头,这个时候needle[i] == needle[j],回到上文中的情况。
模式串 | a | c | a | a | c | c |
---|---|---|---|---|---|---|
next | 0 | 0 | 1 | 1 | ||
i,j的位置 | j | i | ||||
index | 0 | 1 | 2 | 3 | 4 | 5 |
理论上,如果能不断找到最大公共前后缀的最大公共前后缀,在发生不匹配后应该继续往前寻找,直到匹配成功或者回到模式串开头,所以应该有两个条件进行回溯,并且在回溯结束后还要判断needle[i]和needle[j]是否相等,如果到最后都不相等,说明最大公共前后缀为0,此时正好是j的值,所以补全代码得
//获取模式串的next数组
void getNext(int *next, string needle){
int j = 0;
next[0] = 0;
for(int i = 1;i<needle.size();i++){
while(j>0 && needle[i]!=needle[j]){
j = next[j-1];
}
if(needle[i]==needle[j]) j++;
next[i] = j;
}
}
实现KMP算法
在上文中,我们已经获得了next数组,现在我们需要通过next数组来匹配文本串和模式串了,想到这里就会很头疼,为什么,好像又要写一段逻辑来实现这个功能了,其实不然,请看:
在求next数组中,我们做了以下工作:
- 求出模式串的所有前缀子串的最大公共前后缀的长度
- 将求出的长度放到前一个下标所在的位置
那么在实现字符串匹配的时候,我们能不能用相同的思路呢?答案是肯定的。
我们把问题再回到:“字符串匹配”上来,字符串匹配需要文本串和模式串遍历到最后,直到模式串的最后一位与文本串完全匹配,就得到了最后的结果。有没有觉得和求next数组的过程很像。
如果我们把模式串和文本串拼接起来,组成一个新串
例如:文本串为:actactacfaa 模式串为:actacf
则求出next数组后,实现匹配的思路本质上可以看作:
求出串:(actacb)(actactacbaa)
的next数组中第一个为模式串actacf长度6的元素的下标并作出处理,(括号仅仅用于分开模式串和文本串,没有其他含义)
此时,下标为0,直接从文本串部分开始,我们求出其next数组如下:
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
文本串 | a | c | t | a | c | b | a | c | t | a | c | t | a | c | b | a | a |
next | 0 | 0 | 0 | 1 | 2 | 0 | 1 | 2 | 3 | 4 | 5 | 3 | 4 | 5 | 6 | ! | ! |
可以看出,在i=8的时候,出现了next[8] = 6,也就是说,i = 8时正好是这个子串的末尾,所以可以得到模式串在文本串中出现的下标为i-needle.size()+1=3,也正是最终答案。
所以代码与上部分大同小异,就不详细解释
int kmp(string haystack,string needle){
//空串直接返回0
if(needle.size()==0) return 0;
//文本串长度小于模式串直接返回无法找到
if(needle.size()>haystack.size()) return -1;
int j = 0;
int next[needle.size()];
getNext(next,needle);
for(int i=0;i<haystack.size();i++){
while(j>0 && haystack[i] != needle[j]){
j = next[j-1];
}
if(haystack[i] == needle[j]) j++;
//如果找到了长度为要求的最大公共前后缀,直接退出
if(j == needle.size()) return (i-j+1);
}
return -1;
}
最后
这个算法理解还是需要一些时间,思路在理解了最大公共前后缀后并不难想出来,但是将其转化为实际的代码却是能让人想破脑袋,佩服能写出这个算法的大佬!