KMP算法——Knuth–Morris–Pratt algorithm

题目举例:http://hihocoder.com/problemset/problem/1015

         题目:判断一段文字(原串)里面是不是存在那么一些特殊的文字(模式串)?

         输入:第一行一个整数N,表示测试数据组数。接下来的N*2行,每两行表示一组测试数据。在每一组测试数据中,第一行为模式串,由不超过10^4个大写字母组成,第二行为原串,由不超过10^6个大写字母组成。其中N<=2。

         输出:对于每一个测试数据,按照它们在输入中出现的顺序输出一行Ans,表示模式串在原串中出现的次数。

         在hihoCoder上面碰到这道题的时候,一开始觉得很容易,解题的一般思路如下:在原串(original)中逐个字符向后匹配模式串,如果匹配到了模式串(pattern)的末尾,那么count++,直到原串结束。举例如下:

 

找到一个匹配……为找到第一个完全匹配一共进行了8次移动和9轮匹配。

         很显然,上面的算法进行了许多不必要的比较因为每次失配,模式串只往后移动一位,因此以上算法的时间复杂度是O(N*M)。一旦失配,如果能直接在原串找到下一个模式串首字母(A)出现的位置,就可以省去这些不必要的比较了(实际上这也是KMP算法的思路)。

         于是我用C++ string写了一个算法:用find函数在原串(original)中寻找模式串(pattern)第一次出现的位置,count+1,然后对原串切割(跳过几个字符),弃掉已匹配部分(切割的长度为“模式串长度 - 最长相同前后缀长度”,这里需要对每个模式串计算最长相同前后缀,很容易用string的substr实现),重复find过程,直到原串结束。代码不到50行,结果TLE(超时)了。

         究其原因,C++ string类虽然强大,但是效率不够高,表面上看find函数会直接定位到第一个模式串出现的位置,但其根本实现还是对原串和模式串的字符逐一比较,所以本质还是文章开头提到的最原始版的低效算法。


         随后在网上找KMP算法的思想,阅读了不少文章,结果发现大都晦涩难懂,本文最后提到的参考文章是我看到写的最清晰的一篇,但是到next数组求解时又开始晦涩了(而这恰恰是KMP算法的核心)。简单介绍一下next数组:next数组长度为“模式串长度+1”,每个位置的值为模式串到该位置为止的最长相同前后缀长度(模式串从1开始标号),以模式串“ABCABCAD”为例,它的next数组如下:

Index

0

1

2

3

4

5

6

7

8

Pattern string

 

A

B

C

A

B

C

A

D

Next[]

-1

0

0

0

1

2

3

4

0

最长相同前后缀

 

 

 

 

A

AB

ABC

ABCA

 

         

        next数组的设置十分巧妙,假设模式串与原串对齐到了下标4的A(这意味着下标1-4都匹配上了),然后在5的位置失配了,于是模式串应该往后移动4 - 1 = 3个单位,我们发现下标为1的A正好移动到了下标4的A处;再来看,假如在下标1的A就失配,模式串应该后移0 - (-1) = 1个单位,后移一位(这就是next[0]设置为-1的道理)。总结一下,假设模式串在位置 i 处失配,那么模式串应该往后移动 “i-1-next[i-1]” 个单位。容易证明,利用next数组确定往后移动的单位,不会错过原串中任何一个可能的匹配。

找到一个匹配,一共进行了5次移动和6轮匹配(而且 每轮 匹配并不是从头开始 ,详见下一段 )。

        next数组不仅可以确定往后移的单位,同时还确定了下一次比较的起点。假设模式串与原串对齐到了位置4的A,在位置5失配,那么模式串应该后移4 - 1 = 3位,位置1的A对齐到了位置4的A,那么在下一轮的比较中,A位置以后的字母才需要比较,而这个位置恰好就是next[i-1]的值(但是对于next[0]需要额外处理)。

         最后,next数组的巧妙之处还在于,它本身可以递归(或用动态规划)求得,时间复杂度为O(M),M表示模式串的长度。我采用动态规划求解next数组,比传统(递归)求解速度更快,而且更容易理解。算法思想是,对于每一位的next值,向前看一位,如果前一位为0,那么比较当前位和第0位;否则,比较当前位和next[i]位(因为0 ~ next[i]-1位都已经匹配),C语言版本实现如下:

void computeNext(const char par[], int next[])//next[]长度为len+1
{
	int len = strlen(par);
	next[0] = -1;
	next[1] = 0;
	for(int i = 1; i < len; i++) //i是par下标,i+1为next下标
	{
            if(next[i] == 0) //如果前一位无前后缀相同,那么比较0位和当前位 
                next[i+1] = par[0] == par[i] ? 1 : 0; 
            else // next[i] != 0, 指示了最长相同前后缀长度 
            { 
                if(par[next[i]] == par[i]) //next[i]恰好是下一个比较位置 
                    next[i+1] = next[i]+1; 
                else 
                    next[i+1] = par[0] == par[i] ? 1 : 0; 
            } 
	}
}

         求得next数组,就可以用KMP的算法计算模式串在原串中的匹配数量了。KMP算法思想是,将模式串与原串对齐,依次向后比较,若到达模式串末尾,count+1;若遇到失配,则根据失配位置在模式串的下标,结合next数组,计算模式串应该往后移动几位。重复上述过程,直到原串末尾。C语言实现如下:

int kmp(char par[], char ori[]) //pattern string, original string
{
	int count = 0;
	int *next = new int[strlen(par)+1];
	computeNext(par, next);

	int len_ori = strlen(ori), len_par = strlen(par);
	int ipar = 0, iori = 0;//指示par和ori的下标
	while(iori+ipar < len_ori)//原串剩余字母已经完成最后一次匹配 或 不足以进行一次匹配
	{
		while(iori+ipar < len_ori && ipar < len_par && ori[iori+ipar] == par[ipar]) //前两个条件防止数组访问越界
			ipar++;
		if(ipar == len_par) //模式串完全匹配
			count++;
		iori += (ipar - next[ipar]);//ori下标向后移动
		ipar = next[ipar] == -1 ? 0 : next[ipar];//确定下一次比较的起点
	}
	delete[] next;
	return count;
}
int main()
{
	int n;
	char par[10001], ori[1000001];
	cin >> n;
	while(n--)
	{
		scanf("%s", par);
		scanf("%s", ori);
		cout << kmp(par, ori) << endl;
	}

	return 0;
}

         最后分析一下KMP算法的时间复杂度:设原串和模式串的长度分别为N和M,KMP算法首先要计算Next数组,只需要遍历一次,复杂度O(M)。接着就是匹配过程,最坏的移动情形,每次只能移动一个单位(这意味着每次匹配到第一或第二个字母就失配),复杂度O(N),总体复杂度为O(N+M);最好的移动情形,每次都能匹配全模式串,每次移动单位为M-1 - Next[M-1],移动次数(匹配轮数)为⌊N/(M-1 - Next[M-1])⌋,M-1 - Next[M-1]的期望为M/2,每轮匹配需要进行的匹配字符数期望为M/2,因此总共的匹配字符数的期望为⌊N/(M-1 - Next[M-1])⌋*M/2 ≈ N,因此匹配过程的时间复杂度为O(N),总体时间复杂度为O(N+M)。综上所述,KMP算法的复杂度为稳定的O(N+M).


参考文章:

https://en.wikipedia.org/wiki/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm

http://www.cnblogs.com/c-cloud/p/3224788.html



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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值