基础字符串
- Change Log
- 符号与约定
- 匹配、周期和 Border
- 定义
- 基础性质
- 周期引理
- 匹配引理
- Border 的结构
- 小结
- 前缀数组、KMP 与字符串匹配
- Z 函数
- AC 自动机与多串匹配
- Luogu P5357 【模板】AC 自动机
- Luogu P4052 [JSOI2007] 文本生成器
- Codeforces 86C Genetic engineering
- Luogu P2292 [HNOI2004] L 语言
- Luogu P2414 [NOI2011] 阿狸的打字机
- Codeforces 547E Mike and Friends
- Luogu P5840 [COCI2015] Divljak
- Codeforces 710F String Set Queries
- Codeforces 587F Duff is Mad
- Luogu P8203 [传智杯 #4 决赛] DDOSvoid 的馈赠
- Codeforces 1483F Exam
- QOJ5034 >.<
- Luogu P8571 [JRKSJ R6] Dedicatus545
- Luogu P5599 【XR-4】文本编辑器
- SCOI2024 Day1 T1 (口胡)
- 回文相关
- 基础性质
- 回文引理
- Manacher 算法
- 回文树 / 回文自动机
- 构造:末端插入法
- 构造:前端插入法
- 构造:不基于势能分析的末端插入法(可持久化)
- 例题 1 【模板】回文自动机(PAM)Link
- 例题 2 [APIO2014] 回文串 Link
- * 构造:双端插入法(HDU 5421)
- 构造:对给定 Trie 树构造 PAM
- * 带双端插入删除的 PAM 维护
- 例题 3 [CERC2014] Virus synthesis
- 例题 4 [GDKOI2013] 大山王国的城市规划
- 最小回文划分
- 例题 5 CF932G Palindrome Partition
- 例题 6 Codeforces 906E Reverses
- * 例题 7 LOJ6070「2017 山东一轮集训 Day4」基因
- Luogu P4199 万径人踪灭
- 非平凡回文串划分判定
Change Log#
- 5.27:修复了文章里没有图片的问题。更新了若干题目的题解,修改了少量 typo。移除了《喵星球上的点名》一题。
- 5.28 :重新添加了《JOJO》一题。修改了少量 typo。补充了部分题目的题解。
符号与约定#
对于字符串 ss,用 si,s(i),s[i]si,s(i),s[i] 表示 ss 中第 ii 个字符。如无特殊说明,默认字符串下标从 11 开始。
对于字符串 ss,用 s[l...r],s[l,r]s[l...r],s[l,r] 表示 ss 中第 ll 个字符开始到第 rr 个字符结束的子串。特殊地,若 l>rl>r,则认为其为空串。
对于字符串 s,ts,t,用 stst 或 s+ts+t 来表示 ss 和 tt 的拼接。
对于字符串 ss 和非负整数 ll,用 pre(s,l),suf(s,l)pre(s,l),suf(s,l) 分别表示 ss 中长度长度为 ll 的前缀和后缀。特殊地,若 l=0l=0,则认为二者皆为空串。
用 ΣΣ 表示字符集。字符集 ΣΣ 是一个有限全序集,字符串中仅含字符集中的字符。
匹配、周期和 Border#
定义#
对于字符串 ss,若 0≤r<|s|0≤r<|s| 的 rr 使得 s[1,r]=s[|s|−r+1,|s|]s[1,r]=s[|s|−r+1,|s|],则称 s[1,r]s[1,r] 是 ss 的 border。
对于字符串 ss,若 0<p≤|s|0<p≤|s| 的 pp 使得 ∀i∈{1,2,⋯,|s|−p}∀i∈{1,2,⋯,|s|−p},si=si+psi=si+p,则称 pp 是 ss 的周期。
按照上述定义,我们不能说 ss 本身是它的 border,也不能说 00 是 ss 的周期。上述对于不等式符号的精心选取是为了导出以下结论:
Period-Border Lemma
s[1,r]s[1,r] 是 ss 的 border 当且仅当 |s|−r|s|−r 是 ss 的周期。
对于长度为 nn 的字符串 ss,定义其前缀数组(一别称为 nextnext 数组)π=[π0,π1,π2,⋯,πn]π=[π0,π1,π2,⋯,πn],其中 πiπi 表示 s[1,i]s[1,i] 的最长 border 长度。特殊地,规定 π0=0π0=0。
对于字符串 s,ts,t 和 1≤l≤r≤|s|1≤l≤r≤|s|,若 s[l,r]=ts[l,r]=t,则称 s[l,r]s[l,r] 和 tt 匹配。对于所有满足前述条件的 rr,称 rr 构成的集合为 tt 在 ss 中的匹配位置。
基础性质#
-
性质 1:border 的 border 仍然是原串的 border。
-
性质 2:每次令 ss 变为 ss 的最长 border,直到 ss 变为空串,则经过的所有 ss 恰好构成原串的 border 集合。
证明可以考虑反证法,结合 border 的定义即可。
-
性质 3:若 pp 是 ss 的周期,且 kp≤|s|kp≤|s|,则 kpkp 也是 ss 的周期。
只需要考虑周期定义。
周期引理#
弱周期引理 Weak Periodicity Lemma
对于字符串 ss,若 p,qp,q 是 ss 的周期,且 p+q≤|s|p+q≤|s|,则 gcd(p,q)gcd(p,q) 也是 ss 的周期。
周期引理 Periodicity Lemma
对于字符串 ss,若 p,qp,q 是 ss 的周期,且 p+q−gcd(p,q)≤|s|p+q−gcd(p,q)≤|s|,则 gcd(p,q)gcd(p,q) 也是 ss 的周期。
接下来使用较为直观的生成函数方法来证明周期引理。
以下证明中默认字符串的下标从 00 开始,且长度为 nn。
考虑给每种字符分配一个互不相同的正整数权值,即建立映射 f:Σ→N+f:Σ→N+,然后将字符串 s=s0s1⋯sn−1s=s0s1⋯sn−1 看作数列 ⟨f(s0),f(s1),⋯,f(sn−1)⟩⟨f(s0),f(s1),⋯,f(sn−1)⟩。
对于周期 p,qp,q,构造多项式 P(x)=p−1∑i=0f(si)xi,Q(x)=q−1∑i=0f(si)xiP(x)=p−1∑i=0f(si)xi,Q(x)=q−1∑i=0f(si)xi,这便是 s[0,p−1],s[0,q−1]s[0,p−1],s[0,q−1] 的生成函数。
定义字符串 s[0,p−1],s[0,q−1]s[0,p−1],s[0,q−1] 复制无穷多遍的生成函数为 Sp(x),Sq(X)Sp(x),Sq(X),则有 Sp(x)=∑i≥0f(simodp)xiSp(x)=∑i≥0f(simodp)xi,Sq(x)=∑i≥0f(simodq)xiSq(x)=∑i≥0f(simodq)xi。Sp(x),Sq(x)Sp(x),Sq(x) 的系数可以分别看作 P(x),Q(x)P(x),Q(x) 的系数复制无穷多遍产生的,可以表示为 Sp(x)=P(x)+xpP(x)+x2pP(x)+⋯+xkpP(x)+⋯Sp(x)=P(x)+xpP(x)+x2pP(x)+⋯+xkpP(x)+⋯,对于 Sq(x)Sq(x) 同理。根据生成函数的运算规则,Sp(x)=P(x)1−xp,Sq(x)=Q(x)1−xqSp(x)=P(x)1−xp,Sq(x)=Q(x)1−xq。
不难发现字符串 ss 的生成函数 S(x)S(x) 是 Sp(x),Sq(x)Sp(x),Sq(x) 对前 nn 项的截断,即 S(x)=Sp(x)modxn=Sq(x)modxnS(x)=Sp(x)modxn=Sq(x)modxn,从而 [xk]Sp(x)=[xk]Sq(x)[xk]Sp(x)=[xk]Sq(x),其中 k=0,1,⋯,n−1k=0,1,⋯,n−1。
考虑对 Sp(x)Sp(x) 和 Sq(x)Sq(x) 作差,得到
长除法容易证明 (1−xa),(1−xb)(1−xa),(1−xb) 都是 (1−xab)(1−xab) 的因式,因此 1−xq1−xgcd(p,q),1−xp1−xgcd(p,q)1−xq1−xgcd(p,q),1−xp1−xgcd(p,q) 是整式,从而 H(x)=1−xq1−xgcd(p,q)P(x)+1−xp1−xgcd(p,q)Q(x)H(x)=1−xq1−xgcd(p,q)P(x)+1−xp1−xgcd(p,q)Q(x) 是次数不超过 p+q−1−gcd(p,q)p+q−1−gcd(p,q) 的多项式。同时,1−xgcd(p,q)(1−xp)(1−xq)1−xgcd(p,q)(1−xp)(1−xq) 是一个常数项不为 00 的形式幂级数。若 H(x)≠0H(x)≠0,则取左侧幂级数的常数项和 H(x)H(x) 相乘,最终的结果中必然会得到不为 00 的一个 xixi,其中 i≤p+q−1−gcd(p,q)i≤p+q−1−gcd(p,q)。根据上面的讨论,在 k=0,1,⋯,n−1k=0,1,⋯,n−1 处,我们都有 [xk](Sp(x)−Sq(x))=0[xk](Sp(x)−Sq(x))=0,又因为 p+q−gcd(p,q)≤np+q−gcd(p,q)≤n,从而 i≤n−1i≤n−1,这样我们同时有 xixi 项的系数不为 00 和 xixi 项的系数必须为 00,出现了矛盾,因此 H(x)=0H(x)=0。
这样我们就有 Sp(x)−Sq(x)=0Sp(x)−Sq(x)=0,从而 Sp(x)Sp(x) 和 Sq(x)Sq(x) 每一项的系数都相等。根据裴蜀定理,存在整数 a,ba,b 使得 ap+bq=gcd(p,q)ap+bq=gcd(p,q)。这样就有 [xi]Sp(x)=[xi+ap]Sp(x)=[xi+ap]Sq(x)=[xi+ap+bq]Sq(x)=[xi+gcd(p,q)]Sp(x)[xi]Sp(x)=[xi+ap]Sp(x)=[xi+ap]Sq(x)=[xi+ap+bq]Sq(x)=[xi+gcd(p,q)]Sp(x),从而 si=si+gcd(p,q)si=si+gcd(p,q)。如果 apap 是负数并使得 i−api−ap 是负数,那么我们可以先让 ii 变为 i+bqi+bq,因为此时 bqbq 一定是正整数,从而我们可以避免取负数项的系数。
这里,尽管我们直接证明了周期引理,但是大部分时候 WPL 就足够了。
匹配引理#
引理
若字符串 u,vu,v 满足 2|u|≥v2|u|≥v,则 uu 在 vv 中的所有匹配位置构成一个等差数列。
对平凡情况进行考察后,我们只需要考虑 uu 在 vv 中匹配了至少 33 次的情况。
如上图,设 uu 在 vv 中的前两次匹配在 vv 中间隔为 dd,另外某次匹配距离第二次匹配的间隔为 qq,并记 uu 在 vv 中的前两次匹配分别为 u1,u2u1,u2。因为 2|u|≥v2|u|≥v,因此任意两次相邻匹配都会产生重叠位置,从而 d+q≤|u|d+q≤|u|,根据 Period Lemma 得到 r=gcd(d,q)r=gcd(d,q) 也是 uu 的周期。
设 uu 的最小周期为 p≤rp≤r。仅根据周期定义,u1u1 在 vv 中匹配的最后 pp 个位置不一定满足 v(x)=v(x+p)v(x)=v(x+p)。因为 u1u1 和 u2u2 是相同的,且 p≤r=gcd(d,q)≤dp≤r=gcd(d,q)≤d,因此 |u1∩u2||u1∩u2| 中的 |u|−d|u|−d 个位置必然有 v(x)=v(x+p)v(x)=v(x+p)。如果 |u1∩u2|≥p|u1∩u2|≥p,那么 u1u1 中最后 pp 个位置也可以借助 u2u2 中对应位置提供的信息来满足 v(x)=v(x+p)v(x)=v(x+p)(因为 d≤pd≤p,所以这确实成立),从而 pp 也是 u1∪u2u1∪u2 的周期。因为 pp 是 uu 的最小周期,且根据上图有 |u1∩u2|≥q|u1∩u2|≥q,因此 p≤q≤|u1∩u2|p≤q≤|u1∩u2|,从而 |u1∩u2|≥p|u1∩u2|≥p 确实成立。
此时,若 p<dp<d,则 u1u1 向右移动 pp 的距离就会产生一次匹配,和 u2u2 是 uu 在 vv 中第二次匹配矛盾。于是 d≤p≤r=gcd(d,q)≤dd≤p≤r=gcd(d,q)≤d 成立,从而 p=d=r=gcd(d,q)p=d=r=gcd(d,q)。
推论
若字符串 u,vu,v 满足 2|u|≥v2|u|≥v,则 uu 在 vv 中的所有匹配位置构成一个等差数列。若该等差数列项数不小于 33,则其公差 dd 为 uu 的最小周期 per(u)per(u),且此时易知 per(u)≤|u|/2per(u)≤|u|/2。
我们上面的证明可以立刻得到该推论。注意到 d+q≤|u|d+q≤|u|,因此两个和为 |u||u| 的正整数的 gcdgcd 必然不会超过 |u||u| 的一半。
当等差数列仅含 22 项时不一定有 per(u)=dper(u)=d,这是因为存在 u=aabaa,v=aabaaabaa,per(u)=3,d=4u=aabaa,v=aabaaabaa,per(u)=3,d=4 的反例。
Border 的结构#
引理 1
字符串 ss 所有长度不小于 |s|/2|s|/2 的 border 长度组成一个等差数列。
笔者注:此处不取整。
证明:设 ss 的最大 border 长度为 |s|−p|s|−p,另外某个 border 长度为 |s|−q|s|−q,其中 p,q≤|s|/2p,q≤|s|/2。那么 p+q≤|s|p+q≤|s|,从而 gcd(p,q)gcd(p,q) 是 ss 的周期。注意到 |s|−p|s|−p 是 ss 的最大 border 长度,因此 pp 是 ss 的最小周期,因此 p≤gcd(p,q)p≤gcd(p,q)。根据 gcdgcd 的定义有 p≥gcd(p,q)p≥gcd(p,q),因此 p=gcd(p,q)p=gcd(p,q),从而 ss 所有大小不超过 |s|/2|s|/2 的周期恰为 p,2p,⋯,kp,⋯(kp≤|s|/2)p,2p,⋯,kp,⋯(kp≤|s|/2),同时 ss 所有大小不小于 |s|/2|s|/2 的周期恰为 |s|−p,|s|−2p,⋯,|s|−kp|s|−p,|s|−2p,⋯,|s|−kp,它们构成一个等差数列。
接下来对 ss 的所有 border 考虑如下的引理 2:
引理 2
ss 的所有 border 长度构成 O(log|s|)O(log|s|) 个值域上不交的等差数列。
我们将 border 按照长度 xx 分类:x∈[1,2),[2,4),⋯,[2k−1,2k),[2k,n)x∈[1,2),[2,4),⋯,[2k−1,2k),[2k,n)。
若 x∈[2k,n)x∈[2k,n),其中 2k≥n/22k≥n/2,那么使用引理 11 可证。接下来讨论 x∈[2i−1,2i)x∈[2i−1,2i) 的情形。
对于两个长度相等的串 u,vu,v,仿照 border 的定义,定义 u,vu,v 的 PS 集合 PS(u,v)={k∣pre(u,k)=suf(v,k)}PS(u,v)={k∣pre(u,k)=suf(v,k)}。
记 LargePS(u,v)={k∣k∈PS(u,v),k≥|u|/2}LargePS(u,v)={k∣k∈PS(u,v),k≥|u|/2}。
引理 3
LargePS(u,v)LargePS(u,v) 构成一个等差数列。
证明:若 u=vu=v 则只需要考察 |u||u| 是否和 uu 所有长度不小于 |u|/2|u|/2 的 border 构成一个等差数列。根据对引理 1 的证明,这事实上是显然的,因为后者是 |u|−p,|u|−2p,⋯,|u|−kp|u|−p,|u|−2p,⋯,|u|−kp。
若 u≠vu≠v,考察 LargePS(u,v)LargePS(u,v) 中的最大元素 xx。
那么将 u,vu,v 按照上图方式叠放后,它们长度为 xx 的交集应当是相等的。LargePS(u,v)LargePS(u,v) 中任意一个更小的元素(图中深蓝色部分)必然是 pre(u,x)pre(u,x) 的 border,因此利用引理 1 得到这些元素构成一个等差数列。和 u=vu=v 时类似,xx 也可以加入到这个等差数列中。因此引理 3 成立。
现在回到引理 2。根据 border 的定义,LargePS(pre(s,2i),suf(s,2i))LargePS(pre(s,2i),suf(s,2i)) 恰好包含 x∈[2i−1,2i)x∈[2i−1,2i) 的长度为 xx 的 border。利用引理 3,LargePS(pre(s,2i),suf(s,2i))LargePS(pre(s,2i),suf(s,2i)) 构成一个等差数列,因此引理 2 立刻得证。
通过引理 2 的证明,当 |s|>1|s|>1 时,这里的 O(log|s|)O(log|s|) 可以变成一个更紧的界 ⌈log2|s|⌉⌈log2|s|⌉。如果要严谨一点,我们还应该考察长度为 00 的 border,但这不影响结论成立,因为我们完全可以把它和 11 放到同一个等差数列中,不过我们一般也不会需要它。
小结#
通过探讨关于周期和 border 的几个引理,我们最终得到了最重要的引理 22:ss 的所有 border 长度构成 O(log|s|)O(log|s|) 个值域上不交的等差数列。
在具体应用到题目中时,通常会考虑所有非空 border 构成的长度序列 b1≤b2≤⋯≤bkb1≤b2≤⋯≤bk,同时令 b0=0,bk+1=|s|b0=0,bk+1=|s|。一种较为常用的划分方法是,从后往前考虑每个 bi(1≤i≤k)bi(1≤i≤k),并判定:
- 若 bi+1−bi=bi−bi−1bi+1−bi=bi−bi−1,则将 bibi 划分进当前等差数列;
- 否则,将 bibi 划分进下一个等差数列。
例如,我们有 border 长度序列 b=[3,5,7,9,11,33,55]b=[3,5,7,9,11,33,55](很拙劣的例子。此处只是为了演示划分方式,可能并不存在这样的 border 序列),那么我们的划分方式为 [3]/[5,7,9,11]/[33,55][3]/[5,7,9,11]/[33,55]。
这样做的好处是,因为 bi−1≥0bi−1≥0,因此对于同一个等差数列中的相邻元素 bi,bi+1bi,bi+1,都有 bi+1≥2bibi+1≥2bi。这个性质和匹配引理的条件颇为相似,在某些题目中会派上用场。
最后给出一条性质作为结尾:若满足 k>1k>1 的等差数列 b′=[b′1,⋯,b′k]b′=[b′1,⋯,b′k] 存在,那么根据周期的定义,ss 存在大小为公差 b′2−b′1b′2−b′1 的周期,因此,除了 b′kb′k,其余的 b′ib′i 都满足 s[b′i+1]s[b′i+1] 相同。
前缀数组、KMP 与字符串匹配#
前缀数组的求法#
KMP 算法支持在 O(n)O(n) 的时间内在线计算出前缀数组 ππ。根据 border 的定义,若 s[1,i]s[1,i] 存在长度为 x(x>0)x(x>0) 的 border,则 s[1,i−1]s[1,i−1] 存在长度为 x−1x−1 的 border。考虑以下计算流程:
- 令 π1=0π1=0。
- 枚举 i=2,⋯,ni=2,⋯,n,依次执行如下算法:
- 初始化指针 j←πi−1j←πi−1;
- 若 s[j+1]=s[i]s[j+1]=s[i],令 πi←j+1πi←j+1,结束算法;
- 若 j=0j=0,令 πi←0πi←0,结束算法。
- 令 j←πjj←πj,回到 2。
在每次执行算法的时候,jj 不断变为 s[1,j]s[1,j] 的最长 border 的长度(ππ 数组的定义),根据性质 2,我们本质上从长到短遍历了 s[1,i−1]s[1,i−1] 的所有 border,然后依次判断该 border 是否是能在后方接上一个字符 s[i]s[i] 变成 s[1,i]s[1,i] 的 border。
上述流程可以写作如下代码:
for(int i=2;i<=n;++i){
int j=len[i-1];
while(s[i]!=s[j+1]){
if(!j) break;
j=len[j];
}
len[i]=j+(s[i]==s[j+1]);
}
考虑复杂度分析。首先,显然有 πi≤πi−1+1πi≤πi−1+1,其次,在每次算法执行的过程中,每当 jj 变为 πjπj 时,jj 至少变小 11。因为 0≤πi<i0≤πi<i,运用势能分析可以得知该算法的复杂度为 O(n)O(n)。
前缀数组与字符串匹配#
对于字符串 s,t(|s|=n,|t|=m)s,t(|s|=n,|t|=m),利用字符串 tt 的前缀数组 ππ 可以求出 tt 在 ss 中的所有匹配位置。考虑以下计算流程:
-
枚举 i=1,2,⋯,ni=1,2,⋯,n。同时维护指针 jj,表示 s[1,i]s[1,i] 的某个后缀和 t[1,j]t[1,j] 相等。初始令 j←0j←0。
依次执行如下算法:
-
检查是否有 s[i]=s[j+1]s[i]=s[j+1]。
-
若 s[i]=s[j+1]s[i]=s[j+1] 成立,则令 j←j+1j←j+1。此时若 j=mj=m,则找到一个出现位置,标记该位置,并令 j←πjj←πj。
结束算法。
-
若 s[i]=s[j+1]s[i]=s[j+1] 不成立,则判断:若 j=0j=0,则结束算法。否则令 j←πjj←πj,转到 1。
-
该算法的正确性证明和时间复杂度证明与求前缀数组是类似的,此处不再赘述。可以分析出时间复杂度为 O(n+m)O(n+m),其中 O(m)O(m) 的部分为求 ππ 的复杂度,O(n)O(n) 的部分为执行匹配算法的复杂度。
例题:【模板】KMP。
正常写法。
# include <bits/stdc++.h>
const int N=1000010,INF=0x3f3f3f3f;
char s[N],t[N];
int n,m;
int len[N];
int main(void){
scanf("%s",s+1),scanf("%s",t+1),n=strlen(s+1),m=strlen(t+1);
for(int i=2;i<=m;++i){
int j=len[i-1];
while(t[i]!=t[j+1]){
if(!j) break;
j=len[j];
}
len[i]=j+(t[i]==t[j+1]);
}
for(int i=1,j=0;i<=n;){
if(s[i]==t[j+1]){
++i,++j;
if(j==m) printf("%d\n",i-m),j=len[j];
}else if(j) j=len[j]; else ++i;
}
for(int i=1;i<=m;++i) printf("%d ",len[i]);
return 0;
}
或者偷懒:将 ss 和 tt 用分隔符连接,每次只需要查询某一位的 ππ 值是否为 |s||s|。
# include <bits/stdc++.h>
const int N=2000010,INF=0x3f3f3f3f;
char s[N],t[N];
int n,m;
int len[N];
int main(void){
scanf("%s",s+1),scanf("%s",t+1),n=strlen(s+1),m=strlen(t+1);
int q=m;
t[++q]='#';
for(int i=1;i<=n;++i) t[++q]=s[i];
for(int i=2;i<=q;++i){
int j=len[i-1];
while(t[i]!=t[j+1]){
if(!j) break;
j=len[j];
}
len[i]=j+(t[i]==t[j+1]);
}
for(int i=1;i<=n;++i) if(len[m+1+i]==m) printf("%d\n",i-m+1);
for(int i=1;i<=m;++i) printf("%d ",len[i]);
return 0;
}
结合周期引理 / UVA1328#
对于某个字符串,当且仅当某个周期的大小整除字符串长度时,这个周期是该字符串的循环节。
注意到如果一个长度为 nn 的字符串 ss 有最短非平凡(大小不为 nn 本身)循环节 cc,那么一定有 c≤n/2c≤n/2。若 ss 的最小周期 pp 不为 cc,则 p+c≤np+c≤n,根据周期引理,gcd(p,c)≤p<cgcd(p,c)≤p<c 是 ss 的周期。因为 cc 是 nn 的非平凡循环节,因此 gcd(p,c)∣c∣ngcd(p,c)∣c∣n,同时 gcd(p,c)<cgcd(p,c)<c,推出矛盾。
因此,对于本题,只需要求出前缀数组 ππ,对于每个前缀 ii,检查是否有 (i−πi)∣i(i−πi)∣i 即可。同样根据周期引理,我们可以得到,任何循环节长度都是最短循环节长度的整倍数,因此,前缀 ii 的循环节数量恰为 i/(i−πi)i/(i−πi)。
KMP 算法的可持久化#
接下来介绍一种 KMP 算法的变种,时空复杂度可做到 O(nΣ)O(nΣ),或利用可持久化数据结构做到 O(nlogΣ)O(nlogΣ)。
具体来说,记 nex(i,c)nex(i,c) 表示 s[1,i]s[1,i] 所有满足 s[j+1]=cs[j+1]=c 的 border jj(j=0j=0 亦考虑在内)的最大长度,不存在则 nex(i,c)=−1nex(i,c)=−1。那么显然有 πi=nex(i−1,s[i])πi=nex(i−1,s[i])。
接下来只需考虑求出 nex(i)nex(i)。不难发现 nex(i)nex(i) 比起 nex(πi)nex(πi),仅有 c=s[πi+1]c=s[πi+1] 时可能发生改变。因此每次从 nex(πi)nex(πi) 处复制后修改即可。
该变种的优势是,复杂度不依赖均摊,每添加一个字符,需要的复杂度都是 O(Σ)O(Σ) 或 O(logΣ)O(logΣ),因此支持可持久化。
给定长度为 nn 的字符串 ss。mm 次询问,每次给出一个字符串 tt,询问字符串 s+ts+t 的前缀数组 ππ 中,最后 |t||t| 位的值。
字符集为小写字母集。
1≤n≤106,1≤m≤105,1≤|t|≤101≤n≤106,1≤m≤105,1≤|t|≤10
不难发现可持久化 KMP 只需要保证 ∑|t|∑|t|,因此 |t|≤10|t|≤10 无用,可加强到与 nn 同阶。
以下给出代码。
# include <bits/stdc++.h>
const int N=1000110,INF=0x3f3f3f3f;
char s[N],t[N];
int n,m;
int len[N];
int nex[N][26];
inline void extend(int i,int c){
len[i]=nex[i-1][c]+1;
memcpy(nex[i],nex[len[i]],sizeof(nex[i]));
nex[i][s[len[i]+1]-'a']=len[i];
return;
}
int main(void){
for(int i=0;i<26;++i) nex[0][i]=-1;
scanf("%s",s+1),n=strlen(s+1);
for(int i=1;i<=n;++i){
extend(i,s[i]-'a');
}
int m; std::cin>>m;
while(m--){
scanf("%s",t+1);
int k=strlen(t+1);
for(int i=1;i<=k;++i) s[n+i]=t[i],extend(n+i,s[n+i]-'a'),printf("%d ",len[n+i]);
puts("");
}
return 0;
}
Luogu P4156 [WC2016] 论战捆竹竿#
给定长度为 nn 的小写字母串 ss 和正整数 ww。考虑 ss 的所有非空 border 长度与正整数 nn 构成的集合 BB,求:有多少个小于等于 w−nw−n 的非负整数可以被集合 BB 中若干个(可以为 00 个)元素的和所表示。集合中元素可重复使用。
多测,T≤5,1≤n≤5×105,1≤w≤1018T≤5,1≤n≤5×105,1≤w≤1018
求出 BB 是平凡的。不难发现这是元素较小,且值域较大的完全背包问题,可以使用转圈法(或同余最短路)解决,详见 [THUPC 2023 初赛] 背包。
但事实上,元素种类仍然较多,难以直接通过。接下来的做法需要考虑到 border 可以划分为若干个等差数列的性质。
考虑当前模数 MM,并设 f(i)f(i) 表示只使用之前加入的元素时,能够凑出来的最小的模 MM 等于 ii 的非负整数。初始令 M=n,f(0)=0,f(i)=+∞(i≠0)M=n,f(0)=0,f(i)=+∞(i≠0)。
对于一个长度为 l+1l+1 的等差数列,设其为 x,x+d,⋯,x+ldx,x+d,⋯,x+ld。先实现 f(i)f(i) 从模 MM 意义下到模 M′=xM′=x 意义下的转换,那么对于一个旧的 f(i)f(i),其贡献显然为:令 f′(imody)f′(imody) 对 f(i)f(i) 取 min。
此时注意到,因为原来的背包基准元素为 MM,因此旧的 f(i)f(i) 中并没有考虑加入元素 MM 的贡献。因此需要在模 M′M′ 意义下的背包中,加入元素 MM,跑一遍背包。
现在考虑加入等差数列中的元素。将每个点 ii 向 (i+d)modx(i+d)modx 连边,此时形成了 gcd(d,x)gcd(d,x) 个环。每个环上的任务形如:找到环上使得 f(i)f(i) 最小的 ii 作为起始点,然后对于每个 f(i+kd)f(i+kd),有 f(i+kd)=lminj=1{f(i+(k−j)d)+jd}f(i+kd)=lminj=1{f(i+(k−j)d)+jd}。使用单调队列优化 DP 即可。
Codeforces 1286E Fedya the Potter Strikes Back#
本题需要在加入每个字符后,求出当前字符串每个非空 border 的可疑度之和。
考察 s[1,i](i>1)s[1,i](i>1) border 的来源:要么是一个空 border,要么是 s[1,i−1]s[1,i−1] 的某个 border 在后面拼上一个和 s[i]s[i] 相等的字符变来的。
设 S(i,c)S(i,c) 表示 s[1,i]s[1,i] 所有满足下一位字符为 cc 的 border 构成的集合,那么 S(i)S(i) 中只有一个位置和 S(πi)S(πi) 不同:S(i,s[πi+1])S(i,s[πi+1]) 中添加了 πiπi 这一元素。
不难发现,加入 s[i]s[i] 后,需要删除 S(i−1,c)(c≠si)S(i−1,c)(c≠si) 中 border 的贡献。没有被删除的 border 贡献需要对 ii 这一位的权值取 min,可以使用 map 简单维护。另外,若 s[i]=s[1]s[i]=s[1],则加入该 border 的贡献。
考虑如何求出需要删除的 border。记 nex(i,c)nex(i,c) 表示 S(i,c)S(i,c) 中的最长 border 长度,那么依次遍历 nex(i−1,c),nex(nex(i−1,c),c),⋯nex(i−1,c),nex(nex(i−1,c),c),⋯ 即可。
贡献可以使用线段树计算。因为 border 的加入删除数量是均摊 O(1)O(1) 的,因此时间复杂度 O(nlogn)O(nlogn)。
# include <bits/stdc++.h>
const int N=600010,INF=0x3f3f3f3f;
typedef long long ll;
int nex[N][26],fail[N],n,w[N]; // nex[i,x] = pos 表示 [1,pos] 是 [1,i] 的 border 且 s[pos+1] = x
int s[N];
__int128 ans;
ll curv; // 记录下 border 的和(即不包含 [1,x] 的所有相等前缀)
int minx[N<<2];
std::map <int,int> S;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-')f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
inline void print(__int128 x){
if(x<0) putchar('-'),x-=x;
if(x>9) print(x/10);
putchar(x%10+'0');
return;
}
inline int lc(int x){
return x<<1;
}
inline int rc(int x){
return x<<1|1;
}
inline void pushup(int k){
minx[k]=std::min(minx[lc(k)],minx[rc(k)]);
return;
}
void change(int k,int l,int r,int x,int v){
if(l==r) return minx[k]=v,void();
int mid=(l+r)>>1;
if(x<=mid) change(lc(k),l,mid,x,v);
else change(rc(k),mid+1,r,x,v);
pushup(k);
return;
}
int query(int k,int l,int r,int L,int R){
if(L<=l&&r<=R) return minx[k];
int mid=(l+r)>>1,res=2e9;
if(L<=mid) res=std::min(res,query(lc(k),l,mid,L,R));
if(mid<R) res=std::min(res,query(rc(k),mid+1,r,L,R));
return res;
}
int main(void){
n=read();
char in[2];
scanf("%s",in);
s[1]=in[0]-'a',printf("%d\n",w[1]=read()),ans+=w[1],change(1,1,n,1,w[1]);
for(int i=2,val,j=0;i<=n;++i){
scanf("%s",in),val=read();
s[i]=(in[0]-'a'+ans%26)%26,w[i]=val^(ans%(1<<30));
change(1,1,n,i,w[i]);
ans+=query(1,1,n,1,i);
while(j&&s[j+1]!=s[i]) j=fail[j];
if(s[j+1]==s[i]) ++j;
fail[i]=j;
for(int k=0;k<26;++k) nex[i][k]=nex[fail[i]][k];
nex[i][s[fail[i]+1]]=fail[i];
for(int k=0;k<26;++k){
if(k!=s[i]){
for(int j=nex[i-1][k];j;j=nex[j][k]){
int res=query(1,1,n,(i-1)-j+1,i-1);
curv-=res,--S[res];
}
}
}
std::vector <int> delv;
for(std::map <int,int>::iterator it=S.upper_bound(w[i]);it!=S.end();++it){
std::pair <int,int> now=*it;
curv+=1ll*(w[i]-now.first)*now.second,S[w[i]]+=now.second,delv.push_back(now.first);
}
for(auto v:delv) S.erase(v);
if(s[i]==s[1]) curv+=w[i],++S[w[i]];
ans+=curv,print(ans),puts("");
}
return 0;
}
Luogu P6080 [USACO05DEC]Cow Patterns G#
考虑对于两个序列,如何判定其等价。我们发现重复的数非常麻烦,因此可以把序列中的某个数 xx 看作一个二元组 (x,c)(x,c),其中 cc 表示在 xx 所在位置之前大小等于 xx 的数的数量。
两个序列 a,ba,b 相同,当且仅当对于每一位 ii,a[1,i−1]a[1,i−1] 中小于 aiai 的数的数量和 b[1,i−1]b[1,i−1] 中小于 bibi 的数的数量相等。同时,对于这一位,a[1,i−1]a[1,i−1] 中等于 aiai 的数的数量也要和 b[1,i−1]b[1,i−1] 中等于 bibi 的数的数量相等。这些要求本质上确定了插入第 ii 位时这个数要插在的位置。
等价仍然是具有传递性的,因此这不影响 KMP 的过程。因此我们只需要按照上面的方法修改两个字符相等的判定即可。
# include <bits/stdc++.h>
const int N=200010,INF=0x3f3f3f3f;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-') f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
int n,k,s;
int T[N];
int S[N];
int occ[N][30];
int pi[N];
inline int g(int l,int r,int p){
return occ[r][p]-occ[l-1][p];
}
inline bool mat(int x,int y){
if(x>k) return false;
return (g(1,x-1,S[x]-1)==g(y-x+1,y-1,S[y]-1))&&(g(1,x-1,S[x])==g(y-x+1,y-1,S[y]));
}
int main(void){
n=read(),k=read(),s=read();
for(int i=1;i<=n;++i) T[i]=read();
for(int i=1;i<=k;++i) S[i]=read();
for(int i=1;i<=n;++i) S[k+i+1]=T[i];
int len=n+k+1;
for(int i=1;i<=len;++i){
memcpy(occ[i],occ[i-1],sizeof(occ[i]));
for(int j=S[i];j<=s;++j) ++occ[i][j];
}
std::vector <int> vec;
for(int i=2;i<=len;++i){
if(i==k+1) continue;
int j=pi[i-1];
while(!mat(j+1,i)){
if(!j) break;
j=pi[j];
}
pi[i]=j+1;
if(i>k&&pi[i]==k) vec.push_back(i-(k+1)-k+1);
}
printf("%llu\n",vec.size());
for(auto v:vec) printf("%d\n",v);
return 0;
}
Luogu P5287 [HNOI2019] JOJO#
不难发现询问离线,因此可持久化无用,可以建出操作树后 DFS。
将一个字符串看作若干个二元组 (x,c)(x,c) 构成的序列,其中 xx 描述这段字符的数量,cc 描述这段字符的种类。例如,aaaabbbbaaaaaabbbbaa 可以被描述为 [(4,a),(4,b),(2,a)][(4,a),(4,b),(2,a)]。
如何判定这个字符串的某个前缀和后缀相等?事实上,题目保证了相邻两次插入的字符种类不同,因此,分别将该前缀和该后缀所在的二元组序列取出(它们也分别是原二元组序列的一段前缀和后缀。另外,如果某个二元组不被完全包含,我们也会取出它),那么这两个序列需要满足以下条件:
- 两个序列的长度相同;
- 序列中每个位置的字符对应相等;
- 除了头尾,中间的二元组必须完全相等;
- 对于第一个二元组,前缀序列的字符数量不超过后缀序列的字符数量;
- 对于最后一个二元组,后缀序列的字符数量不超过前缀序列的字符数量。
以 aabbcccdaaabbccaabbcccdaaabbcc 为例。它可以被描述为 [(2,a),(2,b),(3,c),(1,d),(3,a),(2,b),(2,c)][(2,a),(2,b),(3,c),(1,d),(3,a),(2,b),(2,c)],取出长度为 66 的前缀和长度为 66 的后缀的二元组序列,它们分别是 [(2,a),(2,b),(3,c)],[(3,a),(2,b),(2,c)][(2,a),(2,b),(3,c)],[(3,a),(2,b),(2,c)]。按照上述规则,我们可以判定长度为 66 的后缀和长度为 66 的前缀相等。
注意到,中间的二元组必须完全相等,因此 KMP 的过程不会发生太大变化。我们只需要略微修改二元组序列中,两个前后缀相等的定义:前后缀长度必须相等;除了第一个二元组,其余二元组必须完全相等;第一个二元组的字符必须相等,前缀中该二元组的字符数量必须不超过后缀中该二元组的字符数量。
在序列中加入第 ii 个二元组 (xi,ci)(xi,ci) 的时候,我们需要关心新加入的这 xixi 个字符对应前缀的最长 border 长度。我们可以从长到短遍历二元组序列中前缀 i−1i−1 的所有 border bb,并检查该 border 的下一个位置 b+1b+1 是否有 cb+1=cicb+1=ci。如果是,那么新加入的第 k(1≤k≤min(xi,xb+1))k(1≤k≤min(xi,xb+1)) 个字符就存在一个长度为 (b∑j=1xj)+k(b∑j=1xj)+k 的 border 了。
注意到我们的操作是在操作树上,因此不能再用带势能的 KMP 了。考虑 WPL,对于一个长度为 nn 的字符串 ss,设其最长的 border 长度为 n−dn−d,那么 dd 是其最小周期。若周期 d′d′ 满足 d′+d≤nd′+d≤n,则 gcd(d′,d)gcd(d′,d) 也是 ss 的周期,因此 d′d′ 只能是 dd 的倍数。从而所有大小在 [d,n−d][d,n−d] 之间的周期都形如 kdkd。
因此,长度为 n−d,n−2d,⋯,nmodd+dn−d,n−2d,⋯,nmodd+d 的 border 构成了一个等差数列,根据结论,这些 border 中除了最长的那个,剩下的 border 的下一个字符都是相同的。因此对于该等差数列,只需要检查该等差数列的最长 border 和次长 border 即可。
具体地,我们维护两个指针 cl,crcl,cr,其中 clcl 表示当前位于的 border,crcr 表示上一个 border。如果 cr−cl=cl−πclcr−cl=cl−πcl,则说明 clcl 是等差数列中的次长 border,我们直接跳到等差数列中的第一个 border,并将 crcr 置为 −1−1(这样下一次判断一定不会成立)。否则,我们只向前跳一步,即将 crcr 置为 clcl,clcl 置为 πclπcl。
# include <bits/stdc++.h>
const int N=100010,INF=0x3f3f3f3f,mod=998244353;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-') f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
struct Edge{
int x,to;
char c;
};
std::vector <Edge> G[N];
int n;
int cnt=1;
int ver[N];
inline int sum(int l,int r){
if(l>r) return 0;
return 1ll*(l+r)*(r-l+1)/2ll%mod;
}
int ans[N];
namespace KM{
int top,pi[N],len[N];
int ans[N];
struct Ele{
int x;
char c;
bool operator == (const Ele &rhs) const{
return x==rhs.x&&c==rhs.c;
}
}v[N];
inline bool chk(int pos,Ele ele){
if(!pos) return (v[1].c==ele.c)&&(v[1].x<=ele.x);
return v[pos+1]==ele;
}
inline void add(int &a,int b){
a=(a+b)%mod;
return;
}
inline void ins(int x,char c){
Ele ele=(Ele){x,c};
++top,pi[top]=0,v[top]=ele,len[top]=len[top-1]+x;
int &res=ans[top];res=ans[top-1];
if(top==1) return res=sum(1,x-1),void(); // 细节 1: 第一次插入
int cl=pi[top-1],cr=top-1,mx=0;
// mx 表示当前插入的前 mx 个字符已经找到了 border.
// 因此只有 > mx 的部分有可能造成贡献
while(cl&&!chk(cl,ele)){
if(v[cl+1].c==c&&v[cl+1].x>mx) // 有贡献
add(res,sum(len[cl]+mx+1,len[cl]+std::min(x,v[cl+1].x))),mx=std::min(x,v[cl+1].x);
if(cr-cl==cl-pi[cl]){ // 位于等差数列的次长 border
int d=cr-cl;
cr=-1,cl=cl%d+d;
}else cr=cl,cl=pi[cl];
}
if(chk(cl,ele)){
pi[top]=cl+1,add(res,sum(len[cl]+mx+1,len[cl]+v[cl+1].x));
add(res,1ll*(len[cl]+v[cl+1].x)*(x-std::max(mx,v[cl+1].x)%mod));
// 此处注意细节:cl = 0 时,v[cl+1].x 未必和 x 相等
}
else{ // 边界:cl = 0 时仍有可能会造成贡献 (此时 v[1].x > x,可以有贡献但不能被 border 匹配)
if(v[cl+1].c==c&&v[cl+1].x>mx)
add(res,sum(len[cl]+mx+1,len[cl]+std::min(x,v[cl+1].x))),mx=std::min(x,v[cl+1].x);
}
return;
}
inline void del(void){
--top;
return;
}
inline int get(void){
return ans[top];
}
}
void dfs(int u){
ans[u]=KM::get();
for(auto edge:G[u]){
int v=edge.to;
KM::ins(edge.x,edge.c),dfs(v),KM::del();
}
return;
}
int main(void){
n=read();
int cur=1;
ver[0]=1;
for(int i=1;i<=n;++i){
int op=read(),x;
if(op==1){
x=read();
char s[2];
scanf("%s",s);
++cnt,G[cur].push_back((Edge){x,cnt,s[0]}),cur=cnt,ver[i]=cnt;
}else x=read(),ver[i]=cur=ver[x];
}
dfs(1);
for(int i=1;i<=n;++i) printf("%d\n",ans[ver[i]]);
return 0;
}
Z 函数#
对于长度为 nn 的字符串 ss,定义其 Z 函数数组 z=[z1,z2,⋯,zn]z=[z1,z2,⋯,zn],其中 zizi 为 s[1,n]s[1,n] 与 s[i,n]s[i,n] 的 LCP 长度。
与前缀函数 ππ 类似,zz 也可以在线性时间内求出。算法如下:
-
令 z1=nz1=n;
-
遍历 i=2,3,⋯,ni=2,3,⋯,n,并维护 Z-box [l,r][l,r],表示最靠右的一段区间,使得它可以和 ss 的某个前缀完全匹配。初始 l=r=0l=r=0。执行以下流程:
- 若 i≤ri≤r,说明 ii 位于 Z-box 中。此时根据定义,有 s[l,r]=s[1,r−l+1]s[l,r]=s[1,r−l+1],从而 s[i,r]=s[i−l+1,r−l+1]s[i,r]=s[i−l+1,r−l+1]。因此 zi≥min(zi−l+1,r−i+1)zi≥min(zi−l+1,r−i+1)。
- 暴力扩展 zizi。即:若 s[zi+1]=s[i+zi]s[zi+1]=s[i+zi] 则令 zizi 增加 11,直到该条不再成立。
- 更新 Z-box。即:若区间 [i,i+zi−1][i,i+zi−1] 的右端点 (i+zi−1)(i+zi−1) 位于 Z-box [l,r][l,r] 的右端点 rr 右侧,则将 Z-box 更新为前者。
z[1]=n;
for(int i=2,l=0,r=0;i<=n;++i){
if(i<=r) z[i]=std::min(z[i-l+1],r-i+1);
while(i+z[i]<=n&&s[i+z[i]]==s[z[i]+1]) ++z[i];
if(i+z[i]-1>r) l=i,r=i+z[i]-1;
}
Luogu P5410 【模板】扩展 KMP/exKMP#
事实上,我们可以使用与求 zz 时几乎一致的方式求出 pp。
z[1]=m;
for(int i=2,l=0,r=0;i<=m;++i){
if(i<=r) z[i]=std::min(z[i-l+1],r-i+1);
while(i+z[i]<=m&&t[i+z[i]]==t[z[i]+1]) ++z[i];
if(i+z[i]-1>r) l=i,r=i+z[i]-1;
}
for(int i=1,l=0,r=0;i<=n;++i){
if(i<=r) p[i]=std::min(z[i-l+1],r-i+1);
while(i+p[i]<=n&&s[i+p[i]]==t[p[i]+1]) ++p[i];
if(i+p[i]-1>r) l=i,r=i+p[i]-1;
}
Z 函数的应用场景就比较局限了。或许我们唯一能够期望的是它能在极端情况下少一个 log。
AC 自动机与多串匹配#
现在有 nn 个字符串 s1,s2,⋯,sns1,s2,⋯,sn。它们的 AC 自动机 AA 可以看作是 s1,s2,⋯,sns1,s2,⋯,sn 的 Trie 树 TT 上,新建出一些边形成的有向图结构。
具体来说,我们希望 TT 扩充为 AA 后,AA 满足如下性质:
- 节点集合不变,原有的边仍然存在。
- 包含所有形如 tr(x,c)tr(x,c) 的边,其中 xx 属于节点集合,cc 属于字符集。
- 设节点 xx 表示的字符串为 str(x)str(x),str(x)+cstr(x)+c 最长的在 TT 上出现过的后缀为 msuf(x)msuf(x),则 tr(x,c)tr(x,c) 指向 msuf(x)msuf(x) 代表的节点。
以下给出扩充方法。考虑对 TT 进行 BFS,维护出 fail(x)fail(x),指向代表 str(x)str(x) 在 TT 上出现过的最长后缀的节点。
算法流程如下:
-
维护一个 BFS 队列,初始将根节点入队。
-
若队列为空,结束算法。否则,取出队列头元素 xx 并弹出,转到 3。
-
遍历 c∈Σc∈Σ,检查 tr(x,c)tr(x,c)。若 tr(x,c)tr(x,c) 存在,转到 4,否则转到 5。
-
令 y=tr(x,c)y=tr(x,c)。将 fail(y)fail(y) 更新为 tr(fail(x),c)tr(fail(x),c),并将 yy 入队,保持 tr(x,c)tr(x,c) 不变,转到 2。
-
令 tr(x,c)←tr(fail(x),c)tr(x,c)←tr(fail(x),c),转到 2。
inline void init(void){
for(int i=0;i<26;++i) tr[0].nex[i]=1;
std::queue <int> q; q.push(1);
while(!q.empty()){
int i=q.front();
q.pop();
for(int j=0;j<26;++j){
int &nex=tr[i].nex[j];
if(!nex) nex=tr[tr[i].fail].nex[j];
else tr[nex].fail=tr[tr[i].fail].nex[j],q.push(nex);
}
}
return;
}
不难发现 (fail(x),x)(fail(x),x) 组成树形结构,称作 fail 树。
以下是几个简单应用。
在应用之前,有几点提示:
- 做不动了,就考虑根号分治。
- 如果你觉得不好做,想想有没有基于 ∑|S|∑|S| 的做法。
- 如果题目询问的是整串在整串中的出现信息,大概率 ACAM 和广义 SAM 近似等价,可以先考虑简单的 ACAM。
Luogu P5357 【模板】AC 自动机#
给定文本串 SS 和模式串 T1,T2,⋯,TnT1,T2,⋯,Tn,求每个模式串在 SS 中出现的次数。
1≤n,∑|T|≤2×105,1≤|S|≤2×1061≤n,∑|T|≤2×105,1≤|S|≤2×106
对 TT 建出 AC 自动机,随后将 SS 放上 AC 自动机进行匹配。流程为:
- 维护指针 pp,初始为根。每个节点维护一个标记大小,初始为 00。
- 遍历 i=1,2,⋯,|S|i=1,2,⋯,|S|,令 p←tr(p,Si)p←tr(p,Si),将 pp 节点的标记大小增加 11。
随后遍历 i=1,2,⋯,ni=1,2,⋯,n,则根据 fail 指针的定义,TiTi 对应节点在 fail 树上子树的标记大小之和即为答案。
Luogu P4052 [JSOI2007] 文本生成器#
在 AC 自动机上 DP。
第一种方法是补集转化,设 f(i,j)f(i,j) 表示长度为 ii 的字符串,把这个字符串在 ACAM 上匹配完成后停在节点 jj 上,且路径上不经过任何终止节点(节点表示的字符串的某个后缀位于字典中)的方案数。转移是简单的。
第二种方法是,直接设 f(i,j,0/1)f(i,j,0/1) 表示长度为 ii 的字符串,把这个字符串在 ACAM 上匹配完成后停在节点 jj 上,且路径还未经过 / 已经经过终止节点的方案数。
这里给出第一种方法的实现。
# include <bits/stdc++.h>
const int N=2000010,INF=0x3f3f3f3f,mod=1e4+7;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-') f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
int n,m;
char s[N];
namespace acam{
int nex[N][26];
int fail[N],cnt=1,fa[N];
bool ed[N];
inline void ins(void){
int len=strlen(s+1),cur=1;
for(int i=1;i<=len;++i){
int &ne=nex[cur][s[i]-'A'];
if(!ne) ne=++cnt,fa[cnt]=cur;
cur=ne;
}
ed[cur]=true;
return;
}
std::vector <int> G[N];
inline void init(void){
for(int i=0;i<26;++i) nex[0][i]=1;
std::queue <int> q;q.push(1);
while(!q.empty()){
int i=q.front();
q.pop(),ed[i]|=ed[fail[i]];
for(int j=0;j<26;++j){
if(!nex[i][j]) nex[i][j]=nex[fail[i]][j];
else fail[nex[i][j]]=nex[fail[i]][j],q.push(nex[i][j]);
}
}
return;
}
}
using namespace acam;
int f[105][6010];
int main(void){
n=read(),m=read();
for(int i=1;i<=n;++i) scanf("%s",s+1),ins();
init();
f[0][1]=1;
int ans=1;
for(int i=1;i<=m;++i) ans=ans*26%mod;
for(int i=0;i<m;++i){
for(int j=1;j<=cnt;++j){
for(int c=0;c<26;++c){
int jj=nex[j][c];
if(ed[jj]) continue;
f[i+1][jj]=(f[i][j]+f[i+1][jj])%mod;
}
}
}
for(int i=1;i<=cnt;++i) ans=(ans-f[m][i]+mod)%mod;
printf("%d",ans);
return 0;
}
Codeforces 86C Genetic engineering#
限制可以表述为:对于每个位置,至少有一个模式串覆盖它。
据此可以 DP。设 f(i,j,k)f(i,j,k) 表示考虑了前 ii 个字符,当前位于 AC 自动机上的 jj 节点,下标最小的没有覆盖的位置在 (i+1)−k(i+1)−k 处(若全部被覆盖,则取 k=0k=0)的方案数。
初始有 f(0,root,1)=1f(0,root,1)=1。考虑从 f(i,j,k)f(i,j,k) 转移到 f(i+1)f(i+1),枚举这一位填的字符 cc,记 tr(j,c)=j′tr(j,c)=j′,那么,记以 j′j′ 结尾的最长模式串长度为 ll,则当 l>kl>k 时,该模式串覆盖掉之前所有的没有被覆盖的位置,f(i,j,k)f(i,j,k) 转移到 f(i+1,j′,0)f(i+1,j′,0);否则,f(i,j,k)f(i,j,k) 转移到 f(i+1,j′,k+1)f(i+1,j′,k+1)。
# include <bits/stdc++.h>
const int N=100010,INF=0x3f3f3f3f,mod=1e9+9;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-') f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
int n,m;
int f[1005][105][12];
char s[20];
std::unordered_map <char,int> mp;
namespace acam{
int nex[N][4],fail[N];
int cnt=1;
int ml[N];
inline void ins(void){
int len=strlen(s+1),cur=1;
for(int i=1;i<=len;++i){
if(!nex[cur][mp[s[i]]]) nex[cur][mp[s[i]]]=++cnt;
cur=nex[cur][mp[s[i]]];
}
ml[cur]=len;
return;
}
inline void init(void){
for(int i=0;i<4;++i) nex[0][i]=1;
std::queue <int> q;
q.push(1);
while(!q.empty()){
int i=q.front();
q.pop();
ml[i]=std::max(ml[i],ml[fail[i]]);
for(int j=0;j<4;++j){
if(!nex[i][j]) nex[i][j]=nex[fail[i]][j];
else fail[nex[i][j]]=nex[fail[i]][j],q.push(nex[i][j]);
}
}
return;
}
}
using namespace acam;
inline void add(int &a,int b){
return a=(a+b)%mod,void();
}
int main(void){
n=read(),m=read();
mp['A']=0,mp['C']=1,mp['T']=2,mp['G']=3;
for(int i=1;i<=m;++i){
scanf("%s",s+1),ins();
}
init();
f[0][1][0]=1;
for(int i=0;i<n;++i){
for(int j=1;j<=cnt;++j){
for(int len=0;len<=10;++len){
for(int d=0;d<4;++d){
int k=nex[j][d];
if(ml[k]>len) add(f[i+1][k][0],f[i][j][len]);
else add(f[i+1][k][len+1],f[i][j][len]);
}
}
}
}
int ans=0;
for(int i=1;i<=cnt;++i) add(ans,f[n][i][0]);
printf("%d",ans);
return 0;
}
Luogu P2292 [HNOI2004] L 语言#
给定大小为 nn 的字典 DD 和 mm 个文本串 T1,T2,⋯,TmT1,T2,⋯,Tm,对于每个文本串,求出最大的 ii,使得 T[1,i]T[1,i] 可以被划分为若干个字典中的单词。
1≤n≤20,1≤m≤50,1≤|t|≤2×1061≤n≤20,1≤m≤50,1≤|t|≤2×106,字典中的单词长度不超过 2020
对于文本串 TT,考虑暴力 DP:设 f(i)f(i) 表示让 T[1,i]T[1,i] 能够被划分是否可行。转移需要枚举字典中的单词判断合法性。
考虑如何快速找出合法单词。称 fail 树上代表 TiTi 的那些节点为终止节点,设 T[1,i]T[1,i] 在 AC 自动机上匹配后位于节点 pp,则 fail 树上节点 pp 到根的路径中,所有的终止节点都是合法的转移串。
进一步地,我们只需要这些串的长度,因此可以进行状态压缩。
但是这道题 n≤20n≤20 真是有深意呀,总感觉暴力都过去了。
# include <bits/stdc++.h>
const int N=100010,INF=0x3f3f3f3f;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-') f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
struct Node{
int fail,nex[26],st,dep;
bool ed;
}tr[N];
char s[2000010];
int n,m;
int cnt=1;
inline void ins(void){
int len=strlen(s+1),cur=1;
for(int i=1;i<=len;++i){
int w=s[i]-'a';
if(!tr[cur].nex[w]) tr[cur].nex[w]=++cnt,tr[cnt].dep=tr[cur].dep+1;
cur=tr[cur].nex[w];
}
tr[cur].ed=true;
return;
}
inline void init(void){
for(int i=0;i<26;++i) tr[0].nex[i]=1;
std::queue <int> q; q.push(1);
while(!q.empty()){
int i=q.front();
q.pop();
tr[i].st=tr[tr[i].fail].st|((int)tr[i].ed<<tr[i].dep);
for(int j=0;j<26;++j){
int &nex=tr[i].nex[j];
if(!nex) nex=tr[tr[i].fail].nex[j];
else tr[nex].fail=tr[tr[i].fail].nex[j],q.push(nex);
}
}
return;
}
inline int query(void){
int len=strlen(s+1),ans=0,cur=1;
unsigned st=1;
for(int i=1;i<=len;++i){
cur=tr[cur].nex[s[i]-'a'];
st<<=1;
if(tr[cur].st&st) st|=1,ans=i;
}
return ans;
}
int main(void){
n=read(),m=read();
for(int i=1;i<=n;++i) scanf("%s",s+1),ins();
init();
for(int i=1;i<=m;++i) scanf("%s",s+1),printf("%d\n",query());
return 0;
}
Luogu P2414 [NOI2011] 阿狸的打字机#
给定一棵 Trie 树,mm 次询问 Trie 树上某个节点 xx 所代表的字符串在另一个节点 yy 所代表的字符串中出现了多少次。
字符集大小为 2626,保证 Trie 树大小不超过 105105。1≤m≤1051≤m≤105
首先建出 AC 自动机。
考虑询问只有一次怎么做。将 yy 的每个前缀节点打上标记,则根据 fail 指针的定义,只需要查询 xx 在 fail 树上的子树和。
存在多次询问时做法区别不大。考虑采用 DFS Trie 树的方式遍历所有的 yy,进入节点时给该节点打上标记,离开节点时撤销。将关于 (x,y)(x,y) 的询问挂在节点 yy 上,遍历到 yy 时查询。子树和使用树状数组维护,时间复杂度 O(nΣ+(n+m)logn)O(nΣ+(n+m)logn),其中 nn 为 Trie 树大小。
# include <bits/stdc++.h>
const int N=100010,INF=0x3f3f3f3f;
struct Node{
int fnex[26],nex[26];
int fail;
int fa;
}trie[N];
struct Edge{
int to,next;
}edge[N<<1];
std::vector <std::pair <int,int> > qu[N];
int head[N],esum;
int n,m,cnt=1;
char op[N];
int endpos[N],dfn[N],t,size[N],sum[N],ans[N];
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-')f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
inline void add(int x,int y){
edge[++esum]=(Edge){y,head[x]},head[x]=esum;
return;
}
inline void GetFail(void){
std::queue <int> q=std::queue <int> ();
for(int i=0;i<26;++i){
int to=trie[1].fnex[i];
if(to)
q.push(to),trie[to].fail=1;
else
trie[1].fnex[i]=1;
}
while(!q.empty()){
int i=q.front();
q.pop();
add(trie[i].fail,i);
for(int j=0;j<26;++j){
int &to=trie[i].fnex[j];
if(to)
trie[to].fail=trie[trie[i].fail].fnex[j],q.push(to);
else
to=trie[trie[i].fail].fnex[j];
}
}
return;
}
void dfs(int i){
dfn[i]=++t,size[i]=1;
for(int j=head[i],to;j;j=edge[j].next)
to=edge[j].to,dfs(to),size[i]+=size[to];
return;
}
inline int lowbit(int x){
return x&(-x);
}
inline void addv(int x,int v){
for(;x<=cnt;x+=lowbit(x))
sum[x]+=v;
return;
}
inline int query(int l,int r){
int res=0;
--l;
for(;l;l-=lowbit(l))
res-=sum[l];
for(;r;r-=lowbit(r))
res+=sum[r];
return res;
}
void solve(int i){
addv(dfn[i],1);
for(int k=0,x;k<(int)qu[i].size();++k)
x=qu[i][k].first,ans[qu[i][k].second]=query(dfn[x],dfn[x]+size[x]-1);
for(int j=0;j<26;++j)
if(trie[i].nex[j])
solve(trie[i].nex[j]);
addv(dfn[i],-1);
return;
}
int main(void){
scanf("%s",op+1);
int p=1,oplen=strlen(op+1);
trie[1].fa=1;
for(int i=1;i<=oplen;++i){
if(op[i]=='B')
p=trie[p].fa;
else if(op[i]=='P')
endpos[++n]=p;
else{
int &to=trie[p].nex[op[i]-'a'];
if(!to)
to=++cnt,trie[to].fa=p;
p=to;
}
}
for(int i=1;i<=cnt;++i)
for(int j=0;j<26;++j)
trie[i].fnex[j]=trie[i].nex[j];
GetFail();
dfs(1);
m=read();
for(int i=1,x,y;i<=m;++i)
x=read(),y=read(),qu[endpos[y]].push_back(std::make_pair(endpos[x],i));
solve(1);
for(int i=1;i<=m;++i)
printf("%d\n",ans[i]);
return 0;
}
Codeforces 547E Mike and Friends#
出现次数可以差分,转化为 sksk 在 s1⋯rs1⋯r 中的出现次数。考虑对 rr 扫描线,对于 srsr 的每个前缀节点,将该节点的标记大小增加 11,查询即查询 sksk 对应节点在 fail 树上的子树和。
# include <bits/stdc++.h>
const int N=500010,INF=0x3f3f3f3f;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-') f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
int n,q;
char s[N];
int edp[N];
namespace acam{
int nex[N][26];
int fail[N],cnt=1,fa[N];
inline void ins(int x){
int len=strlen(s+1),cur=1;
for(int i=1;i<=len;++i){
int &ne=nex[cur][s[i]-'a'];
if(!ne) ne=++cnt,fa[cnt]=cur;
cur=ne;
}
edp[x]=cur;
return;
}
std::vector <int> G[N];
int dfn[N],t,siz[N];
void dfs(int x){
dfn[x]=++t,siz[x]=1;
for(auto y:G[x]) dfs(y),siz[x]+=siz[y];
return;
}
inline void init(void){
for(int i=0;i<26;++i) nex[0][i]=1;
std::queue <int> q;q.push(1);
while(!q.empty()){
int i=q.front();
q.pop();
for(int j=0;j<26;++j){
if(!nex[i][j]) nex[i][j]=nex[fail[i]][j];
else fail[nex[i][j]]=nex[fail[i]][j],q.push(nex[i][j]);
}
}
for(int i=2;i<=cnt;++i) G[fail[i]].push_back(i);
dfs(1);
return;
}
}
using namespace acam;
int sum[N];
inline int lb(int x){
return x&(-x);
}
inline void add(int x,int v){
for(;x<=cnt;x+=lb(x)) sum[x]+=v;
return;
}
inline int query(int x){
int ans=0;
for(;x;x-=lb(x)) ans+=sum[x];
return ans;
}
inline int query(int l,int r){
return query(r)-query(l-1);
}
std::vector <std::pair <int,int> > qr[N];
int ans[N];
int main(void){
n=read(),q=read();
for(int i=1;i<=n;++i) scanf("%s",s+1),ins(i);
init();
for(int i=1;i<=q;++i){
int l=read(),r=read(),k=read();
qr[r].push_back(std::make_pair(k,i));
qr[l-1].push_back(std::make_pair(k,-i));
}
for(int i=1;i<=n;++i){
for(int j=edp[i];j;j=fa[j]) add(dfn[j],1);
for(auto qq:qr[i]){
int x=edp[qq.first];
ans[abs(qq.second)]+=(qq.second>0?1:-1)*query(dfn[x],dfn[x]+siz[x]-1);
}
}
for(int i=1;i<=q;++i) printf("%d\n",ans[i]);
return 0;
}
Luogu P5840 [COCI2015] Divljak#
考虑对 SS 建出 ACAM。则加入一个字符串 TT 的贡献形如:找到 TT 的每个前缀在 ACAM 上匹配后位于的节点 ,将这些节点到根路径并中的每一个点的标记大小增加 11。
这是经典问题。将这些节点按照 DFS 序排序,采用如下方式表示贡献:
- 将每个节点到根的路径上每一个点的标记大小增加 11。
- 将相邻节点的 LCA 到根的路径上每一个点的标记大小减小 11。
需要支持链加单点查询。转换为单点加子树查询,树状数组维护即可。
# include <bits/stdc++.h>
const int N=2000010,INF=0x3f3f3f3f;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-') f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
int n,q;
char s[N];
int edp[N];
namespace acam{
int nex[N][26];
int fail[N],cnt=1,fa[N];
inline void ins(int x){
int len=strlen(s+1),cur=1;
for(int i=1;i<=len;++i){
int &ne=nex[cur][s[i]-'a'];
if(!ne) ne=++cnt,fa[cnt]=cur;
cur=ne;
}
edp[x]=cur;
return;
}
std::vector <int> G[N];
int dfn[N],t,siz[N];
int f[N][20],dep[N];
void dfs(int x,int fa){
dep[x]=dep[fa]+1,dfn[x]=++t,siz[x]=1,f[x][0]=fa;
for(int k=1;k<=19;++k) f[x][k]=f[f[x][k-1]][k-1];
for(auto y:G[x]) dfs(y,x),siz[x]+=siz[y];
return;
}
inline int lca(int u,int v){
if(dep[u]<dep[v]) std::swap(u,v);
for(int k=19;k>=0;--k) if(dep[f[u][k]]>=dep[v]) u=f[u][k];
if(u==v) return u;
for(int k=19;k>=0;--k) if(f[u][k]!=f[v][k]) u=f[u][k],v=f[v][k];
return f[u][0];
}
inline void init(void){
for(int i=0;i<26;++i) nex[0][i]=1;
std::queue <int> q;q.push(1);
while(!q.empty()){
int i=q.front();
q.pop();
for(int j=0;j<26;++j){
if(!nex[i][j]) nex[i][j]=nex[fail[i]][j];
else fail[nex[i][j]]=nex[fail[i]][j],q.push(nex[i][j]);
}
}
for(int i=2;i<=cnt;++i) G[fail[i]].push_back(i);
dfs(1,0);
return;
}
}
using namespace acam;
int sum[N];
inline int lb(int x){
return x&(-x);
}
inline void add(int x,int v){
for(;x<=cnt;x+=lb(x)) sum[x]+=v;
return;
}
inline int query(int x){
int ans=0;
for(;x;x-=lb(x)) ans+=sum[x];
return ans;
}
inline int query(int l,int r){
return query(r)-query(l-1);
}
inline bool cmp(int u,int v){
return dfn[u]<dfn[v];
}
int d[N];
int main(void){
n=read();
for(int i=1;i<=n;++i) scanf("%s",s+1),ins(i);
init();
q=read();
for(int i=1,idx;i<=q;++i){
int op=read();
if(op==1){
scanf("%s",s+1);
int len=strlen(s+1),cur=1;
for(int j=1;j<=len;++j){
cur=nex[cur][s[j]-'a'],d[j]=cur;
}
std::sort(d+1,d+1+len,cmp);
for(int j=1;j<=len;++j){
add(dfn[d[j]],1);
if(j>1) add(dfn[lca(d[j],d[j-1])],-1);
}
}else idx=edp[read()],printf("%d\n",query(dfn[idx],dfn[idx]+siz[idx]-1));
}
return 0;
}
Codeforces 710F String Set Queries#
首先,这里有一个幽默的 Hash,记串长和为 LL,不难发现串长种类数为 √L√L,据此可以 O(L√L)O(L√L) Hash。
AC 自动机的部分有一种奇怪的二进制分组法,暂时没有想明白它的本质是什么。具体方法如下:考虑到出现次数可减,因此我们不在 AC 自动机中删除,而是分别维护被加入串的 AC 自动机和被删除串的 AC 自动机。
对于某一个集合而言,加入一个串会导致整个 AC 自动机的形态发生变化,需要在 Trie 树基础上重新跑 init 函数。考虑二进制分组,设当前集合中有 cc 个串,则把这个集合分为若干组,组的大小为 cc 的二进制拆分。例如,c=23=16+4+2+1c=23=16+4+2+1,则把集合分为大小为 16,4,2,116,4,2,1 的三组,组内维护一个 AC 自动机。考虑加入第 c+1c+1 个串,首先新开一组,在这一组内的 AC 自动机中插入该串。接着检查,如果之前的组大小和最后一组相等,则合并这两个组(模拟二进制加法的过程)。
合并两个组的代价是两个组内字符串的串长之和再乘上 |Σ||Σ|。那么对于每一个串,它被合并一次,所处的集合大小翻倍,因此只会被合并不超过 log2mlog2m 次。从而总复杂度为 O(L|Σ|log2m)O(L|Σ|log2m)。
# include <bits/stdc++.h>
const int N=300010,INF=0x3f3f3f3f;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-') f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
char S[N];
struct acam{
int nex[N][26],cnt,fail[N];
int nnex[N][26];
int ed[N],ss[N];
int siz[30],bcnt;
int root[30];
inline void ins(char *s,int len,int &crt){
if(!crt) crt=++cnt;
int cur=crt;
for(int i=1;i<=len;++i){
int &ne=nex[cur][s[i]-'a'];
if(!ne) ne=++cnt;
cur=ne;
}
++ed[cur];
return;
}
inline void merge(int &cur,int u,int v){
if(!u||!v) return cur=u|v,void();
cur=u,ed[cur]+=ed[v];
for(int j=0;j<26;++j) merge(nex[cur][j],nex[u][j],nex[v][j]);
return;
}
inline void init(int rt){
std::queue <int> q;
fail[rt]=0,q.push(rt);
for(int i=0;i<26;++i) nnex[0][i]=rt;
while(!q.empty()){
int i=q.front();
q.pop();
ss[i]=ed[i]+ss[fail[i]];
for(int j=0;j<26;++j){
if(!nex[i][j]) nnex[i][j]=nnex[fail[i]][j];
else nnex[i][j]=nex[i][j],fail[nex[i][j]]=nnex[fail[i]][j],q.push(nex[i][j]);
}
}
return;
}
inline long long query(char *s,int len,int cur){
long long ans=0;
for(int i=1;i<=len;++i) cur=nnex[cur][s[i]-'a'],ans+=ss[cur];
return ans;
}
inline long long query_all(char *s,int len){
long long ans=0;
for(int i=1;i<=bcnt;++i) ans+=query(s,len,root[i]);
return ans;
}
inline void real_ins(char *s,int len){
siz[++bcnt]=1,root[bcnt]=0;
ins(s,len,root[bcnt]),init(root[bcnt]);
while(siz[bcnt]==siz[bcnt-1]){
siz[bcnt-1]*=2,merge(root[bcnt-1],root[bcnt-1],root[bcnt]),--bcnt,init(root[bcnt]);
}
return;
}
}A,B;
int n;
int main(void){
n=read();
for(int i=1;i<=n;++i){
int op=read();
scanf("%s",S+1);
int len=strlen(S+1);
if(op==1) A.real_ins(S,len);
else if(op==2) B.real_ins(S,len);
else printf("%lld\n",A.query_all(S,len)-B.query_all(S,len));
fflush(stdout);
}
return 0;
}
Codeforces 587F Duff is Mad#
547E 的对偶形式,但是很遗憾这道题没有 polylog 做法。
其原因是,本题的操作形如:将 sl,sl+1.⋯,srsl,sl+1.⋯,sr 对应节点在 fail 树上的子树 +1+1,然后查询 sksk 的所有前缀节点的权值之和。和 547E 不同,我们现在需要在文本串一侧处理前缀节点,这样就没法快速维护了。
这种情况的主流方法是阈值分治。具体来说,设定阈值 BB,分情况讨论:
-
如果 |sk|>B|sk|>B
这样的串只会有不超过 LBLB 个,其中 LL 是串长和。考虑线性求解所有有关 sksk 的询问的答案,具体地,将 sksk 的所有前缀节点权值 +1+1,那么一个 sxsx 的贡献即 sxsx 对应节点的子树和。子树和可以通过 DFS fail 树线性求出。差分后对 rr 扫描线统计答案。
这部分的复杂度是 O(L2B+qlogq)O(L2B+qlogq) 的。
-
如果 |sk|≤B|sk|≤B
这一部分,对于每个串,我们希望在 O(|sk|)O(|sk|) 左右求解。具体地,差分后对 rr 扫描线统计答案,将询问挂在对应端点,每扫过一个字符串,就将该字符串对应的节点在 fail 树上的子树 +1+1(这部分使用树状数组),询问时暴力枚举前缀节点,使用树状数组。这部分的复杂度是 O(qBlogL+nlogL)O(qBlogL+nlogL)。
令 L2B=qBlogLL2B=qBlogL,解得最优阈值为 B=L√qlogLB=L√qlogL,此时时间复杂度为 O(nlogL+qlogq+√qlogL⋅L)O(nlogL+qlogq+√qlogL⋅L)。
# include <bits/stdc++.h>
# define fir first
# define sec second
const int N=100010,INF=0x3f3f3f3f;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-') f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
int n,q;
char s[N];
int edp[N];
namespace acam{
int nex[N][26];
int fail[N],cnt=1,fa[N];
inline void ins(int x){
int len=strlen(s+1),cur=1;
for(int i=1;i<=len;++i){
int &ne=nex[cur][s[i]-'a'];
if(!ne) ne=++cnt,fa[cnt]=cur;
cur=ne;
}
edp[x]=cur;
return;
}
std::vector <int> G[N];
int dfn[N],t,siz[N];
void dfs(int x){
dfn[x]=++t,siz[x]=1;
for(auto y:G[x]) dfs(y),siz[x]+=siz[y];
return;
}
inline void init(void){
for(int i=0;i<26;++i) nex[0][i]=1;
std::queue <int> q;q.push(1);
while(!q.empty()){
int i=q.front();
q.pop();
for(int j=0;j<26;++j){
if(!nex[i][j]) nex[i][j]=nex[fail[i]][j];
else fail[nex[i][j]]=nex[fail[i]][j],q.push(nex[i][j]);
}
}
for(int i=2;i<=cnt;++i) G[fail[i]].push_back(i);
dfs(1);
return;
}
}
using namespace acam;
int m,T;
int len[N];
long long ans[N];
std::vector <std::pair <int,int> > Qs[N],Qh[N];
int sum[N],tag[N];
inline int lb(int x){
return x&(-x);
}
inline void add(int x,int v){
for(;x<=cnt;x+=lb(x)) sum[x]+=v;
return;
}
inline int query(int x){
int ans=0;
for(;x;x-=lb(x)) ans+=sum[x];
return ans;
}
inline int query(int l,int r){
return query(r)-query(l-1);
}
void dfs_h(int x){
for(auto y:G[x]) dfs_h(y),tag[x]+=tag[y];
return;
}
int sig(int x){
return (x>0)-(x<0);
}
int main(void){
n=read(),q=read();
for(int i=1;i<=n;++i){
scanf("%s",s+1),ins(i),m+=(len[i]=strlen(s+1));
}
init();
T=ceil(m/sqrt(q*log2(m)));
for(int i=1;i<=q;++i){
int l=read(),r=read(),k=read();
if(len[k]>T) Qh[k].emplace_back(l-1,-i),Qh[k].emplace_back(r,i);
else Qs[l-1].emplace_back(k,-i),Qs[r].emplace_back(k,i);
}
for(int i=1;i<=n;++i){
if(len[i]>T){
std::fill(tag+1,tag+1+cnt,0);
for(int j=edp[i];j;j=fa[j]) tag[j]=1;
dfs_h(1);
std::sort(Qh[i].begin(),Qh[i].end());
long long tsum=0;
int pt=0;
while(pt<(int)Qh[i].size()&&Qh[i][pt].fir==0) ++pt;
for(int j=1;j<=n;++j){
tsum+=tag[edp[j]];
while(pt<(int)Qh[i].size()&&Qh[i][pt].fir==j){
int id=abs(Qh[i][pt].sec),v=sig(Qh[i][pt].sec);
ans[id]+=v*tsum;
++pt;
}
}
}
}
for(int i=1;i<=n;++i){
add(dfn[edp[i]],1),add(dfn[edp[i]]+siz[edp[i]],-1);
for(auto qa:Qs[i]){
int x=qa.fir,id=abs(qa.sec),v=sig(qa.sec);
for(int j=edp[x];j!=1;j=fa[j]) ans[id]+=v*query(dfn[j]);
}
}
for(int i=1;i<=q;++i) printf("%lld\n",ans[i]);
return 0;
}
Luogu P8203 [传智杯 #4 决赛] DDOSvoid 的馈赠#
对于一个询问 tx,tytx,ty,将 txtx 的每个前缀节点在 fail 树上到根的路径上打上标记 xx,将 tyty 的每个前缀节点在 fail 树上到根的路径打上标记 yy。另外,将 sisi 对应的节点的权值 +1+1。所求即为同时有标记 x,yx,y 的节点的权值之和。可以看作求两个虚树的交。
直接做仍然是不太好做的。因此我们同样考虑阈值分治。称长度大于阈值 BB 的为大串,否则为小串。
考虑 tx,tytx,ty 至少有一个是大串的询问。不失一般性,设 txtx 是大串。将 txtx 的每个前缀节点在 fail 树上到根的路径上打上标记 xx,将具有标记 xx 的 sisi 对应的节点的权值 +1+1。对于每个 tyty,询问时建立虚树,统计答案即可。这部分的复杂度为 O(L2B+LlogL)O(L2B+LlogL)(就算 txtx 不同,我们也只会改变树上权值而非形态,因此对于一个串,虚树只需要建立一次,从而分析出 O(LlogL)O(LlogL))。
接下来考虑 tx,tytx,ty 均为小串的询问。事先将 sisi 对应的节点权值 +1+1,询问时建出 tx,tytx,ty 虚树的交回答询问即可。瓶颈在于建出虚树交的复杂度。若每次询问时抽取 tx,tytx,ty 所有前缀节点,暴力建出虚树,单次询问的复杂度为 O(BlogB)O(BlogB)。
若要去掉复杂度中的 O(logB)O(logB),可考虑以下方法:事先建出所有 txtx 所有前缀节点的虚树,并将节点按照 DFS 序排序。询问时拉出 tx,tytx,ty 的虚树节点序列并归并。具体地,对 tyty 虚树节点序列中的每个节点 rr,找到其在 txtx 序列中的前驱后继 p,sp,s。则 lca(r,p),lca(r,s)lca(r,p),lca(r,s) 中深度较大者即为 tyty 的祖先中最深的位于 txtx 虚树上的节点。将该节点加入初始为空的集合 SS,则集合 SS 的虚树即为两棵虚树的交。使用查询 O(1)O(1) 的 LCA 算法则单次复杂度为 O(B)O(B),从而这部分的总复杂度为 O(LlogL+qB)O(LlogL+qB)。
此时不难发现 B=√LB=√L 时取得最优复杂度 O((L+q)√L)O((L+q)√L)。
Codeforces 1483F Exam#
考虑枚举 sisi,那么对于 sisi 的每个前缀 si[1,l]si[1,l],只有它的最长的作为某个 sjsj 出现的后缀可能成为答案,我们称这个 sjsj 为备选答案。这可以 AC 自动机预处理得出。我们枚举 ll,记录下每个 sjsj 作为备选答案的次数,并记录下每个 sjsj 的出现位置 [st,ed][st,ed]。该过程中,可能会出现某个区间被另一个区间包含的情况。如果这种情况发生,我们将不再认为被包含的区间作为备选答案出现了一次。
现在,枚举每个至少作为备选答案一次的 sjsj。不难发现,当且仅当 sjsj 作为备选答案出现的次数恰好等于其在 sisi 中的出现次数时,(i,j)(i,j) 是一对合法答案。欲求出现次数,只需要使用树状数组即可。
时间复杂度 O(LlogL)O(LlogL)。
# include <bits/stdc++.h>
# define fir first
# define sec second
const int N=2000010,INF=0x3f3f3f3f;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-') f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
int n,q;
std::string S[N];
int edp[N],len[N];
namespace acam{
int nex[N][26],fa[N];
int fail[N],cnt=1;
int mx[N];
inline void ins(int x){
int len=S[x].size(),cur=1;
::len[x]=len;
for(int i=1;i<=len;++i){
int &ne=nex[cur][S[x][i-1]-'a'];
if(!ne) ne=++cnt,fa[cnt]=cur;
cur=ne;
}
if(::len[mx[cur]]<::len[x]) mx[cur]=x;
edp[x]=cur;
return;
}
std::vector <int> G[N];
int dfn[N],t,siz[N];
void dfs(int x){
dfn[x]=++t,siz[x]=1;
for(auto y:G[x]) dfs(y),siz[x]+=siz[y];
return;
}
inline void init(void){
for(int i=0;i<26;++i) nex[0][i]=1;
std::queue <int> q;q.push(1);
while(!q.empty()){
int i=q.front();
q.pop(),mx[i]=(!mx[i])?mx[fail[i]]:mx[i];
for(int j=0;j<26;++j){
if(!nex[i][j]) nex[i][j]=nex[fail[i]][j];
else fail[nex[i][j]]=nex[fail[i]][j],q.push(nex[i][j]);
}
}
for(int i=2;i<=cnt;++i) G[fail[i]].push_back(i);
dfs(1);
return;
}
}
using namespace acam;
int sum[N];
inline int lb(int x){
return x&(-x);
}
inline void add(int x,int v){
for(;x<=cnt;x+=lb(x)) sum[x]+=v;
return;
}
inline int query(int x){
int ans=0;
for(;x;x-=lb(x)) ans+=sum[x];
return ans;
}
inline int query(int l,int r){
return query(r)-query(l-1);
}
int L[N],na[N],qc;
int buc[N];
std::unordered_map <int,int> mp;
int main(void){
n=read();
for(int i=1;i<=n;++i){
std::cin>>S[i];
ins(i);
}
init();
int ans=0;
for(int i=1;i<=n;++i){
for(int j=edp[i];j!=1;j=fa[j]) add(dfn[j],1);
qc=0,mp.clear();
for(int j=1,cur=1;j<=len[i];++j){
cur=nex[cur][S[i][j-1]-'a'];
if(j==len[i]) cur=fail[cur];
if(mx[cur]) L[++qc]=j-len[mx[cur]]+1,na[qc]=mx[cur];
}
for(int j=qc,lim=n+1;j;--j){
if(L[j]>=lim) continue;
else lim=L[j],++mp[na[j]];
}
for(auto ele:mp){
int id=ele.fir,occ=ele.sec;
if(query(dfn[edp[id]],dfn[edp[id]]+siz[edp[id]]-1)==occ) ++ans;
}
for(int j=edp[i];j!=1;j=fa[j]) add(dfn[j],-1);
}
printf("%d",ans);
return 0;
}
QOJ5034 >.<#
本题要求在不经过给定路径作为子路径情况下的最短路。考虑使用 AC 自动机来维护这一限制。具体地,设 f(j)f(j) 表示从原图上点 11 的对应点开始走到 AC 自动机 jj 点的最短路,并强制要求不经过终止节点。但是本题中,字符集大小为 nn,朴素的 AC 自动机难以通过。考虑性质:tr(i)tr(i) 比起 tr(fail(i))tr(fail(i)),只有在 ii 节点出边的部分会有修改。
因此考虑主席树优化建图。具体地,将所有合法路径(即可以在原图上实际走出来的路径)和 1∼n1∼n 这 nn 个单点插入 AC 自动机,并将 1∼n1∼n 这 nn 个单点在 AC 自动机上对应的节点标号为 1∼n1∼n。
那么要计算 tr(i)tr(i) 时,只需要在 tr(fail(i))tr(fail(i)) 的基础上修改 tr(i)tr(i) 转移到的点即可,最后一起跑一遍 Dijkstra。
如果 n,m,kn,m,k 同阶,那么复杂度为 O(nlog2n)O(nlog2n),因为点数、边数均为 O(nlogn)O(nlogn)。但事实上,我们可以说明,精细实现的复杂度是 O(nlogn)O(nlogn) 的:对于主席树上的虚点,我们连的都是边权为 00 的虚边。从而,一旦一个点的最短路固定下来了,其子树内所有虚点的最短路都会固定下来。因此这些虚点和虚边只会贡献 O(1)O(1) 次入队次数,且因为边权为 00,入队时必然位于堆顶,不贡献堆的复杂度。而对于剩下的部分,显然只剩下 O(n)O(n) 个实点和 O(n)O(n) 条实边,因此这一部分复杂度为 O(nlogn)O(nlogn)。
本题不需要在 fail 树上搞花活,所以代码里面根节点被直接省略掉了。代码中仍采用 O(nlog2n)O(nlog2n) 的实现。
# include <bits/stdc++.h>
# define fir first
# define sec second
const int N=200010,INF=0x3f3f3f3f;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-') f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
std::unordered_map <int,int> id[N];
std::vector <std::pair <int,int> > G[N];
int n,m,k;
int lim;
int ch[N];
struct Edge{
int to,next,v;
}edge[N*40];
int head[N*20],esum;
inline void add(int x,int y,int v){
edge[++esum]=(Edge){y,head[x],v},head[x]=esum;
return;
}
namespace acam{
std::unordered_map <int,int> tr[N];
int fail[N];
bool ed[N];
int cnt;
inline void ins(std::vector <int> &pa){
int sz=pa.size(),cur=pa[0];
for(int i=1;i<sz;++i){
if(!tr[cur].count(pa[i])) tr[cur][pa[i]]=++cnt;
cur=tr[cur][pa[i]];
}
ed[cur]=true;
return;
}
}
using namespace acam;
int col[N];
int rt[N];
namespace prit{
struct Node{
int lc,rc,idx;
}tr[N*20];
inline int& lc(int x){
return tr[x].lc;
}
inline int& rc(int x){
return tr[x].rc;
}
void build(int id,int &k,int l,int r){
if(l>r) return;
k=++cnt;
if(l==r) return add(k,tr[k].idx=G[id][l].fir,G[id][l].sec),assert(G[id][l].fir),void();
int mid=(l+r)>>1;
build(id,lc(k),l,mid),build(id,rc(k),mid+1,r);
add(k,lc(k),0),add(k,rc(k),0);
return;
}
int modify(int &k,int lst,int l,int r,int x,int nto,int nw){
k=++cnt,tr[k]=tr[lst];
assert(lst);
if(l==r) return add(k,tr[k].idx=nto,nw),assert(tr[lst].idx),tr[lst].idx;
int mid=(l+r)>>1,res=0;
if(x<=mid) res=modify(lc(k),lc(lst),l,mid,x,nto,nw);
else res=modify(rc(k),rc(lst),mid+1,r,x,nto,nw);
add(k,lc(k),0),add(k,rc(k),0);
return res;
}
}
typedef long long ll;
ll dis[N*20];
bool vis[N*20];
struct Heapval{
int id;
ll w;
bool operator < (const Heapval &rhs) const{
return w>rhs.w;
}
};
std::priority_queue <Heapval> hp;
inline ll dijkstra(void){
memset(dis,INF,sizeof(dis));
dis[1]=0,hp.push((Heapval){1,0});
while(!hp.empty()){
int i=hp.top().id;
hp.pop();
if(!vis[i]) vis[i]=true;
else continue;
for(int j=head[i];j;j=edge[j].next){
int to=edge[j].to;
if(dis[to]>dis[i]+edge[j].v&&!(to<=lim&&ed[to]))
dis[to]=dis[i]+edge[j].v,hp.push((Heapval){to,dis[to]});
}
}
ll ans=dis[0];
for(int i=1;i<=cnt;++i) if(col[i]==n&&!ed[i]) ans=std::min(ans,dis[i]);
return (ans==dis[0])?-1:ans;
}
int main(void){
n=read(),m=read(),k=read();
for(int i=1;i<=m;++i){
int u=read(),v=read(),w=read();
id[u][v]=G[u].size(),G[u].emplace_back(v,w);
}
cnt=n;
for(int i=1,sz;i<=k;++i){
std::vector <int> pa;
pa.resize(sz=read());
for(auto &v:pa) v=read();
for(int i=1;i<sz;++i) if(!id[pa[i-1]].count(pa[i])) goto FAILED;
acam::ins(pa);
FAILED:;
}
lim=cnt;
std::queue <int> q;
for(int i=1;i<=n;++i) q.push(col[i]=i),prit::build(i,rt[i],0,G[i].size()-1);
while(!q.empty()){
int i=q.front();
q.pop(),ed[i]|=ed[fail[i]];
if(fail[i]) rt[i]=rt[fail[i]];
for(auto eg:tr[i]){
int v=eg.fir,j=eg.sec,id=::id[col[i]][v];
fail[j]=prit::modify(rt[i],rt[i],0,G[col[i]].size()-1,id,j,G[col[i]][id].sec);
q.push(j),col[j]=v;
}
if(rt[i]) add(i,rt[i],0);
}
printf("%lld",dijkstra());
return 0;
}
Luogu P8571 [JRKSJ R6] Dedicatus545#
仍然不好做,考虑阈值分治。设定阈值 BB。
对于 sksk 是大串的情形,O(L+qlogn)O(L+qlogn) 解决一个串是容易的。
对于 sksk 是小串的情形,考虑对 rr 扫描线。从左到右扫描 rr,并给 srsr 的终止节点打上标记 rr。扫到 rr 时处理形如 (l,r,k)(l,r,k) 的询问。考虑某个 sksk 的前缀节点的贡献。若该节点到根的路径上,有大于等于 ll 的标记时,该节点对答案有贡献,贡献大小为该节点在 fail 树上的子树和(此时 fail 树上仅 sksk 的前缀节点点权为 11)。
建虚树查询即可。同样,我们可以把单点修改链查询变为区间 chkmax 单点查询。对于每个串,虚树只需建立一次,因此复杂度是均摊 O(LlogL)O(LlogL) 的。取阈值为 √L√L,总复杂度为 O(L√L+qlogn+LlogL)O(L√L+qlogn+LlogL)。
Luogu P5599 【XR-4】文本编辑器#
单词的长度很小,这是无论如何都想利用上的性质。记字典中最长单词长度为 dd。首先对字典建出 ACAM,并维护出每个状态 ii 的对应串有多少个后缀出现在了字典中,记作 cnt(i)cnt(i)。
先不考虑修改。如果我们能够维护出 s[1,i]s[1,i] 在 ACAM 中匹配得到的状态 sta(i)sta(i),那么我们是可以对付查询的。这是因为 dd 很小,所以对于询问 [l,r][l,r] 而言,当 ii 远大于 ll 时,位置 ii 对于答案的贡献就是 cnt(sta(i))cnt(sta(i))。精细分析可知,当 i∈[l+d−1,r]i∈[l+d−1,r] 时,我们就可以认为 ii 远大于 ll 了。
当 i∈[l,l+d−2]i∈[l,l+d−2] 时,cnt(sta(i))cnt(sta(i)) 中的串不一定全部合法。因此,我们应当遍历 sta(i)sta(i) 在 fail 树上的祖先,找到第一个节点长度小于等于 l−i+1l−i+1 的祖先 ff,并取 cnt(f)cnt(f) 作为贡献。注意这一步的复杂度不应当变为 O(d2)O(d2),因此我们要对于每个节点维护 par(x,h)par(x,h),表示 fail 树上 xx 的第一个节点长度不超过 hh 的祖先(可以是它自己),这样在枚举 ii 的时候可以一步定位到 ff。因此,单次询问的复杂度为 O(d)O(d)。
现在考虑修改。当 ii 远大于 ll 的时候,不难发现修改会使得 sta(i)=sta(i+|t|)sta(i)=sta(i+|t|)。因为 s[l,r]s[l,r] 会变为 tt 的若干次循环,因此这是容易证明的(无论 sta(i)sta(i) 的长度更长,还是 sta(i+|t|)sta(i+|t|) 的长度更长,都是不合理的)。
和查询类似地,我们可以发现,当 i∈[l+d−1,r]i∈[l+d−1,r] 时,我们认为 ii 远大于 ll。因此,这部分修改可以使用以下方法完成:
- 从 sta(l−1)sta(l−1) 开始,当 l≤i≤l+d+t−1l≤i≤l+d+t−1 时,暴力匹配出 sta(i)sta(i)。
- 取出 sta[l+d−1,l+d+t−2]sta[l+d−1,l+d+t−2],将这一段向后复制,直到 sta(r)sta(r) 为止。
- 从 sta(r)sta(r) 开始,当 r+1≤i≤r+d−2r+1≤i≤r+d−2 时,暴力匹配出 sta(i)sta(i)。
因此,我们只需要一个支持区间循环覆盖的线段树即可。时间复杂度 O(|Σ|∑S+∑T+qlogn)O(|Σ|∑S+∑T+qlogn)。
SCOI2024 Day1 T1 (口胡)#
考虑分类讨论。存在两种情况:si+tjsi+tj 完整地在某个 sxsx 或者 txtx 中出现,以及 sx+tysx+ty 的分界线将 si+tjsi+tj 分成了两部分。
对于第一种情况,以完整出现在某个 sxsx 中为例。若 si+tjsi+tj 完整地在某个 sxsx 出现,考虑枚举 sxsx,对于 1≤l<|si|1≤l<|si|,计算如下信息:作为 sx[1,l]sx[1,l] 后缀的 ss 串数量,以及作为 sx[l+1,|sx|]sx[l+1,|sx|] 前缀的 tt 串数量。对于前者,对所有 sisi 建 ACAM,将 sxsx 从前往后匹配,若匹配完 sx[1,l]sx[1,l] 后位于的节点为 pp,则作为 sx[1,l]sx[1,l] 后缀的 ss 串数量等于 pp 到根的路径上终止节点数量之和(重复串算多次)。对于后者,对所有 titi 的反串建 ACAM 后将 sxsx 从后往前匹配,类似地可以算出答案。
对于同一个 sxsx,枚举 ll,答案即为二者乘积之和。枚举所有的 sx,txsx,tx,再枚举 ll 计算答案,我们就在 O(∑len)O(∑len) 的时间计算出了第一种情况的答案。
对于第二种情况,有三种子情况:sisi 被 sx+tysx+ty 分成了两部分;tjtj 被 sx+tysx+ty 分成了两部分;si+tjsi+tj 的分界线恰好是 sx+sysx+sy 的分界线。
对于前两种子情况,取第一种子情况为例。枚举 tyty 和 ll,钦定 sisi 在 tyty 中的部分为 ty[1,l]ty[1,l]。那么合法的 tjtj 数量就是作为 sy[l+1,|ty|]sy[l+1,|ty|] 前缀的 tt 串数量。对于每个 sisi,枚举 1≤k<|si|1≤k<|si|,将 sisi 分为非空的两部分 A=si[1,k],B=si[k+1,|si|]A=si[1,k],B=si[k+1,|si|]。那么当且仅当 B=ty[1,l]B=ty[1,l] 时,这种划分有 cnt(A)cnt(A) 的贡献,其中 cnt(A)cnt(A) 表示存在后缀 AA 的 sxsx 的数量。则对于这个 ll,总贡献为前面的贡献乘上合法的 tjtj 数量。
考虑事先预处理,将所有 sisi 的所有后缀计算 Hash 后扔进 Hash Table,然后枚举 sisi 和 kk,同样利用 Hash + Hash Table 可以支持 O(∑len)O(∑len) 处理,O(1)O(1) 询问贡献。
对于第三种子情况,使用 Hash 仍然容易计算。
回文相关#
基础性质#
重排#
一个字符串能够重排成为回文串的充要条件是,只有至多一种字符出现了奇数次。
本质不同回文子串数量#
定理 1
一个长度为 nn 的字符串 ss 的本质不同回文子串数量不超过 nn。
证明:反证法。令每个本质不同回文子串在其第一次结束处被统计。若存在两个本质不同回文子串 p,q(|p|<|q|)p,q(|p|<|q|) 在 ll 处被统计,那么 pp 是 qq 的回文后缀。因为 qq 是回文串,因此 pp 必然也是 qq 长度小于 |q||q| 的一个回文前缀,从而可以在某个小于 ll 的位置被统计,和假设矛盾。
回文引理#
回文串具有非常良好的性质。因此将它和 border 联系起来时,有几个简单的引理成立。
引理 1
若 tt 是回文串 ss 的后缀,则 tt 是 ss 的 border 当且仅当 tt 是回文串。
根据定义可证。
引理 2
若 tt 是 ss 的 border(|t|≥|s|/2|t|≥|s|/2),则 ss 是回文串当且仅当 tt 是回文串。
若 ss 是回文串,利用引理 11。
若 tt 是回文串且是 ss 的 border,根据定义,有 s[i]=s[|s|−|t|+i]=s[|s|−i+1]s[i]=s[|s|−|t|+i]=s[|s|−i+1](1≤i≤|t|1≤i≤|t|)。因为 |t|≥|s|/2|t|≥|s|/2,所以上述信息足以判定 ss 是一个回文串。
引理 3
若 tt 是回文串 ss 的 border,则 |s|−|t||s|−|t| 是 ss 的最小周期当且仅当 tt 是 ss 的最长回文真后缀。
利用 border 和周期的对应关系,结合引理 1 可证。
引理 4
tt 的所有回文 border 可以不重不漏地通过如下方式得到:不断令 tt 变为 tt 的最长回文真后缀。
我们注意到 tt 在至多一步后就会变为回文串。根据引理 1,此后 tt 一定会变为自己的最长 border,因此结合 《周期和 border》一节中的性质 2 即可证明。
定理 2
对于 |s|>1|s|>1,ss 的所有回文后缀按照长度排序后可以划分为 ⌈log2|s|⌉⌈log2|s|⌉ 个等差数列。
利用引理 4,我们注意到 tt 在至多一步后就会变为回文串 t′t′,从而 ss 的所有回文后缀就是 t′t′ 和 t′t′ 的所有 border。结合《border 的结构》一节中的引理 2 立刻得证。
Manacher 算法#
s[0]='~',s[++len]='#';
for(char x=getchar();x>='a'&&x<='z';x=getchar()){
s[++len]=x,s[++len]='#';
}
for(int i=1,md=0,r=0;i<=len;++i){
if(i<=r) p[i]=std::min(p[2*md-i],r-i+1);
else p[i]=1;
while(s[i-p[i]]==s[i+p[i]]) ++p[i];
if(i+p[i]-1>r) md=i,r=i+p[i]-1;
maxx=std::max(maxx,p[i]-1);
}
相信大家都会。
性质:
-
原串中 sisi 在新串 tt 的位置为 s2is2i。
-
原串中子串 s[l,r]s[l,r] 的回文中心为 tl+rtl+r。
分奇偶讨论容易证明。
几个基础应用#
-
求出以某个位置开始 / 结束的最长回文子串长度
以后者为例。因为回文串的性质,前者与后者本质相同,可以将串取反后变为求以某个位置结束的最长回文子串长度。
当 i+pi−1>ri+pi−1>r 时(即回文区间的右端点向右移动时),此时对于新串中所有对应原串中字符的位置(即下标为偶数的位置)r<j<i+pir<j<i+pi,以 jj 为结尾的最长回文子串长度就是 j−i+1j−i+1。
对于一个下标 xx 来说,要想让以 xx 结束的最长回文子串长度尽可能长,那么要找到最靠前的回文中心,使得它的回文半径能够覆盖到 xx。对于 j∈(r,i+pi)j∈(r,i+pi),显然在 ii 之前的回文中心都无法覆盖到 jj,并且此时 ii 的回文半径能够覆盖 jj,因此 ii 就是对于 jj 而言最靠前的回文中心。
由于 Manacher 的特性,分奇偶讨论容易证明新串中以 ii 为回文中心,jj 结尾的回文串在原串中对应一个以 j/2j/2 结尾,长度为 j−i+1j−i+1 的一个字符串。
for(int i=1,r=0,md=0;i<=len;++i){ if(i<=r) p[i]=std::min(r-i+1,p[2*md-i]); else p[i]=1; while(s[i-p[i]]==s[i+p[i]]) ++p[i]; if(i+p[i]-1>r){ for(int j=r+1;j<=i+p[i]-1;++j) if(j%2==0) L[j/2]=j-i+1; r=i+p[i]-1,md=i; } }
-
判断一个区间 s[l,r]s[l,r] 是否为回文串
找到 s[l,r]s[l,r] 的回文中心在 tt 中的位置 l+rl+r,若区间 [l,r][l,r] 是回文串,则 tl+rtl+r 的回文半径必须覆盖 t2rt2r,即回文半径 pl+r≥2r−(l+r)+1pl+r≥2r−(l+r)+1。
inline void manacher_init(void){ len=0,t[0]='~',t[++len]='#'; for(int i=1;i<=n;++i) t[++len]=s[i],t[++len]='#'; for(int i=1,r=0,md=0;i<=len;++i){ if(i<=r) p[i]=std::min(p[2*md-i],r-i+1); else p[i]=1; while(t[i-p[i]]==t[i+p[i]]) ++p[i]; if(i+p[i]-1>r) r=i+p[i]-1,md=i; } return; } inline bool palin(int l,int r){ return p[l+r]>=2*r-(l+r)+1; }
-
例题 CF1943B Non-Palindromic Substring
妈妈再也不用担心我写不完 Hash 还被出题人卡啦!
-
回文树 / 回文自动机#
一般来说称呼为后者,或其英文简写 PAM 时更加常见。
PAM 是接受 ss 所有回文子串的类自动机结构。
该「类自动机」结构的转移边和我们熟知的 SAM, ACAM 略有出入。
左图展示了 babbabbabbab 的 PAM 结构,其中蓝色虚边为其 fail 树结构,右图中用黑色实边展示。
接下来给出 PAM 结构的性质:
-
PAM 中存在两个入度为 00 的起始节点 eveneven 和 oddodd。接下来分别用节点 E,OE,O 来代指起始节点 even 和 odd。
-
除了起始节点 E,O 外,每个节点都代表一个回文字符串。记 len(p) 表示节点 p 所代表字符串的长度,并规定 len(E)=0,len(O)=−1。
-
图中带字母的转移边 tr(p,c):(p→q)(或记作 tr(p,c)=q)含义为:在当前节点 p 代表的字符串左右两侧添加转移边对应字符 c 后,得到的字符串和转移边终点 q 代表的字符串相等。特殊地,对于 tr(O,c):(O→q),应当看作在一个长度为 −1 的字符串左右两侧添加字符 c,最终得到的字符串是单个字符 c。
-
对于任意 tr(p,c)=q,都有 len(p)+2=len(q)。
-
除了起始节点外,每个节点恰为一条转移边的终点。因此,PAM 构成两棵有根树,分别以两个初始节点作为根。其中 even 子树中所有回文串长度均为偶数,odd 相反。
-
节点 p 的 fail 指针 fail(p) 指向表示其最长回文真后缀的节点。该最长回文真后缀长度可以为 0,此时 fail(p) 指向 E。特殊地,fail(E)=fail(O)=O。
因为一个长度为 n 的字符串 s 的本质不同回文子串数量不超过 n,因此一个字符串的 PAM 上最多只有 n+2 个节点。又由树形结构可知 PAM 上转移边不超过 n 条。
构造:末端插入法#
对于字符串 s,我们可以采用末端插入的增量构造法构造 PAM,即:从初始 s[1,0] 的 PAM(仅初始节点)开始,依次插入 si,然后维护出 s[1,i] 的 SAM。
根据本节定理 1 及其证明,插入 si 至多增加 1 个本质不同回文子串,且这个子串只可能是 s[1,i] 的最长回文后缀。
我们考虑维护 last 指针,指向代表 s[1,i−1] 的最长回文后缀的节点。初始时令 last=E,这是因为空串的最长回文后缀为空串。
注意到 s[1,i] 的最长回文后缀要么就是 s[i],要么是 s[1,i−1] 的某个回文后缀的左右两侧添加一个字符 s[i] 得到的。因此,我们初始令 p=last,然后不断地将 p 变为 fail(p),直到 s[i−len(p)−1]=s[i]。此时在 p 代表的字符串左右两侧添加字符 s[i],就得到了 s[1,i] 的最长回文后缀。因为我们规定了 len(E)=0 和 len(O)=−1,所以上面的过程总是正确的,我们不用特意区分 s[i] 的最长回文后缀是否是单个字符 s[i]。
若 tr(p,s[i])=q 存在,则表明这个子串已经出现过,我们不做任何修改。接下来讨论这个子串没有出现过,且需要新建节点 q 的情况。
因为 q 对应的字符串是 s[1,i] 的最长回文后缀,所以当前不会存在 q 出发的转移边,我们只需要计算出 fail(q),即 q 对应字符串的最长回文后缀。和上面类似,我们令 p′=fail(p),然后不断地将 p′ 变为 fail(p′),直到 s[i−len(p′)−1]=s[i]。此时将 fail(q) 置为 q′ 即可,其中 tr(p′,s[i])=q′。
若 q 对应字符串的最长回文后缀为空(此时一定有 len(q)=1),我们总会到达 p′=O。按照定义,我们希望 fail(q)=E。在代码里面我们给 E 标号为 0,O 标号为 1,这样,此时 tr(O,s[i])=tr(1,s[i])=0,我们就可以自然地避免这个边界情况了。
最后,更新 tr(p,s[i])=q,然后令 last=q。注意到我们不能先更新 tr(p,s[i]) 再计算 fail(q),这是因为若 p=O,就有 fail(p)=fail(O)=O,此时如果先更新 tr(O,s[i])=q,再计算 fail(q)=tr(fail(p),s[i])=tr(O,s[i])=q,就会发现 q 的 fail 指针是其本身,而这显然不是我们想要的。
int len[N],fail[N],ch[N][26],siz[N];
int cnt=-1,last;
inline int node(int x){ // 新建一个长度为 x 的空节点
return len[++cnt]=x,cnt;
}
inline void init(void){ // 新建节点 E (标号为 0) 和节点 O (标号为 1)
node(0),node(-1),fail[0]=1;
return;
}
inline int getfail(int x,int pos){
while(s[pos-1-len[x]]!=s[pos]) x=fail[x];
return x;
}
inline void extend(int pos,int c){
int x=getfail(last,pos),p; // 代码中 p 为文中 q; x 为文中 p
if(!ch[x][c]){
p=node(len[x]+2),fail[p]=ch[getfail(fail[x],pos)][c],ch[x][c]=p;
}
++siz[last=ch[x][c]];
return;
}
...
int main(void){
init();
...
}
这种方法被称作基础插入算法。
考虑复杂度分析。记 n 为字符串长度。指针 p 每向上移动一次,节点长度至少减少 2。新建节点 q 使得节点长度增加 2。因为一共只会新建至多 n 个非初始节点,且节点长度不得小于 −1,因此运用势能分析可知,代码实现中这部分的时间复杂度为 O(n),其中 n 为字符串长度,而空间复杂度为 O(nΣ)。
对于指针 p′ 和 fail(q),我们有类似的势能分析。因此,基础插入算法的总势能为 O(n),从而:
- 采用数组存储转移边 tr,时间复杂度 O(n),空间复杂度 O(nΣ);
- 采用 O(1) 额外空间,O(logΣ) 定位的数据结构(如 std::map 或 std::set)存储转移边 tr,时间复杂度 O(nlog|s|),空间复杂度 O(n)。
一个有趣的事实是,我们根本不会用到 fail(O),因此 init()
中仅有 fail[0]=1
一句而非 fail[0]=fail[1]=1
,这也符合网上大部分题解的书写习惯,虽然它看上去不太严谨......
构造:前端插入法#
因为回文串的性质,对于字符串 s 而言,它的反串 s′ 的本质不同回文子串集合和 s 的完全相同。因此,对于 s 采用前端插入法构造 PAM,即维护 last 表示 s[i,n] 的最长回文前缀,然后类似地计算出转移边和 fail 指针,本质上和对于 s 的反串 s′ 采用后端插入法是相同的,因此前端插入法的复杂度和后端插入法的复杂度相同。
构造:不基于势能分析的末端插入法(可持久化)#
如果 PAM 需要支持完全持久化,那么基于势能分析的插入法将不再有效。
基本插入算法的瓶颈在于遍历 last 的 fail 指针,找到第一个前驱字符和 si 相同的回文后缀 t。注意到,除了 len(t)=len(last) 的场合,t 必然是 last 对应串的真后缀。这种情况下,t 和 si 具体是什么无关,而只和 last 有关。
因此,我们对于节点 x,维护出 quick(x,c) 表示节点 x 所代表的字符串第一个前驱字符为 c 的回文真后缀所代表的节点。插入时,如果 p=last 不合法,只需要取出 quick(last,c) 就可以立刻求得最终的节点 p。同理,fail(q) 要么是 tr(fail(p),c),要么可以用 tr(quick(fail(x),c),c) 给出。
最后,我们需要维护出 quick(q,c)。quick(q,c) 和 quick(fail(q),c) 相比,恰有 1 处有差异。维护出 quick(q,c) 同样是简单的。
该算法的复杂度分析较为容易:直接从 fail(q) 进行 quick 的复制,时空复杂度为 O(nΣ)。使用可持久化数组,时空复杂度均变为 O(nlogΣ)。
需要注意边界情况:quick(E,c),quick(O,c) 初始设为 O。
char s[N];
int n;
int len[N],fail[N],ch[N][26],siz[N],quick[N][26];
int cnt=-1,last;
inline int node(int x){
return len[++cnt]=x,cnt;
}
inline void init(void){
node(0),node(-1),fail[0]=1;
for(int i=0;i<26;++i) quick[0][i]=quick[1][i]=1; // 初始化 quick(E,c) / quick(O,c)
return;
}
inline void extend(int pos,int c){
int x=quick[last][c],p; // 代码中 p 为文中 q; x 为文中 p
if(s[pos-len[last]-1]==s[pos]) x=last; // 特殊情况:p = last 合法的场合
if(!ch[x][c]){
p=node(len[x]+2),fail[p]=ch[quick[fail[x]][c]][c];
if(s[pos-len[fail[x]]-1]==s[pos]) fail[p]=ch[fail[x]][c]; // 特殊情况:fail[q] = ch[fail[p]][c] 合法的场合
memcpy(quick[p],quick[fail[p]],sizeof(quick[p]));
quick[p][s[pos-len[fail[p]]]-'a']=fail[p]; // 更新 quick[q]
ch[x][c]=p;
}
++siz[last=ch[x][c]];
return;
}
例题 1 【模板】回文自动机(PAM)Link#
给定一个字符串 s。保证每个字符为小写字母。对于 s 的每个位置,请求出以该位置结尾的回文子串个数。
这个字符串被进行了加密,除了第一个字符,其他字符都需要通过上一个位置的答案来解密。
具体地,若第 i(i≥1) 个位置的答案是 k,第 i+1 个字符读入时的 ASCII 码为 c,则第 i+1 个字符实际的 ASCII 码为 (c−97+k)mod26+97。所有字符在加密前后都为小写字母。
1≤|s|≤5×105
容易发现,所求即为 last 在 fail 树上的深度。
例题 2 [APIO2014] 回文串 Link#
给你一个由小写拉丁字母组成的字符串 s。我们定义 s 的一个子串的存在值为这个子串在 s 中出现的次数乘以这个子串的长度。
对于给你的这个字符串 s,求所有回文子串中的最大存在值。
1≤|s|≤3×105
如果能够求出每个节点代表的字符串的 endpos 集合大小,便可以解决原问题。
采用 ACAM 类似的套路,每次只在 last 处更新 endpos 集合大小,最后再遍历 fail 树求出子树和即为真正的子树大小。
以下给出不基于势能分析的末端插入法的实现。
# include <bits/stdc++.h>
const int N=300010,INF=0x3f3f3f3f;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-') f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
char s[N];
int n;
int len[N],fail[N],ch[N][26],siz[N],quick[N][26];
int cnt=-1,last;
inline int node(int x){
return len[++cnt]=x,cnt;
}
inline void init(void){
node(0),node(-1),fail[0]=1;
for(int i=0;i<26;++i) quick[0][i]=quick[1][i]=1;
return;
}
inline void extend(int pos,int c){
int x=quick[last][c],p;
if(s[pos-len[last]-1]==s[pos]) x=last;
if(!ch[x][c]){
p=node(len[x]+2),fail[p]=ch[quick[fail[x]][c]][c];
if(s[pos-len[fail[x]]-1]==s[pos]) fail[p]=ch[fail[x]][c];
memcpy(quick[p],quick[fail[p]],sizeof(quick[p]));
quick[p][s[pos-len[fail[p]]]-'a']=fail[p];
ch[x][c]=p;
}
++siz[last=ch[x][c]];
return;
}
int main(void){
scanf("%s",s+1),n=strlen(s+1),init();
for(int i=1;i<=n;++i) extend(i,s[i]-'a');
for(int i=cnt;i>1;--i) siz[fail[i]]+=siz[i];
long long ans=0;
for(int i=2;i<=cnt;++i) ans=std::max(ans,1ll*siz[i]*len[i]);
printf("%lld",ans);
return 0;
}
* 构造:双端插入法(HDU 5421)#
我们可以同时维护出 last 和 first,表示当前串的最长回文后缀和最长回文前缀指向的节点,然后同时使用前端插入法和后端插入法。但此时前端插入也可能会使得 last 变化,后端插入同理。仔细分析可知,只有整个串都是回文串的时候,才会出现前述变化,而这是容易判断的。
构造:对给定 Trie 树构造 PAM#
类似的分析可知,Trie 树对应的 PAM 上只有 O(siz) 个节点。
此时不应当采用基础插入算法。考虑 {a,ba,bba,⋯,bbbbb...a}(最后一个字符串中有 n 个 b)构成的 Trie,大小为 O(n)。每次遍历到 a 的分支时,都会消耗所有势能,而往下遍历 b 的分支时势能不会减小,因此复杂度为 O(n2)。
不基于势能分析的末端插入法复杂度不变。
* 带双端插入删除的 PAM 维护#
可以参阅 2017 集训队论文《回文树及其应用》(中山市中山纪念中学 翁文涛)。
核心思想是,维护出那些本身回文且不是任何一个回文串回文前 / 后缀的串,称作重要子串。当字符被删除时,结合当前节点的重要性,以及在 fail 树上是否存在后代,可以判断出这些串是否会被从 PAM 上移除。
但这样会频繁更改 fail 树的结构,因此如果需要支持查询某个串的 endpos 集合大小,可能需要用到 LCT / ETT。当然,如果只需要查回文子串数量(位置不同就算不同),便只需要在插入 / 删除时知道当前所在节点的 fail 树深度,这是容易维护出来的。
带双端插入删除的 PAM 是支持可持久化的,但是这也太迷惑了......相信没有人会写。
例题 3 [CERC2014] Virus synthesis#
初始有一个空串,利用下面的操作构造给定串 S。
串开头或末尾加一个字符
串开头或末尾加一个该串的逆串
求最小操作数。
|S|≤105,字符集为 {A,T,C,G}。
考虑枚举最后一次 2 操作形成的串 T。它必然是 S 的一个偶回文子串。可以使用 PAM 求出所有这样的串。记 f(T) 表示从空串到 T 的最小操作次数,那么最小的 f(T)+n−|T| 就是答案。
现在重点是如何求出 f(T)。有结论:形成串 T 的最后一次操作必然是操作 2。以下为证明:
考虑归纳。|T|≤2 的情况是平凡的。
对于 |T|>2 的情况,若形成串 T=BBT 的最后一次操作不是操作 2,找到最后一次操作 2 结束后形成的串 T′=AAT,其中 ST 表示串 S 的反转。
不难发现,上述总操作次数为 f(T′)+2(|B|−|A|)。注意到 |A|<|B|,那么 A 或 AT 一定完整地出现在 T 回文中心的一侧。根据归纳假设,形成 AAT 的最后一次操作是操作 2。那么,在这次操作之前,在完整地出现在 T 回文中心一侧的部分左右添加上字符,使其成为 B 或 BT,最后再应用操作 2,总操作次数为 f(T′)+|B|−|A|<f(T′)+2(|B|−|A|),因此一定更优。
考虑在 PAM 上 DP。考虑一结论:回文串 T 的所有回文子串都可以通过若干次以下操作得到:
- 将 T 首尾去掉一个字符。
- 将 T 变为 T 的最长回文后缀。
那么有转移:f(T)=min(|T|,f(fa(T))+1,f(link(T))+12(|T|−|link(T)|)+1)。其中 fa(T) 为 T 在 PAM 上的父亲(与 fail 树上的父亲区分),link(T) 为 T 长度不超过 12T 的最长回文后缀。
link(T) 可以使用与求 fail(T) 时类似的方法求出,时间复杂度不变。
# include <bits/stdc++.h>
# define link walawala
const int N=300010,INF=0x3f3f3f3f;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-') f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
std::map <char,int> mp;
char s[N];
int n;
int len[N],fail[N],ch[N][4];
int cnt=-1,last;
int link[N],fa[N];
inline int node(int x){
return len[++cnt]=x,memset(ch[cnt],0,sizeof(ch[cnt])),link[cnt]=fa[cnt]=0,cnt;
}
inline void init(void){
cnt=-1,node(0),node(-1),fail[0]=1,last=0;
return;
}
inline int getfail(int x,int pos){
while(s[pos-1-len[x]]!=s[pos]) x=fail[x];
return x;
}
inline void extend(int pos,int c){
int x=getfail(last,pos),p;
if(!ch[x][c]){
p=node(len[x]+2),fail[p]=ch[getfail(fail[x],pos)][c],ch[x][c]=p;
fa[p]=x;
if(len[p]<=2) link[p]=fail[p];
else{
int cur=link[x];
while(s[pos-1-len[cur]]!=s[pos]||(len[cur]+2)*2>len[p]) cur=fail[cur];
link[p]=ch[cur][c];
}
}
last=ch[x][c];
return;
}
int dp[N];
inline void bfs(void){
std::queue <int> q;
for(int i=2;i<=cnt;++i) dp[i]=len[i];
q.push(0),dp[0]=1;
int ans=n;
while(!q.empty()){
int i=q.front();
q.pop();
for(int k=0;k<4;++k){
int j=ch[i][k];
if(!j) continue;
dp[j]=dp[i]+1;
int l=link[j];
dp[j]=std::min(dp[j],dp[l]+len[j]/2+1-len[l]);
ans=std::min(ans,n-len[j]+dp[j]);
q.push(j);
}
}
printf("%d\n",ans);
return;
}
int main(void){
mp['A']=0,mp['C']=1,mp['T']=2,mp['G']=3;
int T=read();
while(T--){
scanf("%s",s+1),n=strlen(s+1),init();
for(int i=1;i<=n;++i) extend(i,mp[s[i]]);
bfs();
}
return 0;
}
例题 4 [GDKOI2013] 大山王国的城市规划#
给定字符串 s,选出尽可能多的回文子串 t1,t2,⋯,tk 使得不存在 i≠j 满足 ti 为 tj 的子串。
1≤|s|≤105
考虑结论:回文串 T 的所有回文子串都可以通过若干次以下操作得到:
- 将 T 首尾去掉一个字符。
- 将 T 变为 T 的最长回文后缀。
对应到 PAM 上,这两种操作分别为:
- 令某个节点变为它在 PAM 上的父亲。
- 令某个节点变为它在 fail 树上的父亲。
将每个节点的「两个父亲」向它连边,会得到一张 DAG。不难发现,所求即为 DAG 上的最长反链。这是简单的。
复杂度为网络流跑二分图匹配的 O(n√n)。
最小回文划分#
给定字符串 s,求:
将 s 划分程若干个回文串,最少需要使用的回文串个数。(最小回文划分)
将 s 划分成若干个回文串的方案数。(回文划分计数)
1≤|s|≤105
不难发现我们可以使用 DP 解决。两种 DP 是类似的,下文中以回文划分计数为例。
显然,我们可以对 s 建立 PAM,同时进行 DP。具体来说,插入字符 s[i] 后,s[1,i] 的所有回文后缀都可以作为划分中在 i 结束的回文串。
考虑《回文引理》一节的定理 2,一个字符串 t 的回文 border 能够被划分为 O(log2|t|) 个等差数列。因此,插入 s[i] 后,last 到根的链上的节点可以根据长度划分到 O(log2i) 个等差数列中。
考虑维护如下信息:对于一个节点 x,如果它是某个等差数列中长度最长的节点,那么就更新 g(x) 为 ∑y∈S(x)f(i−len(x)),其中 S(x) 表示 x 所属等差数列中的节点。
现在,对于 x,若 fail(x) 和 x 属于同一个等差数列,考察 g(fail(x))。
如图。diff(x) 表示等差数列的公差。因为 fail(x) 是 x 的最长回文后缀,那么 fail(x) 上次出现的位置为 i−diff(x)。同时,在 i−diff(x) 处出现时,fail(x) 必然是作为这个等差数列中最长的节点出现的。
证明
因为 x 是回文串,显然 fail(x) 的确在 i−diff(x) 处出现过。下证其不可能在中间部分再次出现:我们的划分方式保证了 fail(x) 如果和 x 在同一个等差数列中,则 2|fail(x)|≥|x|。若 fail(x) 在 (i−diff(x),i) 中出现,则两次出现必然有重叠。由引理 2,这两次出现的并是一个更长的回文串,且它同样是 x 的前缀。x 是回文串,从而它也是 x 的后缀,且比 fail(x) 长,这与 fail(x) 的定义矛盾。
如果 fail(x) 没有作为这个等差数列中最长的节点出现,那么会发生什么呢?关于这种情况,我已经有了一种绝妙的论证,来说明这不会发生。可惜这里地方太小,我写不下。
这样,g(fail(x))=∑y∈S(x)f((i−diff(x))−len(x)),即上图中蓝色的部分。g(x)(图中橙色部分)和 g(fail(x)) 相比,只多了一个 f(i−|slink(x)|+diff(x))。因此我们可以 O(1) 更新出 g(x)。
要求解 f(i),我们只需要在 last 的 fail 链上找到每个等差数列的 x 节点,更新出 g(x) 并累加。时间复杂度 O(nlogn)。
例题 5 CF932G Palindrome Partition#
给定长度为 n 的字符串 s,字符集为小写字母集。
求将 s 划分为偶数段 t1,t2,⋯,t2k,且对于 i=1,2,⋯,k,满足 ti=t2k−i+1 的方案数。
答案对 109+7 取模。
2≤n≤106
不难注意到我们要求的是划分构成一个回文序列的方案。这有点棘手,不妨令 s′=s1sns2sn−1⋯sn/2sn/2+1。这样 s′ 的合法划分方案与 s′ 的偶回文划分方案一一对应。
对于后者,可以利用上述方法求出。
# include <bits/stdc++.h>
const int N=1000010,INF=0x3f3f3f3f,mod=1e9+7;
char s[N],t[N];
int n;
inline int read(void){
int res,f=1;
char c;
while((c=getchar())<'0'||c>'9')
if(c=='-') f=-1;
res=c-48;
while((c=getchar())>='0'&&c<='9')
res=res*10+c-48;
return res*f;
}
namespace pam{
int ch[N][26],len[N],slink[N],fail[N],diff[N];
int cnt=-1,last;
inline int node(int l){
return len[++cnt]=l,cnt;
}
inline void init(void){
node(0),node(-1),fail[0]=1;
return;
}
inline int getfail(int x,int pos){
while(t[pos-1-len[x]]!=t[pos]) x=fail[x];
return x;
}
inline void extend(int pos,int c){
int x=getfail(last,pos),p;
if(!ch[x][c]){
p=node(len[x]+2),fail[p]=ch[getfail(fail[x],pos)][c],ch[x][c]=p;
diff[p]=len[p]-len[fail[p]];
slink[p]=((diff[p]==diff[fail[p]])?slink[fail[p]]:fail[p]);
// 留意这里的划分方式.
// 我们不会把 3,7,11 划分到同一个等差数列中.而是划分为 {3},{7,11}.
// 这是因为证明中用到了 2 * |fail(x)| >= |x| 的性质.
}
last=ch[x][c];
return;
}
}
using namespace pam;
int g[N],f[N];
int main(void){
scanf("%s",s+1),n=strlen(s+1);
if(n%2) puts("0"),exit(0);
for(int i=1,l=1,r=n;i<=n/2;++i,++l,--r) t[2*i-1]=s[l],t[2*i]=s[r];
init();
f[0]=1;
for(int i=1;i<=n;++i){
extend(i,t[i]-'a');
for(int j=last;j;j=slink[j]){
g[j]=f[i-len[slink[j]]-diff[j]];
if(slink[j]!=fail[j]) g[j]=(g[j]+g[fail[j]])%mod;
if(i%2==0) f[i]=(f[i]+g[j])%mod;
}
}
printf("%d",f[n]);
return 0;
}
例题 6 Codeforces 906E Reverses#
和例题 5 基本相同。
* 例题 7 LOJ6070「2017 山东一轮集训 Day4」基因#
给定长度为 n 的小写字母串 s,q 次查询,每次查询 s[l,r] 的本质不同回文子串数量。
强制在线。
离线版本:BZOJ 5384 有趣的字符串题
1≤n≤105,1≤q≤2×105
-
解法一
采用分块。使用 PAM 维护出从每一块开头 L 到每个右端点 r 的本质不同回文子串数量 f(L,r),以及插入完 [L,r] 后,last,first 指针在 PAM 上的位置。以及从 L 开始,回文子串 t 最早出现的位置(结束位置) p(L,t)。
考虑查询。若 [l,r] 在一块内,则暴力建立 PAM 查询。否则,分为两部分 [l,L),[L,r],其中 L 是 l 所在下一块的开头。我们记录了 first 指针的位置,据此可以使用前端插入法将 [l,L) 倒序插入。插入字符 sx 后得到 [x,r] 最长回文前缀 t,则检验是否有 p(L,t)>r,且 t 未被标记。若是,则表明 t 没有在 (x,r] 中出现过,此时给 t 打上标记,让答案增加 1。另一部分的答案即 f(L,r),最终答案为两部分之和。
取块长为 √n,时间复杂度为 O((n+q)√n|Σ|)。
-
解法二
先考虑允许离线的情形。此时我们对 r 扫描线,维护 l 的答案。
考虑性质:s[1,i] 的所有回文后缀可以被划分为 O(log2i) 个等差数列。
考虑一个等差数列中的回文后缀。
如图。设 a 为最长串,c 为最短串,且公差为 d。根据回文划分时使用到的结论结论,b,c 的上次出现位置如下图。那么当起始位置 l 位于红线部分 (i−|a|+1,i−|a|+1+d] 时,区间 [l,r] 新出现了串 b。位于蓝线部分 (i−|b|+1,i−|b|+1+d] 的时候,区间 [l,r] 新出现了串 c。这两部分的并为 (i−|a|+1,i−|c|+1]。对于更长的等差数列亦然。
对于最长串 a,如何获取其上一次出现的位置?不难发现,当 a 在 fail 树上的子树中节点作为最长回文后缀出现时,a 也会在此时出现。因此只需要查询 a 的子树最大值,就可以得到 a 上一次出现的 endpos,从而求出需要更新的区间。
若需要支持强制在线,则使用主席树。时间复杂度 O(nlog2n+qlogn)。
Luogu P4199 万径人踪灭#
来一道餐后甜点吧。题意即求:有多少个不是原串子串的子序列是回文的,且关于某个位置对称。
考虑两个位置 (l,r)。如果 sl=sr,那么对于 (l+r)/2 这个回文中心生成的回文子序列而言,sl,sr 要么同时选,要么同时不选。
这是一个和卷积的形式。具体地,枚举字符 a∈Σ,令 fi=[si=a],将 [xl+r]f2(x) 累加到某个初始为零的数组 h 上。那么,合法的子序列数量即 ∑p(2hp−1)。
最后需要减去回文子串数,用 Manacher 计算得出即可。
非平凡回文串划分判定#
判定是否有合法方案将长度为 n 的字符串 s 划分为若干个长度不为 1 的回文串。
令 f(i) 表示 i 开始的最短回文串长度,使用 PAM 或 Manacher 容易求得 f(i)(后者不是那么显然。想一想,怎么求?)。考虑从左向右贪心,若实际的划分中,从 i 开始的段长度不为 f(i),则设实际的段长为 d>f(i)。此时考虑两种情况:
-
d/2≤f(i)<d
注意到回文串的回文前缀必然是其 border,从而 s[i,i+d−1] 有周期 d−f(i)<d/2。周期的倍数仍为周期,于是它必然有长度大于 d/2 而小于 d 的周期,从而必然有长度小于 d/2 的 border。当 2f(i)−1≠d 时,必然存在长度不为 1 的 border,这个 border 必然是 s[i,i+d−1] 的回文串且长度小于 f(i),和 f(i) 的定义矛盾。
-
f(i)<d/2
此时 s[i,i+d−1] 必然可以被分为三个回文串,其中第一个和最后一个回文串为 s[i,i+f(i)−1]。只要 2f(i)+1≠d,中间的回文串长度必然不为 1。
因此实际的段长只有 f(i),2f(i)−1,2f(i)+1 三种选择。据此可以 O(n) DP。
作者:Meatherm
出处:https://www.cnblogs.com/Meatherm/p/18214357
版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!