学字符串(1) -- KMP

我的字符串实在太烂了,于是从头开始学字符串。先看KMP

以下内容是基于题目的总结,如果想深入建议建议先把放的题做了再看总结

KMP的自动机解释,建议看看

概念

border

  1. 同时是前缀和后缀的字符串(但不能是整个串)

  2. 是个集合

pi[]

pi[i] = 长度最大的 border of S[0..i]

fail[]

fail[i] = pi[i-1]

或者说,当匹配到位置 i 失败时,应该跳到哪里继续匹配

板子

vector<int> kmp(const string &s) {
	int len = s.length();
	vector<int> fail(len + 1);
	for (int i = 1, j = 0; i < len; i++) {
		while (j && s[i] != s[j])
			j = fail[j];
		fail[i + 1] = (j += (s[i] == s[j]));
	}
	return fail;
}
void match(const string &text, const string &pat) {
	auto f = kmp(pat);
	int tlen = text.length(), plen = pat.length();
	for (int i = 0, j = 0; i < tlen; i++) {
		while (j && text[i] != pat[j])
			j = f[j];
		j += (text[i] == pat[j]);
		if (j == plen) {
			// Successfully Match!!!
			j = f[j];
		}
	}
}

基于jiangly的模板修改

0-base字符串, 1-base的fail数组(或者说是“长度”对应“最长匹配真前后缀”)

或者说,1-base的pi[]对于0-base就是fail[]

好处是不用惦记“-1”了。不过总是会忘的,还是多依赖模板写比较好

前后缀

最基本的功能

CF126B - Password

题意:求既是前缀,又是后缀,又在中间出现过的子串(非前非后)

void solve() {
    // int n;
    // cin >> n;
	string s;cin>>s;
	int len=s.length();
	auto fail=kmp(s);
	vector<bool>exi(len);
	fff(i,1,len-1){
		if(fail[i])exi[fail[i]]=1;
	}
	int j=fail[len];
	while(j){
		if(exi[j]){
			fff(i,0,j-1){
				cout<<s[i];
			}
			cout<<endn;
			return;
		}
		j=fail[j];
	}
	cout << "Just a legend\n";
}

虽然是简单题,先明白正确性

首先正确范围肯定是从len开始跑fail得到的所有长度,在这里面判断

那么怎么知道某个长度是否在中间出现?肯定要遍历

遍历到中间一个长度i,怎么看和它尾部对齐的所有串?就是从i开始跑fail啊

那么,直接枚举1到len-1的所有长度的fail,然后标记大于0的,不就相当于得到所有在中间出现过的前缀长度了吗?

虽然乱搞能过,但是还是要理解一下这一步的逻辑(也可能是我太菜了,多虑了)

CF535D - Tavas and Malekas

题意:给你字符串全长、模式串和一些位置,要在这些位置开始,放模式串(作为模式串开头的位置),问这样的合法字符串有多少

就跑从len跑fail,标记合法距离,然后判断每个相邻的距离是否合法,还要记录空位数量,用来算最后答案

注意位置数量为0的情况

CF119D - String Transformation

题意:给你个两个文本S和T,要你把S分成三段“左 + 中 + 右”,然后拼接成“中 + 反转的右 + 反转的左”,要求拼接后和T相等,还有一些要求:

  1. 中间段可以为空

  2. 左段尽量长

  3. 在满足2的基础上,中段尽量短,或者说右段尽量长

这题我真的搞不定!看了题解,尝试模拟一下思路:

首先,发现左段是最好判定的!我们先延长到最长左段可能长度,若是要从大到小枚举左段长度判定的话,就要O(1)做其他的判定,可行吗?

可以的!我们先知道怎么判定“中”和“右”先,要判断,最好的方法就是把他们当做前缀

所以就先“反转的右 + # + 左”跑KMP,由于此时左段长度固定,就能知道右段最长长度

然后“T + # + S”,跑Z函数,还是由于此时左段长度固定,就能知道中段最长能取多长,要是中段可取的长度足够,就合法输出

你可能会问,为什么不在跑KMP时看短一点的右段?我们现在是只看长度判断,要是右段变短了,只能是可行变成不可行,不会反过来

这题的方法论,就是从最容易判定的段入手,把匹配问题转换为长度判定问题

CF25E - Test

题意:给定三个字符串,要你求出包含这三个子串的字符串的最小长度

直接枚举顺序,然后看看前后能不能尽量前后缀重合,这里就用拼接的方法

比如要接s1和s2,就构造"s2+*+s1",跑kmp,然后看尾部的fail值x,然后s1+=头部削去x的s2

但是呢,s1可能包含s2,就是说fail里的最大值等于s2的长度,此时直接用s1作为结果即可

加入s3也是一样的逻辑

注意数组要足够大,我因此TLE了一次

CF1200E - Compress Words

就是上一题的“拼接”技术,但是简单这样搞会TLE哦

你会发现积累的字符串S会越来越长,这样就O(n^2)了,这不对

因为我们只在意尾部,假设要接到后面的是T,S只保留尾部S' = min(S.length(),T.length())就行了

也就是说用"T + % + S'"跑kmp

fail树

CF432D - Prefixes and Suffixes

这题要我们求满足同为“前缀”“后缀”的子串在整个字符串出现的总次数(可以是原串)

我的错解歪的离谱。直接讲正确做法

首先发现每当长的前后缀匹配一次,就说明它包含的短的前后缀也匹配了一次,而且是尾部对齐的,而且能一条链贡献下去,

又因为是尾部对齐的,所以也不会算重合,所以要不每个前缀都这样弄到底?

但是这样会超时。想想怎么优化

题解是这样做的(cnt[i]表示长度为i的前缀的出现次数)

vector<int>cnt(len+1,1);
for(int i=len;i>0;i--){
	cnt[fail[i]]+=cnt[i];
}

这是什么意思?

首先隐含了一点:从“短前缀”指向“包含它的长前缀”的图,是一颗树

然后cnt全设为1,就是前面提到的“尾部对其,不会算重合”

这就像是在进行树上DP,不断累加,就算出所有前缀的出现次数了

更直观的理解:fail树的前缀的子树大小就是此前缀的出现次数

循环节

SPOJ - Period

题意:问每个前缀能否写成A^K(字符串A重复拼接K次)且K最大

我们知道对于长度为i的前缀,不保证是完整周期循环节是i-fail[i],而且fail可以迭代减小

那么是不是每个前缀都要这样?那可是O(n^2)了

来看个证明:

我们称能当周期的循环节是完美循环节。假设对于前缀i,有个最短的不完美循环节,长度p;以及一个完美循环节,长度q,且p < q

假设q=p*k+r(其中0<r<p)(也就是说先假设p不能整除q)

由于q是循环节之一,s[j]=s[j+q]=s[j+p*k+r]

由于p是循环节之一,s[j]=s[j+r],那么r也是循环节,但是我们规定0<r<p且p是最短的,矛盾

说明r为0,p必须能整除q,p|q,又q|i,则p|i,矛盾,假设不成立

类似地,同样可以证明,假设最短的循环节p是完美循环节,则所有大的循环节都完美,而且是p的倍数

也就是说这道题只要检查最短的循环节就OK了

KMP和DP

CF808G - Anthem of Berland

题意:往带问号(即可以是任意字符)的字符串里面尽可能的多塞一个模式串

先遍历字符串,标记哪里能放,这一步很好想,复杂度也允许

很自然的想到,要是前后缀匹配,岂不是能多放?我们就找所有历史的、可继承的状态里面找一个最大的继承就行

然后就遍历fail,算偏移,取max

还要维护模式串长度之前的所有状态的max来取max继承

最后要是能放则+1

最后取所有状态最大的输出即可

CSES - Required Substring

给定长度n和一个长m的模式串,要你求包含模式串作为子串的字符串的个数

容易想到,算出“不包含的”,然后用所有的减去“不包含的”就行

设dp[i]表示匹配了i个字符的方法数(显然不能有dp[m])

我乱搞的做法很惊悚:搞了个转移图

fff(i,0,m-1){
		// int to=fail[i];
		int cnt=0;
		bitset<26>vis;
		if(i!=m-1){
			e[i].eb(i+1,1);
		}
		cnt++;
		vis[s[i] - 'A'] = 1;
		int j=i;
		do{
			j = fail[j];
			if(!vis[s[j]-'A']){
				vis[s[j]-'A']=1;
				e[i].eb(j + 1, 1);
				cnt++;
			}
		}while(j);
		e[i].eb(0,26-cnt);
	}
	vector<mint>dp(m);
	dp[0]=1;
	fff(nn,1,n){
		vector<mint>ndp(m);
		fff(u,0,m-1){
			for(const auto&[v,w]:e[u]){
				ndp[v]+=dp[u]*mint(w);
			}
		}
		dp=ndp;
	}

也就是说,当匹配了i个,我们就从i用fail转移到0为止

然后对于同一个字符,只有第一个遇到的有效(因为匹配子串,肯定是能增长就增长),往那里转移,之后的fail再遇到就不管

没遇到的,自然往dp[0]转移

注意,m-1不能往m转移,而且在这个状态,这个字符是“不能取”的,所以也不能算到往其他的状态转移里

发现这个转移是线性的,所以可以用矩阵快速幂,记一下,没准以后用到了呢(注意是匹配长度之间转移,而不是字母)

346B - Lucky Common Subsequence

题意:LCS,但是不能包含给定子串

来学新科技:KMP自动机

问:KMP不本来就是自动机吗?不能直接fail吗?

答:话是这么说,但是KMP自动机上对每个状态都有所有字母引出的边,你KMP要转移一下可能要fail到0,虽然势能分析来说没问题,但有的时候很危险!

fff(i,0,plen-1){ //防止匹配的自动机,不用处理到plen
		fff(c,0,25){
			if(pat[i]-'A'==c){ //注意字符到数值的转换,使用时也要注意
				nxt[i][c]=i+1;
			}else{
				nxt[i][c]=nxt[fail[i]][c];
			}
		}
	}

然后怎么匹配呢?其实也不太好写,而且这题还要一条路径,就更麻烦了……

注意,由于KMP自动机是从本状态出发的,所以要写成前推的DP(就是说你只有从本状态的字符推断下一个状态才好写,反过来就不适配KMP自动机了)

dp[0][0][0]=0; //其余的是-1,表示不合法
	fff(i,0,alen)fff(j,0,blen)fff(k,0,plen-1){//i表示前一个字符串匹配了i个,j匹配后一个,k表示匹配上模式的数量
		if(dp[i][j][k]==-1)continue;//不合法状态就跳过,防止出事
		if (i < alen && dp[i][j][k]>dp[i+1][j][k]) {//意思是跳过只有优于已有的才前进
			dp[i+1][j][k]=dp[i][j][k];
			pre[i+1][j][k]={i,j,k,0};//记录路径
		}
		if (j < blen && dp[i][j][k] > dp[i][j+1][k]) {
			dp[i][j+1][k] = dp[i][j][k];
			pre[i][j+1][k] = {i, j, k, 0};
		}
		if (i < alen && j < blen &&sa[i]==sb[j]) {
			int nk=nxt[k][sa[i]-'A'];
			if(nk<plen && dp[i][j][k]+1>dp[i+1][j+1][nk]){// nk < plen是防止完整匹配的判断
				dp[i+1][j+1][nk]=dp[i][j][k]+1;
				pre[i+1][j+1][nk]={i,j,k,sa[i]};
			}
		}
	}

最后输出就直接取max的dp值,并且保存相应状态,反推到[0][0][0],字符合法的才输出

CF494B - Obsessive String

题意:给你字符串和模式串,要你画出多个不重合的区间,每个区间包含一个模式串,问划分数

这题,我感觉我的做法还挺好的!

首先对于一个左端点,它能画的右端点范围是从哪里到哪里?

是从这里往右,它遇到的第一个模式串的右端点开始,直到字符串结尾!

对于这个左端点,怎么继承之前的方案?

当然是看所有左边的“划分”的右端点的可能啦,然后还要加1,表示不继承

那么我们可以设dp[i]表示所有到i为止,存在一个区间右端点画到i的方案数之和

来到i,先预处理要传播的权值w=dp[1]+dp[2]+...+dp[i-1]+1

传播到它遇到的第一个模式串的右端点开始,直到字符串结尾,这个区间的所有dp[j]+=w(可以用差分来代替区间加,因为后面的值现在用不上)

最终答案是所有dp值的和

void solve() {
	string text,pat;
	cin>>text>>pat;
	auto pos=match(text,pat);
	int tlen=text.length();
	int plen=pat.length();
	vector<int>bel(tlen+1,INF32);
	for(const auto&p:pos){
		bel[p]=p;
	}
	rrr(i,tlen-1,1){
		chmin(bel[i],bel[i+1]);
	}
	vector<mint>dp(tlen+1);
	mint pre=0;
	fff(i,1,tlen){
		mint weight=pre+mint(1);
		if(bel[i]!=INF32){
			int st=bel[i];
			int ed=st+plen-1;
			dp[ed]+=weight;
		}
		dp[i]+=dp[i-1];
		pre+=dp[i];
	}
	mint ans=0;
	fff(i,1,tlen){
		ans+=dp[i];
	}
	cout<<ans<<endn;
}

CF1015F - Bracket Substring

题意:给一个长度n和一个括号子串,问有多少个字符串满足:

  1. 长度2n,由左右括号构成

  2. 正则

  3. 包含子串

看了前面的套路,这题连我都能做了!首先肯定是算“不包含的”,构建KMP自动机

然后转移的时候保证正则即可

dp[0][0][0]=mint(1);
	fff(i,0,n)fff(j,0,i)fff(k,0,slen-1){ //j从0到i,保持正则
		//i == (  ; j == )
		if(i+1<=n){
			int nk=nxt[k][0];
			if(nk<slen){ //不能取到模式串长度
				dp[i + 1][j][nk] += dp[i][j][k];
			}
		}
		if(j+1<=n && i>=j+1){ //这里也要保证正则
			int nk=nxt[k][1];
			if(nk<slen){
				dp[i][j+1][nk] += dp[i][j][k];
			}
		}
	}
	mint ill=0;
	fff(i,0,slen-1){
		ill+=dp[n][n][i];
	}
	vector<mint>C(n+1);
	C[0]=1;
	fff(i,1,n){
		fff(j,0,i-1){
			C[i]+=C[j]*C[i-1-j];//卷积算卡塔兰,n比较小可以搞
		}
	}
	mint ans=C[n]-ill;
	debug(C[n],ill);
	cout<<ans<<endn;

CF291E - Tree-String Problem

题意:树上KMP,从根开始

你可能以为就是简单的BFS搞搞就行,然而不太行

考虑模式串"a"*(5e4+1),然后从根开始一条5e4长度的链,边权都是"a",然后链尾部分5e4个节点,边权都是"c"

到链尾,每个"c"边都会回退5e4次,然后就炸了

正确的做法是用KMP自动机O(1)匹配

CF825F - String Compression

题意:若字符串中的一部分s[i...j]可以被表示为A^k,那么就可以写成|A|+|k|,分别是字符串长度和十进制数字长度,问给定字符串的最小压缩长度

给定数据量可以从每个点开始跑KMP,就这么办,然后从前往后枚举每个起点,再枚举每个终点,看看能不能表示为完美循环节,做个dp即可

CF2205E - Simons and Dividing the Rhythm

这题赛时完全没头绪,不过是个好题

题意:问将字符串分割为不重合的区间,每个都反转,能得到多少种结果?

首先我们发现,字符串本身一定是能得到的

然后要和“本身”有区别,就要反转不是“回文”的子段

这里是我想到的,于是我就对每个左端点,枚举右端点,若s[l]!=s[r],就不会是回文的

然后又想到,如果这个字段有周期,那么也不行,于是每个左端点跑kmp,如果有周期,则这个区间不能翻转

结果样例都过不了!

AI告诉我是这样的:考虑"abcab",全部翻转得到"bacba",翻转[1,2][3,3][4,5]还是这个!也就是说,只要border非空(fail[i]>0),就必定能用更小的翻转代替大的翻转!

基本的dp写法类似CF494B,dp[i]表示若最后一次翻转在i的可能数。因为翻转必然带来不同,所以仍然有w要多加1,表示前面一个也不继承的可能性

最后加1,表示原串

void solve() {
    int n;
    cin >> n;
	vector<int>a(n);
	fff(i, 0, n - 1) cin >> a[i];
	fff(i,1,n)fff(j,1,n){
		hav[i][j]=0;
	}
	fff(st,0,n-1){
		kmp(a,st);
		fff(ed,st+2,n){
			int slen=ed-st;
			if(fail[slen]){
				hav[st+1][ed]=1;
			}
		}
	}
	vector<mint>dp(n+1);
	mint pre=0;
	fff(i,1,n){
		mint w=pre+mint(1);
		fff(j,i+1,n){
			if(hav[i][j]==0){
				dp[j]+=w;
			}
		}
		pre+=dp[i];
	}
	pre++;
	cout<<pre<<endn;
	// debug(pre);
}

也有不用开那个二维数组的写法,直接在kmp里面跑dp,会节省很多空间

我们这个思路主线是考虑“无可替代的小翻转”,我觉得很有价值

KMP里的回文

SPOJ - Extend to Palindrome

题意:寻找字符串最长尾部回文长度

翻转的 S + 混淆字符 + S 直接跑KMP,看尾部fail即可,必备的手法

SPOJ - Finding Palindromes

题意:给你一堆回文串,问你这些两两组合,能造出多少个回文串

这个也没想出来,证明过程有意思

设两个能拼成的是A和B,翻转后分别是A'和B',因为回文,所以A = A',B = B'

那么AB = B'A'=BA

由Lyndon-Schützenberger定理,两个字符串可交换,当且仅当它们是同一个串的幂

所以我们看看它们的最小循环节,用哈希和map记录数量,最后每个数量取平方求和即可

记得哦,只用看len-fail[len]能否整除len就行(前面一道题提过)

我开双哈希才过!

改造KMP

有的时候需要修改KMP的内部实现

CF631D - Messenger

给出一种压缩形式:<数量,字符>,然后文本和模式串都这样给出,要你计算模式串的出现次数

注意到只要修改开头和结尾的匹配即可,中间依旧是严格匹配<数量,字符>

还有,模式要是只有一个字符,直接特判就行

bool judge(const ic&ii,const ic&jj,int j,int plen){
	if(j>0 && j<plen-1){
		return ii==jj; //中间要完美匹配
	}else{
		return ii.se==jj.se && ii.fi>=jj.fi; //否则数量大于等于也可以
	}
}
vector<int> kmp(const vector<ic> &s) {
	int len = s.size();
	vector<int> fail(len + 1);
	for (int i = 1, j = 0; i < len; i++) {
		while (j && s[i] != s[j])
			j = fail[j];
		fail[i + 1] = (j += judge(s[i],s[j],j,len));
	}
	return fail;
}
i64 match(const vector<ic> &text, const vector<ic> &pat) {
	auto f = kmp(pat);
	i64 cnt=0;
	int tlen = text.size(), plen = pat.size();
	for (int i = 0, j = 0; i < tlen; i++) {
		while (j && !judge(text[i], pat[j], j, plen))
			j = f[j];
		j += judge(text[i],pat[j],j,plen);
		if (j == plen) {
			// Successfully Match!!!
			cnt++;
            //此时文本处的数量可能大于,也可能等于模式串尾部数量
			if(text[i].fi>pat[plen-1].fi){ //若大于,则不能直接跳fail
				if(pat[plen-1].fi==pat[0].fi && pat[plen-1].se <= text[i].se){
					j=1; //如果是模式头尾字符相同,而且头字符数量不大于文本此处数量,则可以算匹配1个
				}else{
					j=0; //否则完全失配
				}
			}else{
				j=f[j]; //合法跳fail
			}
		}
	}
	return cnt;
}

孤立的技巧

CF471D - MUH and Cube Walls

匹配模式之间的“差的关系”,就用差分来匹配

SPOJ - Cow Patterns

给你文本和“区间大小顺序”,要你匹配这种“大小关系模式”

在模式串中,我们用“在i之前等于color[i]的数量”和“在i之前小于color[i]的数量”还有“i本身”来表示模式串位置i

在文本中,我们有“已经匹配j个”,所以前两者要加上定语“在[i-j,i-1]这个已经匹配的范围内”

原版的text[i] == pat[j]换成计算“如果是这个字符,算出的smaller和equal数量,能否推进”

然后跳fail导致窗口变短,要删掉窗外的字符数量

有个问题,为什么窗口变短,剩下的东西还合法?因为相对大小没变,继续看后面的相对大小就行

posted @ 2026-03-27 19:58  Treow  阅读(6)  评论(0)    收藏  举报