浅析KMP算法

浅析KMP算法

前言和导引

今天数据结构课学了字符串的相关内容,其中着重介绍了KMP算法。

KMP是一种非常好用的字符串匹配算法。对于给定的长度为 m m m的字符串 s ( S t r i n g ) s(String) s(String),和需要匹配的长度为 n n n的模式串 p ( P a t t e r n ) p(Pattern) p(Pattern),KMP能够实现 O ( m + n ) O(m+n) O(m+n)时间复杂度级别的匹配。相比于传统的 O ( m ∗ n ) O(m*n) O(mn)的暴力匹配,是非常大的提升。

高中学竞赛的时候只是接触过KMP,而且第一次看没有什么深刻的理解,更多的只是背一下模板,考试的时候能用上。现在回过头来再次思考KMP算法的实现过程,会发现这个算法思路是非常简单清晰的的。同样,算法的代码也很短。

个人感觉,KMP算法的产生是很自然的。当我们发现暴力匹配会损失掉一些字符串信息的时候,当我们发现暴力匹配做了很多次不必要的检测的时候,KMP算法的思路就出来了:那些已经匹配成功的字符能不能派上用场呢?

算法剖析

要理解KMP算法的重点,我们先回顾一下暴力匹配的过程。假设在字符串 s s s的位置 i i i,字符串 p p p的位置 j j j,发生了字符失配(匹配不成功),那么 i i i会跳转到原来 p [ 1 ] p[1] p[1]对应的位置, j j j则会变成初始位置 0 0 0,以便继续下一次的匹配。注意到,字符串 p p p仅仅滑动了一格。

例如,我们有:

s : a b a a b a a b c a b a a b c s:abaabaabcabaabc s:abaabaabcabaabc
p : a b a a b c p:abaabc p:abaabc

第一次匹配直到 p p p的最后一位 p [ 5 ] = = c p[5]==c p[5]==c才会停止。这之后, p p p向后滑动了一格,变成:

a b a a b a a b c a b a a b c abaabaabcabaabc abaabaabcabaabc
   a b a a b c \ \ abaabc   abaabc

但是,如果细心一些,你会发现, p p p完全可以再往后多移动一些,成为:

a b a a b a a b c a b a a b c abaabaabcabaabc abaabaabcabaabc
   a b a a b c \quad \ \ abaabc   abaabc

这样做移动,不仅 p p p滑动的距离长了,而且我们不用再从 p p p的开头开始匹配。

以上是我们用肉眼观察到的现象。对此,我们不禁要问:为什么可以这么做?要怎样才能把这种机制程序化表达出来?

让我们回到失配的字符, p [ 5 ] = = c p[5]==c p[5]==c上。稍加观察就会发现, p [ 5 ] p[5] p[5]前面的两个字符和 p p p开头的两个字符按从左到右的顺序看起来一样,都是 a b ab ab。于是,我们便把 p p p直接拉过来,让 p [ 2 ] p[2] p[2]移动到原来 p [ 5 ] p[5] p[5]的位置。这样, p [ 0 ] , p [ 1 ] p[0],p[1] p[0],p[1]就和原来的 p [ 3 ] , p [ 4 ] p[3],p[4] p[3],p[4]对齐了,而且我们的匹配也从 p [ 2 ] p[2] p[2]开始,而不是从 p [ 0 ] p[0] p[0]重新匹配。

到这里,我们已经发现了KMP的小秘密:利用一定长度的前缀和后缀相同的特点,拉长模式串移动的距离,并且减少冗余匹配的次数。具体来说,模式串 p p p移动的距离变成了 j − l j-l jl,并且会从 p [ l ] p[l] p[l]开始匹配。其中 l l l是失配字符前面的字符串中,前缀和后缀能相等的最长长度。例如,对于 p [ 5 ] p[5] p[5]来说, l = = 2 l==2 l==2

你可能还会问:中间略过去的那些字符怎么办?难道没有可能匹配成功的吗?要回答这个问题,让我们回头再看:

p : a b a a b c p:abaabc p:abaabc

p [ 5 ] p[5] p[5]前面的字符串 a b a a b abaab abaab,其前缀和后缀最长的能相等的长度就是 2 2 2。对于长度为 3 3 3 4 4 4的前缀后缀,它们是不相等的。如果你让模式串移动比 j − l j-l jl更少的距离,也就是说,考虑了那些本该被略过的字符,那么你会发现,他们一定是不能匹配成功的。因为比 2 2 2更长的前缀和后缀注定无法相等,而此时的后缀与字符串 s s s中的对应字符串相等,从而前缀就不可能与 s s s中的对应字符串相等了。

所以,我们要想加快字符串的匹配,重点就是找出 p p p的每个字符 p [ i ] p[i] p[i]前面的字符串 p [ 0   t o   i − 1 ] p[0\ to\ i-1] p[0 to i1]中,前缀和后缀能相等的最长长度。在KMP中,这些数据用一个叫做 n e x t next next的数组存储。 n e x t [ i ] next[i] next[i]就表示位置 i i i前面的字符串 p [ 0   t o   i − 1 ] p[0\ to\ i-1] p[0 to i1]中,前缀和后缀能相等的最长长度。

具体实现——next数组

要求 n e x t next next数组,我们假设位置 i i i前面的 n e x t next next都已经求出,然后考虑 n e x t [ i ] next[i] next[i]怎么求。

因为 n e x t [ i − 1 ] next[i-1] next[i1]已经求好,我们记 k = n e x t [ i − 1 ] k=next[i-1] k=next[i1]。这就意味着, p [ 0 ] p[0] p[0] p [ k − 1 ] p[k-1] p[k1] p [ i − k − 1 ] p[i-k-1] p[ik1] p [ i − 2 ] p[i-2] p[i2]对应相等。如果双方后面再接上一个相等的字符,也就是 p [ k ] = = p [ i − 1 ] p[k]==p[i-1] p[k]==p[i1],那么 n e x t [ i ] next[i] next[i]的值就可以直接知道了,等于 n e x t [ i − 1 ] + 1 next[i-1]+1 next[i1]+1

上面是简单的情况。如果 p [ k ]   ! =   p [ i − 1 ] p[k] \ !=\ p[i-1] p[k] != p[i1]怎么办呢?这时候,你可能会想到:能不能把相等的前缀和后缀缩短一些,好让他们后面的一个字符能相等?如果能,缩短多少好呢?

这里我们有一个操作: k = n e x t [ k ] k=next[k] k=next[k]。想一想,为什么这么做?

别忘了,我们的 k k k也是有 n e x t next next值的。并且,回顾我们的过程: k k k前面的字符串和 i − 1 i-1 i1前面的字符串是相同的。如果你调取 n e x t [ k ] next[k] next[k],你就获得了一段前缀,它既等于 k k k前面的一顿后缀,又等于 i − 1 i-1 i1前面的一段后缀。巧妙之处就在于此:相等的对称性。我们有相等的大前缀和大后缀,而从大前缀中取出的相等的小前缀和小后缀,可以平移到大后缀上面去。

如此,我们只要递归到 p [ k ] = = p [ i − 1 ] p[k]==p[i-1] p[k]==p[i1],或者不再有相等的前后缀为止。

总结一下,就是:模式串 p p p移动的距离变成了 j − n e x t [ j ] j-next[j] jnext[j],并且会从 p [ n e x t [ j ] ] p[next[j]] p[next[j]]开始匹配。

既然大体上我们已经明白 n e x t next next数组怎么求,我就贴一波代码:

void Get_Next(string p){
	int len = p.size() ;
	int j=0, k=-1 ; //j,k分别代表当前比较的两个字符的位置 
	Next[0] = -1 ;
	while(j < len-1 ){
		if(k == -1 || p[k] == p[j]){
			j++ ; k++ ;
			if(p[k] != p[j]){
				Next[j] = k ;
			}
			else {
				Next[j] = Next[k] ;
				//本来是要递归直到p[k] != p[j]的,但是发现Next[j]之前已经求过了 
				//也就意味着p[j] == p[k] != p[Next[k]]
				//也就是k = Next[k], Next[j] = k
				//简化成Next[j] = Next[k]
				//但是其实不能写成第一种
				//第二种写法没有改变k,因为下一组还要用p[j] == p[k] 
				//如果改变了k,那么Next[j+1]就有可能不对了.p[j+1] 不一定等于 p[k+1] 
			}
		}
		else {
			k = Next[k] ;
		}
	}
}

这里, n e x t next next数组的求法和上文所说有一些不同。

  1. n e x t next next数组中设为 − 1 -1 1的位置,说明不仅 p [ j ] p[j] p[j]前面没有相等的前后缀,并且就连 p [ 0 ] p[0] p[0]也和要比较的 s [ i ] s[i] s[i]不相等。那么我们只好在 j = − 1 j=-1 j=1时把 i i i向后移动,同时令 j + 1 j+1 j+1,这样从下一位重新开始匹配。
  2. 按照上文的思路,代码应该是这样的:
if(k == -1 || p[k] == p[j]){
			j++ ; k++ ;
			Next[j] = k ;

但是这样会有问题。不是思路上的错误,而是会造成时间浪费。
思考:匹配时遇到了 p [ j ] ! = s [ i ] p[j]!=s[i] p[j]!=s[i]。如果 p [ j ] = = p [ n e x t [ j ] ] p[j]==p[next[j]] p[j]==p[next[j]],那么 p p p移动过来之后,匹配照样会失败。必须换一个字符,匹配才有成功的可能。也就是说,我们在选定 n e x t next next的时候,不能让 p [ j ] = = p [ n e x t [ j ] ] p[j]==p[next[j]] p[j]==p[next[j]]。在这里,又要使用我们的递归方法了,直到找到 p [ k ] ! = p [ j ] p[k]!=p[j] p[k]!=p[j]的k为止,然后设定 n e x t [ j ] = n e x t [ k ] next[j]=next[k] next[j]=next[k]。具体在写代码的时候,并没有写成递归,原因在上面的注释中可以看到。

具体实现——模式串匹配

有了 n e x t next next数组,匹配就变得简单快捷了。

void KMP_Match(string s, string p){
	int i=0, j=0 ;
	int len_s = s.size(), len_p = p.size() ;
	while(i < len_s ){
		if(j == -1 || s[i] == p[j]){
			i++ ; j++ ;
		}
		else {
			j = Next[j] ;
		}
		if(j == len_p ){
			j = 0 ;
			i -= len_p-1 ; 
			//出现了匹配成功的例子
			//j回到开头0,i则跳转到这次匹配开始的后一个位置 
			printf("%d\n", i-1 ) ;
		}
	}
}

这就是全部的匹配代码,比暴力匹配也长不了多少,但是在时间上却实现了巨大的突破。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值