复习考研数据结构时遇到了这个KMP算法,听说它被誉为数据结构第三大难学的算法,学完之后感觉雀实有点小绕,因此小陈想写下来,以备将来不时之需。
要想理解这个算法,我们先从一个简单的例子说起:
比如,给你两个字符串,一个作为主串,一个作为子串,(主串比子串要长),让你从主串中找到和子串相同的部分,并求出主串是从哪一位置开始和子串相同的。
大家肯定都知道怎么求,各个位循环匹配不就行了?(这样的方法其实叫做简单的模式匹配算法)
雀实,这么暴力的干,我们终究会得到结果,但是如果遇到比较大,比较复杂的主串和子串时,它的效率并不高,举个栗子吧
主串设为:A B A B C A B C A C B A B
子串设为:A B C A C
当你利用这种简单的模式匹配算法时,它的匹配过程是这样的:
(子串循环匹配到第3位就发现和主串不一样了,将整体移到下一位开始新一轮的循环匹配)
(子串循环匹配到第1位就发现和主串不一样了,将整体移到下一位开始新一轮的循环匹配)
(子串循环匹配到第5位就发现和主串不一样了,将整体移到下一位开始新一轮的循环匹配)
(子串循环匹配到第1位就发现和主串不一样了,将整体移到下一位开始新一轮的循环匹配)
.
.
.
一直以这样的步骤重复的对比,直到最后相等
⬇⬇⬇⬇⬇⬇
这样做其实是比较麻烦的,它的时间复杂度是O(mn),m和n分别是主串和子串的长度。
他的麻烦在于,你会发现,
在第1和第2步之间,完全可以让子串从主串的第3位开始
在第3到第4步之间,完全可以让子串从主串的第6位开始
如果可以避免上面例子中不必要的重复的比较,那我们应该怎么做呢?
由此,我们正式引入KMP算法。
KMP算法:
了解KMP算法之前,我们先来引入3个概念,有利于我们后续理解。
①前缀②后缀③部分匹配值
举个栗子帮助大家理解一下这三个概念吧,我们以“ababa”为例吧
1:“a”,前缀无,后缀无,最长相等前后缀长度:0.
2:“ab”,前缀{a},后缀{b},最长相等前后缀长度:0.
3:“aba”,前缀{a,ab},后缀{a,ba},前后缀同时都有“a”,所以最长相等前后缀长度:1.
4:“abab”,前缀{a,ab,aba},后缀{b,ab,bab},前后缀同时都有“ab”,所以最长相等前后缀长度:2.
5:“ababa”,前缀{a,ab,aba,abab},后缀{a,ba,aba,baba},前后缀同时都有“aba”,所以最长相等前后缀长度:3.
所以字符串“ababa”的部分匹配值(PM)为:00123
同理:所以字符串“abcac”的部分匹配值为:00010.
我们现在可以建立一个关于字符串和部分匹配值的表:
第①趟发现第3(j)位不匹配时,再移动就有了规律:
移动位数=已匹配的字符数(j-1) - 对应的部分匹配值(PM[j-1])
即第1趟检测出不匹配后,子串整体直接右移2两位,让子串的第1位与主串的第3位对应,并开始比较。
(若子串第1位即不匹配,则可在代码中实现将子串循环整体右移1位,或者也可以假设PM[0]=-1再应用以上公式)
↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
这就是KMP算法的思想所在
只是理解了这个算法的思想,那我们离应用和做题还差了一大截,接下来,我们接着讲KMP改进方法、代码,以及KMP如何再优化
由上面的规律可知,当第j位不匹配时,要移动的步数(Move)等于已匹配的字符数(j-1) - 对应的部分匹配值(PM[j-1])位,即:
Move=(j-1)-PM[j-1]
这样每次不匹配时,都要回去找到第(j-1)位才可以进行计算,有些麻烦,所以我们可以将PM表整体右移一位,即:
第1位置-1,防止第1位就不匹配,最后一位溢出,也不用再管,所以,上面的公式就变为了:
Move=(j-1)-Next[j]
所以相当于将子串的“比较指针”j回退到:
j=j-Move
(用通俗一点的话来说就是:"老指针"所指向的不匹配元素,减去应该回退的步数,即为"现指针"应指的元素)
(这里的"指针"是通过数组和其下标来实现的一种标记,并不是真正的指针)
将Move的值带入,即等价于
j=j-Move=j-((j-1)-Next[j])=Next[j]+1
所以,我们又可以为了方便,将Next表再整体加1。(目的是为了把j=Next[j]+1 后面的+1消掉)
所以新的子串和Next数组的对应关系表即为:
这样:
j=Next[j]
即:在子串的第j个字符与主串发生失配时,则跳到子串的Next[j]位置重新与主串当前位置进行比较
这时大家会发现,如果第1位时就不匹配,j将等于0,这显然不对劲。所以,我们需要在代码里实现对这种情况的控制:
int KMP(String S,String T,int next[]){
int i=1,j=1;
while(i<=S.len&&j<=T.len){
if(j==0||S.ch[i]==T.ch[j]){
++i,++j;
}
else
j=next[j];
}
if(j>T.len)
return i-T.len;
else
return 0;
}
当子串的第1位与主串不匹配时,while中if语句判定是失败的,只能进入else语句中,j将置0,但是i此时不变,第二轮开始时,由于j=0,直接进入while中的if语句中,此时,i从1自加为2,j从0自加为1,相当于从子串的第1位与主串的第2位比较。
即:做到了子串在第1位不匹配时,"整个子串后移"一位的操作。
当子串的第1位与主串匹配时,while中的if语句判定成功,一切按部就班,一位接一位的往下比较,i也始终大于j,没有任何问题。
到这里大家基本上已经对KMP算法的工作原理了如指掌了,但是其实说完上面的还不够,算法中还有一个影响效率的因素存在
那就是,next数组需要人为的去计算,计算完后还要手动输入到程序中,太麻烦了…
有没有什么方法让程序自己去求一下next数组呢?有,来往下看:
先设一个子串为:“S1S2S3…Sn”
如果该子串在和主串对比时,第j个字符失配,我们应该怎么滑动子串去和主串继续匹配?
因为在第j个字符失配了,所以前面j-1个字符一定是和主串相同的字符。
再设一个k值,用来代表:重新匹配时,子串开始与主串对比的位置。
由我们上面得到的规律可知:
S1S2…Sk-1=Sj-k+1Sj-k+2…Sj-1
举个栗子:
(只是演示一下k值的应用,实际k值是多少需要让程序自己计算)
针对这个方法,有以下2种特殊情况:
1、子串已匹配的相等字符序列中,不满足条件:S1S2…Sk-1=Sj-k+1Sj-k+2…Sj-1,(即可看作k=1时)就要右移j-1位,让主串第i个字符和子串第1个字符进行比较。
⬇⬇⬇⬇⬇⬇⬇⬇⬇⬇⬇
2、子串第1个字符与主串第i个字符失配时,规定next[1]=0。
(可理解为将主串的第i个字符和子串的第1个字符前面空位置对齐,也即子串整体右移一位)
所以,我们得到了一个关于next的函数公式:
我们现在知道:next[1]=0, 再设next[j]=k。
所以next[j+1]=多少呢?
只有两种情况:(因为前后缀如果相等的话,那么从最后一位往前一定是相等的,所以我们要从最后一位比起,即:只判断Sk和Sj )
一、若Sk=Sj 则表明子串中:
即next[j+1]=next[j]+1
二、若Sk≠Sj 则表明子串中:
这时,可以把求next函数值视为又一个模式匹配问题,(即套娃求值嘿嘿)
通俗来讲就是,把Sj-k+1Sj-k+2…Sj当作主串,把 S1S2…Sk当作子串,让他们两个找到更短的相同前后缀。(即把不匹配的两个子串进行缩短,再匹配是否有相等的前后缀)
如下图:
这样类推下去,直到找到一个k’
此时,
k’=next[next…[k]] (1<k’<k<j)
所以,
next[j+1]=k’+1
如果这个k’不存在,则
next[j+1]=1
举个栗:
一:求next[7]
因为next[6]=3 所以,需比较 S6和S3 , 因为S6 ≠ S3 ,又 next[3]=1
所以比较S6 和S1 ,又S6 ≠ S1
所以不存在相等的前后缀,所以next[7]=1
二:求next[8]
因为next[7]=1 所以,需比较 S7和S1 , 因为S7 = S1
所以next[8]=next[7]+1=2
二:求next[9]
因为next[8]=2 所以,需比较 S8和S2 , 因为S8 = S2
所以next[9]=next[8]+1=3
求next数组的代码:
非常简单,自己把代码在纸上走一遍就会了
void get_next(String T ,next[]){
int i=1,j=0;
next[1]=0;
while(i<T.length){
if(j==0||T.ch[i]==T.ch[j]){
++i;++j;
next[i]=j;
}
else
j=next[j];
}
}
KMP算法到此基本可以说完结了,不过他还具有更妙的改进方法,可以更好的避免重复比较操作。
KMP算法的改进:
举个缺陷的例子吧先:
很明显,在第4位失配时,模式串和主串按照next数组去匹配时,是一位接着一位的去匹配的,而不是直接将模式串的第1位右移到主串的第5位的,导致了重复操作。
所以,面对这种情况,我们要对next数组进行加工才可以避免,即再次递归,将next[j]修正为next[next[j]],循环下去,一直到前后两个next数组不再相等为止。将修正后的数组命名为nextval。
求nextval的代码:
非常简单,自己把代码在纸上走一遍就会了
void get_nextval(String T ,nextval[]){
int i=1,j=0;
nextval[1]=0;
while(i<T.length){
if(j==0||T.ch[i]==T.ch[j]){
++i;++j;
if(T.ch[i]!=T.ch[j]) nextval[i]=j;
else nextval[i]= nextval[j];
}
else
j=nextval[j];
}
}
恭喜你看完了整个文章,希望这篇文章让你对程序底层世界的了解更加清晰。