KMP字符串匹配问题

2023-06-20,,

KMP算法

本文参考资料:https://www.zhihu.com/question/21923021

KMP算法是一种字符串匹配算法,可以在 \(O(n+m)\) 的时间复杂度内实现两个字符串的匹配。

字符串匹配问题

首先我们需要了解一下什么是字符串匹配问题。

比如给你两个串 \(s1\),\(s2\),问你 \(s1\) 是否为 \(s2\) 的子串,以及每次出现在 \(s2\) 中的位置,其中称 \(s1\) 为模式串,\(s2\) 为主串。看看下面这张阮行止老师的图:

我们可以发现,对于字符串 no 的匹配结果是在主串中出现了 1 次,位置为 6,对于字符串 ob 的匹配结果就是在主串中出现过 2 次,出现的位置分别为 1 和 10。

生活中有很多类似的问题,比如当你在成绩单里找自己的名字或者说在小说的章节里面查找你所想看的带有某个关键字的部分,都是和这个是差不多的。

Brute-Force

直译过来就是暴力破解的意思,所以这个算法也是非常的暴力,首先,我们应该如何实现两个字符串 \(s1\),\(s2\) 的比较?所谓字符串比较,就是问“两个字符串是否相等”。最朴素的思想,就是从前往后逐字符比较,一旦遇到不相同的字符,就返回 False;如果两个字符串都结束了,仍然没有出现不对应的字符,则返回 True。

那么我们理解了如何判断两个字符串相等的话,我们就可以想到,假设要查询 \(s1\) 是否为 \(s2\) 的子串,我们可以枚举 \(s2\) 的每一个点为起点,然后截取与 \(s1\) 长度相等的一段串,来判断是否相等,就可以暴力判断出 \(s1\) 在 \(s2\) 中的出现位置了。

当然这个算法的复杂度是很大的,在一般情况下是会 T 飞的,所以我们需要对其进行改进。

Brute-Force的改进

上图为 Brute-Force 的最坏情况,可以看到复杂度并不乐观,所以有了这一部分。

我们很难降低字符串比较的复杂度(因为比较两个字符串,真的只能逐个比较字符)。因此,我们考虑降低比较的趟数。如果比较的趟数能降到足够低,那么总的复杂度也将会下降很多。要优化一个算法,首先要回答的问题是“我手上有什么信息?” 我们手上的信息是否足够、是否有效,决定了我们能把算法优化到何种程度。请记住:尽可能利用残余的信息,是 KMP 算法的思想所在。

在 Brute-Force 中,如果从 \(s2[i]\) 开始的那一趟比较失败了,算法会直接开始尝试从 \(s2[i+1]\) 开始比较。这种行为,属于典型的“没有从之前的错误中学到东西”。我们应当注意到,一次失败的匹配,会给我们提供宝贵的信息——如果 \(s2[i]\) 到 \(s2[i+len[s1]]\) 与 \(s1\) 的匹配是在第 \(r\) 个位置失败的,那么从 \(s2[i]\) 开始的 \(r-1\) 个连续字符,一定与 \(s1\) 的前 \(r-1\) 个字符一模一样!

所以我们可以发现,以从 \(i\) 到 \(r-1\) 为开头的那一段区间是不可能是 \(s2\) 中 \(s1\) 的起点的,也就是说,中间的那一块 \(i\) 到 \(r-1\) 的位置,是不用再去比较的!

这时候我们需要一个新的东西——next 数组。

\(next\) 数组是对于模式串而言的。\(s1\) 的 \(next\) 数组定义为:\(next[i]\) 表示 \(P[0]\) ~ \(P[i]\) 这一个子串,使得 前k个字符恰等于后 \(k\) 个字符的最大的 \(k\). 特别地,\(k\) 不能取 \(i+1\)(因为这个子串一共才 \(i+1\) 个字符,自己肯定与自己相等,就没有意义了)。

上图给出了一个例子。\(s1=\)"\(abcabd\)"时,\(next[4]=2\),这是因为 \(P[0]\) ~ \(P[4]\) 这个子串是 "\(abcab\)",前两个字符与后两个字符相等,因此 \(next[4]\) 取 \(2\). 而 \(next[5]=0\),是因为"\(abcabd\)"找不到前缀与后缀相同,因此只能取 \(0\).如果把模式串视为一把标尺,在主串上移动,那么 Brute-Force 就是每次失配之后只右移一位;改进算法则是每次失配之后,移很多位,跳过那些不可能匹配成功的位置。但是该如何确定要移多少位呢?

在 \(S[0]\) 尝试匹配,失配于 \(S[3] <=> P[3]\) 之后,我们直接把模式串往右移了两位,让 \(S[3]\) 对准 \(P[1]\). 接着继续匹配,失配于 \(S[8] <=> P[6]\), 接下来我们把 \(P\) 往右平移了三位,把 \(S[8]\) 对准 \(P[3]\). 此后继续匹配直到成功。  我们应该如何移动这把标尺?很明显,如图中蓝色箭头所示,旧的后缀要与新的前缀一致(如果不一致,那就肯定没法匹配上了)!

回忆 \(next\) 数组的性质:\(P[0]\) 到 \(P[i]\) 这一段子串中,前 \(next[i]\) 个字符与后 \(next[i]\) 个字符一模一样。既然如此,如果失配在 \(P[r]\), 那么 \(P[0]\)~\(P[r-1]\)这一段里面,前 \(next[r-1]\) 个字符恰好和后 \(next[r-1]\) 个字符相等——也就是说,我们可以拿长度为 \(next[r-1]\) 的那一段前缀,来顶替当前后缀的位置,让匹配继续下去!  您可以验证一下上面的匹配例子:\(P[3]\) 失配后,把 \(P[next[3-1]]\) 也就是 \(P[1]\) 对准了主串刚刚失配的那一位;\(P[6]\) 失配后,把 \(P[next[6-1]]\) 也就是 \(P[3]\) 对准了主串刚刚失配的那一位。

如上图所示,绿色部分是成功匹配,失配于红色部分。深绿色手绘线条标出了相等的前缀和后缀,其长度为 \(next[\text{右端}]\). 由于手绘线条部分的字符是一样的,所以直接把前面那条移到后面那条的位置。因此说,\(next\) 数组为我们如何移动标尺提供了依据。

然后我们如何快速的求出 \(next\) 数组呢?

我们可以想到,我们可以用某种暴力手段慢慢求,但是那样太慢了,没有效率,所以我们要用一些比较奇♂妙的方式来快速计算出 \(next\) 数组:我们可以让模式串和他自己匹配。我们需要注意的是我们的模式串在与自己匹配的时候我们可以从 \(2\) 开始而不是从 \(1\) 开始,因为如果从一开始的话,那自己匹配自己肯定是能完美匹配上的,那进行这一步就没有多大意义了。当自己匹配自己的时候,就是相当于从第 \(i\) 个点开始匹配,看与模式串的前缀相同的有多少,这样效率就大大提高了。描述的很抽象,看一下代码:

for(int i=2;i<=lenb;i++)//处理next数组,过程相当于拿自己和自己匹配
{
while(j&&b[i]!=b[j+1])j=next[j];//如果已经开始匹配上了但是当前点不同,那就往后移动
if(b[i]==b[j+1])j++;//如果能匹配上,指针右移
next[i]=j;//存放当前点的最长公共前后缀长度
}

最后我们就需要进行模式串与主串的匹配,和上面差不多的道理,就是当每一次匹配失败后直接利用 \(next\) 数组蹦到某一处,总的匹配复杂度接近 \(O(lena)\),处理 \(next\) 数组的复杂度接近于 \(O(lenb)\),所以 KMP 算法的总体复杂度是接近于 \(O(lena+lenb)\) 的。

P3375 【模板】KMP字符串匹配

code:

#include<bits/stdc++.h>
#define next Next
#define N 1000100
using namespace std;
int next[N],lena,lenb,j;
string a,b;
signed main()
{
cin>>a>>b;
lena=a.size();
lenb=b.size();
a.insert(a.begin(),' ');
b.insert(b.begin(),' ');//前面插入一个空格使其下标从1开始
for(int i=2;i<=lenb;i++)//处理next数组,过程相当于拿自己和自己匹配
{
while(j&&b[i]!=b[j+1])j=next[j];//如果已经开始匹配上了但是当前点不同,那就往后移动
if(b[i]==b[j+1])j++;//如果能匹配上,指针右移
next[i]=j;//存放当前点的最长公共前后缀长度
}
j=0;//清空j
for(int i=1;i<=lena;i++)//开始匹配
{
while(j&&b[j+1]!=a[i])j=next[j];//如果当前开始匹配但是当前点不同,就直接将b串后移
if(b[j+1]==a[i])j++;//如果当前点能匹配上,指针右移
if(j==lenb)//如果完全匹配上了
{
cout<<(i-lenb+1)<<endl;//输出开始的下标
j=next[j];//继续往后匹配
}
}
for(int i=1;i<=lenb;i++)
cout<<next[i]<<" ";//输出
return 0;
}

P2375 [NOI2014] 动物园

这题的题解好不容易才看明白赶紧记录一下

首先我们需要先和正常的一样求出 \(next\) 数组,然后我们需要来看一下这个:

从上面可以看到,当前串的公共前后缀串的公共前后缀串也是当前串的公共前后缀串,所以我们可以像递推一样把所有的公共前后缀串的数量递推出来,但是,这里面是包含重叠的,这时我们可以发现,如果是第 \(i\) 个的话,前半部分也就是 \(\frac{i}{2}\) 的所有的公共前后缀串即为答案,是不重叠的(好像没讲清楚)。

#include<bits/stdc++.h>
#define int long long
#define P 1000000007
#define next Next
#define N 1000100
using namespace std;
int T,n,next[N],j,ans,sum[N];
string s;
signed main()
{
cin>>T;
while(T--)
{
ans=1;j=0;
sum[1]=1;
cin>>s;
n=s.size();
s.insert(s.begin(),' ');
for(int i=2;i<=n;i++)
{
while(j&&s[i]!=s[j+1])j=next[j];
if(s[i]==s[j+1])j++;
next[i]=j;
sum[i]=sum[j]+1;
}
j=0;
for(int i=2;i<=n;i++)
{
while(j&&s[i]!=s[j+1])j=next[j];
if(s[i]==s[j+1])j++;
while(j*2>i)j=next[j];
ans=(ans*1ll*(sum[j]+1))%P;
}
cout<<ans<<endl;
// for(int i=1;i<=n;i++)
// cout<<next[i]<<" ";
// cout<<endl;
// for(int i=1;i<=n;i++)
// cout<<sum[i]<<" ";
// cout<<endl;
}
return 0;
}

P4824 [USACO15FEB] Censoring S

这道题目需要进行字符串匹配,所以想到KMP算法,打开标签发现有栈,所以想到用栈

我们可以发现我们如果要是想要防止前面的串当删除其中的子串后与后面的串接成新的串,最暴力的办法就是每删除一个串就往前跳 \(len2-1\) 个位置,然后在重新开始找,但是这样太慢了,而且没有思维难度,是对不起这道题蓝的标签的,所以我们想到可以用栈来维护一下,每一个点与 \(s2\) 的匹配上的值,如果当前刚好匹配上,就把栈上前 \(len2-1\) 个元素给弹走,然后再把 \(j\) 替换成栈顶的值继续进行匹配,这样就可以完美的做到把不可能的匹配次数都省掉了,下面我用的是手写栈,因为 STL 在匹配成功的时候要一个一个弹,复杂度高且代码冗长。

#include<bits/stdc++.h>
#define int long long
#define next Next
#define N 1000100
using namespace std;
int len1,len2,next[N],j,f[N],stk[N],top;
string s1,s2;
signed main()
{
cin>>s1>>s2;
len1=s1.size();
len2=s2.size();
s1.insert(s1.begin(),' ');
s2.insert(s2.begin(),' ');
for(int i=2;i<=len2;i++)
{
while(j&&s2[i]!=s2[j+1])j=next[j];
if(s2[i]==s2[j+1])j++;
next[i]=j;
}
j=0;
for(int i=1;i<=len1;i++)
{
while(j&&s1[i]!=s2[j+1])j=next[j];
if(s1[i]==s2[j+1])j++;
f[i]=j;
if(j==len2)j=f[stk[(top-=len2-1)]];
else stk[++top]=i;
}
for(int i=1;i<=top;i++)
cout<<s1[stk[i]];
cout<<endl;
return 0;
}

KMP字符串匹配问题的相关教程结束。

《KMP字符串匹配问题.doc》

下载本文的Word格式文档,以方便收藏与打印。