KMP算法

配套练习题https://www.luogu.com.cn/problem/P3375

目录

配套练习题https://www.luogu.com.cn/problem/P3375

算法引入

  算法简介

        当失配时,不会直接归位重新匹配,

        而是通过不断改变模式串的匹配位置来达到减小时间复杂度的目的。

  莽夫写法

算法优化

  最长相同真前后缀

  π数组

        如果暴力——

        使用DP的思想

事实上,还有一种更简单的写法


算法引入

  算法简介

        KMP算法,是字符串匹配的经典算法。其名称的由来没有什么故事:仅仅是三位计算机科学家(D.E.Knuth,J.H.Morris和V.R.Pratt)的名字的首字母的组合。

        该算法的精髓在于:

        当失配时,不会直接归位重新匹配,

        而是通过不断改变模式串的匹配位置来达到减小时间复杂度的目的

        至于如何移动,且听下回分解(bushi)

  莽夫写法

        要想学会KMP,就必须由浅入深,首先从暴力看起。

const int N=1e5+5;
char s[N],p[N];
int n,m;
vector<int> match(char *s,char *p){//返回所有匹配位置的首字符
	int i=0,j=0;
	vector<int>ans;
	while(i<n){
		if(s[i]==p[j]) i++,j++;
		if(j==m) ans.push_back(i-j),i=i-j+1,j=0;
		if(s[i]!=p[j]) i=i-j+1,j=0;
	}
	return ans;
}
signed main(){
	scanf("%s\n%s",s,p);
	n=strlen(s),m=strlen(p);
	vector<int> ans=match(s,p);
	for(int i=0,siz=ans.size();i<siz;i++){
		printf("%d ",ans[i]);
	}
}

        显然,上方的代码最差时间复杂度为O(n*m),而且这个时间复杂度非常好卡。

(举个例子,s=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,p=aaaaaaaaaaaaaaaaaaaaaab) 

算法优化

        一切的一切都要从一个样例说起:

                s=abadabac,p=abac     

        注:在本文中,绿色(AC的颜色)表示合配,红色(WA的颜色)表示失配。            

         首先,s[0~2]=p[0~2],但是s[3]!=p[3]。如果是暴力的话,我们会直接把p向右移动一位(即暴力中的 i=i-j+1,j=0 )但是,这就违背了我们的宗旨:

        当失配时,不会直接归位重新匹配,

        而是通过不断改变模式串的匹配位置来达到减小时间复杂度的目的。

        于是,我们就要把p往右移。

        但是,我们要移动多少呢?

  最长相同真前后缀

        何为最长相同真前后缀

                1.真前缀or真后缀:非字符串本身的前缀or后缀

                2.相同:完全一致

                3.最长:长度最长

        因此,其意思即为:长度最大的、完全相同的、非该字符串本身的前缀与后缀

        为了便于描述,对于字符串q,我们记 \pi_{i} 表示 q_{0\sim i} 的最长相同真前后缀。

        所以,对于样例中的p=abac,则有\pi_{0}=0,\pi_{1}=0,\pi_{2}=1,\pi_{3}=0

        为什么它能告诉我该移动几位呢?

        首先,我们要实现 i 不往左移,那么,若与 i 匹配的是 j ,则必须要求满足s[i-j+1~i]=p[0,j-1](即j之前的部分必须与s相匹配)。

        设 p 向右移动的距离为k,那么必须要满足对于任意的\forall x \in [0,j-k-1],都需要满足p_{x}=p_{x+k}。这说明什么?这说明 j-k+1 就是相同真前后缀的长度,p_{0\sim k-1},p_{j-k\sim j-1}就是p_{0\sim j-1}的公共真前后缀。为什么一定要是最长公共前后缀呢?短一点不行吗?古语有云:一寸长,一寸强因为向右移动的距离为k,而相同真前后缀的长度为j-k-1。要是移动距离过大,可能就越过了一个解。因此,移动距离要在贪心的前提下尽可能小,相应的,公共真前后缀的长度就应尽可能大。但是它不能等于自身的长度,否则就是在原地踏步,死循环了。

        有了上面的推论,我们就很好推导了。当失配的字符分别为 s[i] 与 p[j] 时, j 左端的匹配部分的最长相同真前后缀的长度为\pi_{j-1}j=j-1-(j-\pi_{j-1}-1)=\pi_{j-1}。若仍然失配,仅需重复上述操作即可(思考一下为什么);若 j=0 时仍不匹配,说明解不可能在这部分中,直接跳过。

(下方图文注解)

        这是求匹配位置的代码。↓

const int N=1e6+5;
char s[N],p[N];
int pi[N];
int n,m;
inline int get_next(int i,int j){//返回当s[i]与p[j]失配后,j应该变为几
	while(j&&s[i]!=p[j])
		j=pi[j-1];
	if(s[i]==p[j]) return j;//特判j==0&&s[i]!=p[j]的情况
	else return -1;
}
vector<int> kmp(){//kmp算法匹配字符串
	int i=0,j=0;
	vector<int> ans;//记录答案
	while(i<n){
		if(s[i]==p[j]) j++,i++;//若匹配,直接找到下一位
		else j=get_next(i,j);//否则往右移动p
		if(j==m) ans.push_back(i-j),j=get_next(i,pi[j-1]);//若匹配成功,加入答案,并跳转
		if(j==-1) i++,j++;//如果退无可退,则跳过这个i
	}
	return ans;//返回答案
}

  π数组

        现在唯一的问题就是如何求π数组了。

        如果暴力——

const int N=1e1+5;
char s[N],p[N];
int pi[N];
int n,m;
inline bool same(int i,int j,int siz){
	while(siz--)
		if(p[i++]!=p[j++]) return false;
	return true;
}
void get_pi(){
	pi[0]=0;
	for(int i=1;i<m;i++){
		for(int siz=i;siz+1;siz--){
			if(same(0,i-siz+1,siz)){
				pi[i]=siz;
				break;
			}
		}
	}
}

        是的,暴力的时间复杂度甚至飙到了O(m^{3})。这样的话优化就失去了意义。那我们怎么办呢?

        使用DP的思想

        使用DP的思想。如果我们已经知道了 0~j-1的所有数字的最长相同真前后缀的长度,想必会对求π[j]有所帮助(当然,这是猜测,还需要严谨地证明)。

        (说明一点,π [i] 在以0为开头的字符串中,还能表示最长相同真前后缀后一位的下标,如p=abaa,π [2] = 1不但表示子串aba的最长相同真前后缀的长度为1,还表示 p [ π [2] ]  是最长相同真前后缀后面的第一位)

        (可能有点绕,边看边理解就好)

        如图, 假设此时 i=5 。则下图中绿色框框选出的两部分是完全相同的。若有 p_{\pi_{i-1}}=p_{i},那么\pi_{i}=\pi_{i-1}+1,可以轻松证明这是最优解。

        但是,样例显然不符合上面的情况。这时候该怎么办呢?

        我们要明确一点:一定要往前找。因为大的我们都遍历过,都不符合情况。

        往前找多少呢?既然是往前找 ,那么最后求得的子串必然是p_{0 \sim \pi_{i-1}-1}的前缀对于其前缀,因为前后缀的是完全相同的,所以对于每一个前缀的子串,都必然有一个后缀与之对应完全相等。对于p_{0 \sim \pi_{i-1}-1},最大的可能的前缀又是什么呢?是的,就是p_{ 0 \sim \pi_{\pi_{i-1}-1}-1 }(如下图)是不是有那味儿了呢? 

         没错,这是一个循环,不断往前寻找尽可能大的长度。在寻找过程中,如满足条件,就直接赋值 π [i] = π [k] +1 。边界条件也很简单,就是当前缀长度为0且 p [i] ≠ p [0]。(如果有些难消化,可以多看几遍)

using namespace std;
const int N=1e6+5;
char s[N],p[N];
int pi[N];
int n,m;

void get_pi(){
	pi[0]=0;
	for(int i=1;i<m;i++){
		int k=i;
		while(k&&p[pi[k-1]]!=p[i])
			k=pi[k-1];
		if(k==0) pi[i]=0;
		else pi[i]=pi[k-1]+1;
	}
}

        将这几段代码合在一起,就成了了我们的最终代码了——

​
#include<iostream>
#include<stdio.h>
#include<cstring>
#include<vector>
using namespace std;
const int N=1e6+5;
char s[N],p[N];
int pi[N];
int n,m;

void get_pi(){
	pi[0]=0;
	for(int i=1,k;i<m;i++){
		k=i;
		while(k&&p[pi[k-1]]!=p[i])
			k=pi[k-1];
		if(k==0) pi[i]=0;
		else pi[i]=pi[k-1]+1;
	}
}
inline int get_next(int i,int j){
	while(j&&s[i]!=p[j])
		j=pi[j-1];
	if(s[i]==p[j]) return j;
	else return -1;
}
vector<int> kmp(){
	int i=0,j=0;
	vector<int> ans;
	while(i<n){
		if(s[i]==p[j]) j++,i++;
		else j=get_next(i,j);
		if(j==m) ans.push_back(i-j),j=get_next(i,pi[j-1]);
		if(j==-1) i++,j++;
	}
	return ans;
}
signed main(){
	scanf("%s\n%s",s,p);
	n=strlen(s);
	m=strlen(p);
	get_pi();
	vector<int> ans=kmp();
	for(int i=0,siz=ans.size();i<siz;i++){
		printf("%d\n",ans[i]+1);
	}
	for(int i=0;i<m;i++){
		printf("%d ",pi[i]);
	}
	return 0;
}

​/*
输出说明:
前有若干列,表示主串与模式串匹配的位置
后有一行,第i个数字表示 π[i]
*/

AC

事实上,还有一种更简单的写法

        我们只需要将两个字符拼接在一起(模式串在前,主串在后),记为 all ,中间加一个在两个字符串中都没有出现的字符 起分割作用。则对于 all 的后半段(即主串),如有 π [i] = m (m为模式串长度),则表明在主串中有一段与模式串相同。这样也可以起到查找子串的作用。

        

        like this ↑

        完结撒花!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值