KMP算法
子串的定位操作通常称做串的模式匹配,是各种串处理系统中最重要的操作之一.在很多应用中都会涉及子串的定位问题,如普通的字符串查找问题.如果我们把模式匹配的串看成一字节流的话,那应用空间一下子就广阔了很多,如HTTP协议里就是字节流,有各种关键的字节流字段,对HTTP数据进行解释就需要用到模式匹配算法.
本文是试图清楚的讲解模式匹配算法里两个最为重要的算法:KMP与BM算法,这两个算法都较为高效,特别是BM算法在工程用应用得非常多的,然而网上很多BM算法都不算准确的。本文开始讲解简单回溯字符串匹配算法,后面过渡到KMP算法,最后再过渡到BM算法,希望能够讲得明白易懂。
模式匹配问题抽象为:给定主串S(Source,长度为n),模式串P(Pattern, 长度为m),要求查找出P在S中出现的位置,一般即为第一次出现的位置,如果S中没有P子串,返回相应的结果。如下图,查找成功,则查找结果返回2:
本文,接下来,将一步一步讲解KMP算法。希望看完本文后,读者日后对Kmp算法能做到胸中丘壑自成。文章有任何错误,烦请一定指出来。谢谢。
· 1、回溯法字符串匹配算法
回溯法字符串匹配算法就是用一个循环来找出所有有效位移,该循环对n-m+1个可能的位移中的每一个index值,检查条件为P[0…m-1]= S[index…index+m-1](因为模式串的长度是m,索引范围为0…m-1)。
S0......index.... index+m-1 (src[i]表示)
P 0 .... m-1 (patn[j]表示)
//代码1-1
//int search(char const*, int, char const*, int)
//查找出模式串patn在主串src中第一次出现的位置
//plen为模式串的长度
//返回patn在src中出现的位置,当src中并没有patn时,返回-1
int search(char const* src, int slen, char const* patn, int plen)
{
int i = 0, j = 0;
while( i < slen && j < plen )
{
if( src[i] == patn[j] ) //如果相同,则两者++,继续比较
{
++i;
++j;
}
else
{
//否则,指针回溯,重新开始匹配
i = i - j + 1; //退回到最开始时比较的位置
j = 0;
}
}
if( j >= plen )
return i - plen; //如果字符串相同的长度大于模式串的长度,则匹配成功
else
return -1;
}
该算法思维比较简单(但也常被一些公司做为面试题),很容易分析出本算法的时间复杂度为O(pattern_length*target_length),我们主要是把时间浪费在什么地方呢,相信,你已经看到上面的代码注释中有这么一句话:“指针回溯,重新开始匹配”,这句话的意思就是好比我们乘坐一辆火车已经离站好远了,后来火车司机突然对全部乘客说,你们搭错了列车,要换一辆火车。也就是说在咱们的字符串匹配中,本来已经比较到前面的字符去了,现在又要回到原来的某一个位置重新开始一个个的比较。这就是问题的症结所在。
在继续分析之前,咱们来思考这样一个问题:为什么快排或者堆排序比直接的选择排序快?直接的选择排序,每次都是重复的比较数值的大小,每扫描一次,只得出一个最大(小值),再没有其它的结果信息能给下一次扫描带来便捷。我们看看快排,每扫一次,将数据按某一值分成了两边,至少有右边的数据都大于左边的数据,所以在比较的时候,下一次就不用比较了。再看看堆排序,建堆的过程也是O(n)的比较,但比较的结果得到了最大(小)堆这种三角关系,之后的比较就不用再每一个都需要比较了。
由上述思考,咱们总结出了一点优化的归律:采用一种简单的数据结构或者方式,将每次重复性的工作得到的信息记录得尽量多,方便下一次做同样的工作,这样将带来一定的优化(个人性总结)。
回溯法做的多余的工作
以下给出一个例子来启发,如下图1-1:
图1-1 回溯法的一个示例
可以看出当匹配到g与h的时候,不匹配了(后面,你将看到,KMP算法会直接从匹配失效的位置,即g位置处重新开始匹配,这就是KMP的高效之处),模式串的下一个位置该怎么移动,需要回溯到第二个位置如:
图1-2 回溯到第二个位置
在第二个位置发现还是不匹配,便再次回溯到第三个位置:
图1-3 回溯到第三个位置
其实可以分析一下模式串里,如果前面与主串有匹配成功的元素,因模式串的每个字符都不相同,那移动模式串一位或者几位后,是不可能匹配成功的。
启示:模式串里有蕴含信息的,可以简化扫描。接下来深入的讨论另一算法KMP算法。
· 2、KMP算法的简介
KMP算法就是一种基于分析模式串蕴含信息的改进算法,是D.E.Knuth与V.R.Pratt和J.H.Morris同时发现的,因此人们称它为KMP算法。
咱们还是以上面的例子为例,如下图2-1:
图2-1 KMP算法的一个例子
如果是普通的匹配算法,那么接下来,模式串的下一个匹配将如上一节读者所看到的那样,回溯到第二个位置b处。而KMP算法会怎么做呢?KMP算法会直接把模式串移到匹配失效的位置上,如下图2-2,g处:
图2-2 直接移到匹配失效的位置g处
Ok,咱们下面再看一个例子,如下图2-3/4:
图2- 3/4 另一个例子
我们为什么要这么做呢?如上面的例子,每个字符都不相同,如果前面有匹配成功,那移动一位或者几位后,是不可能匹配成功的,所以我们完全可以就模式串的特点来决定下一次匹配从哪个地方开始。
问题转化成为对于模式串P,当P[j](0<=j<m)与主串匹配到第i个字符(S[i],0<=i<n)失败的时候,接下来应该用什么位置的字符P[j_next](我们设j_next即匹配失效后下一个匹配的位置)与主串S[i]开始匹配呢?重头开始匹配?No,在P[j]!=S[i]之前的时候,有S[i-j…i-1]与P[0…j-1]是相同的,所以S不用回溯,因为S[i]前面的值都已经确切的知道了。
S 0 i-j..i-1i .... n (S[i]表示,S[i]处匹配失败)
P 0.. j-1j.. m (P[j]表示,要找下一个匹配的位置P[j_next])
以上,在P[j]!=S[i]之前的时候,有S[i-j…i-1]与P[0…j-1]是匹配即相同的字符,各自都用下划线表示。
咱们先写下算法,你将看到,其实KMP算法的代码非常简洁,只有20来行而已。如下描述为:
//代码2-1
//int kmp_seach(char const*, int, charconst*, int, int const*, int pos) KMP匹配函数
//输入:src, slen主串
//输入:patn, plen模式串
//输入:nextval KMP算法中的next函数值数组
int kmp_search(char const* src, intslen, char const* patn, int plen, int const* nextval, int pos)
{
int i = pos;
int j = 0;
while ( i < slen && j < plen )
{
if( j == -1 || src[i] == patn[j] )
{
++i;
++j; //匹配成功,就++,继续比较。
}
else
{
j = nextval[j];
//当在j处,P[j]与S[i]匹配失败的时候直接用patn[nextval[j]]继续与S[i]比较,
//所以,Kmp算法的关键之处就在于怎么求这个值拉,
//即匹配失效后下一次匹配的位置。下面,具体阐述。
}
}
if( j >= plen )
return i-plen;
else
return -1;
}
· 3、如何求next数组各值
现在的问题是p[j_next]中的j_next即上述代码中的nextval[j]怎么求。下面,我们用变量k来代表求得的j_next。如下:
S0 s1 s2 s3 …………….Si……;
P0 p1 p2 p3 ……pk …..pj……;
当s[i]!=p[j],且p[0]->p[j-1]= s[i-j+1]->s[i-1]的时候,该如何找到索引值k(0<=k<j),使得:p[0]->p[k-1] = s[i-k+1]->s[i-1]。因此,下一次,直接比较a[i]与p[k]是否相等就可以了。
S0 s1 s2…s[i-k+1]…s[i]……;
P[0]……...p[k]……;
选取了k值,则p[0]->p[k-1]= s[i-k+1]->s[i-1],又因为p[0]->p[j-1]= s[i-j+1]->s[i-1]。所以,p[0]->p[k-1]= p[j-k]->p[j-1]。显然,K应该选取最大的值使得这个条件满足。这样,跳过的步数就是最多的。这个条件用文字描述就是,找到最大索引值k,使得模式串p中的前k个字符串与后k个字符串相同。
接下来的问题是,怎么求最大的数k使得p[0…k-1] =p[j-k…j-1]呢。这就是KMP算法中最核心的问题,即怎么求next数组的各元素的值?只有真正弄懂了这个next数组的求法,你才能彻底明白KMP算法到底是怎么一回事。
那么,怎么求这个next数组呢?咱们一步一步来考虑。
求最大的数k使得P[0…k-1] =P[j-k…j-1],一个直接的办法是对于j,从P[j-1]往回查,看是否有满足P[0…k-1] = P[j-k…j-1]的k存在,而且还要最大的一个k。但是这种算法太慢,下面咱们换一个角度思考,采用递推的方法:
如果已经知道了k=next[j],并且规定next[0]=-1,那么,如何求得next[j+1]的值?考虑两种情况:
1:P[j] = p[k], 那么next[j+1]=k+1,这个很容易理解。
2:P[j] != p[k],那么需要继续往前找到最大的k’(0 <= k’ < k),使得
p[0]->p[k’-1]= p[j-k’]->p[j-1],并且p[k’]=p[j]。那么,nest[j+1] = k’+1。
p[0]->p[k-1]= p[j-k]->p[j-1]( 因为k=next[j]),从而推出:
p[k-k’]->p[k-1]= p[j-k’]->p[j-1],最后得到:
p[0]->p[k’-1]= p[k-k’]->p[k-1]。所以,k’ = next(k)。
稍后,你将看到,由这个方法得出的next值还不是最优的,也就是说是不能允许P[j]=P[next[j]]出现的。ok,请跟着我们一步一步登上山顶,不要试图一步登天,那是不可能的。由以上,可得如下代码:
//代码3-1,稍后,你将由下文看到,此求next数组元素值的方法不是最优的
void get_next(char const* ptrn, intplen, int* nextval)
{
int i = 0;
nextval[i] = -1;
int j = -1;
while( i < plen-1 )
{
if( j == -1 || ptrn[i] == ptrn[j] ) //上述分析的情况1
{
++i;
++j;
nextval[i] = j;
}
else //上述分析的情况2
j = nextval[j]; //递推
}
}
· 4、next数组求值的验证
上述求next数组各值的方法(代码)是否正确呢?我们来举一个例子,应用上述的get_next函数来试验一下,即具体求解一下next数组各元素的值。ok,请看:
首先,模式串如下:字符串abab下面对应的数值即是已经求出的对应的nextval[i]值:
图3-3 求next数组各值的示例
接下来,咱们来具体解释下上面next数组中对应的各个nextval[i]的值是怎么求得来的,因为,理解KMP算法的关键就在于这个求next值的过程。Ok,如下,咱们再次引用一下上述求next数组各值的核心代码:
int i = 0;
nextval[i] = -1;
int j = -1;
while( i < plen-1 )
{
if( j == -1 || ptrn[i] == ptrn[j] ) //循环的if部分
{
++i;
++j;
nextval[i] = j;
}
else //循环的else部分
j = nextval[j]; //递推
}
所以,根据上面的代码,咱们首先要初始化nextval[0] = -1,我们得到第一个next数组元素值即-1(注意,咱们现在的目标是要求nextval[i]各个元素的值,i是数组的下标,为0.1.2.3);
首先初始化:i = 0,j = -1,由于j == -1,进入上述函数中循环的if部分,++i得 i=1,++j得j=0,所以我们得到第二个next值即nextval[1] = 0;
i= 1,j = 0,由于不满足条件j == -1 || ptrn[i] == ptrn[j],所以进入上述循环的else部分,得到j = nextval[j] = -1(原来的nextval[0]=-1并没有改变),得到i = 1,j = -1;此时,由于j == -1且i<plen-1依然成立,所以再次进入上述循环的if部分,++i的i=2,++j得j=0,所以得到第三个next值即nextval[2] = 0;
此时,i = 2,j = 0,由于ptrn[i] == ptrn[j],进入循环的if部分,++i得i=3,++j得j=1,所以得到我们的第四个next值即nextval[3] = 1(由下文的第4小节,你将看到,求出的next数组之所以有误,问题就是出在这里。正确的解决办法是,如下文的第4小节所述,++i,++j之后,还得判断patn[i]与patn[j]是否相等,即杜绝出现P[j]=P[next[j]]这样的情况);
自此,我们得到了 nextval[i]数组的4个元素值,分别为-1,0,0,1。如下图3-8所示:
求得了相应的next数组(本文约定,next数组是指一般意义的next数组,而nextval[i]则代表具体求解next数组各数值的意义)各值之后,接下来的一切工作就好办多了。
第一步:主串和模式串如下,由下图可以看到,我们在p[3]处匹配失败(即p[3]!=s[3])。
图3-4 第一步,在p[3]处匹配失败
第二步:接下来要用p[next[3]](看到了没,是该我们上面求得的next数组各值大显神通的时候了),即p[1]与s[3]匹配(不要忘了,上面我们已经求得的nextval[i]数组的4个元素值,分别为-1,0,0,1)。但在p[1]处还是匹配失败(即p[1]!=s[3])。
图3-5 第二步,p[1]处还是匹配失败
第三步:接下来模式串指针指向下一位置next[1]=0处(注意此过程中主串指针是不动的),即模式串指针指向p[0],即用p[0]与s[3]匹配(看起来,好像是k步步减小,这就是咱们开头所讲到的怎么求最大的数k使得P[0…k-1] = [j-k…j-1])。而p[0]与s[3]还是不匹配。
图3-6 第三步,p[0]与s[3]还是不匹配
第四步:由于上述第三步中,P[0]与S[3]还是不匹配。此时i=3,j=nextval[0]=-1,由于满足条件j==-1,所以进入循环的if部分,++i=4,++j=0,即主串指针下移一个位置,从p[0]与s[4]处开始匹配。最后j==plen,跳出循环,输出结果i-plen=4(即字串第一次出现的位置)
图3-7 第四步,跳出循环,输出结果i-plen=4
所以,综上,总结上述四步为:
1. P[3]!=S[3],匹配失败;
2. nextval[3]=1,所以P[1]继续与S[3]匹配,匹配失败;
3. nextval[1]=0,所以P[0]继续与S[3]匹配,再次匹配失败;
4. nextval[0]=-1,满足循环if部分条件j==-1,所以,++i,++j,主串指针下移一个位置,从P[0]与S[4]处开始匹配,最后j==plen,跳出循环,输出结果i-plen=4,算法结束。
不知,读者是否已看出,上面的匹配过程隐藏着一个不容忽略的问题,即有一个完全可以改进的地方。对的,问题就出现在上述过程的第二步。
观察上面的匹配过程,看匹配的第二步,在第一步的时候已有P[3]=b与S[3]=c不匹配,而下一步如果还是要让P[next[3]]=P[1]=b与s[3]=c匹配的话,那么结果很明显,还是肯定会匹配失败的。由此可以看出我们的next值还不是最优的,也就是说是不能允许P[j]=P[next[j]]出现的,即上面的求next值的算法需要修正。
也就是说上面求得的nextval[i]数组的4个元素值,分别为-1,0,0,1是有问题的。有什么问题呢?就是不容许出现这种情况P[j]=P[next[j]]。为什么?
好比上面的例子。请容许我再次引用上面例子中的两张图。在上面的第一步匹配中,我们已经得出P[3]=b是不等于S[3]=c的。而在上面的第二步匹配中,根据求得的nextval[i]数组值中的nextval[3]=1,即让P[1]重新与S[3]再次匹配。这不是明摆着有问题么?因为P[1]也等于b阿,而在第一步匹配中,我们已经事先得知b是不可能等于S[3]的。所以,第二步匹配之前就已注定是失败的。
图3-8/9 求next数组各值的错误解法
· 4、求解next数组各值的方法修正
那么,上面求解next数组各值的问题到底出现在哪儿呢?我们怎么才能摆脱掉这种情况呢?:即不能让P[j]=P[next[j]]成立。因为s[i]已经于p[j]不匹配了,接下来要匹配的元素p[next(j)],当然不能与p[j]相等了。
根据上面的分析,我们知道求next值的时候还要考虑P[j]与P[k]是否相等。当有P[j]=P[k]的时候,只能向前递推出一个p[j]!=p[k'],其中k'=next[next[j]]。修正的求next数组的get_nextval函数代码如下:
//代码4-1
//修正后的求next数组各值的函数代码
void get_nextval(char const* ptrn, intplen, int* nextval)
{
int i = 0;
nextval[i] = -1;
int j = -1;
while( i < plen-1 )
{
if( j == -1 || ptrn[i] == ptrn[j] ) //循环的if部分
{
++i;
++j;
//修正的地方就发生下面这4行
if( ptrn[i] != ptrn[j] ) //++i,++j之后,再次判断ptrn[i]与ptrn[j]的关系
nextval[i] = j; //之前的错误解法就在于整个判断只有这一句。
else
nextval[i] = nextval[j];
}
else //循环的else部分
j = nextval[j];
}
}
注意,理论上讲,因p[j]不能等于p[k],所以,以p[j] = p[k]为递推依据的代码3-1的架构也就不能再使用了。但是,在3-1中,实际上j表示的是对于索引i来说,j表示0->i-1的元素中,最大满足p[0]->p[j-1]=p[i-j]->p[i-1]的元素个数。修正后的get_nextval函数,还是要记录索引i之前的元素中,满足上述条件的元素个数的。只不过加了一条限制条件,如果p[i]=p[j]的话,则next(i)的值就不能在等于j,而是要等于next(j)。
举个例子,举例说明下上述求next数组的方法。上例中模式串的next数组各值最终应该为:
图4-1 正确的next数组各值
next数组求解的具体过程如下:
初始化:nextval[0] = -1,我们得到第一个next值即-1.
i = 0,j = -1,由于j == -1,进入上述循环的if部分,++i得i=1,++j得j=0,且ptrn[i] !=ptrn[j](即a!=b)),所以得到第二个next值即nextval[1] = 0;
上面我们已经得到,i= 1,j = 0,由于不满足条件j == -1 || ptrn[i] == ptrn[j],所以进入循环的esle部分,得j = nextval[j] =-1;此时,仍满足循环条件,由于i = 1,j = -1,因为j == -1,再次进入循环的if部分,++i得i=2,++j得j=0,由于ptrn[i] == ptrn[j](即ptrn[2]=ptrn[0],也就是说第1个元素和第三个元素都是a),所以进入循环if部分内嵌的else部分,得到nextval[2] = nextval[0] = -1;
i = 2,j = 0,由于ptrn[i] ==ptrn[j],进入if部分,++i得i=3,++j得j=1,所以ptrn[i] ==ptrn[j](ptrn[3]==ptrn[1],也就是说第2个元素和第4个元素都是b),所以进入循环if部分内嵌的else部分,得到nextval[3] = nextval[1] = 0;
如果你还是没有弄懂上述过程是怎么一回事,请现在拿出一张纸和一支笔出来,一步一步的画下上述过程。相信我,把图画出来了之后,你一定能明白它的。
· 5、利用求得的next数组各值运用Kmp算法
Ok,next数组各值已经求得,万事俱备,东风也不欠了。接下来,咱们就要应用求得的next值,应用KMP算法来匹配字符串了。还记得KMP算法是怎么一回事吗?容我再次引用下之前的KMP算法的代码,如下:
//代码5-1
//int kmp_seach(char const*, int, charconst*, int, int const*, int pos) KMP匹配函数
//输入:src, slen主串
//输入:patn, plen模式串
//输入:nextval KMP算法中的next函数值数组
int kmp_search(char const* src, intslen, char const* patn, int plen, int const* nextval, int pos)
{
int i = pos;
int j = 0;
while ( i < slen && j < plen )
{
if( j == -1 || src[i] == patn[j] )
{
++i;
++j;
}
else
{
j = nextval[j];
//当匹配失败的时候直接用p[j_next]与s[i]比较,
//下面阐述怎么求这个值,即匹配失效后下一次匹配的位置
}
}
if( j >= plen )
return i-plen;
else
return -1;
}
以下是匹配过程,分三步:
第一步:主串和模式串如下,S[3]与P[3]匹配失败。
图5-1 第一步,S[3]与P[3]匹配失败
第二步:S[3]保持不变,P的下一个匹配位置是P[next[3]],而next[3]=0,所以P[next[3]]=P[0],即P[0]与S[3]匹配。在P[0]与S[3]处匹配失败。
图5-2 第二步,在P[0]与S[3]处匹配失败
第三步:与上文中第3小节末的情况一致。由于上述第三步中,P[0]与S[3]还是不匹配。此时i=3,j=nextval[0]=-1,由于满足条件j==-1,所以进入循环的if部分,++i=4,++j=0,即主串指针下移一个位置,从P[0]与S[4]处开始匹配。最后j==plen,跳出循环,输出结果i-plen=4(即字串第一次出现的位置),匹配成功,算法结束。
图5-4 第三步,匹配成功,算法结束
所以,综上,总结上述三步为:
1. 开始匹配,直到P[3]!=S[3],匹配失败;
2. nextval[3]=0,所以P[0]继续与S[3]匹配,再次匹配失败;
3. nextval[0]=-1,满足循环if部分条件j==-1,所以,++i,++j,主串指针下移一个位置,从P[0]与S[4]处开始匹配,最后j==plen,跳出循环,输出结果i-plen=4,算法结束。
与上文中第3小节的四步匹配相比,本节运用修正过后的next数组,去掉了第3小节的第2个多余步骤的nextval[3]=1,所以P[1]继续与S[3]匹配,匹配失败(缘由何在?因为与第3小节的next数组相比,此时的next数组中nextval[3]已等于0)。所以,才只需要三个匹配步骤了。
ok,KMP算法已宣告完结,希望已经了却了心中的一块结石。毕竟,这个KMP算法此前也困扰了我很长一段时间。耐心点,慢慢来,总会搞懂的。
注:本文节选自http://blog.csdn.net/v_july_v/article/details/6545192#comments,略有改动。