KMP算法详解

KMP算法详解

  • 算法功能
    有时我们需要从一串字符中寻找其指定子串(主串的一部分)的位置,即在主串S中查找子串A的位置。举例:
    当两个字符串S = { abacabbaba } A={ aba } 时, 在S中查找A出现的的起始位置结果为1,8(下标从1开始)。
    这种应用十分广泛,如在文章中寻找关键字(句),数据统计,文字编辑工具中的关键字查找替换等。KMP就是一种解决此类基本问题较为高效的算法。
  • 算法推导
    首先思考暴力写法,我们只需将关键字串A在主串S中依次放在每一个位置进行比较,如图:

图一
代码十分简单,如下(本文代码中所有字符串下标从1开始):

//n为S串(主串)长度,m为A串(匹配串或关键字串)长度 
for(int i=1;i<=n-m+1;i++)//枚举S串的i指针,指向匹配过程中A串在S串中的起始位置
  	for(int j=1;j<=m;j++){//枚举j指针,表示匹配到了A中的第j位
  		if(S[i+j-1]!=A[j])break;//如果匹配失败
  		if(j==m)printf("%d ",i);//输出匹配成功的起始位置 
  	}	

很显然,算法效率最差为O(mn),不能满足要求。
那如何优化呢?思考一下,当 “S[i+j-1]!=A[j]” 时,是否有必要将j指针退回1?
其实并不需要,举个例子,当S={ abceabceabcd }A={ abceabcd } 时 ( 如图 )
在这里插入图片描述
A[1]至A[7]已与S[1]至S[7]匹配成功,A[8]与S[8]匹配失败,由于黄色部分(图中)相同,可直接将字符串A左部黄色部分移至与右部黄色部分重合(因为1至7的位置已经匹配成功,故A中黄色部分一定与S中对应部分相等),再尝试匹配A[4]与S[8],省去了暴力算法中一步一步移过来的过程(正确性见后文)。

那么在比对过程中,我们如何知道A串可以如何移动(即求出已匹配成功部分中既是前缀又是后缀的部分,也就是上图中黄色部分)呢?显然如果我们使用一次求一次,将会非常低效,我们发现,A串可以如何移动只与A串有关,则我们可以引入一个关于A串的next 数组,next[i] 表示在 A[1]A[i] 中,最长的既是前缀,又是后缀且长度小于i的部分的前缀的最后一位的下标(不好表达,上图)

对于字符串A={abadaba},其next数组如下

在这里插入图片描述
图中黄色部分即为最长的既是前缀又是后缀的部分。

注意一下next[1]!=1(明白的读者可以跳过),因为如果next[i]=i,即整个串均为黄色,无意义(一直移动不了),所以定义中要求“既是前缀,又是后缀且长度小于i的部分(为黄色部分)”。同理,next[2]!=2,next[3]!=3 …… next[i]!=i。

暂且不管next数组怎么求,我们先看看有了next数组后,怎样在S串中查找A串的位置。首先我们需要i,j两个指针,i指针表示在S串中匹配至哪一位,j指针可以理解为在A串中已匹配成功部分的最后一位(具体看后文)。所以我们匹配的总是S[i]与A[j+1] 。一开始,我们像暴力算法一样逐位匹配,但当我们发现某一位匹配失败时,我们将j指针按next数组向前跳,即将j=next[j],寻找将A串向右移动进行匹配(即使黄色部分重合)的可能方案。当j指针跳到0时,意味着没有可行方案了,则将i++,尝试匹配s[i]和A[1](A[0+1])。当j==m时,意味着已在S中找到一串完整的A,将j=next[j](或next[m])(即将A向右移动最短的长度进行尝试匹配),继续匹配,代码如下

for(int i=1,j=0;i<=n;i++){//i从1开始,j初始化为0,即从S[1]和A[1]开始匹配
	while(j!=0&&S[i]!=A[j+1])j=next[j];//见后文注释
	if(S[i]==A[j+1])j++;//如果匹配成功,j指针后移,i指针会随for自动后移,避免j==0,而S[i]!=A[1]的情况
	if(j==m){//找到完整的一串
 		printf("%d ",i-m+1);
 		j=next[j];
 	}
}

关于第二行代码:
这是一个将j指针不断按next数组前移,寻找A[j+1]=S[i] 的过程。因为next数组保证在移动过后,j[next[j]]以及之前每一位都可以与S串对应的第i位前几位相匹配(即黄色部分相匹配),只要满足A[j+1]=S[i],就可以将A串向右移动一定位数(我们无需知道如何移动,只需将i,j指针移动即可,即将j=next[j],i++)附图帮助理解(图片不清晰可按住拖动或放大):
在这里插入图片描述
在这里插入图片描述

图中我们无妨令S[1,2,3,4] 与 A[1,2,3,4]均不相等。图一为不移动A串,模拟其i,j指针变化的示意图,图二即为比对过程中移动A串的形象示意图。图中当S[5~14] 与 A[1~10]匹配成功,S[15] 与 A[11]匹配失败时,我们按照A串的next数组将j指针移至下标4的位置(图一中),即图二中A串右移对其黄色部分的过程的模拟。移动过后再尝试匹配S[15]与A[5] (A[4+1])。


那么为什么这种跳跃式的移动是正确的(即中间不会跳过一处可以与A串匹配成功的部分)呢?(正确性说明部分,如果不感兴趣或了解,可跳过直接看next的求解)
我们同样令两串字符串匹配成功一部分后匹配失败,如图
在这里插入图片描述
令黄色部分为匹配成功部分最长的既是前缀又是后缀的部分,此时我们将A串右移尝试匹配,易看出,当A串的首位在图中a区域移动是不可能匹配成功的(b部分无论如何比可能匹配成功,因为如果可以,最长部分将不是黄色部分),故如此按next移动A串不会跳过任何可能答案。同理,顺次继续将A按第二长,第三长……既是前缀又是后缀的部分移动时,也不会错过任何可能答案。

那为什么我们不需要记录第二长,第三长等等的既是前缀又是后缀的部分呢?看图:
在这里插入图片描述
我们令一字符串有黄,红,蓝三个分别为最长,第二长,第三长的既是前缀又是后缀的部分。图中字符串的next数组中有next[18]=7,next[7]=3,next[3]=1
将前两串所表示结构用一串表示:(后缀的后面与前缀的前面相同)
在这里插入图片描述
所以此串前后3格部分(最前面和最后面的绿色部分)即为次长的既是前缀又是后缀的部分。又因为next[7]==3,所以next[7]即为次长部分的长度。
同理,将三串合在一起即为:
在这里插入图片描述
所以结合此串的
next
数组可以看出next[next[i]] 就是第i位之前次长的既是前缀又是后缀的部分中前缀的最后一位的下标。
所以我们根据next数组就可以方便地推出第二长,第三长的既是前缀又是后缀的部分,并不需要单独记录各长度时第二长,第三长…的部分长度。


那么另一个重要的问题就是next数组怎么求?(建议先复习一下next数组的定义)
按照定义暴力求解长度位m的字符串的next数组,其效率为O(m3),还不如直接暴力匹配。
直接介绍优化的求解方法。优化思想比较类似动态规划,是一个递推的过程。
假设在求解A串的next[i]时,next[1~i-1]已经解出(很容易实现,只需按next[1] ~ next[m]顺序求解即可),此时定义一个j指针,指向i-1,将j=next[j](next[j]已求解出),如果A[j+1]=A[i],将j++,i=j,结束next[i]的求解。
还是用上一幅图理解一下:

在这里插入图片描述此时next[1 ~ 18]求解完成,求解next[19]时,按上述思路(不懂可以先理解一下后文代码),先尝试匹配A[next[19-1]]与A[19] (也就是A[7]与A[19]),如果成功,next[19]=8,否则再尝试匹配A[next[7]]与A[19] (也就是A[3]与A[19])……同理依次向前尝试匹配。如果最后(下标退到了0)也没有匹配成功,则next[19]=0。

正确性的证明类似上述证明,前面的next数组保证不会错过最长解,感兴趣可以自行尝试证明。程序非常类似上一段代码,如下:

next[1]=0;//初始化,参考上文next的定义介绍
for(int i=2,j=0;i<=m;i++){//依次求解next[2~m]
	while(j!=0&&A[j+1]!=A[i])j=next[j];//不断按next前移j指针,寻找A[j+1]==A[i]
	if(A[j+1]==A[i])j++;//避免j==1.而A[j+1]!=A[i]。此时next[i]应为0(找不到)
	next[i]=j;
}

将上述两段代码合起来,便有了我们的模板:

next[1]=0;
for(int i=2,j=0;i<=m;i++){
	while(j!=0&&A[j+1]!=A[i])j=next[j];
	if(A[j+1]==A[i])j++;
	next[i]=j;
}
for(int i=1,j=0;i<=n;i++){
	while(j!=0&&S[i]!=A[j+1])j=next[j];
	if(S[i]==A[j+1])j++;
	if(j==m){
		printf("%d ",i-m+1);
		j=next[j];
	}
}
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值