kmp算法和next数组求解
欢迎在评论区指正!
kmp概要
通过观察暴力子字符串查找算法(又称简单的模式匹配算法、BF算法)不难发现,造成其时间开销大的原因是由于 i 指针的多次回溯导致产生了重复的匹配操作。因此,kmp算法充分利用了已匹配过的子串内容,通过将 j 设为某个值来使得 i 指针不回退,成功减少了不必要的重复操作。
kmp详细过程
记录文本串s1和匹配字符串s2分别如下:
string s1 = "aabaabaaf";
string s2 = "aabaaf";
若使用暴力匹配,在执行如下代码时:
//使用i标记s1,j标记s2
while(i < s1.length && j < s2.length) {
if(s1[i] == s2[j]) {
//如果相同则同时后移i j指针
i++;
j++;
} else {
//不相同回溯i j指针
i = i - j + 2;
j = 1;
}
}
发现,由于从s1[0]~s1[4]两字符串相同,直当匹配到s1[5]时失配,此时 i 指针需要回溯到 i=1,j 指针回溯到 j=1的位置(图解上表现为匹配串右移动)并重新开始匹配,使得前五次的匹配为无效匹配。那有什么办法能利用起前五次的匹配操作呢?kmp算法。kmp算法通过将模式串直接移动至下图位置,即在i=5不变的前提下,将模式串直接右移三位,大大减少了匹配的次数。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|
a | a | b | a | a | b | a | a | b | a | a | f |
a | a | b | a | a | f |
那么问题来了,kmp算法是通过什么来确定的右移位数呢?答案是部分模式匹配表(PMT)。
什么是PMT
PMT中的值叫做部分匹配值(PM),意为失配位置前的子串最大的相等前后缀长度。
前后缀是求解PMT中的值的关键,相关概念较为简单,在此不做赘述。
string s2 = "aabaaf";
s2的前后缀表如下。注意前缀不包含字符串本身,即不含最后一位,后缀同理,最长后缀不含第一位。
字符串s2的前缀表 |
---|
a |
aa |
aab |
aaba |
aabaa |
字符串s2后缀表 |
---|
f |
af |
aaf |
baaf |
abaaf |
所以s2="aabaaf"的PM值为0。
PMT值的求解(手算过程)
当失配位置为 j 时,字符串substring(s2, 0, j - 1)的最长相等前后缀长度即为PMT中 j 对应的PM值。
失配位置 | 失配前子串 | PM |
---|---|---|
0 | / | 失配位置前为空串,因此PM暂时还没法确定 |
1 | a | 0 |
2 | aa | 1 |
3 | aab | 0 |
4 | aaba | 1 |
5 | aabaa | 2 |
那么这个PMT对kmp算法的帮助体现在哪里?为什么可以通过PMT可以知道模式串需要右移的位数不多不少刚好3位?
我们以字符串s1和模式串s2为例,第一次匹配时情况如下(绿色匹配,红色失配):
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|
a | a | b | a | a | b | a | a | b | a | a | f |
a | a | b | a | a | f |
不难发现失配位置 i=j=5,通过PMT表发现失配位置为5时PM=2。这个PM=2代表了其失配前子串“aabaa”的最长相等前后缀长度为2,即{s2[0],s2[1]} == {s2[3],s2[4]},而在到达失配位置之前,算法已经依次比较过并得到结论{s1[3],s1[4]} == {s2[3],s2[4]},所以我们可以得到一个结果:{s2[0],s2[1]} == {s1[3],s1[4]} ,所以我们就可以将s2[0]移动到s1[3]的位置,即图5所示。
至此,我们得到了右移位数为什么是3的原因。那么具体到每一步操作中,右移位数和PM的值应该是什么关系呢?观察表1和表5不难发现,右移位数move和PM的关系如下(需要注意到此时 j = i = 失配位置 = 5)
m
o
v
e
=
j
−
p
m
move = j -pm
move=j−pm
在图解中,我们移动了s2字符串的位置,但是在实际内存中s2的位置是固定不变的,因此只能通过移动 j 指针来达到相通的效果。向右移动字符串move位等于向左移动 j 指针move位。
j
=
j
−
m
o
v
e
=
j
−
j
+
p
m
=
p
m
j=j-move=j-j+pm=pm
j=j−move=j−j+pm=pm
至此我们知道了在遇到失配时,i指针和j指针分别应该进行的操作是什么了:i 指针保持不变,j 指针移动到PMT中PM对应的数组下标位置。
此时我们回过头来看失配位置为的0时候的PM值。不难发现,无论是什么串,当字符串和匹配串的首位就发生失配时,很显然,只能让匹配串右移一位,而此时 j=0,因此失配位置0对应的pm值为:
p
m
=
j
−
m
o
v
e
=
−
1
pm=j-move=-1
pm=j−move=−1
PMT机算过程(next数组的求解)
写在前面:这里我的措辞写的很罗嗦,好像也说的不是很清楚,如果看不懂请移步这个视频:KMP算法之求next数组代码讲解 14分10秒开始。
在聊机算过程之前,我想先聊聊我对next数组的一些感受。
这一部分是整个kmp算法实现中最难理解的部分。b站上大部分的资料在这一块粗略讲过,同时,由于next数组的版本有许多种,很容易导致大家无法理解next数组。看过王道书的同学不难发现,在本篇文章中,我的next数组相较于王道书中的next数组都少了1。即同样的匹配串,我的next[]={-1,0,1,0,1,2}对应的王道书中的next[]={0,1,2,1,2,3}。可是pmt表不同,为什么通过公式得到的 j 需要回退到的位置都是等于next[j]呢?明明相差了1呀?这一开始让我百思不得其解。后来发现,王道书中对字符串s1、匹配串s2、pmt的处理都与我有一些差异:王道将字符串存储从数组的第二位开始,即字符串第一位存储于s1[1],默认弃用s1[0],同理,他也弃用了s2[0],next[0],至此,所有问题迎刃而解,王道中的字符串下标全部右移了一位,对应的 j 回溯位置也应该相应右移,所以他的pmt比我的pmt的值大1(这也是为什么22版p108在应用了我的表之后得到的最终公式中 j=next[j]+1)。而我个人习惯从数组的第一位即s1[0]起开始存储,因此,我的pmt会较王道少1。
ok,我们回归到next的求解中来。观察以下匹配串:
index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
s2[] | a | a | b | a | a | c | a | b | a |
next[] | -1 | 0 | 1 | 0 | 1 | 2 | ? | ? | ? |
假设目前已知道next[0]~next[5]的值,求next[6]。
记 i 指针指向所求串的后缀最后一位,j 指针指向所求串的前缀最后一位。则图中这种情况下,j = 2,i = 5(这里不理解为什么指向这两个位置也没关系,往下看看代码就行)。
此时我们已经知道next[5]的值为2,就说明{s2[0],s2[1]} == {s2[3],s2[4]}。此时,最好的情况也就是当s2[2] == s2[5]的时候,其最大相等前后缀长度为next[5]+1=3。
但这里的s2显然不满足这种条件,此时就要对j进行操作。怎么操作?看下图:
由next[5] == 2,next[2] == 1可以得到的信息是,两红框内容相等(有效信息为s2[0] == s2[3]),两蓝框内容相等,两绿框内容相等(有效信息为s2[3] == s2[4]),就可以得到a[0] == a[4]!此时就又回到了刚开始的问题:我们把 j 移动到下标为next[j](即j=next[5]=1)的位置,a[1]a[5]如果a[1]==a[5],那么next[6]=next[2]+1=2,但这里依旧不满足。那就再次把j移动到next[j](即j=next[1]=0),比较s2[j]和s2[i],相等+1,不相等继续这个操作。本例中,j最后=-1,表示此次匹配下来发现的最长相同前后缀长度为0,于是,将i后移一位用作记录新一个next的下标,同时为用作下一次匹配的后缀末尾指针;将j=0赋给next数组,移动j指针用作下一次匹配的前缀末尾指针(别忘了这里j已经=-1了,再后移一位指向的位置也就是s2[0])。
为什么是j=next[j]?别忘了一开始我们把j指针定义为要比较的前缀的最后一个字符位置,而前缀的最后一个字符位置刚好就是next[j]的值(最长相等前后缀长度=>满足条件的前缀最长长度,对应的数组下笔标是该前缀的下一位)。
对应的代码如下:
void getNext2(string str, int next[]) {
next[0] = -1; //一开始就初始化next[0] 肯定是等于-1的
int i = 0, j = -1;
while(i < str.length()) {
if(j == -1){
i++;
j++;
next[i] = 0;
} else if(str[i] == str[j]) {
i++;
j++;
next[i] = j;
} else {
j = next[j];
}
}
}
你是不是有所疑问,怎么这么麻烦?我看书上第一个if和第二个if是拼起来的呀?确实,当j=-1时,移动j的操作使得j从-1变了0,因此,第一个if的内容可以改成和第二个if的内容完全一样的代码,同时可以合并两个if,变成如下代码:
void getNext(string str, int next[]) {
next[0] = -1;
int i = 0, j = -1;
while(i < str.length()) {
if(j==-1||str[i]==str[j]){ //这里j==-1一定要写在比较前面 因为||在前一半成立的时候是不会判断后一半的,否则就要str[-1]报数组越界的错了
i++;
j++;
next[i] = j;
} else {
j = next[j];
}
}
}
从头开始过一遍kmp
①
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|
i | |||||||||||
a | a | b | a | a | b | a | a | b | a | a | f |
a | a | b | a | a | f | ||||||
j |
②
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|
i | |||||||||||
a | a | b | a | a | b | a | a | b | a | a | f |
a | a | b | a | a | f | ||||||
j |
此时失配,失配位置j=5,查next表将j移动Next[5]=2的位置
③
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|
i | |||||||||||
a | a | b | a | a | b | a | a | b | a | a | f |
a | a | b | a | a | f | ||||||
j |
④
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|
i | |||||||||||
a | a | b | a | a | b | a | a | b | a | a | f |
a | a | b | a | a | f | ||||||
j |
从i和j开始重新匹配 到i=8,j=5时失配
查next表得next[5]=2
⑤
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|
i | |||||||||||
a | a | b | a | a | b | a | a | b | a | a | f |
a | a | b | a | a | f | ||||||
j |
从i和j开始重新匹配,到i=11 j=5时匹配成功
⑥
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|
i | |||||||||||
a | a | b | a | a | b | a | a | b | a | a | f |
a | a | b | a | a | f | ||||||
j |
int index_KMP(string s1, string s2, int next[]) {
int i = 0;
int j = 0;
while(i<s1.length() && j<s1.length()){
if (j == -1 || s1[i] == s2[j]){
//j==-1时的情况就是s1[i]开始是串连s2的第一个元素都不匹配时的情况,操作和s1[i] == s2[j]时一样,直接后移i和j指针。
//或者可以理解为s2[-1] == s1[i]恒成立
i++;
j++;
} else {
j = next[j]; // 失配时移动j到next[j]的位置
}
}
if (j >= s2.length()) { //王道书这里是大于没有等于,其原因也是他字符串开始位置位数数组第二位即s1[1]
return i - s2.length();
} else {
return -1;
}
}
int main() {
//测试代码:
string str1= "AABAABAAF";
string str = "AABAAF";
int next[str.length()];
getNext(str, next);
int i = index_KMP(str1,str,next);
cout<<i;
return 0;
}