字符串查找-KMP算法

        这个算法是自己对购买课程中的一个章节的一个总结。

1 KMP 算法基本原理

        KMP 算法是根据三位作者(D.E.Knuth,J.H.Morris 和 V.R.Pratt)的名字来命名的,算法的全称是 Knuth Morris Pratt 算法,简称为 KMP 算法。

        KMP 算法的核心思想,跟上一节讲的 BM 算法非常相近,但是KMP是从前向后依次对比,与BM算法中从后向前对比是不同的,这里从前向后和从后向前,都是以模式串来看的,也就是将主串与模式串从前向后对比,或者是从后向前对比。

        我们假设主串是 a,模式串是 b。在模式串与主串匹配的过程中,当遇到不可匹配的字符的时候,我们希望找到一些规律,可以将模式串往后多滑动几位,跳过那些肯定不会匹配的情况。

        还记得我们上一节讲到的好后缀坏字符吗?这里我们可以类比一下,在模式串和主串匹配的过程中,把不能匹配的那个字符仍然叫作坏字符,把已经匹配的那段字符串叫作好前缀(这里用的就是“前缀”)。具体如图1.1所示。

 图 1.1

        当遇到坏字符的时候,我们就要把模式串往后滑动,在滑动的过程中,只要模式串和好前缀有上下重合,前面几个字符的比较,就相当于好前缀后缀子串,跟模式串前缀子串在比较,具体如图1.2所示。这个比较的过程能否更高效了呢?可以不用一个字符一个字符地比较了吗?

 图 1.2

        KMP 算法就是在试图寻找一种规律:在模式串和主串匹配的过程中,当遇到坏字符后,对于已经比对过的好前缀,能否找到一种规律,将模式串一次性滑动很多位?

        我们只需要拿好前缀本身,在它的后缀子串中,查找最长的那个可以跟好前缀的前缀子串匹配的。假设最长的可匹配的那部分前缀子串{v},长度是 k。我们把模式串一次性往后滑动 j-k 位((其中j是坏字符对应的模式串中的字符下标,那么前缀子串{v}就可用b[j-k, j-1]表示,长度恰好为k,将模式串滑动到与前缀子串重合,就需要滑动(j-k) - 0的距离,其中0是前缀子串的起始索引,这样计算下来模式串就需要滑动j-k的距离。如图1.3所示) ),相当于,每次遇到坏字符的时候,我们就把 j 更新为 k((前缀子串的长度为k,且索引从0开始,即前缀子串可用b[0, k-1]表示,该前缀子串的下一个字符的索引就是k,然后再从模式串中索引为k的字符开始与主串中的坏字符开始比较,因为好前缀的后缀子串已经与好前缀的前缀子串对齐了,所以主串中坏字符之前的就不用再比较了) ) i 不变(i就是坏字符主串中的索引),然后继续比较,具体如图1.3所示。

        注意:这里用的字母与图1.6中的字母并不是一一对应的,要领略原理,这里需要注意。

 图 1.3

        为了表述起来方便,我把好前缀的所有后缀子串中,最长的可匹配前缀子串的那个后缀子串,叫作最长可匹配后缀子串;对应的前缀子串,叫作最长可匹配前缀子串,具体如图1.4所示。

 图 1.4

        如何来求好前缀的最长可匹配前缀和后缀子串呢?我发现,这个问题其实不涉及主串只需要通过模式串本身就能求解。所以,我就在想,能不能事先预处理计算,在模式串和主串匹配的过程中,直接拿过来就用呢?

        类似 BM 算法中的 bc、suffix、prefix 数组,KMP 算法也可以提前构建一个数组,用来存储模式串中每个前缀(这些前缀都有可能是好前缀)的最长可匹配前缀子串结尾字符下标(((所以要从模式串的最长可匹配前缀子串的结尾字符下标的下一个字符开始与主串中的坏字符开始比较,也就是前面下划线地方所分析的) ) )。我们把这个数组定义为 next 数组,很多书中还给这个数组起了一个名字,叫失效函数(failure function)。

        数组的下标是每个前缀子串结尾字符下标,数组的是这个前缀的最长可以匹配前缀子串结尾字符下标。这句话有点拗口,我举了一个例子,你一看应该就懂了,具体如图1.5所示。

 图 1.5

        有了 next 数组,我们很容易就可以实现 KMP 算法了。我先假设 next 数组已经计算好了,先给出 KMP 算法的框架代码,具体如图1.6所示。

 图 1.6

2 算法代码实现

2.1 失效函数计算方法

        KMP 算法的基本原理讲完了,我们现在来看最复杂的部分,也就是 next 数组(((数组的下标是每个前缀子串结尾字符下标,数组的是这个前缀的最长可以匹配前缀子串结尾字符下标) ) )是如何计算出来的?

        当然,我们可以用非常笨的方法,比如要计算下面这个模式串 b 的 next[4],我们就把 b[0, 4]的所有后缀子串,从长到短找出来,依次看看,是否能跟模式串的前缀子串匹配,具体如图2.1所示。很显然,这个方法也可以计算得到 next 数组,但是效率非常低。有没有更加高效的方法呢?

 图 2.1

        这里的处理非常有技巧,类似于动态规划。不过,动态规划我们在后面才会讲到,所以,我这里换种方法解释,也能让你听懂。

        我们按照下标从小到大,依次计算 next 数组的值。当我们要计算 next[i] (((数组的下标是每个前缀子串结尾字符下标,数组的是这个前缀的最长可以匹配前缀子串结尾字符下标) ) )的时候,前面的 next[0],next[1],……,next[i-1]应该已经计算出来了。利用已经计算出来的 next 值,我们是否可以快速推导出 next[i]的值呢?

        如果 next[i-1]=k-1,也就是说,子串 b[0, k-1]是 b[0, i-1] ((好前缀) )最长可匹配前缀子串。如果子串 b[0, k-1]的下一个字符 b[k],与 b[0, i-1]的下一个字符 b[i]匹配,那子串 b[0, k]就是 b[0, i]的最长可匹配前缀子串。所以,next[i]等于 k,如图2.2所示。

 图 2.2

        但是,如果 b[0, k-1]的下一字符 b[k]跟 b[0, i-1] ((好前缀) )的下一个字符 b[i]不相等呢?这个时候就不能简单地通过 next[i-1]得到 next[i]了。这个时候该怎么办呢?

        我们假设 b[0, i] ((好前缀) )最长可匹配后缀子串是 b[r, i]。如果我们把最后一个字符去掉,那 b[r, i-1]肯定是 b[0, i-1] ((好前缀) )的可匹配后缀子串,但不一定最长可匹配后缀子串。所以,既然 b[0, i-1] ((好前缀) )最长可匹配后缀子串对应的模式串的前缀子串的下一个字符并不等于 b[i],那么我们就可以考察 b[0, i-1] ((好前缀) )次长可匹配后缀子串 b[x, i-1]对应的可匹配前缀子串 b[0, i-1-x]的下一个字符 b[i-x]是否等于 b[i]。

        如果等于,那 b[x, i]就是 b[0, i] ((好前缀) )的最长可匹配后缀子串;

        如果不等于,那这个次长可匹配后缀子串b[x, i-1]就不是要找的后缀子串,即使它是次长的,然后继续判断除了次长可匹配后缀子串之外的其余可匹配后缀子串,直到找到可匹配后缀子串 b[n, i-1],它对应的前缀子串的下一个字符等于 b[i],那这个 b[n, i]就是 b[0, i]的最长可匹配后缀子串或者所有的 b[0, i-1] ((好前缀) )的可匹配后缀子串被遍历完(就是没有找到,图2.5程序中的while循环)。具体如图2.3所示。

 图 2.3

        可是,如何求得 b[0, i-1] ((好前缀) )次长可匹配后缀子串呢?次长可匹配后缀子串肯定被包含在最长可匹配后缀子串中,而最长可匹配后缀子串对应最长可匹配前缀子串 b[0, y] (( y= next[i-1],这里说长可匹配后缀子串又“对应”最长可匹配前缀子串,实际上就是“相同”,这里的y就是图2.5程序中的k) )。于是,查找 b[0, i-1] ((好前缀) )次长可匹配后缀子串,这个问题就变成,查找 b[0, y] ((y= next[i-1],这里的y就是图2.5程序中的k,y的标注如图2.4所示;next[i-1]表示当好前缀b[0, i-1]时,此好前缀最长可以匹配前缀子串的结尾字符下标,例子见图1.5所示) )最长匹配后缀子串的问题了。具体如图2.4所示。

        问题:查找 b[0, i-1] ((好前缀) )次长可匹配后缀子串,这个问题为什么就变成,查找 b[0, y]的最长匹配后缀子串的问题了?????

        解答:假设b[0, i-1] ((好前缀) )最长可匹配后缀子串为b[a, i-1](1<=a<=i-1),那么次长可匹配后缀字串就可写为b[m, i-1](a<m<=i-1),也就是“次长可匹配后缀子串肯定被包含最长可匹配后缀子串中”,最长可匹配后缀子串最长可匹配前缀子串相同,故最长可匹配后缀子串为b[a, i-1](1<=a<=i-1)就可以用b[0, y] (( y= next[i-1]) )表示,由于b[m, i-1](a<m<=i-1)可看作b[a, i-1](1<=a<=i-1)的后缀字串,并且针对b[0, i-1] ((好前缀) )b[m, i-1]是仅次于b[a, i-1]的“次长可匹配后缀子串”,这就说明b[a, i-1](1<=a<=i-1)作为好前缀时,b[m, i-1]是其最长可匹配后缀字串,而最长可匹配后缀子串为b[a, i-1](1<=a<=i-1)又可以用b[0, y] (( y= next[i-1]) )表示,那么就可以说针对好前缀b[0, y] (( y= next[i-1]) ) ,b[m, i-1]是其最长可匹配后缀子串。

 图 2.4

        按照这个思路,我们可以考察完所有的 b[0, i-1] ((好前缀) )的可匹配后缀子串 b[m, i-1],直到找到一个可匹配的后缀子串,它对应的前缀子串的下一个字符等于 b[i],那这个 b[m, i]就是 b[0, i]的最长可匹配后缀子串;如果没找到,就将next[i]置为-1。

        前面(图1.6)我已经给出 KMP 算法的框架代码了,现在我把这部分的代码也写出来了。这两部分代码合在一起,就是整个 KMP 算法的代码实现。如图2.5所示。

 图 2.5

2.2 代码实现


#include <string.h>


// pattern表示模式串, patternLen表示模式串的长度
int* getNexts(char pattern[], int patternLen) 
{
	int *next = new int[patternLen];
	next[0] = -1;
	int k = -1;
	//求解所有好前缀的最长可匹配前缀子串,最长的好前缀为[0, patternLen-2],所以下面可以修改为: i < patternLen - 1
	//for (int i = 1; i < patternLen; ++i) //这里就算是计算了next[patternLen-1]其实也用不到,所以这里就是计算了也没影响
	for (int i = 1; i < patternLen - 1; ++i) 
	{
		while (k != -1 && pattern[k + 1] != pattern[i]) 
		{
			k = next[k];
		}

		if (pattern[k + 1] == pattern[i]) 
		{
			++k;
		}
		next[i] = k;
	}

	return next;
}


// src, pattern分别是主串和模式串;srcLen, patternLen分别是主串和模式串的长度。
int KnuthMorrisPratt(char src[], int srcLen, char pattern[], int patternLen) 
{
	int *next = getNexts(pattern, patternLen);
	int j = 0;
	for (int i = 0; i < srcLen; ++i) 
	{
		while (j > 0 && src[i] != pattern[j]) // 一直找到a[i]和b[j]
		{ 
			//next[j - 1]表示模式串中最长可匹配前缀子串的结尾字符的下标,那么就可将主串中索引 i 处的坏字符开始与next[j - 1] + 1字符进行比较
			j = next[j - 1] + 1;//当j=0时退出循环,表示下一轮主串开始从索引 i+1 处与模式串(从头开始也就是j=0)开始新一轮的比较
		}

		if (src[i] == pattern[j]) 
		{
			++j;
		}

		if (j == patternLen) // 找到匹配模式串的了
		{ 
			//src[r, i]恰好等于pattern,所以 i - r + 1 = pattern, r = i + 1 - pattern
			return i - patternLen + 1;
		}
	}

	return -1;
}

int main()
{
	//char src[] = "abcacabcbcbacabc";
	//char pattern[]="cbacabc";

	//test 1
	//char *src =  "abcacabcbcbacabccbacabc";
	//char *pattern = "cbacabc";

	//test2
	char *src =  "ababaeabacababac";
	char *pattern = "ababac";

	int n1 = strlen(src);
	int n2 = strlen(pattern);

	int index = KnuthMorrisPratt(src, n1, pattern, n2);

	return 0;
}

3 KMP算法复杂度分析

        KMP 算法的原理和实现就讲完了,现在来分析一下 KMP 算法的时间、空间复杂度是多少?

        空间复杂度很容易分析,KMP 算法只需要一个额外的 next 数组,数组的大小跟模式串相同。所以空间复杂度是 O(m),m 表示模式串的长度。

        KMP 算法包含两部分,第一部分是构建 next 数组,第二部分才是借助 next 数组匹配。所以,关于时间复杂度,我们要分别从这两部分来分析。

        我们先来分析第一部分的时间复杂度。计算 next 数组的代码中,第一层 for 循环中 i 从 1 到 m-1,也就是说,内部的代码被执行了 m-1 次。for 循环内部代码有一个 while 循环,如果我们能知道每次 for 循环、while 循环平均执行的次数,假设是 k,那时间复杂度就是 O(k*m)。但是,while 循环执行的次数不怎么好统计,所以我们放弃这种分析方法。

        我们可以找一些参照变量,i 和 k。i 从 1 开始一直增加到 m,而 k 并不是每次 for 循环都会增加,所以,k 累积增加的值肯定小于 m。而 while 循环里 k=next[k],实际上是在减小 k 的值,k 累积都没有增加超过 m,所以 while 循环里面 k=next[k]总的执行次数也不可能超过 m。因此,next 数组计算的时间复杂度是 O(m)。

        我们再来分析第二部分的时间复杂度。分析的方法是类似的。i 从 0 循环增长到 n-1,j 的增长量不可能超过 i,所以肯定小于 n。而 while 循环中的那条语句 j=next[j-1]+1,不会让 j 增长的,那有没有可能让 j 不变呢?也没有可能。因为 next[j-1]的值肯定小于 j-1,所以 while 循环中的这条语句实际上也是在让 j 的值减少。而 j 总共增长的量都不会超过 n,那减少的量也不可能超过 n,所以 while 循环中的这条语句总的执行次数也不会超过 n,所以这部分的时间复杂度是 O(n)。

        所以,综合两部分的时间复杂度,KMP 算法的时间复杂度就是 O(m+n)。

4解答开篇 & 内容小结

        KMP 算法讲完了,不知道你理解了没有?如果没有,建议多看几遍,自己多思考思考。KMP 算法和上一节讲的 BM 算法的本质非常类似,都是根据规律在遇到坏字符的时候,把模式串往后多滑动几位。

        BM 算法有两个规则,坏字符和好后缀。KMP 算法借鉴 BM 算法的思想,可以总结成好前缀规则。这里面最难懂的就是 next 数组的计算。如果用最笨的方法来计算,确实不难,但是效率会比较低。所以,我讲了一种类似动态规划的方法,按照下标 i 从小到大,依次计算 next[i],并且 next[i]的计算通过前面已经计算出来的 next[0],next[1],……,next[i-1]来推导。

        KMP 算法的时间复杂度是 O(n+m),不过它的分析过程稍微需要一点技巧,不那么直观,你只要看懂就好了,并不需要掌握,在我们平常的开发中,很少会有这么难分析的代码。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值