基础字符串
- 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。补充了部分题目的题解。
符号与约定
对于字符串 \(s\),用 \(s_i,s(i),s[i]\) 表示 \(s\) 中第 \(i\) 个字符。如无特殊说明,默认字符串下标从 \(1\) 开始。
对于字符串 \(s\),用 \(s[l...r],s[l,r]\) 表示 \(s\) 中第 \(l\) 个字符开始到第 \(r\) 个字符结束的子串。特殊地,若 \(l>r\),则认为其为空串。
对于字符串 \(s,t\),用 \(st\) 或 \(s+t\) 来表示 \(s\) 和 \(t\) 的拼接。
对于字符串 \(s\) 和非负整数 \(l\),用 \(\operatorname{pre}(s,l),\operatorname{suf}(s,l)\) 分别表示 \(s\) 中长度长度为 \(l\) 的前缀和后缀。特殊地,若 \(l = 0\),则认为二者皆为空串。
用 \(\Sigma\) 表示字符集。字符集 \(\Sigma\) 是一个有限全序集,字符串中仅含字符集中的字符。
匹配、周期和 Border
定义
对于字符串 \(s\),若 \(0 \leq r < |s|\) 的 \(r\) 使得 \(s[1,r] = s[|s|-r+1,|s|]\),则称 \(s[1,r]\) 是 \(s\) 的 border。
对于字符串 \(s\),若 \(0 < p \leq |s|\) 的 \(p\) 使得 \(\forall i\in\{1,2,\cdots,|s|-p\}\),\(s_i = s_{i+p}\),则称 \(p\) 是 \(s\) 的周期。
按照上述定义,我们不能说 \(s\) 本身是它的 border,也不能说 \(0\) 是 \(s\) 的周期。上述对于不等式符号的精心选取是为了导出以下结论:
Period-Border Lemma
\(s[1,r]\) 是 \(s\) 的 border 当且仅当 \(|s|-r\) 是 \(s\) 的周期。
对于长度为 \(n\) 的字符串 \(s\),定义其前缀数组(一别称为 \(\operatorname{next}\) 数组)\(\pi = [\pi_0,\pi_1,\pi_2,\cdots,\pi_n]\),其中 \(\pi_i\) 表示 \(s[1,i]\) 的最长 border 长度。特殊地,规定 \(\pi_0 = 0\)。
对于字符串 \(s,t\) 和 \(1 \leq l \leq r \leq |s|\),若 \(s[l,r] = t\),则称 \(s[l,r]\) 和 \(t\) 匹配。对于所有满足前述条件的 \(r\),称 \(r\) 构成的集合为 \(t\) 在 \(s\) 中的匹配位置。
基础性质
-
性质 1:border 的 border 仍然是原串的 border。
-
性质 2:每次令 \(s\) 变为 \(s\) 的最长 border,直到 \(s\) 变为空串,则经过的所有 \(s\) 恰好构成原串的 border 集合。
证明可以考虑反证法,结合 border 的定义即可。
-
性质 3:若 \(p\) 是 \(s\) 的周期,且 \(kp \leq |s|\),则 \(kp\) 也是 \(s\) 的周期。
只需要考虑周期定义。
周期引理
弱周期引理 Weak Periodicity Lemma
对于字符串 \(s\),若 \(p,q\) 是 \(s\) 的周期,且 \(p+q \leq |s|\),则 \(\gcd(p,q)\) 也是 \(s\) 的周期。
周期引理 Periodicity Lemma
对于字符串 \(s\),若 \(p,q\) 是 \(s\) 的周期,且 \(p+q -\gcd(p,q) \leq |s|\),则 \(\gcd(p,q)\) 也是 \(s\) 的周期。
接下来使用较为直观的生成函数方法来证明周期引理。
以下证明中默认字符串的下标从 \(0\) 开始,且长度为 \(n\)。
考虑给每种字符分配一个互不相同的正整数权值,即建立映射 \(f:\Sigma \to \mathbb N_+\),然后将字符串 \(s = s_0s_1\cdots s_{n-1}\) 看作数列 \(\lang f(s_0),f(s_1),\cdots, f(s_{n-1})\rang\)。
对于周期 \(p,q\),构造多项式 \(P(x) = \sum \limits_{i=0}^{p-1} f(s_i)x^i,Q(x) = \sum \limits_{i=0}^{q-1} f(s_i)x^i\),这便是 \(s[0,p-1],s[0,q-1]\) 的生成函数。
定义字符串 \(s[0,p-1],s[0,q-1]\) 复制无穷多遍的生成函数为 \(S_p(x),S_q(X)\),则有 \(S_p(x)= \sum \limits_{i \geq 0} f(s_{i \bmod p})x^i\),\(S_q(x)= \sum \limits_{i \geq 0} f(s_{i \bmod q})x^i\)。\(S_p(x),S_q(x)\) 的系数可以分别看作 \(P(x),Q(x)\) 的系数复制无穷多遍产生的,可以表示为 \(S_p(x) = P(x) + x^pP(x)+x^{2p}P(x)+\cdots+x^{kp}P(x)+\cdots\),对于 \(S_q(x)\) 同理。根据生成函数的运算规则,\(S_p(x) = \dfrac{P(x)}{1-x^p},S_q(x) = \dfrac{Q(x)}{1-x^q}\)。
不难发现字符串 \(s\) 的生成函数 \(S(x)\) 是 \(S_p(x),S_q(x)\) 对前 \(n\) 项的截断,即 \(S(x) = S_p(x) \bmod x^n = S_q(x) \bmod x^n\),从而 \([x^k]S_p(x) = [x^k]S_q(x)\),其中 \(k = 0,1,\cdots,n-1\)。
考虑对 \(S_p(x)\) 和 \(S_q(x)\) 作差,得到
长除法容易证明 \((1-x^a),(1-x^b)\) 都是 \((1-x^{ab})\) 的因式,因此 \(\frac{1-x^q}{1-x^{\gcd(p,q)}},\frac{1-x^p}{1-x^{\gcd(p,q)}}\) 是整式,从而 \(H(x) = \dfrac{1-x^q}{1-x^{\gcd(p,q)}} P(x)+ \dfrac{1-x^p}{1-x^{\gcd(p,q)}}Q(x)\) 是次数不超过 \(p+q-1-\gcd(p,q)\) 的多项式。同时,\(\dfrac{1-x^{\gcd(p,q)}}{(1-x^p)(1-x^q)}\) 是一个常数项不为 \(0\) 的形式幂级数。若 \(H(x) \neq 0\),则取左侧幂级数的常数项和 \(H(x)\) 相乘,最终的结果中必然会得到不为 \(0\) 的一个 \(x^i\),其中 \(i \leq p+q-1-\gcd(p,q)\)。根据上面的讨论,在 \(k = 0,1,\cdots,n-1\) 处,我们都有 \([x^k](S_p(x)-S_q(x)) = 0\),又因为 \(p+q-\gcd(p,q) \leq n\),从而 \(i \leq n-1\),这样我们同时有 \(x^i\) 项的系数不为 \(0\) 和 \(x^i\) 项的系数必须为 \(0\),出现了矛盾,因此 \(H(x) = 0\)。
这样我们就有 \(S_p(x) - S_q(x) = 0\),从而 \(S_p(x)\) 和 \(S_q(x)\) 每一项的系数都相等。根据裴蜀定理,存在整数 \(a,b\) 使得 \(ap+bq=\gcd(p,q)\)。这样就有 \([x^i]S_p(x) = [x^{i+ap}]S_p(x) = [x^{i+ap}]S_q(x) = [x^{i+ap+bq}] S_q(x) = [x^{i+\gcd(p,q)}]S_p(x)\),从而 \(s_i = s_{i+\gcd(p,q)}\)。如果 \(ap\) 是负数并使得 \(i-ap\) 是负数,那么我们可以先让 \(i\) 变为 \(i+bq\),因为此时 \(bq\) 一定是正整数,从而我们可以避免取负数项的系数。
这里,尽管我们直接证明了周期引理,但是大部分时候 WPL 就足够了。
匹配引理
引理
若字符串 \(u,v\) 满足 \(2|u| \geq v\),则 \(u\) 在 \(v\) 中的所有匹配位置构成一个等差数列。
对平凡情况进行考察后,我们只需要考虑 \(u\) 在 \(v\) 中匹配了至少 \(3\) 次的情况。
如上图,设 \(u\) 在 \(v\) 中的前两次匹配在 \(v\) 中间隔为 \(d\),另外某次匹配距离第二次匹配的间隔为 \(q\),并记 \(u\) 在 \(v\) 中的前两次匹配分别为 \(u_1,u_2\)。因为 \(2|u| \geq v\),因此任意两次相邻匹配都会产生重叠位置,从而 \(d+q \leq |u|\),根据 Period Lemma 得到 \(r = \gcd(d,q)\) 也是 \(u\) 的周期。
设 \(u\) 的最小周期为 \(p \leq r\)。仅根据周期定义,\(u_1\) 在 \(v\) 中匹配的最后 \(p\) 个位置不一定满足 \(v(x) = v(x+p)\)。因为 \(u_1\) 和 \(u_2\) 是相同的,且 \(p \leq r =\gcd(d,q) \leq d\),因此 \(|u_1 \cap u_2|\) 中的 \(|u|-d\) 个位置必然有 \(v(x) = v(x+p)\)。如果 \(|u_1 \cap u_2| \geq p\),那么 \(u_1\) 中最后 \(p\) 个位置也可以借助 \(u_2\) 中对应位置提供的信息来满足 \(v(x) = v(x+p)\)(因为 \(d \leq p\),所以这确实成立),从而 \(p\) 也是 \(u_1 \cup u_2\) 的周期。因为 \(p\) 是 \(u\) 的最小周期,且根据上图有 \(|u_1 \cap u_2| \geq q\),因此 \(p \leq q \leq |u_1 \cap u_2|\),从而 \(|u_1 \cap u_2| \geq p\) 确实成立。
此时,若 \(p <d\),则 \(u_1\) 向右移动 \(p\) 的距离就会产生一次匹配,和 \(u_2\) 是 \(u\) 在 \(v\) 中第二次匹配矛盾。于是 \(d \leq p \leq r = \gcd(d,q) \leq d\) 成立,从而 \(p =d = r = \gcd(d,q)\)。
推论
若字符串 \(u,v\) 满足 \(2|u| \geq v\),则 \(u\) 在 \(v\) 中的所有匹配位置构成一个等差数列。若该等差数列项数不小于 \(3\),则其公差 \(d\) 为 \(u\) 的最小周期 \(\text{per}(u)\),且此时易知 \(\text{per}(u) \leq |u|/2\)。
我们上面的证明可以立刻得到该推论。注意到 \(d+q \leq |u|\),因此两个和为 \(|u|\) 的正整数的 \(\gcd\) 必然不会超过 \(|u|\) 的一半。
当等差数列仅含 \(2\) 项时不一定有 \(\text{per}(u) = d\),这是因为存在 \(u =\texttt{aabaa}, v =\texttt{aabaaabaa}, \text{per}(u) = 3, d = 4\) 的反例。
Border 的结构
引理 1
字符串 \(s\) 所有长度不小于 \(|s|/2\) 的 border 长度组成一个等差数列。
笔者注:此处不取整。
证明:设 \(s\) 的最大 border 长度为 \(|s|-p\),另外某个 border 长度为 \(|s| - q\),其中 \(p,q \leq |s|/2\)。那么 \(p+q \leq |s|\),从而 \(\gcd(p,q)\) 是 \(s\) 的周期。注意到 \(|s| -p\) 是 \(s\) 的最大 border 长度,因此 \(p\) 是 \(s\) 的最小周期,因此 \(p \leq \gcd(p,q)\)。根据 \(\gcd\) 的定义有 \(p \geq \gcd(p,q)\),因此 \(p = \gcd(p,q)\),从而 \(s\) 所有大小不超过 \(|s|/2\) 的周期恰为 \(p,2p,\cdots,kp,\cdots(kp \leq |s|/2)\),同时 \(s\) 所有大小不小于 \(|s|/2\) 的周期恰为 \(|s|-p,|s|-2p,\cdots,|s|-kp\),它们构成一个等差数列。
接下来对 \(s\) 的所有 border 考虑如下的引理 2:
引理 2
\(s\) 的所有 border 长度构成 \(O(\log|s|)\) 个值域上不交的等差数列。
我们将 border 按照长度 \(x\) 分类:\(x \in [1,2),[2,4),\cdots,[2^{k-1},2^k),[2^k,n)\)。
若 \(x \in [2^k,n)\),其中 \(2^k \geq n/2\),那么使用引理 \(1\) 可证。接下来讨论 \(x \in [2^{i-1},2^i)\) 的情形。
对于两个长度相等的串 \(u,v\),仿照 border 的定义,定义 \(u,v\) 的 PS 集合 \(\text{PS}(u,v) =\{k\mid\text{pre}(u,k) = \text{suf}(v,k)\}\)。
记 \(\text{LargePS}(u,v) = \{k \mid k \in \text{PS}(u,v),k \geq |u|/2\}\)。
引理 3
\(\text{LargePS}(u,v)\) 构成一个等差数列。
证明:若 \(u = v\) 则只需要考察 \(|u|\) 是否和 \(u\) 所有长度不小于 \(|u|/2\) 的 border 构成一个等差数列。根据对引理 1 的证明,这事实上是显然的,因为后者是 \(|u|-p,|u|-2p,\cdots,|u|-kp\)。
若 \(u \neq v\),考察 \(\text{LargePS}(u,v)\) 中的最大元素 \(x\)。
那么将 \(u,v\) 按照上图方式叠放后,它们长度为 \(x\) 的交集应当是相等的。\(\text{LargePS}(u,v)\) 中任意一个更小的元素(图中深蓝色部分)必然是 \(\text{pre}(u,x)\) 的 border,因此利用引理 1 得到这些元素构成一个等差数列。和 \(u=v\) 时类似,\(x\) 也可以加入到这个等差数列中。因此引理 3 成立。
现在回到引理 2。根据 border 的定义,\(\text{LargePS}(\text{pre}(s,2^i),\text{suf}(s,2^i))\) 恰好包含 \(x \in [2^{i-1},2^i)\) 的长度为 \(x\) 的 border。利用引理 3,\(\text{LargePS}(\text{pre}(s,2^i),\text{suf}(s,2^i))\) 构成一个等差数列,因此引理 2 立刻得证。
通过引理 2 的证明,当 \(|s|>1\) 时,这里的 \(O(\log|s|)\) 可以变成一个更紧的界 \(\lceil \log_2 |s|\rceil\)。如果要严谨一点,我们还应该考察长度为 \(0\) 的 border,但这不影响结论成立,因为我们完全可以把它和 \(1\) 放到同一个等差数列中,不过我们一般也不会需要它。
小结
通过探讨关于周期和 border 的几个引理,我们最终得到了最重要的引理 \(2\):\(s\) 的所有 border 长度构成 \(O(\log|s|)\) 个值域上不交的等差数列。
在具体应用到题目中时,通常会考虑所有非空 border 构成的长度序列 \(b_1 \leq b_2 \leq \cdots \leq b_k\),同时令 \(b_0 = 0,b_{k+1} = |s|\)。一种较为常用的划分方法是,从后往前考虑每个 \(b_i(1 \leq i \leq k)\),并判定:
- 若 \(b_{i+1} - b_i = b_{i} - b_{i-1}\),则将 \(b_i\) 划分进当前等差数列;
- 否则,将 \(b_i\) 划分进下一个等差数列。
例如,我们有 border 长度序列 \(b = [3,5,7,9,11,33,55]\)(很拙劣的例子。此处只是为了演示划分方式,可能并不存在这样的 border 序列),那么我们的划分方式为 \([3] / [5,7,9,11] / [33,55]\)。
这样做的好处是,因为 \(b_{i-1} \geq 0\),因此对于同一个等差数列中的相邻元素 \(b_i,b_{i+1}\),都有 \(b_{i+1} \geq 2b_i\)。这个性质和匹配引理的条件颇为相似,在某些题目中会派上用场。
最后给出一条性质作为结尾:若满足 \(k>1\) 的等差数列 \(b'=[b'_1,\cdots,b'_{k}]\) 存在,那么根据周期的定义,\(s\) 存在大小为公差 \(b'_2 - b'_1\) 的周期,因此,除了 \(b'_k\),其余的 \(b'_i\) 都满足 \(s[b'_i+1]\) 相同。
前缀数组、KMP 与字符串匹配
前缀数组的求法
KMP 算法支持在 \(O(n)\) 的时间内在线计算出前缀数组 \(\pi\)。根据 border 的定义,若 \(s[1,i]\) 存在长度为 \(x(x > 0)\) 的 border,则 \(s[1,i-1]\) 存在长度为 \(x-1\) 的 border。考虑以下计算流程:
- 令 \(\pi_1 = 0\)。
- 枚举 \(i = 2,\cdots,n\),依次执行如下算法:
- 初始化指针 \(j \leftarrow \pi_{i-1}\);
- 若 \(s[j+1] = s[i]\),令 \(\pi_{i} \leftarrow j+1\),结束算法;
- 若 \(j = 0\),令 \(\pi_i \leftarrow 0\),结束算法。
- 令 \(j \leftarrow \pi_{j}\),回到 2。
在每次执行算法的时候,\(j\) 不断变为 \(s[1,j]\) 的最长 border 的长度(\(\pi\) 数组的定义),根据性质 2,我们本质上从长到短遍历了 \(s[1,i-1]\) 的所有 border,然后依次判断该 border 是否是能在后方接上一个字符 \(s[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]);
}
考虑复杂度分析。首先,显然有 \(\pi_i \leq \pi_{i-1} + 1\),其次,在每次算法执行的过程中,每当 \(j\) 变为 \(\pi_j\) 时,\(j\) 至少变小 \(1\)。因为 \(0 \leq \pi_i < i\),运用势能分析可以得知该算法的复杂度为 \(O(n)\)。
前缀数组与字符串匹配
对于字符串 \(s,t(|s| = n,|t| = m)\),利用字符串 \(t\) 的前缀数组 \(\pi\) 可以求出 \(t\) 在 \(s\) 中的所有匹配位置。考虑以下计算流程:
-
枚举 \(i = 1,2,\cdots,n\)。同时维护指针 \(j\),表示 \(s[1,i]\) 的某个后缀和 \(t[1,j]\) 相等。初始令 \(j \leftarrow 0\)。
依次执行如下算法:
-
检查是否有 \(s[i] = s[j+1]\)。
-
若 \(s[i] = s[j+1]\) 成立,则令 \(j \leftarrow j+1\)。此时若 \(j = m\),则找到一个出现位置,标记该位置,并令 \(j \leftarrow \pi_j\)。
结束算法。
-
若 \(s[i] = s[j+1]\) 不成立,则判断:若 \(j = 0\),则结束算法。否则令 \(j \leftarrow \pi_{j}\),转到 1。
-
该算法的正确性证明和时间复杂度证明与求前缀数组是类似的,此处不再赘述。可以分析出时间复杂度为 \(O(n+m)\),其中 \(O(m)\) 的部分为求 \(\pi\) 的复杂度,\(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;
}
或者偷懒:将 \(s\) 和 \(t\) 用分隔符连接,每次只需要查询某一位的 \(\pi\) 值是否为 \(|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
对于某个字符串,当且仅当某个周期的大小整除字符串长度时,这个周期是该字符串的循环节。
注意到如果一个长度为 \(n\) 的字符串 \(s\) 有最短非平凡(大小不为 \(n\) 本身)循环节 \(c\),那么一定有 \(c \leq n/2\)。若 \(s\) 的最小周期 \(p\) 不为 \(c\),则 \(p+c \leq n\),根据周期引理,\(\gcd(p,c) \leq p < c\) 是 \(s\) 的周期。因为 \(c\) 是 \(n\) 的非平凡循环节,因此 \(\gcd(p,c) \mid c \mid n\),同时 \(\gcd(p,c) < c\),推出矛盾。
因此,对于本题,只需要求出前缀数组 \(\pi\),对于每个前缀 \(i\),检查是否有 \((i - \pi_i) \mid i\) 即可。同样根据周期引理,我们可以得到,任何循环节长度都是最短循环节长度的整倍数,因此,前缀 \(i\) 的循环节数量恰为 \(i/(i- \pi_i)\)。
KMP 算法的可持久化
接下来介绍一种 KMP 算法的变种,时空复杂度可做到 \(O(n\Sigma)\),或利用可持久化数据结构做到 \(O(n \log \Sigma)\)。
具体来说,记 \(nex(i,c)\) 表示 \(s[1,i]\) 所有满足 \(s[j+1] = c\) 的 border \(j\)(\(j = 0\) 亦考虑在内)的最大长度,不存在则 \(nex(i,c) = -1\)。那么显然有 \(\pi_i = nex(i-1,s[i])\)。
接下来只需考虑求出 \(nex(i)\)。不难发现 \(nex(i)\) 比起 \(nex(\pi_i)\),仅有 \(c = s[\pi_i + 1]\) 时可能发生改变。因此每次从 \(nex(\pi_i)\) 处复制后修改即可。
该变种的优势是,复杂度不依赖均摊,每添加一个字符,需要的复杂度都是 \(O(\Sigma)\) 或 \(O(\log \Sigma)\),因此支持可持久化。
给定长度为 \(n\) 的字符串 \(s\)。\(m\) 次询问,每次给出一个字符串 \(t\),询问字符串 \(s+t\) 的前缀数组 \(\pi\) 中,最后 \(|t|\) 位的值。
字符集为小写字母集。
\(1 \leq n \leq 10^6,1 \leq m \leq 10^5,1 \leq |t| \leq 10\)
不难发现可持久化 KMP 只需要保证 \(\sum |t|\),因此 \(|t| \leq 10\) 无用,可加强到与 \(n\) 同阶。
以下给出代码。
# 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] 论战捆竹竿
给定长度为 \(n\) 的小写字母串 \(s\) 和正整数 \(w\)。考虑 \(s\) 的所有非空 border 长度与正整数 \(n\) 构成的集合 \(B\),求:有多少个小于等于 \(w-n\) 的非负整数可以被集合 \(B\) 中若干个(可以为 \(0\) 个)元素的和所表示。集合中元素可重复使用。
多测,\(T \leq 5,1 \leq n \leq 5\times 10^5,1 \leq w \leq 10^{18}\)
求出 \(B\) 是平凡的。不难发现这是元素较小,且值域较大的完全背包问题,可以使用转圈法(或同余最短路)解决,详见 [THUPC 2023 初赛] 背包。
但事实上,元素种类仍然较多,难以直接通过。接下来的做法需要考虑到 border 可以划分为若干个等差数列的性质。
考虑当前模数 \(M\),并设 \(f(i)\) 表示只使用之前加入的元素时,能够凑出来的最小的模 \(M\) 等于 \(i\) 的非负整数。初始令 \(M = n,f(0) = 0,f(i) = +\infty ( i \neq 0)\)。
对于一个长度为 \(l+1\) 的等差数列,设其为 \(x,x+d,\cdots,x+ld\)。先实现 \(f(i)\) 从模 \(M\) 意义下到模 \(M' = x\) 意义下的转换,那么对于一个旧的 \(f(i)\),其贡献显然为:令 \(f'(i \bmod y)\) 对 \(f(i)\) 取 min。
此时注意到,因为原来的背包基准元素为 \(M\),因此旧的 \(f(i)\) 中并没有考虑加入元素 \(M\) 的贡献。因此需要在模 \(M'\) 意义下的背包中,加入元素 \(M\),跑一遍背包。
现在考虑加入等差数列中的元素。将每个点 \(i\) 向 \((i+d) \bmod x\) 连边,此时形成了 \(\gcd(d,x)\) 个环。每个环上的任务形如:找到环上使得 \(f(i)\) 最小的 \(i\) 作为起始点,然后对于每个 \(f(i+kd)\),有 \(f(i+kd) = \min \limits_{j = 1}^{l} \{f(i+(k-j)d) + jd\}\)。使用单调队列优化 DP 即可。
Codeforces 1286E Fedya the Potter Strikes Back
本题需要在加入每个字符后,求出当前字符串每个非空 border 的可疑度之和。
考察 \(s[1,i] (i>1)\) border 的来源:要么是一个空 border,要么是 \(s[1,i-1]\) 的某个 border 在后面拼上一个和 \(s[i]\) 相等的字符变来的。
设 \(S(i,c)\) 表示 \(s[1,i]\) 所有满足下一位字符为 \(c\) 的 border 构成的集合,那么 \(S(i)\) 中只有一个位置和 \(S(\pi_i)\) 不同:\(S(i,s[\pi_i + 1])\) 中添加了 \(\pi_i\) 这一元素。
不难发现,加入 \(s[i]\) 后,需要删除 \(S(i-1,c) (c \neq s_i)\) 中 border 的贡献。没有被删除的 border 贡献需要对 \(i\) 这一位的权值取 min,可以使用 map 简单维护。另外,若 \(s[i] = s[1]\),则加入该 border 的贡献。
考虑如何求出需要删除的 border。记 \(nex(i,c)\) 表示 \(S(i,c)\) 中的最长 border 长度,那么依次遍历 \(nex(i-1,c),nex(nex(i-1,c),c),\cdots\) 即可。
贡献可以使用线段树计算。因为 border 的加入删除数量是均摊 \(O(1)\) 的,因此时间复杂度 \(O(n \log n)\)。
# 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
考虑对于两个序列,如何判定其等价。我们发现重复的数非常麻烦,因此可以把序列中的某个数 \(x\) 看作一个二元组 \((x,c)\),其中 \(c\) 表示在 \(x\) 所在位置之前大小等于 \(x\) 的数的数量。
两个序列 \(a,b\) 相同,当且仅当对于每一位 \(i\),\(a[1,i-1]\) 中小于 \(a_i\) 的数的数量和 \(b[1,i-1]\) 中小于 \(b_i\) 的数的数量相等。同时,对于这一位,\(a[1,i-1]\) 中等于 \(a_i\) 的数的数量也要和 \(b[1,i-1]\) 中等于 \(b_i\) 的数的数量相等。这些要求本质上确定了插入第 \(i\) 位时这个数要插在的位置。
等价仍然是具有传递性的,因此这不影响 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\) 描述这段字符的种类。例如,\(\texttt{aaaabbbbaa}\) 可以被描述为 \([(4,\texttt a),(4,\texttt b),(2,\texttt a)]\)。
如何判定这个字符串的某个前缀和后缀相等?事实上,题目保证了相邻两次插入的字符种类不同,因此,分别将该前缀和该后缀所在的二元组序列取出(它们也分别是原二元组序列的一段前缀和后缀。另外,如果某个二元组不被完全包含,我们也会取出它),那么这两个序列需要满足以下条件:
- 两个序列的长度相同;
- 序列中每个位置的字符对应相等;
- 除了头尾,中间的二元组必须完全相等;
- 对于第一个二元组,前缀序列的字符数量不超过后缀序列的字符数量;
- 对于最后一个二元组,后缀序列的字符数量不超过前缀序列的字符数量。
以 \(\texttt{aabbcccdaaabbcc}\) 为例。它可以被描述为 \([(2,\texttt a),(2,\texttt b),(3,\texttt c),(1,\texttt d),(3,\texttt a),(2,\texttt b),(2,\texttt c)]\),取出长度为 \(6\) 的前缀和长度为 \(6\) 的后缀的二元组序列,它们分别是 \([(2,\texttt a),(2,\texttt b),(3,\texttt c)],[(3,\texttt a),(2,\texttt b),(2,\texttt c)]\)。按照上述规则,我们可以判定长度为 \(6\) 的后缀和长度为 \(6\) 的前缀相等。
注意到,中间的二元组必须完全相等,因此 KMP 的过程不会发生太大变化。我们只需要略微修改二元组序列中,两个前后缀相等的定义:前后缀长度必须相等;除了第一个二元组,其余二元组必须完全相等;第一个二元组的字符必须相等,前缀中该二元组的字符数量必须不超过后缀中该二元组的字符数量。
在序列中加入第 \(i\) 个二元组 \((x_i,c_i)\) 的时候,我们需要关心新加入的这 \(x_i\) 个字符对应前缀的最长 border 长度。我们可以从长到短遍历二元组序列中前缀 \(i-1\) 的所有 border \(b\),并检查该 border 的下一个位置 \(b+1\) 是否有 \(c_{b+1} = c_i\)。如果是,那么新加入的第 \(k(1 \leq k \leq \min(x_i,x_{b+1}))\) 个字符就存在一个长度为 \((\sum \limits_{j=1}^{b} x_j)+k\) 的 border 了。
注意到我们的操作是在操作树上,因此不能再用带势能的 KMP 了。考虑 WPL,对于一个长度为 \(n\) 的字符串 \(s\),设其最长的 border 长度为 \(n-d\),那么 \(d\) 是其最小周期。若周期 \(d'\) 满足 \(d' +d \leq n\),则 \(\gcd(d',d)\) 也是 \(s\) 的周期,因此 \(d'\) 只能是 \(d\) 的倍数。从而所有大小在 \([d,n-d]\) 之间的周期都形如 \(kd\)。
因此,长度为 \(n-d,n-2d,\cdots,n \bmod d + d\) 的 border 构成了一个等差数列,根据结论,这些 border 中除了最长的那个,剩下的 border 的下一个字符都是相同的。因此对于该等差数列,只需要检查该等差数列的最长 border 和次长 border 即可。
具体地,我们维护两个指针 \(cl,cr\),其中 \(cl\) 表示当前位于的 border,\(cr\) 表示上一个 border。如果 \(cr - cl = cl - \pi_{cl}\),则说明 \(cl\) 是等差数列中的次长 border,我们直接跳到等差数列中的第一个 border,并将 \(cr\) 置为 \(-1\)(这样下一次判断一定不会成立)。否则,我们只向前跳一步,即将 \(cr\) 置为 \(cl\),\(cl\) 置为 \(\pi_{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 函数
对于长度为 \(n\) 的字符串 \(s\),定义其 Z 函数数组 \(z = [z_1,z_2,\cdots,z_n]\),其中 \(z_i\) 为 \(s[1,n]\) 与 \(s[i,n]\) 的 LCP 长度。
与前缀函数 \(\pi\) 类似,\(z\) 也可以在线性时间内求出。算法如下:
-
令 \(z_1 = n\);
-
遍历 \(i =2,3,\cdots,n\),并维护 Z-box \([l,r]\),表示最靠右的一段区间,使得它可以和 \(s\) 的某个前缀完全匹配。初始 \(l = r = 0\)。执行以下流程:
- 若 \(i \leq r\),说明 \(i\) 位于 Z-box 中。此时根据定义,有 \(s[l,r] = s[1,r-l+1]\),从而 \(s[i,r] = s[i-l+1,r-l+1]\)。因此 \(z_i \geq \min(z_{i-l+1},r-i+1)\)。
- 暴力扩展 \(z_i\)。即:若 \(s[z_i+1] = s[i+z_i]\) 则令 \(z_i\) 增加 \(1\),直到该条不再成立。
- 更新 Z-box。即:若区间 \([i,i+z_i-1]\) 的右端点 \((i+z_i - 1)\) 位于 Z-box \([l,r]\) 的右端点 \(r\) 右侧,则将 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
事实上,我们可以使用与求 \(z\) 时几乎一致的方式求出 \(p\)。
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 自动机与多串匹配
现在有 \(n\) 个字符串 \(s_1,s_2,\cdots,s_n\)。它们的 AC 自动机 \(\mathcal A\) 可以看作是 \(s_1,s_2,\cdots,s_n\) 的 Trie 树 \(\mathcal T\) 上,新建出一些边形成的有向图结构。
具体来说,我们希望 \(\mathcal T\) 扩充为 \(\mathcal A\) 后,\(\mathcal A\) 满足如下性质:
- 节点集合不变,原有的边仍然存在。
- 包含所有形如 \(tr(x,c)\) 的边,其中 \(x\) 属于节点集合,\(c\) 属于字符集。
- 设节点 \(x\) 表示的字符串为 \(\text{str}(x)\),\(\text{str}(x)+c\) 最长的在 \(\mathcal T\) 上出现过的后缀为 \(\text{msuf}(x)\),则 \(tr(x,c)\) 指向 \(\text{msuf}(x)\) 代表的节点。
以下给出扩充方法。考虑对 \(\mathcal T\) 进行 BFS,维护出 \(\text{fail}(x)\),指向代表 \(\text{str}(x)\) 在 \(\mathcal T\) 上出现过的最长后缀的节点。
算法流程如下:
-
维护一个 BFS 队列,初始将根节点入队。
-
若队列为空,结束算法。否则,取出队列头元素 \(x\) 并弹出,转到 3。
-
遍历 \(c \in \Sigma\),检查 \(tr(x,c)\)。若 \(tr(x,c)\) 存在,转到 4,否则转到 5。
-
令 \(y = tr(x,c)\)。将 \(\text{fail}(y)\) 更新为 \(tr(\text{fail}(x),c)\),并将 \(y\) 入队,保持 \(tr(x,c)\) 不变,转到 2。
-
令 \(tr(x,c) \leftarrow tr(\text{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;
}
不难发现 \((\text{fail}(x),x)\) 组成树形结构,称作 fail 树。
以下是几个简单应用。
在应用之前,有几点提示:
- 做不动了,就考虑根号分治。
- 如果你觉得不好做,想想有没有基于 \(\sum |S|\) 的做法。
- 如果题目询问的是整串在整串中的出现信息,大概率 ACAM 和广义 SAM 近似等价,可以先考虑简单的 ACAM。
Luogu P5357 【模板】AC 自动机
给定文本串 \(S\) 和模式串 \(T_1,T_2,\cdots,T_n\),求每个模式串在 \(S\) 中出现的次数。
\(1 \leq n,\sum |T| \leq 2\times 10^5,1 \leq |S| \leq 2 \times 10^6\)
对 \(T\) 建出 AC 自动机,随后将 \(S\) 放上 AC 自动机进行匹配。流程为:
- 维护指针 \(p\),初始为根。每个节点维护一个标记大小,初始为 \(0\)。
- 遍历 \(i = 1,2,\cdots,|S|\),令 \(p \leftarrow tr(p,S_i)\),将 \(p\) 节点的标记大小增加 \(1\)。
随后遍历 \(i = 1,2,\cdots,n\),则根据 fail 指针的定义,\(T_i\) 对应节点在 fail 树上子树的标记大小之和即为答案。
Luogu P4052 [JSOI2007] 文本生成器
在 AC 自动机上 DP。
第一种方法是补集转化,设 \(f(i,j)\) 表示长度为 \(i\) 的字符串,把这个字符串在 ACAM 上匹配完成后停在节点 \(j\) 上,且路径上不经过任何终止节点(节点表示的字符串的某个后缀位于字典中)的方案数。转移是简单的。
第二种方法是,直接设 \(f(i,j,0/1)\) 表示长度为 \(i\) 的字符串,把这个字符串在 ACAM 上匹配完成后停在节点 \(j\) 上,且路径还未经过 / 已经经过终止节点的方案数。
这里给出第一种方法的实现。
# 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)\) 表示考虑了前 \(i\) 个字符,当前位于 AC 自动机上的 \(j\) 节点,下标最小的没有覆盖的位置在 \((i+1)-k\) 处(若全部被覆盖,则取 \(k=0\))的方案数。
初始有 \(f(0,\text{root},1)=1\)。考虑从 \(f(i,j,k)\) 转移到 \(f(i+1)\),枚举这一位填的字符 \(c\),记 \(tr(j,c) = j'\),那么,记以 \(j'\) 结尾的最长模式串长度为 \(l\),则当 \(l > k\) 时,该模式串覆盖掉之前所有的没有被覆盖的位置,\(f(i,j,k)\) 转移到 \(f(i+1,j',0)\);否则,\(f(i,j,k)\) 转移到 \(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 语言
给定大小为 \(n\) 的字典 \(D\) 和 \(m\) 个文本串 \(T_1,T_2,\cdots,T_m\),对于每个文本串,求出最大的 \(i\),使得 \(T[1,i]\) 可以被划分为若干个字典中的单词。
\(1 \leq n \leq 20,1 \leq m \leq 50,1 \leq |t| \leq 2\times 10^6\),字典中的单词长度不超过 \(20\)
对于文本串 \(T\),考虑暴力 DP:设 \(f(i)\) 表示让 \(T[1,i]\) 能够被划分是否可行。转移需要枚举字典中的单词判断合法性。
考虑如何快速找出合法单词。称 fail 树上代表 \(T_i\) 的那些节点为终止节点,设 \(T[1,i]\) 在 AC 自动机上匹配后位于节点 \(p\),则 fail 树上节点 \(p\) 到根的路径中,所有的终止节点都是合法的转移串。
进一步地,我们只需要这些串的长度,因此可以进行状态压缩。
但是这道题 \(n \leq 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 树,\(m\) 次询问 Trie 树上某个节点 \(x\) 所代表的字符串在另一个节点 \(y\) 所代表的字符串中出现了多少次。
字符集大小为 \(26\),保证 Trie 树大小不超过 \(10^5\)。\(1 \leq m \leq 10^5\)
首先建出 AC 自动机。
考虑询问只有一次怎么做。将 \(y\) 的每个前缀节点打上标记,则根据 fail 指针的定义,只需要查询 \(x\) 在 fail 树上的子树和。
存在多次询问时做法区别不大。考虑采用 DFS Trie 树的方式遍历所有的 \(y\),进入节点时给该节点打上标记,离开节点时撤销。将关于 \((x,y)\) 的询问挂在节点 \(y\) 上,遍历到 \(y\) 时查询。子树和使用树状数组维护,时间复杂度 \(O(n \Sigma + (n+m) \log n)\),其中 \(n\) 为 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
出现次数可以差分,转化为 \(s_k\) 在 \(s_{1\cdots r}\) 中的出现次数。考虑对 \(r\) 扫描线,对于 \(s_r\) 的每个前缀节点,将该节点的标记大小增加 \(1\),查询即查询 \(s_k\) 对应节点在 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
考虑对 \(S\) 建出 ACAM。则加入一个字符串 \(T\) 的贡献形如:找到 \(T\) 的每个前缀在 ACAM 上匹配后位于的节点 ,将这些节点到根路径并中的每一个点的标记大小增加 \(1\)。
这是经典问题。将这些节点按照 DFS 序排序,采用如下方式表示贡献:
- 将每个节点到根的路径上每一个点的标记大小增加 \(1\)。
- 将相邻节点的 LCA 到根的路径上每一个点的标记大小减小 \(1\)。
需要支持链加单点查询。转换为单点加子树查询,树状数组维护即可。
# 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,记串长和为 \(L\),不难发现串长种类数为 \(\sqrt L\),据此可以 \(O(L\sqrt L)\) Hash。
AC 自动机的部分有一种奇怪的二进制分组法,暂时没有想明白它的本质是什么。具体方法如下:考虑到出现次数可减,因此我们不在 AC 自动机中删除,而是分别维护被加入串的 AC 自动机和被删除串的 AC 自动机。
对于某一个集合而言,加入一个串会导致整个 AC 自动机的形态发生变化,需要在 Trie 树基础上重新跑 init 函数。考虑二进制分组,设当前集合中有 \(c\) 个串,则把这个集合分为若干组,组的大小为 \(c\) 的二进制拆分。例如,\(c = 23 = 16+4+2+1\),则把集合分为大小为 \(16,4,2,1\) 的三组,组内维护一个 AC 自动机。考虑加入第 \(c+1\) 个串,首先新开一组,在这一组内的 AC 自动机中插入该串。接着检查,如果之前的组大小和最后一组相等,则合并这两个组(模拟二进制加法的过程)。
合并两个组的代价是两个组内字符串的串长之和再乘上 \(|\Sigma|\)。那么对于每一个串,它被合并一次,所处的集合大小翻倍,因此只会被合并不超过 \(\log_2 m\) 次。从而总复杂度为 \(O(L|\Sigma|\log_2m)\)。
# 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 做法。
其原因是,本题的操作形如:将 \(s_l,s_{l+1}.\cdots,s_r\) 对应节点在 fail 树上的子树 \(+1\),然后查询 \(s_k\) 的所有前缀节点的权值之和。和 547E 不同,我们现在需要在文本串一侧处理前缀节点,这样就没法快速维护了。
这种情况的主流方法是阈值分治。具体来说,设定阈值 \(B\),分情况讨论:
-
如果 \(|s_k| > B\)
这样的串只会有不超过 \(\dfrac{L}{B}\) 个,其中 \(L\) 是串长和。考虑线性求解所有有关 \(s_k\) 的询问的答案,具体地,将 \(s_k\) 的所有前缀节点权值 \(+1\),那么一个 \(s_x\) 的贡献即 \(s_x\) 对应节点的子树和。子树和可以通过 DFS fail 树线性求出。差分后对 \(r\) 扫描线统计答案。
这部分的复杂度是 \(O(\dfrac{L^2}{B} + q \log q)\) 的。
-
如果 \(|s_k| \leq B\)
这一部分,对于每个串,我们希望在 \(O(|s_k|)\) 左右求解。具体地,差分后对 \(r\) 扫描线统计答案,将询问挂在对应端点,每扫过一个字符串,就将该字符串对应的节点在 fail 树上的子树 \(+1\)(这部分使用树状数组),询问时暴力枚举前缀节点,使用树状数组。这部分的复杂度是 \(O(qB\log L+n \log L)\)。
令 \(\dfrac{L^2}{B} = qB \log L\),解得最优阈值为 \(B = \dfrac{L}{\sqrt{q \log L}}\),此时时间复杂度为 \(O(n \log L + q \log q + \sqrt{q \log L}\cdot 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 的馈赠
对于一个询问 \(t_x,t_y\),将 \(t_x\) 的每个前缀节点在 fail 树上到根的路径上打上标记 \(x\),将 \(t_y\) 的每个前缀节点在 fail 树上到根的路径打上标记 \(y\)。另外,将 \(s_i\) 对应的节点的权值 \(+1\)。所求即为同时有标记 \(x,y\) 的节点的权值之和。可以看作求两个虚树的交。
直接做仍然是不太好做的。因此我们同样考虑阈值分治。称长度大于阈值 \(B\) 的为大串,否则为小串。
考虑 \(t_x,t_y\) 至少有一个是大串的询问。不失一般性,设 \(t_x\) 是大串。将 \(t_x\) 的每个前缀节点在 fail 树上到根的路径上打上标记 \(x\),将具有标记 \(x\) 的 \(s_i\) 对应的节点的权值 \(+1\)。对于每个 \(t_y\),询问时建立虚树,统计答案即可。这部分的复杂度为 \(O(\frac{L^2}{B} + L \log L)\)(就算 \(t_x\) 不同,我们也只会改变树上权值而非形态,因此对于一个串,虚树只需要建立一次,从而分析出 \(O(L \log L)\))。
接下来考虑 \(t_x,t_y\) 均为小串的询问。事先将 \(s_i\) 对应的节点权值 \(+1\),询问时建出 \(t_x,t_y\) 虚树的交回答询问即可。瓶颈在于建出虚树交的复杂度。若每次询问时抽取 \(t_x,t_y\) 所有前缀节点,暴力建出虚树,单次询问的复杂度为 \(O(B \log B)\)。
若要去掉复杂度中的 \(O(\log B)\),可考虑以下方法:事先建出所有 \(t_x\) 所有前缀节点的虚树,并将节点按照 DFS 序排序。询问时拉出 \(t_x,t_y\) 的虚树节点序列并归并。具体地,对 \(t_y\) 虚树节点序列中的每个节点 \(r\),找到其在 \(t_x\) 序列中的前驱后继 \(p,s\)。则 \(\text{lca}(r,p),\text{lca}(r,s)\) 中深度较大者即为 \(t_y\) 的祖先中最深的位于 \(t_x\) 虚树上的节点。将该节点加入初始为空的集合 \(S\),则集合 \(S\) 的虚树即为两棵虚树的交。使用查询 \(O(1)\) 的 LCA 算法则单次复杂度为 \(O(B)\),从而这部分的总复杂度为 \(O(L \log L + qB)\)。
此时不难发现 \(B = \sqrt L\) 时取得最优复杂度 \(O((L+q) \sqrt L )\)。
Codeforces 1483F Exam
考虑枚举 \(s_i\),那么对于 \(s_i\) 的每个前缀 \(s_i[1,l]\),只有它的最长的作为某个 \(s_j\) 出现的后缀可能成为答案,我们称这个 \(s_j\) 为备选答案。这可以 AC 自动机预处理得出。我们枚举 \(l\),记录下每个 \(s_j\) 作为备选答案的次数,并记录下每个 \(s_j\) 的出现位置 \([st,ed]\)。该过程中,可能会出现某个区间被另一个区间包含的情况。如果这种情况发生,我们将不再认为被包含的区间作为备选答案出现了一次。
现在,枚举每个至少作为备选答案一次的 \(s_j\)。不难发现,当且仅当 \(s_j\) 作为备选答案出现的次数恰好等于其在 \(s_i\) 中的出现次数时,\((i,j)\) 是一对合法答案。欲求出现次数,只需要使用树状数组即可。
时间复杂度 \(O(L \log L)\)。
# 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)\) 表示从原图上点 \(1\) 的对应点开始走到 AC 自动机 \(j\) 点的最短路,并强制要求不经过终止节点。但是本题中,字符集大小为 \(n\),朴素的 AC 自动机难以通过。考虑性质:\(tr(i)\) 比起 \(tr(\text{fail}(i))\),只有在 \(i\) 节点出边的部分会有修改。
因此考虑主席树优化建图。具体地,将所有合法路径(即可以在原图上实际走出来的路径)和 \(1 \sim n\) 这 \(n\) 个单点插入 AC 自动机,并将 \(1 \sim n\) 这 \(n\) 个单点在 AC 自动机上对应的节点标号为 \(1 \sim n\)。
那么要计算 \(tr(i)\) 时,只需要在 \(tr(\text{fail}(i))\) 的基础上修改 \(tr(i)\) 转移到的点即可,最后一起跑一遍 Dijkstra。
如果 \(n,m,k\) 同阶,那么复杂度为 \(O(n \log^2 n)\),因为点数、边数均为 \(O(n \log n)\)。但事实上,我们可以说明,精细实现的复杂度是 \(O(n \log n)\) 的:对于主席树上的虚点,我们连的都是边权为 \(0\) 的虚边。从而,一旦一个点的最短路固定下来了,其子树内所有虚点的最短路都会固定下来。因此这些虚点和虚边只会贡献 \(O(1)\) 次入队次数,且因为边权为 \(0\),入队时必然位于堆顶,不贡献堆的复杂度。而对于剩下的部分,显然只剩下 \(O(n)\) 个实点和 \(O(n)\) 条实边,因此这一部分复杂度为 \(O(n \log n)\)。
本题不需要在 fail 树上搞花活,所以代码里面根节点被直接省略掉了。代码中仍采用 \(O(n \log ^2 n)\) 的实现。
# 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
仍然不好做,考虑阈值分治。设定阈值 \(B\)。
对于 \(s_k\) 是大串的情形,\(O(L+q \log n)\) 解决一个串是容易的。
对于 \(s_k\) 是小串的情形,考虑对 \(r\) 扫描线。从左到右扫描 \(r\),并给 \(s_r\) 的终止节点打上标记 \(r\)。扫到 \(r\) 时处理形如 \((l,r,k)\) 的询问。考虑某个 \(s_k\) 的前缀节点的贡献。若该节点到根的路径上,有大于等于 \(l\) 的标记时,该节点对答案有贡献,贡献大小为该节点在 fail 树上的子树和(此时 fail 树上仅 \(s_k\) 的前缀节点点权为 \(1\))。
建虚树查询即可。同样,我们可以把单点修改链查询变为区间 chkmax 单点查询。对于每个串,虚树只需建立一次,因此复杂度是均摊 \(O(L \log L)\) 的。取阈值为 \(\sqrt L\),总复杂度为 \(O(L \sqrt L + q \log n+ L \log L)\)。
Luogu P5599 【XR-4】文本编辑器
单词的长度很小,这是无论如何都想利用上的性质。记字典中最长单词长度为 \(d\)。首先对字典建出 ACAM,并维护出每个状态 \(i\) 的对应串有多少个后缀出现在了字典中,记作 \(cnt(i)\)。
先不考虑修改。如果我们能够维护出 \(s[1,i]\) 在 ACAM 中匹配得到的状态 \(sta(i)\),那么我们是可以对付查询的。这是因为 \(d\) 很小,所以对于询问 \([l,r]\) 而言,当 \(i\) 远大于 \(l\) 时,位置 \(i\) 对于答案的贡献就是 \(cnt(sta(i))\)。精细分析可知,当 \(i \in [l+d-1,r]\) 时,我们就可以认为 \(i\) 远大于 \(l\) 了。
当 \(i \in [l,l+d-2]\) 时,\(cnt(sta(i))\) 中的串不一定全部合法。因此,我们应当遍历 \(sta(i)\) 在 fail 树上的祖先,找到第一个节点长度小于等于 \(l-i+1\) 的祖先 \(f\),并取 \(cnt(f)\) 作为贡献。注意这一步的复杂度不应当变为 \(O(d^2)\),因此我们要对于每个节点维护 \(par(x,h)\),表示 fail 树上 \(x\) 的第一个节点长度不超过 \(h\) 的祖先(可以是它自己),这样在枚举 \(i\) 的时候可以一步定位到 \(f\)。因此,单次询问的复杂度为 \(O(d)\)。
现在考虑修改。当 \(i\) 远大于 \(l\) 的时候,不难发现修改会使得 \(sta(i)=sta(i+|t|)\)。因为 \(s[l,r]\) 会变为 \(t\) 的若干次循环,因此这是容易证明的(无论 \(sta(i)\) 的长度更长,还是 \(sta(i+|t|)\) 的长度更长,都是不合理的)。
和查询类似地,我们可以发现,当 \(i \in [l+d-1,r]\) 时,我们认为 \(i\) 远大于 \(l\)。因此,这部分修改可以使用以下方法完成:
- 从 \(sta(l-1)\) 开始,当 \(l \leq i \leq l+d+t-1\) 时,暴力匹配出 \(sta(i)\)。
- 取出 \(sta[l+d-1,l+d+t-2]\),将这一段向后复制,直到 \(sta(r)\) 为止。
- 从 \(sta(r)\) 开始,当 \(r+1 \leq i \leq r+d-2\) 时,暴力匹配出 \(sta(i)\)。
因此,我们只需要一个支持区间循环覆盖的线段树即可。时间复杂度 \(O(|\Sigma|\sum S + \sum T+q \log n)\)。
SCOI2024 Day1 T1 (口胡)
考虑分类讨论。存在两种情况:\(s_i+t_j\) 完整地在某个 \(s_x\) 或者 \(t_x\) 中出现,以及 \(s_x+t_y\) 的分界线将 \(s_i+t_j\) 分成了两部分。
对于第一种情况,以完整出现在某个 \(s_x\) 中为例。若 \(s_i+t_j\) 完整地在某个 \(s_x\) 出现,考虑枚举 \(s_x\),对于 \(1 \leq l < |s_i|\),计算如下信息:作为 \(s_x[1,l]\) 后缀的 \(s\) 串数量,以及作为 \(s_x[l+1,|s_x|]\) 前缀的 \(t\) 串数量。对于前者,对所有 \(s_i\) 建 ACAM,将 \(s_x\) 从前往后匹配,若匹配完 \(s_x[1,l]\) 后位于的节点为 \(p\),则作为 \(s_x[1,l]\) 后缀的 \(s\) 串数量等于 \(p\) 到根的路径上终止节点数量之和(重复串算多次)。对于后者,对所有 \(t_i\) 的反串建 ACAM 后将 \(s_x\) 从后往前匹配,类似地可以算出答案。
对于同一个 \(s_x\),枚举 \(l\),答案即为二者乘积之和。枚举所有的 \(s_x,t_x\),再枚举 \(l\) 计算答案,我们就在 \(O(\sum \text{len})\) 的时间计算出了第一种情况的答案。
对于第二种情况,有三种子情况:\(s_i\) 被 \(s_x+t_y\) 分成了两部分;\(t_j\) 被 \(s_x+t_y\) 分成了两部分;\(s_i+t_j\) 的分界线恰好是 \(s_x+s_y\) 的分界线。
对于前两种子情况,取第一种子情况为例。枚举 \(t_y\) 和 \(l\),钦定 \(s_i\) 在 \(t_y\) 中的部分为 \(t_y[1,l]\)。那么合法的 \(t_j\) 数量就是作为 \(s_y[l+1,|t_y|]\) 前缀的 \(t\) 串数量。对于每个 \(s_i\),枚举 \(1 \leq k < |s_i|\),将 \(s_i\) 分为非空的两部分 \(A=s_i[1,k],B=s_i[k+1,|s_i|]\)。那么当且仅当 \(B=t_y[1,l]\) 时,这种划分有 \(cnt(A)\) 的贡献,其中 \(cnt(A)\) 表示存在后缀 \(A\) 的 \(s_x\) 的数量。则对于这个 \(l\),总贡献为前面的贡献乘上合法的 \(t_j\) 数量。
考虑事先预处理,将所有 \(s_i\) 的所有后缀计算 Hash 后扔进 Hash Table,然后枚举 \(s_i\) 和 \(k\),同样利用 Hash + Hash Table 可以支持 \(O(\sum \text{len})\) 处理,\(O(1)\) 询问贡献。
对于第三种子情况,使用 Hash 仍然容易计算。
回文相关
基础性质
重排
一个字符串能够重排成为回文串的充要条件是,只有至多一种字符出现了奇数次。
本质不同回文子串数量
定理 1
一个长度为 \(n\) 的字符串 \(s\) 的本质不同回文子串数量不超过 \(n\)。
证明:反证法。令每个本质不同回文子串在其第一次结束处被统计。若存在两个本质不同回文子串 \(p,q(|p|<|q|)\) 在 \(l\) 处被统计,那么 \(p\) 是 \(q\) 的回文后缀。因为 \(q\) 是回文串,因此 \(p\) 必然也是 \(q\) 长度小于 \(|q|\) 的一个回文前缀,从而可以在某个小于 \(l\) 的位置被统计,和假设矛盾。
回文引理
回文串具有非常良好的性质。因此将它和 border 联系起来时,有几个简单的引理成立。
引理 1
若 \(t\) 是回文串 \(s\) 的后缀,则 \(t\) 是 \(s\) 的 border 当且仅当 \(t\) 是回文串。
根据定义可证。
引理 2
若 \(t\) 是 \(s\) 的 border(\(|t| \geq |s|/2\)),则 \(s\) 是回文串当且仅当 \(t\) 是回文串。
若 \(s\) 是回文串,利用引理 \(1\)。
若 \(t\) 是回文串且是 \(s\) 的 border,根据定义,有 \(s[i] = s[|s|-|t|+i] = s[|s|-i+1]\)(\(1 \leq i \leq |t|\))。因为 \(|t| \geq |s|/2\),所以上述信息足以判定 \(s\) 是一个回文串。
引理 3
若 \(t\) 是回文串 \(s\) 的 border,则 \(|s|-|t|\) 是 \(s\) 的最小周期当且仅当 \(t\) 是 \(s\) 的最长回文真后缀。
利用 border 和周期的对应关系,结合引理 1 可证。
引理 4
\(t\) 的所有回文 border 可以不重不漏地通过如下方式得到:不断令 \(t\) 变为 \(t\) 的最长回文真后缀。
我们注意到 \(t\) 在至多一步后就会变为回文串。根据引理 1,此后 \(t\) 一定会变为自己的最长 border,因此结合 《周期和 border》一节中的性质 2 即可证明。
定理 2
对于 \(|s| > 1\),\(s\) 的所有回文后缀按照长度排序后可以划分为 \(\lceil \log_2|s|\rceil\) 个等差数列。
利用引理 4,我们注意到 \(t\) 在至多一步后就会变为回文串 \(t'\),从而 \(s\) 的所有回文后缀就是 \(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);
}
相信大家都会。
性质:
-
原串中 \(s_i\) 在新串 \(t\) 的位置为 \(s_{2i}\)。
-
原串中子串 \(s[l,r]\) 的回文中心为 \(t_{l+r}\)。
分奇偶讨论容易证明。
几个基础应用
-
求出以某个位置开始 / 结束的最长回文子串长度
以后者为例。因为回文串的性质,前者与后者本质相同,可以将串取反后变为求以某个位置结束的最长回文子串长度。
当 \(i+p_i - 1 > r\) 时(即回文区间的右端点向右移动时),此时对于新串中所有对应原串中字符的位置(即下标为偶数的位置)\(r < j < i+p_i\),以 \(j\) 为结尾的最长回文子串长度就是 \(j-i+1\)。
对于一个下标 \(x\) 来说,要想让以 \(x\) 结束的最长回文子串长度尽可能长,那么要找到最靠前的回文中心,使得它的回文半径能够覆盖到 \(x\)。对于 \(j \in (r,i+p_i)\),显然在 \(i\) 之前的回文中心都无法覆盖到 \(j\),并且此时 \(i\) 的回文半径能够覆盖 \(j\),因此 \(i\) 就是对于 \(j\) 而言最靠前的回文中心。
由于 Manacher 的特性,分奇偶讨论容易证明新串中以 \(i\) 为回文中心,\(j\) 结尾的回文串在原串中对应一个以 \(j/2\) 结尾,长度为 \(j-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]\) 的回文中心在 \(t\) 中的位置 \(l+r\),若区间 \([l,r]\) 是回文串,则 \(t_{l+r}\) 的回文半径必须覆盖 \(t_{2r}\),即回文半径 \(p_{l+r} \geq 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 是接受 \(s\) 所有回文子串的类自动机结构。
该「类自动机」结构的转移边和我们熟知的 SAM, ACAM 略有出入。
左图展示了 \(\texttt{babbab}\) 的 PAM 结构,其中蓝色虚边为其 fail 树结构,右图中用黑色实边展示。
接下来给出 PAM 结构的性质:
-
PAM 中存在两个入度为 \(0\) 的起始节点 \(\text{even}\) 和 \(\text{odd}\)。接下来分别用节点 \(E,O\) 来代指起始节点 \(\text{even}\) 和 \(\text{odd}\)。
-
除了起始节点 \(E,O\) 外,每个节点都代表一个回文字符串。记 \(\text{len}(p)\) 表示节点 \(p\) 所代表字符串的长度,并规定 \(\text{len}(E)=0,\text{len}(O)=-1\)。
-
图中带字母的转移边 \(tr(p,c):(p \to q)\)(或记作 \(tr(p,c) = q\))含义为:在当前节点 \(p\) 代表的字符串左右两侧添加转移边对应字符 \(c\) 后,得到的字符串和转移边终点 \(q\) 代表的字符串相等。特殊地,对于 \(tr(O,c) : (O \to q)\),应当看作在一个长度为 \(-1\) 的字符串左右两侧添加字符 \(c\),最终得到的字符串是单个字符 \(c\)。
-
对于任意 \(tr(p,c) = q\),都有 \(\text{len}(p)+2 = \text{len}(q)\)。
-
除了起始节点外,每个节点恰为一条转移边的终点。因此,PAM 构成两棵有根树,分别以两个初始节点作为根。其中 \(\text{even}\) 子树中所有回文串长度均为偶数,\(\text{odd}\) 相反。
-
节点 \(p\) 的 fail 指针 \(\text{fail}(p)\) 指向表示其最长回文真后缀的节点。该最长回文真后缀长度可以为 \(0\),此时 \(\text{fail}(p)\) 指向 \(E\)。特殊地,\(\text{fail}(E) = \text{fail}(O) = O\)。
因为一个长度为 \(n\) 的字符串 \(s\) 的本质不同回文子串数量不超过 \(n\),因此一个字符串的 PAM 上最多只有 \(n+2\) 个节点。又由树形结构可知 PAM 上转移边不超过 \(n\) 条。
构造:末端插入法
对于字符串 \(s\),我们可以采用末端插入的增量构造法构造 PAM,即:从初始 \(s[1,0]\) 的 PAM(仅初始节点)开始,依次插入 \(s_i\),然后维护出 \(s[1,i]\) 的 SAM。
根据本节定理 1 及其证明,插入 \(s_i\) 至多增加 \(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\) 变为 \(\text{fail}(p)\),直到 \(s[i-\text{len}(p)-1]=s[i]\)。此时在 \(p\) 代表的字符串左右两侧添加字符 \(s[i]\),就得到了 \(s[1,i]\) 的最长回文后缀。因为我们规定了 \(\text{len}(E)=0\) 和 \(\text{len}(O)=-1\),所以上面的过程总是正确的,我们不用特意区分 \(s[i]\) 的最长回文后缀是否是单个字符 \(s[i]\)。
若 \(tr(p,s[i]) = q\) 存在,则表明这个子串已经出现过,我们不做任何修改。接下来讨论这个子串没有出现过,且需要新建节点 \(q\) 的情况。
因为 \(q\) 对应的字符串是 \(s[1,i]\) 的最长回文后缀,所以当前不会存在 \(q\) 出发的转移边,我们只需要计算出 \(\text{fail}(q)\),即 \(q\) 对应字符串的最长回文后缀。和上面类似,我们令 \(p' = \text{fail}(p)\),然后不断地将 \(p'\) 变为 \(\text{fail}(p')\),直到 \(s[i-\text{len}(p')-1]=s[i]\)。此时将 \(\text{fail}(q)\) 置为 \(q'\) 即可,其中 \(tr(p',s[i]) = q'\)。
若 \(q\) 对应字符串的最长回文后缀为空(此时一定有 \(\text{len}(q) = 1\)),我们总会到达 \(p' = O\)。按照定义,我们希望 \(\text{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])\) 再计算 \(\text{fail}(q)\),这是因为若 \(p = O\),就有 \(\text{fail}(p) = \text{fail}(O) = O\),此时如果先更新 \(tr(O,s[i]) = q\),再计算 \(\text{fail}(q) = tr(\text{fail(p)},s[i]) = tr(O,s[i]) = q\),就会发现 \(q\) 的 \(\text{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\Sigma)\)。
对于指针 \(p'\) 和 \(\text{fail}(q)\),我们有类似的势能分析。因此,基础插入算法的总势能为 \(O(n)\),从而:
- 采用数组存储转移边 \(tr\),时间复杂度 \(O(n)\),空间复杂度 \(O(n \Sigma)\);
- 采用 \(O(1)\) 额外空间,\(O(\log \Sigma)\) 定位的数据结构(如 std::map 或 std::set)存储转移边 \(tr\),时间复杂度 \(O(n \log |s|)\),空间复杂度 \(O(n)\)。
一个有趣的事实是,我们根本不会用到 \(\text{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 指针,找到第一个前驱字符和 \(s_i\) 相同的回文后缀 \(t\)。注意到,除了 \(\text{len}(t) = \text{len}(last)\) 的场合,\(t\) 必然是 \(last\) 对应串的真后缀。这种情况下,\(t\) 和 \(s_i\) 具体是什么无关,而只和 \(last\) 有关。
因此,我们对于节点 \(x\),维护出 \(quick(x,c)\) 表示节点 \(x\) 所代表的字符串第一个前驱字符为 \(c\) 的回文真后缀所代表的节点。插入时,如果 \(p = last\) 不合法,只需要取出 \(quick(last,c)\) 就可以立刻求得最终的节点 \(p\)。同理,\(\text{fail}(q)\) 要么是 \(tr(\text{fail}(p),c)\),要么可以用 \(tr(quick(\text{fail}(x),c),c)\) 给出。
最后,我们需要维护出 \(quick(q,c)\)。\(quick(q,c)\) 和 \(quick(\text{fail}(q),c)\) 相比,恰有 \(1\) 处有差异。维护出 \(quick(q,c)\) 同样是简单的。
该算法的复杂度分析较为容易:直接从 \(\text{fail}(q)\) 进行 \(quick\) 的复制,时空复杂度为 \(O(n \Sigma)\)。使用可持久化数组,时空复杂度均变为 \(O(n \log \Sigma)\)。
需要注意边界情况:\(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\geq 1)\) 个位置的答案是 \(k\),第 \(i+1\) 个字符读入时的 \(\rm ASCII\) 码为 \(c\),则第 \(i+1\) 个字符实际的 \(\rm ASCII\) 码为 \((c-97+k)\bmod 26+97\)。所有字符在加密前后都为小写字母。
\(1 \leq |s| \leq 5 \times 10^5\)
容易发现,所求即为 \(last\) 在 fail 树上的深度。
例题 2 [APIO2014] 回文串 Link
给你一个由小写拉丁字母组成的字符串 \(s\)。我们定义 \(s\) 的一个子串的存在值为这个子串在 \(s\) 中出现的次数乘以这个子串的长度。
对于给你的这个字符串 \(s\),求所有回文子串中的最大存在值。
\(1 \leq |s| \leq 3 \times 10^5\)
如果能够求出每个节点代表的字符串的 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)\) 个节点。
此时不应当采用基础插入算法。考虑 \(\{\texttt{a},\texttt{ba},\texttt{bba},\cdots,\texttt{bbbbb...a}\}\)(最后一个字符串中有 \(n\) 个 \(\texttt b\))构成的 Trie,大小为 \(O(n)\)。每次遍历到 \(\texttt a\) 的分支时,都会消耗所有势能,而往下遍历 \(\texttt b\) 的分支时势能不会减小,因此复杂度为 \(O(n^2)\)。
不基于势能分析的末端插入法复杂度不变。
* 带双端插入删除的 PAM 维护
可以参阅 2017 集训队论文《回文树及其应用》(中山市中山纪念中学 翁文涛)。
核心思想是,维护出那些本身回文且不是任何一个回文串回文前 / 后缀的串,称作重要子串。当字符被删除时,结合当前节点的重要性,以及在 fail 树上是否存在后代,可以判断出这些串是否会被从 PAM 上移除。
但这样会频繁更改 fail 树的结构,因此如果需要支持查询某个串的 endpos 集合大小,可能需要用到 LCT / ETT。当然,如果只需要查回文子串数量(位置不同就算不同),便只需要在插入 / 删除时知道当前所在节点的 fail 树深度,这是容易维护出来的。
带双端插入删除的 PAM 是支持可持久化的,但是这也太迷惑了......相信没有人会写。
例题 3 [CERC2014] Virus synthesis
初始有一个空串,利用下面的操作构造给定串 \(S\)。
串开头或末尾加一个字符
串开头或末尾加一个该串的逆串
求最小操作数。
\(|S| \leq 10^5\),字符集为 \(\{A,T,C,G\}\)。
考虑枚举最后一次 2 操作形成的串 \(T\)。它必然是 \(S\) 的一个偶回文子串。可以使用 PAM 求出所有这样的串。记 \(f(T)\) 表示从空串到 \(T\) 的最小操作次数,那么最小的 \(f(T) + n- |T|\) 就是答案。
现在重点是如何求出 \(f(T)\)。有结论:形成串 \(T\) 的最后一次操作必然是操作 2。以下为证明:
考虑归纳。\(|T| \leq 2\) 的情况是平凡的。
对于 \(|T| > 2\) 的情况,若形成串 \(T = BB^T\) 的最后一次操作不是操作 2,找到最后一次操作 2 结束后形成的串 \(T' = AA^T\),其中 \(S^T\) 表示串 \(S\) 的反转。
不难发现,上述总操作次数为 \(f(T') + 2(|B|-|A|)\)。注意到 \(|A| < |B|\),那么 \(A\) 或 \(A^{T}\) 一定完整地出现在 \(T\) 回文中心的一侧。根据归纳假设,形成 \(AA^T\) 的最后一次操作是操作 2。那么,在这次操作之前,在完整地出现在 \(T\) 回文中心一侧的部分左右添加上字符,使其成为 \(B\) 或 \(B^T\),最后再应用操作 \(2\),总操作次数为 \(f(T') + |B| - |A| < f(T') + 2(|B| - |A|)\),因此一定更优。
考虑在 PAM 上 DP。考虑一结论:回文串 \(T\) 的所有回文子串都可以通过若干次以下操作得到:
- 将 \(T\) 首尾去掉一个字符。
- 将 \(T\) 变为 \(T\) 的最长回文后缀。
那么有转移:\(f(T) = \min(|T|,f(\text{fa}(T))+1,f(\text{link}(T)) + \frac{1}{2}(|T| - |\text{link}(T)|)+1)\)。其中 \(\text{fa}(T)\) 为 \(T\) 在 PAM 上的父亲(与 \(\text{fail}\) 树上的父亲区分),\(\text{link}(T)\) 为 \(T\) 长度不超过 \(\frac{1}{2} T\) 的最长回文后缀。
\(\text{link}(T)\) 可以使用与求 \(\text{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\),选出尽可能多的回文子串 \(t_1,t_2,\cdots,t_k\) 使得不存在 \(i \neq j\) 满足 \(t_i\) 为 \(t_j\) 的子串。
\(1 \leq |s| \leq 10^5\)
考虑结论:回文串 \(T\) 的所有回文子串都可以通过若干次以下操作得到:
- 将 \(T\) 首尾去掉一个字符。
- 将 \(T\) 变为 \(T\) 的最长回文后缀。
对应到 PAM 上,这两种操作分别为:
- 令某个节点变为它在 PAM 上的父亲。
- 令某个节点变为它在 fail 树上的父亲。
将每个节点的「两个父亲」向它连边,会得到一张 DAG。不难发现,所求即为 DAG 上的最长反链。这是简单的。
复杂度为网络流跑二分图匹配的 \(O(n \sqrt n)\)。
最小回文划分
给定字符串 \(s\),求:
将 \(s\) 划分程若干个回文串,最少需要使用的回文串个数。(最小回文划分)
将 \(s\) 划分成若干个回文串的方案数。(回文划分计数)
\(1 \leq |s| \leq 10^5\)
不难发现我们可以使用 DP 解决。两种 DP 是类似的,下文中以回文划分计数为例。
显然,我们可以对 \(s\) 建立 PAM,同时进行 DP。具体来说,插入字符 \(s[i]\) 后,\(s[1,i]\) 的所有回文后缀都可以作为划分中在 \(i\) 结束的回文串。
考虑《回文引理》一节的定理 2,一个字符串 \(t\) 的回文 border 能够被划分为 \(O(\log_2 |t|)\) 个等差数列。因此,插入 \(s[i]\) 后,\(last\) 到根的链上的节点可以根据长度划分到 \(O(\log_2 i)\) 个等差数列中。
考虑维护如下信息:对于一个节点 \(x\),如果它是某个等差数列中长度最长的节点,那么就更新 \(g(x)\) 为 $ \sum \limits_{y \in S(x)} f(i-\text{len}(x))$,其中 \(S(x)\) 表示 \(x\) 所属等差数列中的节点。
现在,对于 \(x\),若 \(\text{fail}(x)\) 和 \(x\) 属于同一个等差数列,考察 \(g(\text{fail}(x))\)。
如图。\(\text{diff}(x)\) 表示等差数列的公差。因为 \(\text{fail}(x)\) 是 \(x\) 的最长回文后缀,那么 \(\text{fail}(x)\) 上次出现的位置为 \(i - \text{diff}(x)\)。同时,在 \(i - \text{diff}(x)\) 处出现时,\(\text{fail}(x)\) 必然是作为这个等差数列中最长的节点出现的。
证明
因为 \(x\) 是回文串,显然 \(\text{fail}(x)\) 的确在 \(i - \text{diff}(x)\) 处出现过。下证其不可能在中间部分再次出现:我们的划分方式保证了 \(\text{fail}(x)\) 如果和 \(x\) 在同一个等差数列中,则 \(2|\text{fail}(x)| \geq |x|\)。若 \(\text{fail}(x)\) 在 \((i - \text{diff}(x),i)\) 中出现,则两次出现必然有重叠。由引理 2,这两次出现的并是一个更长的回文串,且它同样是 \(x\) 的前缀。\(x\) 是回文串,从而它也是 \(x\) 的后缀,且比 \(\text{fail}(x)\) 长,这与 \(\text{fail}(x)\) 的定义矛盾。
如果 \(\text{fail}(x)\) 没有作为这个等差数列中最长的节点出现,那么会发生什么呢?关于这种情况,我已经有了一种绝妙的论证,来说明这不会发生。可惜这里地方太小,我写不下。
这样,\(g(\text{fail}(x)) = \sum \limits_{y \in S(x)} f((i - \text{diff}(x))-\text{len}(x))\),即上图中蓝色的部分。\(g(x)\)(图中橙色部分)和 \(g(\text{fail}(x))\) 相比,只多了一个 \(f(i - |\text{slink}(x)| + \text{diff}(x))\)。因此我们可以 \(O(1)\) 更新出 \(g(x)\)。
要求解 \(f(i)\),我们只需要在 \(last\) 的 fail 链上找到每个等差数列的 \(x\) 节点,更新出 \(g(x)\) 并累加。时间复杂度 \(O(n \log n)\)。
例题 5 CF932G Palindrome Partition
给定长度为 \(n\) 的字符串 \(s\),字符集为小写字母集。
求将 \(s\) 划分为偶数段 \(t_1,t_2,\cdots,t_{2k}\),且对于 \(i = 1,2,\cdots,k\),满足 \(t_i = t_{2k - i+1}\) 的方案数。
答案对 \(10^9+7\) 取模。
\(2 \leq n \leq 10^6\)
不难注意到我们要求的是划分构成一个回文序列的方案。这有点棘手,不妨令 \(s' = s_1s_ns_{2}s_{n-1}\cdots s_{n/2}s_{n/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 \leq n \leq 10^5,1 \leq q \leq 2 \times 10^5\)
-
解法一
采用分块。使用 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)\) 倒序插入。插入字符 \(s_x\) 后得到 \([x,r]\) 最长回文前缀 \(t\),则检验是否有 \(p(L,t) > r\),且 \(t\) 未被标记。若是,则表明 \(t\) 没有在 \((x,r]\) 中出现过,此时给 \(t\) 打上标记,让答案增加 \(1\)。另一部分的答案即 \(f(L,r)\),最终答案为两部分之和。
取块长为 \(\sqrt n\),时间复杂度为 \(O((n+q)\sqrt n|\Sigma |)\)。
-
解法二
先考虑允许离线的情形。此时我们对 \(r\) 扫描线,维护 \(l\) 的答案。
考虑性质:\(s[1,i]\) 的所有回文后缀可以被划分为 \(O(\log_2 i)\) 个等差数列。
考虑一个等差数列中的回文后缀。
如图。设 \(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(n \log^2 n + q \log n)\)。
Luogu P4199 万径人踪灭
来一道餐后甜点吧。题意即求:有多少个不是原串子串的子序列是回文的,且关于某个位置对称。
考虑两个位置 \((l,r)\)。如果 \(s_l = s_r\),那么对于 \((l+r)/2\) 这个回文中心生成的回文子序列而言,\(s_l,s_r\) 要么同时选,要么同时不选。
这是一个和卷积的形式。具体地,枚举字符 \(a \in \Sigma\),令 \(f_i = [s_i = a]\),将 \([x^{l+r}]f^2(x)\) 累加到某个初始为零的数组 \(h\) 上。那么,合法的子序列数量即 \(\sum \limits_{p} (2^{h_p}-1)\)。
最后需要减去回文子串数,用 Manacher 计算得出即可。
非平凡回文串划分判定
判定是否有合法方案将长度为 \(n\) 的字符串 \(s\) 划分为若干个长度不为 \(1\) 的回文串。
令 \(f(i)\) 表示 \(i\) 开始的最短回文串长度,使用 PAM 或 Manacher 容易求得 \(f(i)\)(后者不是那么显然。想一想,怎么求?)。考虑从左向右贪心,若实际的划分中,从 \(i\) 开始的段长度不为 \(f(i)\),则设实际的段长为 \(d > f(i)\)。此时考虑两种情况:
-
\(d/2 \leq f(i) < d\)
注意到回文串的回文前缀必然是其 border,从而 \(s[i,i+d-1]\) 有周期 \(d-f(i) < d/2\)。周期的倍数仍为周期,于是它必然有长度大于 \(d/2\) 而小于 \(d\) 的周期,从而必然有长度小于 \(d/2\) 的 border。当 \(2f(i)-1 \neq 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 \neq d\),中间的回文串长度必然不为 \(1\)。
因此实际的段长只有 \(f(i),2f(i)-1,2f(i)+1\) 三种选择。据此可以 \(O(n)\) DP。