引入
假设给定两个字符串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()
那么我们就要思考另一种思路构建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;
}