带你一篇文章彻底搞懂KMP算法


前言

我看网上很多博文介绍KMP都是直接就着算法本身去讲解,其实读者可以假设自己是要解决如何高效进行字符串匹配的研究者,带着这个想法去思考一个个迎面而来的问题会容易理解的多。


一、KMP算法简介

KMP算法是一种比较高效的字符串匹配算法,其核心就是解决BF算法中主串需要回溯匹配导致效率低的问题。既然主串不回溯,要继续与子串进行比较,那么就得知道主串的当前字符要跟子串的哪一个字符进行下一轮比较最合适。这就要求要进行比较的子串的当前字符前面的子串跟主串当前字符前面的子串是一致的,如主串如S1S2S3S4S5…Si,子串
T1T2T3T4…Tj,假设下一轮主串的Sn比较要从子串的Tm位置进行比较,那么T1…Tm-1共m-1个字符与Sn-m+1…Sn-1是匹配的(之所以下标是n-m+1,因为是从Sn往前推m-1个,n-(m-1)即n-m+1)。
问题就来了,我们怎么知道主串这时候该跟子串的哪个位置比较呢,也就是m的值是什么呢。这就是KMP算法的核心所在。之所以很多地方都叫next[j]数组,它保存的就是你下一个要跟子串比较的子串位置下标。比如当前 j 值上的字符发生了不匹配,这时候主串保持不动,你要用子串next[j]位置的字符与主串进行匹配,直到到子串首,还不匹配就将主串下移一个位置,就避免了主串的回溯。这里的next数组中的值是我们提前可以根据子串计算得到的。因此,我们主要可以总结成两步:
(1)计算得到next数组;
(2)主串与待匹配模式串比较,若当前主串与当前模式串字符不匹配,根据next数组确定主串要与下一个要模式串比较的字符位置。
next数组的求取原理是比较难理解的部分,但这里也会先给读者介绍next数组的求取。

二、next数组值的计算推导

1.真前缀与真后缀

因为网上很多博文没有说清楚真前缀与真后缀,前缀与后缀,KMP算法是要用到真前缀与真后缀,因此这里简单介绍一下。
来看一个简单的示例:
假设有如下字符串:
ababc
它的真前缀是
a
ab
aba
abab
如果是前缀,那ababc也算是前缀
真后缀是
babc
abc
bc
c
如果是后缀,那ababc也算是后缀。

2.NEXT数组的推导与思想

假设有字符串S=S1 S2 … … Si-j+1 Si-j+2 … … Si-k+1 … Si-1 Si … … Sn要与模式串匹配,模式串如下

				S=S1 S2 ... ....Si-j+1  Si-j+2 ... ... Si-k+1 ... Si-1 Si ... ...  ... Sn
                T=              T1      T2     ...     Tj-k+1 ... Tj-1 Tj ... ... Tm             (1)
                T=                                     T1     ... Tk-1 Tk ... Tj-k+1 ... Tj      (2)

这里要求1 < K < j。因为当 K等于1时,只有一个字符T1,这种情况直接比较字符即可,没有真前缀,当J等于2时,我们在比较了T1之后,仍需要比较T2,也是相当于从头比较,并且J等于2是大于1小于2的k值也不存在。

2.1 关于J为什么不从0开始要从1开始

很多人都会有这个疑问,为什么很多地方j不从0开始要从1开始呢。其实从0和从1开始都可以,从1开始更方便理解,只要把握住一个核心,不管从0还是从1开始,开始的那一位是模式字符串的第一个字符。我们下文也从1开始,来给读者推导和证明next数组的求法。

2.2 手动推导next数组

这里要注意的是,next数组的求取实际上是分为两类的,一种是人工求取,这种是可以通过求最长公共真前后缀子串的长度,再加1获得,网上也有通过部分匹配值,实际上根本思想都一样,看完下文论证你就明白了。
另外一种则是将程序求取,因为程序不知道怎么对比公共前后缀,这时我们就需要去推导next数组的计算公式,再将它转换成代码。

假设当前主串已经匹配到了i位置,对应字符Si,假如当前的模式串字符Tj与Si不相等,即我们前面说的,如(1),则要将主串的字符Si与模式串的另一个字符开始比较,我们假设另一个字符的下标为Tk,如(2)所示。
简介我们提到行比较的子串的当前字符前面的子串跟主串当前字符前面的子串是一致的,这里即是证明:
因为Si-k+1… … Si-1与Tj-k+1 … Tj-1这两个长度为k-1的子串在是匹配的,也就是相等的,且下一个比较的子串位置Tk前面的子串T1 … Tk-1这k-1长度的子串也与Si-k+1 … Si长度的子串是匹配的。等价关系,所以
Tj-k+1 … Tj-1 = T1 … Tk-1 (3)。
由此可以看出,K值只跟模式串本身有关,与主串无关,也就是说,我们要想在模式串中,找到一个k值(1<k<j),使得模式串满从Tk位置继续与主串比较,就必然要满足式(3)。这个k值,也就是我们所说的next[j],因为当前j位置的字符不匹配,所以找下一个j位置的字符进行比较。因为我们下标是从1开始,所以K值必须大于1,理解起来也简单,K等于1相当于只有一个字符(Tk-1都不存在)这时没有找K的必要,K小于j是因为等于J就是模式串本身了。因此我们有k(next[j])值的定义如下:
1、当j=1时,即字符串的第一个字符,这时再往前推压根不存在字符,因此我们定义此时的k=0,也就是next[1]=0;
2、k = max{k| 1 < k < j 且 T1 … Tk-1 = Tj-k+1 … Tj-1}。即当存在k,满足1<k<j及T1…Tk-1=Tj-k+1…Tj-1,那么k的值就取所有满足情况下k的最大值。为什么要取最大值呢,而不是取另外一个满足条件的k’(1<k’<k),我们观察式(2)很容易就知道,K越大,说明已匹配的内容越多(叫部分匹配值),那么仍需要比较的内容就会越少。
3、当我们找不到这样的k值的时候,我们就得从模式串的第一个字符开始,也就是k=1。
因此我们在手动计算k值时,一种方法是观察其最长的真子串长度,如串:

J:          1 2 3 4 5
            a b a b c
next[j]:    0 1 1 2 3
J等于1时,next[1]由定义为0
J等于2时,大于1小于2的k值不存在,其实也就是J等于2的子串就一个字符,这时不存在什么真前缀和真后缀。
这里需要提醒各位的是,我们看真前缀和真后缀,是看J值子串的真前缀与真后缀,也就是J值子串的真子串.
因为J值是我们要比较的字符下标,T1...Tk-1与Tj-k+1...Tj-1是T1...Tk-1 Tk子串T1...Tk-1的真子串。
所以不要弄混了,J等于2时,子串就一个字符a,其真子串不存在,因此假如我们2位置不匹配要找next[j],
也只能是1,就是说要从第一个字符再比较。很多博文这里说的不清楚,甚至有的把J=1和J=2的next[j]值赋值一样为0-1,
这是不对的,从意义上来说,一个代表不存在子串,不能再往前比较,一个代表子串不存在真子串,要从首位开始比较。
J等于3时,子串的真子串为:
a,b,这时不相等,即找不到这样的K值,按定义next[3]=1;意味着也要从模式串首位开始比较。
J=4时,子串的真前缀:
a,ab
真后缀:
ba,a
有一个公共子串长度为1,因此next[4]等于该长度加1。可能有的读者这会又忘了为什么要加1了,我们前面已经说明了
k值即next[j]值,并且K值前面k-1长的子串是匹配的,也就是说这里的长度就是前面已匹配的长度,所以这里K值就是k-1 + 1,因为这里
K-1就是公共子串长度1,所以K=2.
同样的我们有j=5时,由于子串真前缀:
aba
ab
a
真后缀
bab
ab
b
有最大公共真子串ab,长度为2,所以k=2+1=3

网上也有另外一种利用部分匹配长来求k的方法,思想本质是一样的,感兴趣的可以自行去看,但网上很多博文比较杂乱,建议多思考,不要被带偏。

2.3 编程推导next数组

说完了手动推导next数组值,我们不可能在每次程序启动前都算好,人为导入对吧,那么要将next数组的计算转换成编程语句,我们就得来推导一下next数组的计算公式了。
我们已经知道,要确定next数组的值,就等价于找到一个k值,使得
T1…Tk-1 = Tj-k+1 … Tj-1(1 < k < j)
这时就只有两种情况:
如果找不到,那么next[j] = 1;
如果找到了,k值存在,那么next[j] = k

为方便观察,这里再将(1(2)式写在下面:
 T=              T1      T2     ...     Tj-k+1 ... Tj-1 Tj ... ... Tm             (1)
 T=                                     T1     ... Tk-1 Tk ... Tj-k+1 ... Tj      (2)

这时我们下一步要比较的是Tj与Tk,假如有Tj = Tk;我们就得到
T1 … Tk = Tj-k+1 … Tj,也就是对于j+1位置的Tj+1,存在值K+1,使得T1 … Tk = Tj-k+1 … Tj,
所以当K值存在时,next[j] = k,并且这时候又有Tj = Tk时,next[j+1] = k + 1 = next[j] +1。
这就意味着这种情况下,我们只要知道当前的next下标的数组值和模式串本身,就能推出下一个下标的数组值。
但另外一种情况就是Tj != Tk的情况,这时我们只能继续找另外一个k’下标进行对比,同理,也要求

T=              T1      T2     ...     Tj-k+1 ... Tj-1  Tj ... ... Tm             (1)
T=                                     T1     ... Tk-1  Tk ... Tj-k+1 ... Tj      (2)
T=                                         T1 ... Tk'-1 Tk' ... Tk ... Tj         (3)

T1 … Tk’ = Tj-k’ … Tj-1(k’ < k)。因此对于(2)(3)式来说,k’的值又相当于找next[k]的值,也就是说k’=next[k],因为我们找next[k]的依据是T1 … Tk’ - 1 = Tk-k’+1 … Tk - 1,若能找到k’使得T1 … Tk’-1 = Tk-k’+1 … Tj-1,
因为T1 … Tk-1 = Tj-k+1 … Tj-1
所以必然有T1 … Tk’-1 = Tj-k’+1 … Tj-1.
如下更直观:

T=              T1      T2...Tj-k+1      ...    Tj-1  Tj ... ... Tm             (1)
T=                           T1 ... Tj-k'+1 ... Tk-1  Tk ... Tj-k+1 ... Tj      (2)
T=                                  T1      ... Tk'-1 Tk' ... Tk ... Tj         (3)

同样的,若Tk’ = Tj时,有next[j+1] = k’ + 1,因为k’ = next[k],所以next[j + 1] = next[k] + 1;
需要注意的是,因为我们目的是从前一个推后一个值,并且已经有next[j] = k了,我们这里目的并不是要推next[k+1],而是要推next[j+1],所以是Tk’与Tj比较,而不是与Tk比较,因为我们T已经与Tj不等了我们才要找k’的。读者不要弄混了。
综合上述就有
1、当k值不存在时,next[j]=1
2、当k值存在时,我们分为两种情况
2.1 Tk=Tj时,next[j+1] = k + 1
2.2 Tk != Tj时,next[j+1] = next[k] + 1,

写成代码就如下:

//很多书籍写法都如下,但其实有个漏洞,比如严蔚敏的教材
void get_next(char *pattern, int *next, int pattern_len)
{
	int j = 1;
	int k = 0;
	next[1] = 0;
	while(j < pattern_len)
	{
		if(k == 0 || pattern[j] == pattern[k])
		{
			j++;
			k++;
			//这里j++后,j值可能已经等于pattern_len了,这时会产生越界,所以更安全写法应在这里再加个
			//j范围判断
			if(j >= pattern_len)
				break;
			next[j] = k;
		}
		else
			k = next[k];
	}
}

通过递推的思想,我们可以再来看一下拿到一个字符串怎么人工给它把next数组推出来:
跟前面一样我们使用同样的例子

j:          1 2 3 4 5
            a b a b c
next[j]:	0 1 1 2 3
j:			1 2 3 4 5
k:			0 1 1 2 3
首先我们j,k及next[1]按定义初始化完成如j=1那一列,那么我们这时候要推next[2],
因为j等于2的时候我们说过这时k值是不存在的,所以k=1,
当j等于3的时候,很多读者这里容易不理解,假如我这样写,j = 2 + 1,令j' = 2,j = j' + 1,
我们要推j' + 1处的next[j'+1]的值,是不是可以由j'处的值推出来,因为此时j'处的next[j']我们已经有了
是1,也就是说只需要判断此时Tj'是否等于Tnext[j'],若相等,就有next[j' + 1]= next[j'] + 1,若不等,我们
令j' = next[j']继续往前推,直到找到或未找到即可。这里因为j' = 2,next[j']=1,T2 != T1,所以令j' = next[j'] = 0,这时我们发觉已经找不到了,所以T[j' +1] = 1,也就是T[j]=1。然后让j继续++。
来到j = 4,同样的,方便理解读者可以认为此时j' = 3,3位置的next值我们已经知道了,也是只需要比较3位置的T3与Tnext[3]来
推导next[4]的值,因为T3 == T1,所以此时next[j' + 1] = next[j'] + 1 = 1 + 1 = 2。
当j = 5时,跟前面一样,比较 T4与Tnext[4],发觉也相等,所以next[5] = next[4]+1

可以发现,我们这样推出来的结果跟我们使用最大公共真前后缀长度得到的结果一致,因此也相互印证我们结论是对的。

3.KMP算法的实现

前面已经说完了KMP算法的最大难点next数组的实现,接下来就是根据CPM算法的思想来实现CMP算法了。
根据kmp算法思想:
(1)比较首主串与模式串首个字符,如果相等,则将主串和模式串字符下标都后移;
(2)如果不相等,则主串当前下标不动,从模式串中找到下一个下标,用该下标的位置的字符与主串当前字符比较,如不等,则继续从模式串中找再下一个下标位置;
(3)若找不到,主串下标后移,比较再次从模式串的首位开始,若找到,则主串下标后移,模式串下标也后移,再比较当前下标的字符。
(4)比较的过程中,若模式串下标大于模式串长度了,说明找到匹配的串了,此时就返回匹配的位置,即当前主串下标减去模式串长度
(5)若当主串下标走完了模式串下标仍未出现大于模式串长度的情况,说明找不到。返回0;
按上述思路,我们可以编写程序如下:
(实际上我们kmp可能不会在知道主串长度的情况下去应用,比如外设输入匹配时,那么我们怎么知道什么时候终止匹配呢,一个巧妙的方法就是我们输入的第一个字符作为主串的S[0],我再次检测到输入字符与终止符匹配时,则认为结束,模式串的下标0位置可以用来存模式串长,但这仅是个人的一种用法,仅供参考,以下以假设主串长为len来编写kmp算法)

//从主串pos位置开始进行匹配
//模式串按上文从下标1开始
//我们在kmp之前已经是求得next数组了的
int kmp(char *target_str, char *pattern_str, int t_len, int p_len, int pos)
{
	int i = pos; //主串下标
	int j = 1;//模式串下标
	while((i <= t_len) && (j <= p_len))
	{
		if((j == 0|| (target_str[i] == pattern_str[j]))
		{	
			i++;
			j++;
		}
		else
			j = next[j];		
	}
	//若找到
	if(j > p_len)
		return i- plen;
	else
		return 0;
}

4.KMP算法的优化

前面我们已经讲完了kmp算法的实现,是不是感觉KMP算法仿佛简单,实际上很容易绕晕。如到这里还没懂的筒子建议再看几遍。关键是要理解它的原理,而不是去依葫芦画瓢地左next数组的推导。
那么Kmp算法能做什么优化呢,我们来看这样一个模式串:

        a a a a b
j:      1 2 3 4 5
next[j]:0 1 2 3 4

根据签名的方法我们很容易得到next[j]值如上,我们假设有主串:

i: 1 2 3 4 5 6 7 8 9
   a a a b a a a a b
   a a a a b

如图,当我们匹配的过程中,i = 4, j = 4时,会出现字符的不匹配,这时j = next[4] ,j等于3,还是a仍然不匹配,又会比较next[3], next[2]位置的字符,因为签名四个字符都是重复的。也就是说,主串中Si = Tj,这时next[j] = k, 由于这时的Tk是等于Tj的,所以没必要比较Tk,而将Tj与k位置的next[k]比较即可,这时可优化next值求取算法如下:

void get_nextval(char *pattern, int *nextval, int len)
{
	int j = 1;
	int k = 0;
	nextval[0] = 0;
	while(j < len)
	{
		if(j == 0 || pattern[j] == pattern[k])
		{
			++j;
			++k;
			//再来做一个预判断看此时的字符值是否相等,若相等则说明与前一个字符是重复的,重复时next[j]的值就跟
			//上一个j值的next值保持一致
			if(pattern[j] == pattern[k]) 
				nextval[j] = nextval[k];
			else
				//不相等仍然是next[j] = k
				nextval[j] = k;
		}
		else
			k = nextval[k];
	}	
}

总结

本文详细讲了KMP算法的核心思想及代码实现,再次建议读者以解决问题的思路去思考KMP算法为什么会这样做,会更容易理解。如果一遍没看懂没关系,多看几遍。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值