瞎扯KMP

瞎扯\(KMP\)

众所周知,\(KMP\)是一种玄学的字符串模式匹配算法。

  • 什么是字符串模式匹配?

通俗的讲,就是统计一个字符串(通常很长)中某个子串(即一段连续的字符)出现的次数或位置。一般来说,我们把需要进行统计的那个很长的字符串叫做文本串,把要查找的子串叫模式串。字符串模式匹配,顾名思义,就是在文本串里面匹配模式串的意思。

  • 从暴力引入

很容易想到解决字符串模式匹配的一种暴力的解法,就是枚举文本串每个位置,看看它后面几位是否完全与模式串匹配,可以结合字符串hash一起做。这种做法复杂度是\(O(nm)\)的。

有没有更好的办法呢?当然,文题不就是吗

  • 什么是\(KMP\)

不要在意这个名字。

其实\(KMP\)的做法大体上与上面提到的暴力一致,都是线性的枚举和匹配,不过是用了一些东西优化。

用了什么来优化(不过是一些名词,理解其思想才是最重要的):

  1. 前缀、后缀,前缀、后缀。
  2. 一个辅助数组,通常叫\(next\)数组

它们是这样定义的:

前缀、后缀:

对于这样的一个字符串:

abcda

它的前缀是:a, ab, abc, abcd, abcda

它的后缀是:e, de, cde, bcde, abcda

加个真的意思就是不包含字符串本身的前缀、后缀。

\(next\)数组比较难理解,我们先思考一个问题。

假设对于任意的一个字符串X,我们用一个模式串A去匹配。先试着按照暴力的思路做下去,比如从第一个字符开始,逐一将文本串和模式串进行比对,向后枚举了一些字符之后,我们遇到第一个失配字符(文本串与模式串匹配时不相同的字符),暴力的思路是从这个失配字符开始,又从模式串起点开始用模式串去匹配文本串

试着做一些假设,在失配时,我们是否可以不必从模式串的起点重新开始匹配呢?我们是否可以从模式串的在起点后面的位置开始匹配?这样就可以尽可能节省时间。

\(KMP\)算法正是以此为突破口(我猜的),搞出来的。

回到上面的话题,\(next\)数组就是用来干这件事的,也就是从模式串的在起点后面的位置开始匹配,不过它比较巧妙。

\(next\)数组有如下定义:

对于一个字符串\(A[1\sim n]\),对于其中任意的位置\(i\),必然存在一个位置\(j\),且\(j<=i,j!=1\)\(A[i]=A[j]\),使得\(A[1]\sim A[j-1]\)\(A[i-j+1]\sim A[i-1]\)每一位都相同,此时我们令\(next[i]=j\)。换句话说,其实就是对于任意字符串\(A\)的所有子串,这个位置\(j\)就代表某个前缀,与它长度相同的后缀与它完全匹配。比较费解的是,\(next[i]\)是对于字符串\(A[1\sim i]\)而言的,也就是对于总的字符串的某个前缀子串,其记录的信息意味着这个前缀子串的真前缀等价于真后缀,实际上记录的是前缀子串等价于真后缀的真前缀的末尾位置。

其实在失配时,对于模式串,之前我们是又从起点开始匹配,现在呢,我们是从失配的位置\(i\)(模式串中)变成从\(next[i]\)的位置(就是上面提到的那个\(j\)啦)开始匹配。

为啥?仔细想想,假设文本串\(A\)从位置\(i\)开始尝试与模式串\(B\)进行匹配。那么在失配之前,文本串从某个位置\(i\)开始与模式串的某个从头开始的子串肯定是完全匹配的。那么显然,对于这一段完全匹配的子串(注意此子串一定不等价于模式串且比模式串短),假设它结尾的位置为\(j\),那么\(j+1\)这个位置就是失配的。而根据我们的假设,失配之前的完全匹配的子串如果存在一个真前缀与它的真后缀完全等价,计这段真前/后缀的长度为\(x\),那么对于下一次重新开始的匹配,我们就可以从\(i+x-1\)这个位置,也就是失配之前完全匹配的子串的与真后缀等价的真前缀的末尾,开始匹配了。这就是\(KMP\)的精髓,实际上是对“任意一次失配后再次用模式串去匹配文本串该从模式串的何处开始”这个问题作了恰当的优化。

至于为什么可以这样做,由于上面提到的失配之前的子串必定是完全匹配的,那么就意味着\(next\)数组在适用于模式串时,同时适用于这段完全匹配的子串,也意味着这个子串的真前后缀等价的情况是与模式串相同的,那么显而易见,我们就可以确定模式串的该子串的一个真前缀等价于文本串的该子串真后缀,比如这个对于模式串这个真前缀的末尾位置是\(j'\),那么失配后如果我们从\(j'\)重新开始用模式串去匹配,我们可以确保从模式串的起始点到\(j'\)是与文本串完全匹配的

呼,看到这里是不是觉得我十分口胡?没事,如果没看懂你可以再看几遍上面这几段话或者看一下别人的博客(逃

理解了上面的部分,我们可以考虑一个简单的优化:为了尽可能压缩枚举模式串浪费的时间,我们取的\(next\)数组应当都是与真后缀等价的最长的真前缀的末尾位置。

看很多人用”跳“这个动词形容next数组干的事情,其实我觉得不太准确,应该与暴力类比比较好理解(个人感觉)。

上面这一大堆就是对\(next\)数组的解释,是不是看着就很可怕。(因为我没有图,也没有例子)

下面举一个简单的例子。

我们有这样一个情况:

对于文本串和模式串,我们都假设起始位置为0。

文本串:abcaeabcabedd
模式串:abcab

对于模式串,它的\(next\)数组就长这样:\([0,0,0,1,2]\)

显然匹配到位置4的时候,我们失配了。此时模式串也匹配到位置4,\(next[4]=1\),我们就从模式串的位置1重新开始匹配。此时对于文本串我们枚举到位置4。

文本串:abcaeabcabedd
模式串:   abcab

我们发现位置3是一定匹配的。然后继续往后匹配。其他情况同理。

上代码:

//b是模式串,a是文本串,la是文本串长度,lb是模式串长度
j=0;
for(int i=1;i<=la;i++){
    while(j&&b[j+1]!=a[i]) j=next[j];//寻找符合条件的匹配开始位置
	if(b[j+1]==a[i]) j++;//匹配过程
	if(j==lb){//与模式串完全匹配,输出并开始下一轮匹配
		printf("%d\n",i-lb+1);
		j=next[j];
	}
}

然而在这之前,我们还需要预处理出\(next\)数组,根据定义,我们很容易知道暴力做法。但是我们有一种玄学做法,就是让模式串自己匹配自己(雾。

首先显而易见\(next\)数组的第一位肯定是0。(想一想,为什么其实是我懒得解释

计算跟上面的过程是很一致的。

int j=0;
for(int i=2;i<=lb;i++){
	while(j&&b[j+1]!=b[i]) j=next[j];
	if(b[j+1]==b[i]) j++;
	next[i]=j;
}

复杂度是严格的\(O(n+m)\)

posted @ 2019-07-30 11:27  DarkValkyrie  阅读(138)  评论(0编辑  收藏  举报