KMP算法最与众不同的解析:1小时内你必懂!

阅读本文前要求:

必须先懂BF算法

阅读本文前的小唠叨:

    众所周知,BF算法的思想是常人的思想,逐个比较字符,不相等则主串和子串的指针都要回溯。而KMP算法不是常人的思想,主串的指针i不需回溯,为了省去不必的比较,子串的指针j要恰到好处地回溯到某个位置。你且先不要管j需要如何回溯。你得先搞懂为什么可以省去不必要的那些比较!

    很多人一开始接触这个算法连为什么i不需要回溯都不知道,直接看next数组,结果只能是越来越懵逼。还有的人只知道j回溯的位置就是失配字符前字符串相似度+1,那我问你,为什么有相似度?相似度是什么?相似度是怎么来的?为什么相似度加1就是j回溯的位置?你如果连这些都没搞懂就去看next数组,不懵逼才怪!

    所以,你先不要管next数组是什么,你先得知道BF算法是如何过渡到KMP算法的!至于这个过渡,很多关于KMP算法的文章都是举具体例子,我认为从整体角度来看更清晰易懂,废话不多说,进入正题!

正文:

一、主串指针i不用回溯的情况分析

通过BF算法,我们发现,主串指针i与子串指针j每次都需要回溯,那么,是否可以不用回溯呢?不用回溯的情况又是什么呢?通过举出大量例子,我们发现如下情况的i指针可以不用回溯:

如上图,假设主串S和子串T在匹配的过程中出现"ABA"的情况,即“A组,B组,A组”的情况,那么子串指针j就可以回溯。通过观察上图,不难发现,当S的ch1和T的ch2失配时,按照BF算法的思想,主串指针i回到S第1列的A组的第二个字符,即A2的位置,子串指针j回到T第1列的A组最开头,即A1的位置。显然,接下来有2个过程是必然要进行的: 

第1个过程:不等过程,由于S中第1列的A2到第2列的Bn与T第1列的A1不等,根据BF算法,S的指针i更新为i-j+2,而T的指针j又是1,故S的指针i更新为i+1,即i必然不断向后移动,T的指针j必然大部分时候回溯至A1,最后一步是:T第1列的A1与S的Bn不等,T的指针j回溯至A1,S的指针i移动至第3列的A1。

第2个过程:相等过程,由于S第3列的A组和T第1列的A组相等,那么i和j必然会并行移动到各自的Am。

经过上述2个过程,必然是S的指针i移动到ch1,T的指针j移动到B1,然后再进行B1和ch1的比较。既然上述2个过程是必然会进行的,那么我们就可以在每一次出现ch1和ch2失配时,跳过这2个过程。对于S,i指针已经在ch1上了,所以不用回溯,而T的j指针可以直接回溯到B1上,这就大大提高了模式匹配的效率。

如果说“ABA”的情况是主串指针i不需回溯的一般情况,那么“AAA”与“ABC”,即“三个相等的组”,“三个不等的组”的情况就是指针i不需回溯的特殊情况。当然,我们可以很快得出,“ABC”的情况下,S第1列A2到ch1之前的所有字符都与T第1列A1不等,则最终的结果为:T的第1列A1与ch1比较。“AAA”的情况:显然,和“ABA”的情况类似,S第1列A2到第2列Bn之前的所有字符都与T第1列A1不等,最终的结果为:T的B1与ch1比较。如果是"AB"的情况,稍加观察,结果为:T的第1列A1与ch1比较。

还有一种最特殊的情况:单组且组内字符全相等的情况,即只有A组,且A1=A2=A3=...=Am,当ch1和ch2失配时,S的A2和T的A1比较,由于S的A2到ch1之间的字符和T的A1到ch2之间的字符都相等,故最终结果为,T的单组A中的Am和ch1比较。

我们可以做个阶段性总结,主串指针i不需回溯的情况下,结果有3种:T的B1与ch1比较,T的第1列A1与ch1的比较,及T的单组A中的Am和ch1比较。

二、前后缀与相似度的概念及一些可能的疑问解答

关于串的前后缀与相似度,不知道是谁提出的,百度上也没有明确的定义,但是通过阅读一些资料,大致可定义如下:

取串中某个字符(串),从串的第1个字符到这个字符(串)之前的第1个字符组成的一个子串,为串的前缀。

取串中某个字符(串),从这个字符(串)之后的第1个字符到串的最后1个字符组成的一个子串,为串的后缀。

如果一个串的前后缀相等且前后缀的长度达到最大,则这个串的前后缀长度叫做这个串的相似度。

当然,我的叙述不一定严谨,但是对于理解KMP算法来说足矣。

可能疑问:在“ABA”的情况下,假设S中第1列的A2到第2列的Bn组成字符串M,乍看之下,M中的字符未必与T第1列的A1不等,在M中可能也存在某个字符与T第1列的A1或T第1列的某个字符相等,或者在M中存在某一段字符与T第1列的某段字符相等,如果这两种情况发生其中任意一种,那么最终结果是否一定能保证“T的B1与ch1比较”呢?

我在研究KMP算法的时候也想过上述疑问,但是经过一些实例的演示,结果一定是T的B1与ch1比较。因为上文提到的“ABA”的情况是建立在A的长度m作为子串T的相似度的基础之上的,如果有这两种情况的发生,则A的长度就不是T的相似度了,你可以容易地发现一些B中的字符还可以和A组成一个新的串,这个串的长度来将作为新的相似度。如果上述情况的发生导致A的长度不改变,则你可以发现结果还是T的B1与ch1比较。综上,在“ABA”的情况下,结果一定是T的B1与ch1比较,其他情况的结果也必然会成立,如果有兴趣,不妨一试,这里不再赘述。

三、j需回溯的位置和相似度的关系

我们先假设ch2之前的串为P,结合上述总结的3种情况,讨论j需回溯的位置和相似度的关系:

1.T的B1与ch1比较的情况:我们容易看出,B1的位置即j所在位置就是P的相似度+1,确切地说,就是在P中的B组前面的A组和B组后面的A组是相等的,也就是B之前的A1到Am和B之后的A1到Am是相等的。这个相似度为m,故位置m+1就是B1的位置,也即当ch1和ch2失配时,j需要回溯的位置。

2.T的单组A中的Am与ch1比较的情况,j需回溯的位置其实也是P的相似度+1,因为组内字符都相等的串,除去第1个字符,后面的字符是后缀,除去最后一个字符,前面的字符是前缀,前后缀相等且长度达到最大,故P的相似度为m-1,j需回溯的位置就是m。

3.T的第1列A1与ch1的比较的情况,显然这种情况的前提就是P的相似度为0,故1就是j需回溯的位置。

于是我们得出结论:模式串指针需回溯的位置=模式串失配字符前的字符串的相似度+1

显然,模式串指针j需回溯的位置只与模式串的相似度有关,即只和模式串本身有关。

四、next数组的定义

经过上述3个主题的讲解,才到了这个主题,也是最核心的主题之一,显然,没有上述3个主题的理解,再聪明的人也不可能在短时间内精通KMP算法,更何况像我们这一类初步学习数据结构的学子,更不能心急直接了解后面的一些东西,那样的话是什么收获都没有的。好了,我们接着往下讲。

既然我们知道了模式串指针需回溯的位置=模式串失配字符前的字符串的相似度+1,那么不妨将模式串指针j需回溯的位置构成一个叫做next的数组,模式串失配字符前的字符串为P,构造自变量为P,应变量为P的相似度的函数X(P),则可得:

next[j]=X(P)+1

于是,似乎我们只需每次在失配时计算P的相似度即可得出每次失配时指针j需要回溯的位置,然而你会发现这样做是相当麻烦的。能不能一次性把next数组填充好,在每次失配时使用next数组来获取下一次需要回溯的位置呢?答案是肯定的,只不过你可能不再需要用到“相似度”这个东西了。

在说这个之前,我们先来讲下使用next数组来获取下一次需要回溯的位置在KMP算法(不是求next数组算法)中对应的语句:

j=next[j]

意思是当j需要回溯时,通过以j作为索引(下标)来从next数组中获取需要回溯的位置,而在求next数组算法中也有j=next[j],这个j的作用不仅是获取需要回溯的位置,而且会作为新的next数组中的值,之后会讲到。

为什么我说在一次性计算next数组的时候,你可能不需要用到“相似度”?如果你看过严蔚敏教授写的数据结构的话,你会发现她在讲解KMP算法的时候,从头到尾都没有提及“相似度”这个概念,更没有“前后缀”这个概念。她在书中大致是这样写的:

当主串中第i个字符与模式中第j个字符“失配”时,假设此时应与模式中第k(k<j)个字符继续比较,则模式必须满足模式头k-1个字符的子串与主串第i个字符之前k-1个字符的子串相等,而已经得到的匹配结果是模式中第j个字符之前k-1个字符的子串与主串第i个字符之前k-1个字符的子串相等,故模式头k-1个字符的子串与第j个字符之前k-1个字符的子串相等,即p1p2…pk-1=pj-k+1pj-k+2…pj-1,不妨称之为4-4式。反之,模式串中存在满足4-4式的两个子串,则在失配后,匹配仅需从模式中第k个字符与主串中第i个字符比较起继续进行。

但是,并非所有人,或者说大部分人一开始在阅读教材时,不会轻松地读懂严蔚敏教授的讲解的,为了方便理解,才有了“前后缀”和“相似度”这个概念。

接着我们来解读下严蔚敏教授给出的next函数的定义:

当j=1时,next[j]=0

即主串的某个字符和模式串的第1个字符失配,那么下一次j需回溯的位置只能是0

当模式满足4-4式时,next[j]=maxk

即当出现“ABA”这种情况,也就是有前后缀相等时,此时模式满足4-4式,而当前后缀长度达到最大时,也即前后缀长度等于相似度时,B1应与ch1比较,这里B1的位置就是最大的k,也即maxk

其他情况,next[j]=1

其他情况就是我在上文提到的,“AB”,“ABC”的情况,其实就是模式串不出现相等部分的时候,模式相似度为0,所以next[j]直接等于1。

 

五、next数组的求解

这是最难,最核心的一步,我相信,有了前4个主题的理解,再加上我接下来的讲解,你一定能完全掌握next数组的求解方法!

在上文,我们知道了next[j]=k,如何求得next[j+1]的值呢?一般有两种情况:

1.pk=pj

即j需回溯的位置k上的字符pk与模式中和主串失配的字符pj相等,如果按照上文我给出的图,也就是B1和ch2相等了。上文我说过,“ABA”的情况是在模式有最大前后缀长度的前提下的,现在B1和ch2相等了,需要重新计算相似度了。那么B1可以归并到S第1列的A中去,ch2可以归并到S第3列的A中,所以下一次j回溯的位置是k+1。如果按照严蔚敏教授的思想,原来的4-4式,即p1p2…pk-1=pj-k+1pj-k+2..pj-1,现在pk=pj,则p1p2…pk=pj-k+1pj-k+2..pj,显然,next[j+1]=k+1,也即next[j+1]=next[j]+1

2.pk!=pj

这是next数组求解中最难最关键的一环,这个情况的求解可以完美解释求next数组算法中语句j=next[j]的意义,很多人可能一开始看到这个语句会有点懵,把自己作为索引去更新自己,而且还作为新的next值,反复进行后变成了类似next[next[next[j]]]这样的形式,什么意思啊?我想很多人可能最不理解next数组的地方就在这里了。但是现在你已经理解了前面一些步骤,接下来再加上我的详细解释,你必定能通过这“最后一关”!

要求next[j+1],如果pk=pj那么是再好不过了,但是可能大多数情况下,pk!=pj,这就麻烦了,到底要怎么求呢?聪明的先辈们想到了一个至少在我看来绝妙的方法。我们经常听到这句话“不要和别人比,要自己和自己比。”,自己和自己比才是最好的,而pk!=pj的情况下,模式串只能和自己比,引用严蔚敏教授教材上的话,“此时可把求next函数值的问题看成是一个模式匹配的问题,整个模式串既是主串又是模式串”,为进一步理解,附图:

我们将模式串作为主串,不妨称为T,再将T作为子串,也即模式串,因为都是相同的串,不妨称之为CT。然后将CT向右滑动直到CT的pk和T的pj对齐,至于为什么pk能和pj对齐,原因在我的图中。通过上图,你可以看到模式串T的p1到pk-1,是部分1,pj-k+1到pj-1,是部分2,然后CT的p1到pk-1,是部分3。根据4-4式,或者“ABA”的情况,不难得出,部分1=部分2=部分3,而pk和pj失配,所以pk和pj对齐。在之前的讲解中,模式串是有指针j的,但这张图的模式串CT没有指针,但没有关系。在“ABA”的情况中,指针j在ch2处失配,所以j要回溯的位置是next[j]。如果我们不用指针,且假设ch2即表示失配字符也表示它在串中的位置的话,那么叙述就转为ch2和ch1失配,下一待匹配位置是next[ch2],现在这张图也是一样的道理,pk和pj失配,下一待匹配位置是next[k]。而假设next[k]=k',则pk'需和pj重匹配,如果pj=pk',则

p1...pk'=pj-k'+1…pj

也就是在主串T中第j+1个字符之前存在一个长度为k'的最长子串,和模式串CT中从首字符起长度为k'的子串相等。于是参照第1种情况,next[j+1]=k'+1,也即next[j+1]=next[k]+1,而k又等于next[j],故

next[j+1]=k'+1=next[k]+1=next[next[j]]+1

若pj!=pk',则将pj与pk'对齐,现在pj与pk'失配,下一待匹配位置是next[k'],假设next[k']=k'',则pk''需和pj重匹配,而如果pj=pk'',则

p1...pk''=pj-k''+1…pj

说明在主串T中第j+1个字符之前存在一个长度为k''的最长子串,和模式串CT中从首字符起长度为k''的子串相等。于是参照第1种情况,next[j+1]=k''+1,也即next[j+1]=next[k']+1,而k'又等于next[k],k又等于next[j],故

next[j+1]=k''+1=next[k']+1=next[next[k]]+1=next[next[next[j]]]+1,

若pj!=pk'',则……以此类推。

假设序列k',k'',k'''...的通项为ki,则上述情况会一直类推直到下述两种情况中的一种发生:

为叙述方便,不妨假设主串T和模式串CT的指针各为j和k,这样T的pj的位置就是j,CT的pk的位置就是k

1.pj和模式中某个字符pk匹配成功

则继续往后匹配,也即j++,k++,此时需要更新next数组,则next[j]=k。也就是说相对于原来的j,现在的next[j]就是next[j+1],相对于原来的k,现在的k就是k+1,而指针是需要移动的,故本来next[j+1]=k+1的表达式需改为j++,k++,next[j]=k。因为本文信息量大,所以如果你没完全理解next[j+1]=k+1,可以再回顾下之前的第1种情况,即pk=pj的情况,这里不再赘述。

 2.不存在任何ki(1<ki<j)满足

p1…pki=pj-ki+1…pj

不妨设此式为4-5式,则4-5式说明ki=0,设序列k',k'',k''',…中ki之前的字符为ki-1,则ki=next[ki-1],

而按照next函数的定义,ki-1必然是1,也即T的pj和CT的第1个字符p1失配,下一次待匹配位置ki只能是0。ki=0就说明不存在任何ki满足4-5式,按照next函数的定义,这属于其他情况,所以next[j+1]也就是下一次匹配位置是1,计算过程为

next[j+1]=ki+1= next[ki-1]+1=next[1]+1=0+1=1

若以指针i和k的变化情况来转述上述分析,则

k对应ki-1=1,而为了寻找最后一个ki,在求解next数组的算法中,是将同一个变量不断更新的,故你会继续在CT中往前寻找,所以ki-1会变更至ki,即ki-1=ki=next[ki-1]=next[1]=0,用指针的方式就是k=next[k]=next[1]=0,终于等到你,最终的k,因为你等于0了,失配字符前的字符串相似度为0了,我知道寻找匹配的位置已经到头了,所以我需要将指针各增1,以便再继续往后匹配,然后再更新next数组,具体语句为j++,k++,next[j]=k,所以j就增加到j+1,k从0增加到1,所以next[j+1]=k+1=0+1=1

 

六、KMP完整算法与详细注释

读懂前5个主题,你已经理解70%了,还有30%是算法理解与实现,至于实现,读者可在独立写出算法后自行完善。

Talk is cheap,show me the code!

void GetNext(char *p,int *next)
{
	next[1]=0;//初始化下标为1的next值为0 
	int j=1,k=0;
/*注意,串的指针和位置其实都是从1开始的,但是由于每次更新next数组前指针都要先自增,
故复制模式串的指针k从0开始,而下标为1的next值已经有了,所以模式串的指针j从1开始*/ 
	while(j<p[0])//模式串位置0处存放串长 
/*注意j<p[0]不能写成j<=p[0],因为最后一次next[p[0]]=k时,j已经完成任务,需要及时退出循环*/
	{
		if(k==0||p[j]==p[k])//只有复制模式串指针k=0或者匹配成功时 
		{
			j++;
			k++;//指针才继续向后移动 
			next[j]=k;//更新next数组,也即next[j+1]=k+1 
		}
		else
		k=next[k];//若每次pj!=pk,即都失配了,则k以自己为索引更新自己为下一匹配位置
	}
}
int KMP(char *S,char *T)
{
	int next[100];
	GetNext(T,next);
	int i=1,j=1;//主串S和模式串T的指针都从1开始 
	while(i<=S[0]&&j<=T[0])//只有其中一个指针越界了才跳出循环 
	{
		if(j==0||S[i]==T[j])//只有模式串指针=0,也即j需回溯的位置为0 
		{//或者当前相应字符匹配成功 
			i++;
			j++;//指针才分别往后移动 
		}
		else
		j=next[j];//否则j从next数组那里获得需要回溯的位置 
	}
	if(j>T[0])//如果循环跳出是因为模式串指针越界
	return i-T[0];//说明匹配成功,返回模式串在主串中匹配成功的位置 
	return 0;//否则匹配失败,返回0 
}

七、GetNext算法优化

我们知道,当pj和主串的si失配时,如果pj和pk相等,则next[j+1]=k+1,但其实这个操作可能是多余的。如果pj==pk,则j++,k++,当pj+1和主串的si+1失配时,若pk+1==pj+1,如果next[j+1]=k+1执行,则下一次将pk+1和si+1比较,其也必然是失配的,只有当pk+1!=pj+1时,才能执行next[j+1]=k+1,也即将pk+1和si+1比较。那么,当pk+1==pj+1时,我们就可以跳过next[j+1]=k+1这个过程,进而进行next[j+1]=next[k+1]这个过程,因为我们知道pk+1一定和si+1是失配的,所以我们寻找k+1的下一个待匹配位置。

优化后的GetNext算法如下:

void GetNextVal(char *p,int *nextval)
{
	nextval[1]=0;
	int j=1,k=0;
	while(j<p[0])
	{
		if(k==0||p[j]==p[k])
		{
			j++;
			k++;
			if(p[j]!=p[k])//如果pj+1!=pk+1 
			nextval[j]=k;//更新next数组,也即nextval[j+1]=k+1 
			else//如果pj+1==pk+1 
			nextval[j]=nextval[k];//更新next数组,也即nextval[j+1]=nextval[k+1]
		}
		else
		k=nextval[k];
	}
}

猜你喜欢

转载自blog.csdn.net/qq_37729102/article/details/80998826