【复习笔记】2021-12 字符串

本文主要是记录复习模板的情况,个人感觉做一些Luogu上的比赛题也可以综合练习套路


后缀自动机

  • 线段树维护 endpos 例题,注意这里线段树合并的时候多是需要新建节点的

  • 计算子串在多少个模板串中出现,可以建立广义 SAM 后对于每一个模板串找到其所有前缀在广义 SAM 上的节点,然后从它们开始暴力跳 parent 树算贡献,并给每个访问了的节点打上访问标记保证在同一个模板串的计算过程中一个点不经过两次的做法复杂度为 Θ(nn),证明考虑根号分治

  • 先区别叫法:按照正序加入自动机再连接 (i,faili) 得到的树是 Parent Tree,而逆序加入得到的是后缀树

  • Parent Tree 上跳 fail 表示取后缀,走自动机上的出边表示在当前字符串后面添加字符,在后缀树上进行这些操作含义同但是方向完全取反

  • Parent Tree 上根链操作表示将这些后缀进行一次在当前最后一次出现位置上进行更新:出现次数的增加/更新最后一次出现位置

  • 广义 SAM 的可能正确写法是加入的时候判断 las 是否已经有这个儿子,也因此可以任意调整 las 的值进行加入新点,可以应用于 trie 树上建后缀自动机

  • LCT 维护 SAM 得到的 parent 树或者后缀树是一个常见技巧

    基于是不是强制在线要求是不是需要写 access 之外的函数,Luogu7361 是统一处理,可以离线,而另外的 Luogu5212 不行,需要写全套

  • 使用 Parent Tree 或者后缀树维护字典序(例如求 SA

    对于树上同一节点的子节点的代表元是 str[len[fa[x]]+pos[x]],连边的时候排序即可

    直接求 SA 需要注意比较的是前缀信息,所以使用的是倒序建立的后缀树

Luogu5115

可能的计算答案方式并不多,尝试按照位置统计贡献失败之后转行来按照每个字符串来统计贡献

观察一下一个特定长度的字符串会带来多少的贡献:

i=1len(leni+1)i[ik1][leni+1k2]

lenlen+1 时,增量只有三种:添加 i=len+1 的贡献/删掉 i=len+1k1 的贡献/(leni+1)i 的变化量,可以 Θ(n) 递推得到

剩下的问题就是每个字符串在统计进答案的时候一定要保证是极长的,由于 endpos 的原因,在节点对应的字符串前面加入相同字符不再可行,但是可以在后面加

注意到 SAM 每个节点可以维护出来对应在原字符串中的后面一个字符,于是每次统计的时候用总的对数减去后继相同的对数即可

时间复杂度 Θ(nΣ)

Luogu7361

能离线就离线,所以扫描线,观察添加最后一个节点会带来哪些字符串出现了第二次

首先在 LCT 上面维护第一次/第二次出现的位置,此时每个 splay 上的点最后一次出现次数相同

不难发现在 access 的过程中,跳虚边的父节点是能经过的链上的字符串长最大值,又由于最后一次出现节点被更新成一样的了,又因为是在同一个 splay 上,更新前视角下最后一次出现也是一样的,所以它可以代表整个 splay 来更新

此时考察答案的形式:一个节点代表的字符串被完全包含在区间里面/被部分包含

如果完全包含直接用 len[x] 更新答案,否则用 lastleft 来更新

由于是扫描线,所以维护两个线段树维护两种情况下最大值,每次跳虚边时给能更新的更新即可

注意不能包含时对答案的贡献一定是 rposqueryl 而每个叶子对应的 queryl 一定,所以维护 rpos 最值

后缀数组

  • 后缀排序巧妙之处其一是在倍增过程中利用已经比较出来顺序的字符串,其二就是双关键字基数排序:

    计算第二关键字排序为 i 的数第一关键字的位次,在基数排序的过程中倒序在桶里面减少,这样子达到了基数排序的目的

  • 求解 LCP 的过程中注意是在 (rk[i]+1,rk[j]) 之中找最小值

    这里的 height 定义在 sa 数组上那么还有一个启示是:一个区间中数的 LCP 是两两 LCP 的最小值

  • 树上后缀排序和序列上的后缀排序本质相同,选取的第二关键字不过变成了 2k 级祖先( Luogu5346 涉及到了三关键字排序,先排后两个,将后两个的排序结果作为第二个来使得其不可重复就行了)

  • 后缀平衡树:动态维护 SA 数组,只能在字符串首加入字符(如果全是在串尾操作并比较字典序可以直接维护反串,但翻转字符串之后就不能正序比较字典序)

    典例是维护求 SA,逆序加入字符,使用替罪羊树维护后缀之间的字典序,由于平衡树 Insert 操作本质上是一个平衡树二分的过程,考察比较节点上代表的后缀字符串和当前字符串的顺序的过程:

    特判第一个字符不同的情况,否则大小关系依赖于已经比较过的两个后缀的大小关系,可以访问数组 key 得到,其中每个节点的 key 等于其前驱后继 key 的平均值,由于 double 精度的问题推荐使用 263 作为哨兵节点的 key

    删除串首的后缀也是可行的,可以直接使用平衡树操作解决,也不用更新被影响 key 因为只关注其大小关系,应该不能随便删除字符

Code Display
inline void push_up(int x){siz[x]=siz[ls[x]]+siz[rs[x]]+(bool)x; return ;}
/*-------------------------------*/
int bin[N],nds;
inline bool Unb(int node){return siz[node]*alp<siz[ls[node]]||siz[node]*alp<siz[rs[node]];}
pair<int,int> up;
pair<double,double>v;
int unb=-1;
inline int build(int l,int r,double lv,double rv){
	if(r<l) return 0;
	int mid=(l+r)>>1,now=bin[mid]; double mv=(lv+rv)/2;
	key[now]=mv;
	ls[now]=build(l,mid-1,lv,mv); rs[now]=build(mid+1,r,mv,rv);
	return push_up(now),now;
}
/*-------------------------------*/
inline void dfs(int p){if(ls[p]) dfs(ls[p]); bin[++nds]=p; if(rs[p]) dfs(rs[p]); return ;}
inline bool les(int x,int y){
	if(s[x]!=s[y]) return s[x]<s[y];
	if(y==1) return 0;
	return key[x-1]<key[y-1];
}
inline void insert(int &rt,int pos,double l,double r){
	if(!rt){
		rt=pos; key[rt]=(l+r)/2; siz[rt]=1;
		ls[rt]=rs[rt]=0;
		return ;
	}
	if(les(pos,rt)){
		insert(ls[rt],pos,l,key[rt]);
		if(Unb(ls[rt])) up={rt,0},unb=ls[rt],v={l,key[rt]};
	}else{
		insert(rs[rt],pos,key[rt],r);
		if(Unb(rs[rt])) up={rt,1},unb=rs[rt],v={key[rt],r};
	} return push_up(rt);
}
inline void Insert(int pos){
	unb=-1; insert(rt,pos,0,lim);
	if(Unb(rt)){
		nds=0; dfs(rt);
		rt=build(1,nds,0,lim);
	}else if(~unb){
		nds=0; dfs(unb);
		(up.sec?rs:ls)[up.fir]=build(1,nds,v.fir,v.sec);
	}
}
inline void del(int &rt,int pos){
	if(pos==rt){
		if(!ls[rt]||!rs[rt]) rt=rs[rt]+ls[rt];
		else{
			int fat=rt,p=ls[rt];
			if(!rs[p]) rs[p]=rs[rt],rt=p;
			else{
				while(rs[p]) siz[fat=p]--,p=rs[p];
				rs[fat]=ls[p]; ls[p]=ls[rt]; rs[p]=rs[rt]; rt=p;
			}
		} return push_up(rt);
	}
	if(les(pos,rt)) del(ls[rt],pos);
	else del(rs[rt],pos);
	return push_up(rt);
}

LOJ6498

不枚举所有 (i,j) 是不可行的,那么考虑怎么枚举

钦定 xor 是不可行的,所以钦定 LCP,配合可持久化 0/1 trie

height 数组上维护出来每个元素作为最小值的区间,每次扫左边的区间,在右边的区间对应的 0/1 trie 上二分即可

优秀的拆分

非常巧妙的做法,目前做的题里面用到的非常多

C1[i] 表示 有几个 AA 结尾在 i 点,C2[i] 表示有几个 BB 开始于 i 点,答案就是 iC1[i]×C2[i1]

那么考虑如何计算出来这两个数组,其本质就是维护有多少个相同且相连的子串,做法是每 len[1,n] 个设置一个关键点,使用 SA 求出关键点之间的 LCP,LCS 就做完了

Z-Function

Z 函数算法本质是减少冗余,尽可能利用已经计算过的信息

if(i<=r) z[i]=min(r-i+1,z[i-l+1]); 一句中就很好体现了这点 ,这也恰好保证了整个处理过程复杂度

这和经典的难背 Manacher 算法思路类似,后者代码中是

if(i<=r) r[i]=min(r[(i<<1)-r],rmax-i); else r[i]=1;

(所以 Manacher 就不单独写了 )

CF432D

主要是处理前缀在字符串中出现次数:

z[i] 计算的是每个后缀和全串的 LCP,那么对于一个前缀 S[1l],满足 zjlj 为起始点,必然出现了一次

不难发现子串在母串出现必然有唯一起始点,所以该做法可以不重不漏统计

直接对 zi 开桶然后后缀和即可

LOJ6158

翻转 S 串变成整串匹配后缀的形式,写暴力观察发现可以加速的部分是让整串和后缀和为 9

使用 Z 函数求一个 T[i]=9S[i]S[i] 的相等情况进行匹配即可

细节稍多,需要注意匹配完和为 9 的部分之后在一个串上走一段 9 和 翻转串之后存在的前导 0

AC 自动机

  • ACAM 上求子串信息的方式是在自动机上走出边,每走一步扫一下 Fail 树上的根链

  • 比较关键的是如果一个节点类似被标记非法,其 fail 树上根链上所有点也要被标记,可以直接暴力跑,因为每个点最多被标记一次

Luogu7582

其实是明示根号做法的,根号个询问一修改的做法好像重新统计贡献处出了点问题,所以简记对根号个字符串开 ACAM 的做法

如上,对根号个字符串开一个 ACAM,每次查询直接在这些 ACAM 上跑,跳到一个节点就扫根链,求出来这些字符串出现了多少次,

考虑修改权值的操作:维护一个 tag,表示被推平成了 0 再使用区间加法把 k 扔到另一个维护的变量 delta 上,

上述情况中边角重构复杂度可以接受,直接重构即可,完全覆盖的块里面见机行事得到答案

这里躲不开根号平衡,也就是说有 q 次修改但是有 qn 次查询,那么选择一个根号修改,Θ(1) 查询的数据结构就行了

回文自动机

  • 一种不基于暴力跳 fail 的插入方式:考察跳 fail 的目的:找到一个节点使得起前驱和当前所需一致

    不难发现需求是量级是 Θ(Σ) 的,尝试开数组 qu[x][i] 记录,如果字符集较大就使用可持久化数组

    考察从 fail 到当前节点变化只有 fail 它自己,所以直接修改即可

    实现的时候注意使用 qu 数组的时候特判一步走到的情况,因为这并不会在数组里面得到体现,同时注意奇根和偶根的的不同点:空儿子指向偶根,没 fail 找奇根

    这种加字符的方法在把 trie 转化成 PAM 时有降低复杂度的功效

  • 从头插入:

    本质上是维护最长回文前缀,不难发现这个量本质上就是最长回文后缀,所以可以维护两个插入指针:front,tail(有别于原来单一的 last ),往哪边加入字符就更新谁

    这里需要注意如果插入得到的节点长度是全串长就要把两个指针都放到这个新建的节点上面

  • 从头/尾删除一个字符:(这部分全是口胡,因为没找到例题)

    一个节点是重要的当且仅当它是对于其两个端点都是极长的回文串,在回文树上维护每个节点 imp 值和回文树上的儿子数,其中 imp 值表示在整串的出现中有哪些是重要的

    后端插入会让最长回文前缀不重要,前端插入可能会让最长回文后缀不重要,用 map 维护 cnt[l][r] 表示被标记了多少次不重要,值是 0 说明重要

    删除的判定是 imp 和儿子数量都是 0,因为儿子空了所以不会被标记不重要,如果 imp 还是 0 就说明真的没有出现过了,删掉的时候需要修改最长回文前后缀的 cntfail 的儿子数

LOJ6070

简记论文里面的 Θ((n+q)n) 的做法

将原串分成 n 个部分,维护每个块左端点到 |S| 的回文树,由于回文树每次最多添加一个节点,那么每个节点维护时间戳表示最左边访问到其的点即可

如果直接使用 nΣ 的前端插入复杂度会多 Σ,但是上面是打标记那就一打到底,这部分也使用维护标记的做法来解决

注意到对于一个回文树上的节点,其向前插入使用的 qu[x][i] 和向后插入使用的是一样的,原理是跳的 fail 是一样的,那么向后插入找的目标是被当前节点所包含的,直接对称过去也就顺理成章的一样了

所以花费 nΣ 的复杂度直接建出完整串的回文自动机并求出所有 qu 指针,在进行处理每个块左端点 x 到所有 xy 时维护前端插入指针位置和后面这段已经在回文树上覆盖的节点数

对于每个询问,l,r 在一个块里面的直接暴力跑回文树,否则继承上面维护的前端插入指针信息,插入字符看看是不是新的就行了

2021-03-30 有趣的字符串题

使用 BIT 和扫描线维护答案,PAM 维护字符串

有一个结论:每个回文串在自动机上的祖先链,不构成等差的断点不超过 logn

自动机上的上下必然是 border,那么根据回文和 border 的性质那么不等差必然差 len2 以上

维护每个子串的最后一次的出现位置,在 PAM 上维护每个 end 对应的节点,使用线段树维护回文树(dfn 序)

对于扫描线时扩展的 r 在回文树上跳非等差的 border

Lyndon Word

定义 Lyndon 串为所有后缀(不要求是真后缀)中字典序最小的是本身的串,等价定义是所有循环同构中最小的是本身的串,等价性的证明可以简单反证得到

Lyndon Word uv 满足 (u<v)u,v 都是 Lyndon Word,性质证明目的就是证 uv 是最小后缀,对于 uv 前缀的部分需要简单反证

定义串 S 的 Lyndon 分解为将 S 分成 s1sk i[1,k1]sisi+1

一个串有且仅有一个 Lyndon 分解,唯一性是平凡的,考虑存在性:

将原串 S 的每个字符作为初始 Lyndon Word,合并 si<si+1 直至不能再合并,最后得到的即是唯一的 Lyndon 分解

这个方式求太慢了,有一个 Duval 算法,维护 i,j,k 变量,i 表示前 i1 完成了划分 k 表示 S[i(k1)] 划分成了一个循环串,循环节为 S[j(k1)]

添加 Sk 时分开讨论:

  • Sk=Sj 直接指针加一

  • Sk<Sj 说明前面的串要完成划分,即 Sik 是划分中的一个新的确定段,因为如果按照循环节划分则小节小于大节

  • Sk>Sj 修改循环节(即 j=i )再继续走即可,不能在这里划分的原因是最后划分得到的字典序单调不增

int i=1,j,k;
while(i<=n){
    k=(j=i)+1; 
    //notice pointer k is always the position waiting to be expanded
    while(k<=n&&s[j]<=s[k]){
        if(s[j]==s[k]) ++j; // maintain period
        else j=i; // rebuild period
        ++k;
    }
    while(i<=j) ans^=i+k-j-1,i+=k-j; 
    // k-j is length of the period without adding 1
}

求出来 Lyndon Word 可以求出来每个前缀的最大/最小后缀:最小后缀就是将这个前缀视作一个字符串进行 Lyndon 分解所得到的最后一段的起始位置

但是值得指出的是最大后缀并不是第一段的末尾,反例是下串:bcdabcde,求解还是需要翻转字符集


字符串理论

弱周期引理

如果 pq 均为 s 的周期,且 p+q|s|,那么 gcd(p,q) 也是 s 的周期

证明考虑辗转相减,设 p<q,则有 s[1]=s[p+1]=s[q+1],所以 qp 也是 s 的周期

迭代即可证明

这里的 p+q|S| 的限制,考虑实际含义,也就是每个点向后面下标差为 i,j 的点连边,那么联通块内的字符一样

如果 p+q>|S|,那么不难证明

周期引理

如果 pq 均为 s 的周期,且 p+qgcd(p,q)|s|,那么 gcd(p,q) 也是 s 的周期

关于为什么要加上 p+qgcd(p,q)|S|,以 S=ABACABA,p=4,q=6 为例

此时 p+qgcd(p,q)=8>|S|,gcd(p,q) 也不是周期

证明不会,但是找到了资料:https://zhuanlan.zhihu.com/p/89385360

因为一些原因,hzoi机房看不了这个网站了机房不给开知乎这样好的证明网站是怎么回事呢?yspm也很好奇

CF1205E

平方仍然化成点对的数量,也就是考虑有多少点对 (i,j) 满足 iperiod 的同时 j 也是 period

考虑 period 同时为 i,j 的组合意义,也就是每个点向后面下标差为 i,j 的点连边所形成的联通块个数

ij 互质的时候

  • i+jn,那么xx+(i1)ji 取模的结果均不同,那么对于下标差为 i 形成的链,必然可以连成一个联通块

  • i+j>n,此时对于模 i0 的链不会有出边,那么这个图没有环,所以此时联通块数为点减边,即 n(ni+nj)=i+jn

对于不互质的情况

  • 若满足 i+jgcd(i,j)> [1,n] 中模 gcd(i,j)x 的数,也就是 i+j>n,此时仍没有环

  • 否则套用弱周期引理可以证明有 gcd(i,j) 个联通块

至此我们把原问题转化成了求下式:

i=1n1j=1n1kmax(gcd(i,j),i+jn)

将这个式子分成 gcd(i,j)>i+jn 和假令 i+jn 最大两个部分

第二个部分也就是

i=1n1j=1n1ki+jn

枚举 i+j>n,可行的方案是 2n(i+j)1,注意这里我们并不需要次数小于 0 的部分

第一个部分很巧:

i=1n1j=1n1[gcd(i,j)>i+jn]kgcd(i,j)ki+jn

g=1n1i=1(n1)/gj=1(n1)/g[gcd(i,j)=1][i+jng](kgk(i+j)gn)

这样的限制并不好处理,按照上面的思路,枚举 i+j 的值

g=1n1s=2ngi=1s1[gcd(i,si)=1](kgksgn)

这次的 gcd(i,si)=1 的求和显然是 φ(s)

所以对于 kg 的部分,对欧拉函数求前缀和可以做到 Θ(n)

后面的部分,考虑到我们只关注 sg>n 的部分,那么不难发现每个位置至多有 1 项,也是 Θ(n)

所以总复杂度就是 Θ(n) 的,比那个用 ϵ=μI 的不知道高明到哪里去了

Border 理论

如下结论大多与等差数列有关,在题目中可能需要配合数据结构加以使用

  • 2|S||T|,那么 ST 中匹配的位置构成一个等差数列

证明设第一段和第二段的间距为 p,第二,三段的间距为 q,那么因为长度限制,p+q|S|,首先得到 p,q 均为 Speriod

使用弱周期引理得到 gcd(p,q)Spreiod

S 的最小正周期 rgcd(p,q) 所以得到 rgcd(p,q)q|S1S2|

所以最小正周期是 |S1S2| 的周期,也就是说如果 r<q 那么将首个匹配位置右移 r 个也可以匹配

按照上述定义,等差数列至少有 3 项,那么公差不难证明是最小周期,此时 r|S|/2

这个结论是直接从上面推的

  • 字符串不小于 len/2border 构成一个等差数列

考虑两个周期 p,q 满足 p<q<|S|/2np 是最大 border

再次使用弱周期引理 gcd(p,q) 也是周期,同时 nq,np,ngcd(p,q) 均为 border

注意到 gcd(p,q)p,又有 np 是最大 border,所以得到 p=gcd(p,q),也就是 p|q

那么 border 就会有 np,n2p,n3p

  • 推论:每个串的所有 border 可以分成不超过 log 组,每组长度为等差数列

把所有的 border 的长度按照 [2i1,2i) 这样子分组,最后一段已经证明了

剩下的部分,对于一组里面的短的是长的的 border,画图可以证,所以原命题也就得证了

WC2016 论战捆竹竿

考虑暴力:设 period 集合为 {S} 最小数字为 x,建立 x 个点表示 mod x 意义下的数字,每个点 i(i+y)%x (yS) 连边,边权为 y

之后以 0 为源点,求出来到每个点的最短路,表示在 modx 意义下最小能被表示出来的数字,大于其的同余数字一定能被表示出来(这种方法貌似被称作 “同余最短路”)

5×105log 死贴只有 border 形成的等差数列数,考察一个等差数列能进行的松弛操作,设当前的等差数列为 (v0,delt,len) 不难发现连边会形成 Gcd(v0,delt) 个环

对于每个环而言,在当前进行松弛前有一个最小值,它在这轮松弛不会改变最短路的值,那么以它为起始点拆成一条链,考察松弛的方式就是每个点找到链上在其前且距离其不超过 len 的点进行 dpt+v0+k×delt 的转移

那么使用单调队列进行优化单个等差数列里面的部分,复杂度是 Θ(nlogn),剩下的是考虑合并若干个等差数列的松弛结果:

不同的等差树立本质上是 v0 的变化,每个原来的点直接挪给新点,另外新增的转移是加上若干个 vpre,其实还是等差数列,但是项数没有限制,找到环上最小值记录前缀 min 即可

posted @   没学完四大礼包不改名  阅读(191)  评论(4编辑  收藏  举报
编辑推荐:
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律
点击右上角即可分享
微信分享提示