title: KMP模式匹配算法
date: 2020-02-26 17:24:29
tags: datastructure
这几天复习到数据结构的串的模式匹配,看到KMP算法那么寥寥几行的代码,原本觉得这部分内容很简单,直到我开始尝试去理解它,我才发现我跟大佬的差别。
KMP算法的基本思路
KMP算法,是名分别为K、M、P的三个美国大佬发明的,算是对传统的朴素模式匹配算法的改进。我们先规定,讨论模式串下标均从1开始。朴素模式匹配算法就不谈了,这个有脑子就能想出来,但朴素模式匹配算法存在一个弊端——当模式串有较多重复元素存在时,主串指针i其实做了很多没有必要的回溯。
而如果想跳过这些没有必要比较的情况,那么就可以想到,主串指针是没有必要回退的,因为当发生与模式串失配的情况时,可以确定的是,此前成功匹配的若干元素形成的字串,其实就是模式串的一个真字串,那么对于这样的一个字串,它的头部和尾部如果发生了重复,就只需要往回移动模式串指针就好了,但是又不需要往回移到初始位置,因为我们看到他的头部和尾部发生了重复,又由于这个头部重复的部分与现在主串指针前的那个模式串真字串重复,而这部分就是真字串的尾部,所以最有利的做法应该是将模式串指针移动至头部重复字串的后一位,再来与当前的主串指针比较。这样,就避免了主串的回溯,单就这个算法本身,简单来看时间复杂度应该是o(n)。
以下是KMP主代码,对于模式串每一个位置发生失配模式串指针所应该回溯的位置,用一个叫next的整型数组来存储,发生失配时直接用里面的数据来对模式串指针进行回溯。
int KMPpatternmatch(string s1,string s2,int* next)
{
int i = 0, j = 0;
while (i < s1.size())
{
if (s1[i] == s2[j]) {i++;j++;}
else if (j == 0) i++;
else j = next[j]-1;
if (j == s2.size())return (i - s2.size());
}
return -1;
}
确定模式串指针的回溯位置
但问题来了,如何确定在模式串的不同位置发生失配时模式串指针需要回溯的位置呢?我们可以发现,这些位置的确定,只跟模式串有关,并且对于每一个需要求的位置,只跟从模式串当前位置截断,取前者的这个字串有关。我们如果要来求这个神秘的next数组,就只需要看包含模式串第一位的模式串的各个字串就好。根据上述的推理,这个位置应该是这个字串前后重复的部分+1。到这里,我们可能自以为问题就解决了,以后我们遇到每一个模式串,手动去算一下它的next数组就好了。
可是呢,懒人总是有的,他就是不想去算这个next数组,觉得太麻烦了,怎么办呢,交给计算机算吧,所以又展开了一系列头脑风暴,被认为是KMP算法的核心,next数组的求解以及其原理。前方高能。
next数组代码实现原理
为了放松一下,先来看下代码吧:
我之所以在那个字符串前随便填充一个元素,是因为规定了模式串的下标由1开始算(被数据结构书束缚了头脑)。
int* getNext(string s)
{
int *next = new int[s.size()+1];
s = "0" + s;
next[1] = 0;
int i = 1,j = 0;
while (i < s.size()+1) {
if (j == 0 || s[i] == s[j])next[++i] = ++j;
else j = next[j];
}
return next;
}
是不是觉得很简单,就是这么简单的代码我研究了一天半。来讲解一下。
传统思维
在明白了next数组各个元素应该怎么填之后,我们的惯性思维就是将问题转化为,对于一个字符串,求其包含头部元素的每一个真字串的头部和尾部重复的最大部分。对了,为什么要的是最大重复个数,我的理解是,为了跳步骤,有辣么多重复的不跳,干嘛去挑一个短的来跳,对于这一点网上很多博客的说法有点复杂,我这个还挺好理解的。好,话说回来,为了解决这个问题,让我们先往暴力法方向想下,好不好将其代码化。我们人脑的思维是,看着这个串,看开头,看结尾,不重复,这次看长一点,看开头,看结尾,还是不重复,如此下去,直到找到重复的,但还要最长的重复串啊,好吧,那就继续…这个思路要翻译成代码,那就是要定义两个指针,一个用来指向头部,一个用来指向尾部,再定义一个变量,在循环中以这个变量的值不大于原串的长度为基准来循环,再再定义一个变量,用来记录最大重复串长度,每次比较之后选择性更新。这跟刚刚贴出来的代码相比,那真是又臭又长,主要是不怎么优雅。我们虽是懒人,代码的优雅性还是得考虑的,就这么简单的事情要搞两层循环??我们KMP主函数的复杂度才o(n)呢,好吧,要说优化的方法,那肯定是有的。
数学归纳法
有时候觉得学计算机,数学的用处不大,但有时候,数学学的好确实能让你这个麻木的码农变得更加优雅。我的理解是,考虑数学归纳法,用递推关系来求next数组的元素。我们要找的是某个子串的头部与尾部重复的最大长度,那么直接用头部来匹配尾部不就好了,那我们就用尾部作为模式串来匹配头部。既然是归纳法,那就先考虑一般情况,先看中间。假设尾部指针指到了i这个位置,尾部指针指到了j这个位置,而且现在已经知道前面的结果,那么就只剩下两种情况,如果next[i]等于next[j],考虑到一般情况下(如果指针不越界)next[i-1]=next[j-1],那么重复的最大子串长度只要在原来的基础上加一,所以next[i]的值应该是数组起始位置到j目前位置的长度,那么就是j-0=j,那么直接将j的值给next[i]好了;另外一种情况,next[i]不等于next[j],根据KMP算法的规则,不就是找包含模式串第一位一直到i-1位的这个子串的头部与尾部的最大重复长度吗,而且正好,这个值就在next[j]里面,因为next[j]的意思就是如果匹配到i位发生失配,模式串指针应该跳到next[j]这个位置,所以就直接将next[j]赋值给j,让j跳回去重新跟i比较。这样就形成一个循环体,还有一个问题,如果跳回去的过程中一直失配,那不就回不来了,那怎么行,模式串指针可是有一个下限的,那就是1,情况坏也得从1开始比较吧。如果j=0的时候也需要让i和j都移动一位,那就可以让匹配一直进行下去了。
既然是数学归纳法,考虑完一般情况,还得考虑初值情况。我们说j是子串头部指针,i是子串尾部指针,开始的时候长度起码是2吧,不然的话就不用匹配了,这样的话就令j为0,i为1,next[1]为0,也就是说如果第一个位置就失配的话,那就让i无脑后移,然后j也可以到模式串的第一个位置。这样的好处是,由于j为0,进入循环体之后i就能开始后移,j就算回跳到next[1],也能够顺利进入让i进入到后移比较的阶段。也可以看出,在next数组里,除了next[1]为0,后面的元素就都不可能是0了。还能发现,next[2]肯定是1。整个算法过程如下图:
这个算法巧妙的利用已知条件递推下一个答案,直到得到所有的答案。可是到了这里,大多数人觉得KMP算法告一段落了,但,还没完。
KMP算法的优化
目前的next数组令模式串的j指针在失配的回跳,但有的时候会连续回跳,而每一次的回跳都伴随着与主串的比较,如果每次回跳到的那个模式串的值等于目前失配的值的话,那还不如让模式串指针一次性跳到后面目标位置,一样的值就没有必要再次比较了。所以就引出了KMP算法的优化措施,将next数组转化为nextval数组,用来避免模式串j指针回跳之后的值等于目前的值而做的重复的比较。那么将next数组转化为nextval数组的方法也很简单,下标的定义仍然值得注意:
int* getNextval(string s,int* next) {
int* nextval = new int[s.size()+1];
nextval[1] = 0;
for (int i = 2; i < s.size()+1; i++)
{
if (s[next[i]] == s[i]) {
nextval[i] = nextval[next[i]];
}
else {
nextval[i] = next[i];
}
}
return nextval+1;
}
总结
算上求next数组这一部分,整个KMP模式匹配算法的时间复杂度就大致是o(m+n)了,m是模式串的长度,n是主串的长度。网上其他的讲解基本会用到前缀后缀的概念,那样可能讲述起来会更严谨,但也不易懂。但总的来说,困扰大部分人的是难以理解如何让自己来匹配自己,但准确来讲,是用自己的尾部去匹配自己的头部,然后就是头部尾部指针的初值设定,搞清楚这两点,那就成功十之八九了。
不过我感觉我上述抽象的地方描述的也不算好,但算法这种东西,就是得花时间去磨,有些部分确实是难以言传的。总之,懂的人自然懂,不懂的人花时间磨,总能磨出来的。最后,向那些脑洞大开想出这种奇葩算法的三人组致敬。
来源:CSDN
作者:weixin_44727283
链接:https://blog.csdn.net/weixin_44727283/article/details/104541124