Loading

字符串笔记

标注 \({\color{Green} {\Large \star} }\) 的是简单题或者模板题。

标注 \({\color{Cyan} {\Large \star} }\) 的是常规的应用。

标注 \({\color{Gold} {\Large \star} }\) 的是难题或者好题。

ACAM

首先总结一下 AC 自动机配合 fail 树完成的最常见的几种操作。

求字符串 \(s_x\) 在字符串 \(s_y\) 内出现了几次。

  1. \(s_x\) 在 AC 自动机上对应的节点标记出来,对于每个 \(s_y\) 的前缀对应的节点,求其 fail 树上的祖先是否有被标记的,可以转化为子树加,单点查询。
  2. \(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 所不具备的。

img

左端点只多了一个,那么直接维护就好了(图片来自 @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 表查询区间最大值即可,这种套路是经常出现的。

posted @ 2024-12-17 14:55  紊莫  阅读(36)  评论(0)    收藏  举报