KMP算法

引入

假设给定两个字符串a,b,且a的长度大于b,那么如何判断b在a中出现的次数呢? 

 所谓字符串匹配,就是询问:“字符串b是否是字符串a的子串?如果是,它出现在a的哪些位置,出现了几次?”等问题。其中a被称为模板串(主串),b称为模式串。

首先我们想到的就是暴力算法,枚举a中的每一个字符,并从这个字符开始枚举,判断之后的字符和b中的各个元素是否相同,如果全部相同就ans++,如果出现不同就break掉,从下一位字符作为开头重新枚举。

暴力算法非常简单,只要会循环和字符串就能轻松写出来。

#include<bits/stdc++.h>
using namespace std;
string a,b;
int len1,len2;
char ch1[510],ch2[510];
bool flag;
int cnt;
int ans;
void init()
{
	cin>>a>>b;
	len1=a.size();
	len2=b.size();
	for(int i=1;i<=len1;i++)
		ch1[i]=a[i-1];
	for(int i=1;i<=len2;i++)
		ch2[i]=b[i-1];
}
void work()
{
	for(int i=1;i<=len1-len2+1;i++)
	{
		flag=1;
		for(int j=i;j<=len2+i-1;j++)
		{
			++cnt;
			if(ch1[j]!=ch2[cnt]) 
			{
				cnt=0;
				flag=0;
				break;
			}
		}
		if(flag==1) ans++;
		cnt=0;
	}
}
int main()
{
	init();
	work();
	cout<<ans;
}

此时的时间复杂度为O(n*m)

那如果a,b的长度达到1e6呢?

显然用暴力算法会超时,那么我们就需要在暴力算法的基础上进行优化。

我们发现,暴力算法的一大弊端就是比较时是一位一位接着比较的,这就意味着有许多次比较可能是无效比较,如下图:

                   

当我们比较出A中的“b”和B中的“f”失配时,我们需要往后移一位进行比较,但“c” 和“h”明显是不一样的,如果我们能提前知道这一点,也就是说我们提前知道之后比较的多少信息,就能有效规避这种不必要的比较。

这就是KMP算法的核心思想所在:尽可能利用手中残余的信息优化算法

那么基于这一思想,我们的优化思路就是尽可能跳过那些不可能成功的字符串比较,将时间复杂度降低到能接受的范围

如下图:

                   

当A中的“b”与B中的“f”失配后,我们发现只有当A中的“f”与B中的“f”相匹配时,才有成功的可能性

                   

 带着这种“跳过不可能成功的比较”这一思想,我们来看next数组

next数组

next数组是相对模式串而言的

next[i]数组的定义为:模式串中以i结尾的非前缀子串与模式串的前缀能够匹配的最长长度

通俗解释来说,next[i]就是模式串[0]--模式串[i]中使前k个字符与后k个字符的最大的k

                    

如上图,第4个字符“a”可以与前缀的“a”匹配,所以next[3]=1;第5个字符前的“ac”可以与前缀的“ac”匹配,所以next[4]=2;第6个字符“a”可以与前缀的“a”匹配,所以next[5]=1。

再回顾KMP算法的思想:利用已经掌握的信息,失配后向后移多位进行比较。那么我们如何确定要移多少位呢?

            

 首先第3个字符配对,然后第4个字符失配,此时我们直接将字符串右移3位,使字母“a”配对,以此类推不断配对。

那么我们如何移动字符串?显然,如图中箭头所示,旧的后缀要与新的前缀一致

回到next[ ]数组的定义:前k个字符与后k个字符相同,也就是说如果失配在P[r],那么P[0]~P[r-1]这一段里面,前next[r-1]个字符恰好和后next[r-1]个字符相等——也就是说,我们可以拿长度为 next[r-1] 的那一段前缀,来顶替当前后缀的位置,让匹配继续下去。所以next数组为我们如何移动字符串提供了信息。

构造next[ ]数组

首先我们仍思考用暴力算法构造,同样要开双重循环,如果前缀等于后缀就返回此时的值(代码请自己实现),此时的时间复杂度是O(n^{2}

那么我们就要思考另一种思路构建next数组,核心思想就是“自己与自己做匹配”。

假设我们现在已经知道了next[x-1]的值,如果 P[x] 与 P[next[x-1]] 一样,那么最长相等前后缀的长度就可以扩展一位,很明显 next[x] = next[x-1] + 1. 图示如下。

                      


那如果 P[x] 与 P[next[x-1]] 不一样,又该怎么办?

        如图,我们假设此时模式串的前缀为M,后缀为N,那么M右边的字符与N右边的字符处于失配状态,因此我们需要缩小next[x-1],试试P[x]是否等于新的值。

        显然,我们要优化时间复杂度,就不能让next[x-1]缩小得太多,所以我们保持新的前缀仍等于后缀的情况下尽量取最大值,也就是说仍然取前后缀相等的最大值。那么next[x-1]应该缩小为:使得M的前缀等于N的后缀的最大值

        又因为M和N是相同的,所以N的后缀就等于M的前缀,也就得到了M的后缀等于M的前缀。那么我们就得到了next[x-1]应该缩小的值:next[next[x-1]-1]。

         这样,我们所要求的next[ ]就可以由上一个next[ ]数组的下标-1的值得到。因此,我们就可以利用递推的思想求解构造next[ ]数组。

        代码如下:

next[1]=0;
for(int i=2,j=0;i<=len_b;i++)
{
	while(j>0&&b[i]!=b[j+1]) j=next[j];
	if(b[i]==b[j+1]) j++;
	next[i]=j;
}

 

构造f[ ]数组

        在上一步中,我们得到了next[ ]的数组,也就得到了我们进行模式串与模板串匹配的信息,那么接下来我们就要进行模板串与模式串的匹配。

        此时我们引入一个新的数组:f[ ]数组。

        f[i]表示模版串中以i结尾的子串与模式串的前缀能够匹配的最长长度。

        有没有发现它和next[ ]数组的定义非常像?!其实,next[ ]数组的求解就是模式串与模式串进行匹配,f[ ]数组就是模板串与模式串进行匹配。

        也因此,求解构造f[ ]的过程也与求解构造next[ ]的过程基本一致,这里不再一一赘述。

        求解f[ ]数组的代码如下:

for(int i=1,j=0;i<=len_a;i++)
	{
		while(j>0&&(j==len_b||a[i]!=b[j+1])) 
		{
			j=next[j];
		}
		if(a[i]==b[j+1]) j++;
		f[i]=j;
        //if(f[i]==len_b) ,此时就是模式串在模板串的某一次出现
	}

        此时还要注意一点:模板串的长度一般要比模式串的长度要长,所以我们在求解时,要根据问题的设问,灵活运用长度等关键量进行判断。

        这就是KMP模式匹配算法,时间复杂度降到了O(N+M),使字符串匹配问题得到了大大优化。


附:这里给出开头问题中求解模式串在模板串出现次数的代码(其实就是板子题)

#include<bits/stdc++.h>
using namespace std;
string a,b;
const int maxn=1e6+10;
int next[maxn],f[maxn];
int j;
int ans=0;
char aa[maxn],bb[maxn];
int len_a,len_b;
void init()
{
	cin>>a>>b;
	len_a=a.size();
    len_b=b.size();
	for(int i=1;i<=a.size();i++)
		aa[i]=a[i-1];
	for(int i=1;i<=b.size();i++)
		bb[i]=b[i-1];
}
void work()
{
	next[1]=0;j=0;
	for(int i=2,j=0;i<=len_b;i++)
	{
		while(j>0&&bb[i]!=bb[j+1]) j=next[j];
		if(bb[i]==bb[j+1]) j++;
		next[i]=j;
	}
	for(int i=1,j=0;i<=len_a;i++)
	{
		while(j>0&&(j==len_b||aa[i]!=bb[j+1])) 
		{
			j=next[j];
		}
		if(aa[i]==bb[j+1]) j++;
		if(j==len_b) ans++;//j++后得到最后一位的匹配,检验最后j是否为模式串的最后一位 
	}
}
int main()
{
	init();
	work();
    cout<<ans;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值