字符串笔记
标注 \({\color{Green} {\Large \star} }\) 的是简单题或者模板题。
标注 \({\color{Cyan} {\Large \star} }\) 的是常规的应用。
标注 \({\color{Gold} {\Large \star} }\) 的是难题或者好题。
ACAM
首先总结一下 AC 自动机配合 fail 树完成的最常见的几种操作。
求字符串 \(s_x\) 在字符串 \(s_y\) 内出现了几次。
- 将 \(s_x\) 在 AC 自动机上对应的节点标记出来,对于每个 \(s_y\) 的前缀对应的节点,求其 fail 树上的祖先是否有被标记的,可以转化为子树加,单点查询。
- 将 \(s_y\) 的所有前缀在 AC 自动机上对应的节点标记出来,求 \(s_x\) 对应的节点的子树内有几个标记即可。
求集合 \(T\) 内有多少字符串包含了集合 \(S\)。
- 见下文 P5840 [COCI2015] Divljak 一题。
\({\color{Green} {\Large \star} }\) P4052 [JSOI2007] 文本生成器
存在至少一个套路的容斥,变成不存在,也就是不能到 AC 自动机上的某些节点,直接 DP 即可。
设 \(s_i\) 对应的节点为 \(p\) 则在 fail 树上 \(p\) 这个子树都是不能到达的节点,因为 fail 树上一个点包含这个点的祖先(精确的说是祖先为这个点的后缀)。
\({\color{Cyan} {\Large \star} }\) P2414 [NOI2011] 阿狸的打字机
首先建出 Trie 树,虽然 \(\sum |s_i|\) 可能很大,但是由于题目中的生成方式 Trie 上的节点数量仍然是 \(\mathcal{O}(n)\) 的。
然后遇到上文提到的经典问题:求 \(s_x\) 在 \(s_y\) 内出现的次数。
对于多次询问呢,考虑离线下来,枚举这个 \(y\),然后对于所有 \(x\) 计算子树和即可。
相同的应用 CF1207G - Indie Album
这个题可以有两种建 AC 自动机的方法,值得一提的是,如果你选择对 \(s\) 建立 AC 自动机,需要把 \(t\) 中的字符串也加入其中,否则会出现询问串不存在导致的问题。
\({\color{Cyan} {\Large \star} }\) P7456 [CERC2018] The ABCD Murderer
他同时允许剪出的单词互相重叠,只需要重叠部分相同。
这是题目的关键,这允许对于一个点,只使用最长的以这个点结尾的那块,称其为 \(L_i\)。
那么对于 \(s[1,i]\) 这个前缀在 AC 自动机上对应的点 \(p\),\(L_i\) 就是到根路径上的最大 \(L\),直接递推即可,然后使用线段树优化 DP。
\({\color{Cyan} {\Large \star} }\) P5840 [COCI2015] Divljak
首先对 \(s\) 建立 ACAM。
那么对于 \(T\) 中的一个字符串 \(t_i\),要做的事情是将 \(t_i\) 所有前缀对应的节点 \(p\) 到根路径的并加一,比较无脑的是线段树合并,但是这里介绍另一种方法。
将所有点按照 dfn 序排序,我们在加完之后对相邻两个的 LCA 减即可容斥掉多的情况,这个也是经典技巧。
\({\color{Green} {\Large \star} }\) P5231 [JSOI2012] 玄武密码
\(s\) 的子串这个东西直接拿所有前缀的所有后缀来刻画,也就是 fail 树上的若干到根路径的并,直接 dfs 即可。
然后对于每个串的查询就是简单的了。
\({\color{Green} {\Large \star} }\) CF696D - Legen...
直接对 \(T\) 进行 DP,发现 DP 中的过程是一样的,那么直接矩阵乘法优化即可。
\({\color{Cyan} {\Large \star} }\) CF547E - Mike and Friends
差分询问,变成求 \(s_x\) 在 \(s_1 \sim s_i\) 的出现次数。
从前往后扫描,把询问挂在 \(i\) 上,然后直接计算。
\({\color{Cyan} {\Large \star} }\) CF1202E - You Are Given Some Strings...
考虑计算 \(f_i\) 表示有多少字符串是 \(t[1,i]\) 的后缀,\(g_i\) 表示有多少字符串是 \(t[i,n]\) 的前缀。直接用 AC 自动机求出来,然后计算答案。
\({\color{Gold} {\Large \star} }\) P8147 [JRKSJ R4] Salieri
首先二分答案 \(ans\),转化成求排名。
每次建立大小为 \(|S|\) 的虚树,查询的时候虚树的一条边对应原树的一条链,这些 cnt 都是相同的,只需要查询 \(w \ge \lceil \dfrac{ans}{cnt} \rceil\) 的个数就好了,这个直接主席树。
\({\color{Gold} {\Large \star} }\) CF1483F - Exam
将串按长度排序,那么对于一个串去计算答案,考虑枚举右端点,那么左端点必须是极左的,这也说明了答案是 \(O(n)\) 级别的。
那么首先这些可能的区间是不能有包含的,先判断一下。
然后发现对于 AC 自动机上的一个结点,这个字符串能成为答案,当且仅当其被某个区间包含,又不被区间的子区间包含,这个等价与其被计算到的次数(fail 树上)等于出现的次数。
PAM
\({\color{Gold} {\Large \star} }\) CF932G - Palindrome Partition
此题和 CF906E - Reverses 是相同的思路和做法。
每次在左右寻找?将序列从中间断开,右边的部分折叠过来,那么能选择一段必然有正反序列互为逆序。
重新构建这个序列,原来是 \(s_1s_2s_3\dots\) 和 \(t_1t_2t_3\dots\) 现在是 \(s_1t_1s_2t_2s_3t_3\dots\)。
那么条件变成了一段是偶回文串才能转移,直接建立 PAM,同时进行 DP。
现在的问题是每次暴力跳 fail 来得到所有回文后缀的复杂度是 \(\mathcal{O}(n^2)\) 的。
需要进一步挖掘性质,考虑回文后缀是原串的 Border,那么根据一个串的 Border 可以被划分为 \(\mathcal{O}(\log n)\) 个连续段,使得每个段都是一个等差数列,同样可以对 fail 树做这样的划分,记录一个 link 变量即可。
现在的转移是 \(f_i = \sum\limits_{j}f_j[s_{[j + 1, i]}\text{ is palindromic}]\),直接根据等差数列还是做不了。
那么我们额外记录一个 \(g_i\) 表示 \(i\) 到 \(\mathrm{link}(i)\) 的路径上点的 \(f\) 之和,也就是 \(f_{i - len},f_{i-len+d},f_{i-len+2d},\dots\)。
考虑加入一个点对答案的贡献。
这种 Border 的性质是 ACAM 所不具备的。

左端点只多了一个,那么直接维护就好了(图片来自 @zhylj)。
SA
比 SAM 简单而同样有力的工具,核心在于对每个后缀按照排序,得到 sa[i] 表示排名为 \(i\) 的后缀的起始位置,和 rk[i] 表示 \(i\) 这个后缀的排名。
通过倍增的方法构造,一开始比较后缀的前 \(1\) 位,然后是前 \(2\) 位,前 \(4\) 位等等,每次用之前得到的信息进行双关键字排序。

代码实现如下:
int sa[N], rk[N], od[N], id[N], cnt[N];
int n, m = 128 /*字符集大小*/, p = 0;
for (int i = 1; i <= n; i++) cnt[rk[i] = s[i]]++;
for (int i = 1; i <= m; i++) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; i--) sa[cnt[rk[i]]--] = i;
for (int w = 1; p != n; w *= 2, m = p) {
int cur = 0;
for (int i = n - w + 1; i <= n; i++) id[++cur] = i;
for (int i = 1; i <= n; i++) if (sa[i] > w) id[++cur] = sa[i] - w;
for (int i = 1; i <= m; i++) cnt[i] = 0;
for (int i = 1; i <= n; i++) cnt[rk[i]]++;
for (int i = 1; i <= m; i++) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; i--) sa[cnt[rk[id[i]]]--] = id[i];
memcpy(od, rk, sizeof rk), p = 0;
for (int i = 1; i <= n; i++) {
if (od[sa[i]] == od[sa[i - 1]] &&
od[sa[i] + w] == od[sa[i - 1] + w])
rk[sa[i]] = p;
else rk[sa[i]] = ++p;
}
}
还有比较重要的 height 数组,求法如下:
for (int i = 1, k = 0; i <= n; i ++) {
if (rk[i] == 1) continue; if (k) k--;
while (s[i + k] == s[sa[rk[i] - 1] + k]) k++;
h[rk[i]] = k;
}
SAM
\({\color{Green} {\Large \star} }\) P3804 【模板】后缀自动机(SAM)
简述一下 SAM。
首先定义 \(\mathrm{endpos}(s)\) 表示字符串 \(s\) 出现的结束位置的集合。
在 SAM 中,\(\mathrm{endpos}\) 集合相同的点属于同一个结点,每次通过后缀链接来动态构建。
从任意一个点出发,到达终止节点的路径构成了字符串的所有后缀,而过程中得到了所有后缀的所有前缀,相当于是所有子串,也就是说这是能表示子串信息的一种结构,区别于 AC 自动机,SAM 是一种单串的结构,可以更详细的表示所有子串,AC 自动机能表示的子串仅限于能匹配的子串。
本题需要求解每个结点对应 \(\mathrm{endpos}\) 集合的大小,首先建出 parent 树,然后对于原串所有前缀对应的节点,\(\mathrm{size}\) 初始化为 \(1\),然后子树的 \(\mathrm{size}\) 之和就是该结点的 \(\mathrm{endpos}\) 集合大小。
\({\color{Green} {\Large \star} }\) P3975 [TJOI2015] 弦论
求解第 \(k\) 小的子串,可以利用类似线段树上二分的思想,一位位的确定下去,这样我们只需要知道 DAG 上一个点向后的路径数量,记为 \(\mathrm{sum}_i\)。
对于不要求本质不同子串的情况,\(\mathrm{sum}_i = \mathrm{size}_i\),否则为 \(1\),跑一个拓扑排序即可,实际上可以将点按照 \(\mathrm{len}\) 排序,这样可以不用真的写拓扑排序,以及之前算 \(\mathrm{size}\) 也是一样。
\({\color{Cyan} {\Large \star} }\) SP1811 LCS - Longest Common Substring
SAM 也可以当 ACAM 使用,用来做一些匹配问题。
对于 \(s\) 建立 SAM,尝试计算 \(t\) 每个前缀和 \(s\) 的最长公共子串,记录一个 \(len\) 表示答案。
如果当前点不能匹配,那么一直跳 \(\mathrm{fail}\),否则就 \(len \leftarrow len + 1\)。
这样的时间复杂度是 \(\mathcal{O}(n)\) 的,因为 \(len\) 每次跳 \(\mathrm{fail}\) 就会减少,减少复杂度不超过增加的,增加是 \(O(n)\) 的,因此总复杂度也是。
对于有 \(k\) 个字符串的情况,也可以类似的做。
\({\color{Cyan} {\Large \star} }\) P6640 [BJOI2020] 封印
对 \(t\) 建立 SAM,然后按照上题的做法得到一个 \(val_i\) 表示以 \(i\) 结尾的最长公共子串。
查询的时候二分答案,那么只需要一个 ST 表查询区间最大值即可,这种套路是经常出现的。

浙公网安备 33010602011771号