常见字符串算法 II:自动机相关

CHANGE LOG

  • 2021.12.25:新增 ACAM 部分。
  • 2021.12.26:新增 SAM 部分。
  • 2022.2.9:计划重构文章。
  • 2022.2.20:重构完成,增加部分例题。

建议先学习 确定有限状态自动机,上接 常见字符串算法

基本定义与约定:

  • 称字符串 T 匹配 STS 中出现。
  • 模式串:相当于题目给出的 字典,用于匹配的字符串。下文也称 单词
  • 文本串:被匹配的字符串。
  • 更多约定见 常见字符串算法。

1. AC 自动机 ACAM

前置知识:字典树,KMP 算法与 动态规划 思想。

AC 自动机是一类确定有限状态自动机,这说明它有完整的 DFA 五要素,分别是起点 s(Trie 树根节点),状态集合 Q(Trie 树上所有节点),接受状态集合 F(所有以某个单词作为后缀的节点),字符集 Σ(题目给定)和转移函数 δ(类似 KMP 求解)。

AC 自动机全称 Aho-Corasick Automaton,简称 ACAM。它的用途非常广泛,是重要的字符串算法(8 级)。

1.1 算法详解

AC 自动机用于解决 多模式串 匹配问题:给定 字典 s 和文本串 t,求每个单词 sit 中出现的次数。当然,它的实际应用十分广泛,远超这一基本问题。ACAM 与 KMP 的不同点在于后者仅有一个模式串,而前者有多个。

朴素的基于 KMP 的暴力时间复杂度为 |t|×N+|si|,其中 N 是单词个数。因为进行一次匹配的时间复杂度为 |si|+|t|。当单词数量 N 较大时,无法接受。

多串问题自然首先考虑建出字典树。根据其定义,字典树上任意节点 qQ 与所有单词的某个前缀 一一对应。设节点(节点也称状态)i 表示的字符串为 ti

借鉴 KMP 算法的思想,我们考虑对于每个状态 q,求出其 失配指针 failq。类似 KMP 的失配数组 nxt,失配指针的含义为:q 所表示字符串 tq最长真后缀 tq[j,|tq|] (2j|tq|+1),使得该后缀作为某个单词的前缀出现。这说明 tq[j,|tq|] 恰好对应了字典树上某个状态,因此一个状态的失配指针指向另一个长度比它短的状态。注意,这样的后缀 可能不存在,因此失配指针可能指向表示空串的根节点。

q 向字符串 failq 连一条有向边,就得到了 ACAM 的 fail 树

  • 例如,当 s={b, ab} 时,ab 会向 b 连边,因为 ab 最长的(也是唯一的)在 si 中作为前缀出现的后缀为 b
  • 再例如,当 s={aba, baba} 时,ab 会向 b 连边, bab 会向 ab 连边,aba 会向 ba 连边,而 baba 会向 aba 连边。对于每一条有向边 qfailq,后者是前者的后缀,也是 si 的前缀。

考虑用类似 KMP 的算法求解失配指针:首先令 failqfailfaq。若当前的 failq 没有 faqq 这条(字典树上的)边所表示的字符 c 的转移,则令 failqfailfailq,否则 failq=trans(failq,c),即字典树上在 failq 处添加字符 c 后到达的状态。若 failq 已经指向根,但还是没找到出边,则 failq 最终就指向根。


失配指针已经足够强大,但这并不是 AC 自动机的完全体。我们尝试将每个状态的所有字符转移 δ(i,c) 都封闭在状态集合 Q 里面。把 KMP 自动机的转移拎出来观察

δ(i,c)={i+1si+1=c0si+1ci=0δ(nxti,c)si+1ci0

设字典树的根为节点 0,AC 自动机的转移可类似地写为:

δ(i,c)={trans(i,c)if trans(i,c) exist0if trans(i,c) doesnt existi=0 (which is root)δ(faili,c)if trans(i,c) doesnt existi0

δ(i,c) 表示往状态 i 后面添加字符 c,所得字符串的 最长的si 前缀 匹配的 后缀 所表示的状态。也可理解为从 i 开始跳 fail 指针,遇到的第一个有字符 c 的转移对应转移到的节点:若 i 本身有转移,则 δ(i,c) 就等于 trans(i,c),否则向上跳一层 fail 指针,等于 δ(faili,c)

根据已有信息递推,这是 动态规划 的核心思想。即求解 δ 函数的的过程本质上是一类 DP。

trans(i,c) 存在时,设其为 q, 则有 failq=δ(faili,c)。因为根据求 failq 的方法,我们会先令 failqfaili,然后跳到第一个有字符 c 的位置,令 failq 等于该位置添加 c 转移到的状态。这和 δ(faili,c) 的定义等价。

有了这一性质,我们就不需要预先求出失配指针,而是在建造 AC 自动机的同时一并求出。由于我们需要保证在计算一个状态的转移时,其失配指针指向的状态的转移已经计算完毕,又因为失配指针长度小于原串长度,故使用 BFS 建立 AC 自动机。一般形式的 AC 自动机代码如下:

int node, son[N][S], fa[N];
void ins(string s) { // 建出 trie 树
	int p = 0;
	for(char it : s) {
		if(!son[p][it - 'a']) son[p][it - 'a'] = ++node;
		p = son[p][it - 'a'];
	}
}
void build() { // 建出 AC 自动机
	queue <int> q;
	for(int i = 0; i < S; i++) if(son[0][i]) q.push(son[0][i]); // 对于第一层特判,因为 fa[0] = 0,此处即转移的第二种情况
	while(!q.empty()) { // 求得的 son[t][i] 就是文章中的转移函数 delta(t, i),相当于合并了 trie 和 AC 自动机的转移函数
		int t = q.front(); q.pop();
		for(int i = 0; i < S; i++)
			if(son[t][i]) fa[son[t][i]] = son[fa[t]][i], q.push(son[t][i]); // 转移的第一种情况:原 trie 图有 trans(t, i) 的转移
			else son[t][i] = son[fa[t]][i]; // 转移的第三种情况
	}
}

特别的,在 ACAM 上会有一些 终止节点 p,代表一个单词或以一个单词结尾,即 p 对应的字符串 tp 的某个 后缀 在字典 s 中作为 单词 出现。 若状态 p 本身表示一个单词,即 tps,则称为 单词节点。所有终止节点 p 对应着 DFA 的 接受状态集合 F:ACAM 接受且仅接受以给定词典中的某一个单词结尾的字符串。


总结一下我们使用到的约定和定义:

  • 节点也被称为 状态
  • 设字典树上状态 i 所表示的字符串为 ti
  • 失配指针 failq 的含义为 q 所表示字符串 tq 的最长真后缀 tq[j,|tq|] (2j|tq|+1) 使得该后缀作为某个单词的前缀出现。
  • δ(i,c) 表示往状态 i 后添加字符 c,所得字符串的 最长的 与某个单词的 前缀 匹配的 后缀 所表示的状态。它也是从 i 开始,不断跳失配指针直到遇到一个有字符 c 转移的状态 p,添加字符 c 后得到的状态 trans(p,c)
  • 终止节点 p 代表一个单词,或以一个单词结尾。
  • 所有终止节点 p 组成的集合对应着 DFA 的 接受状态集合 F
  • 若状态 p 本身表示一个单词,即 tps,则称为 单词节点

1.2 fail 树的性质与应用

AC 自动机的核心就在于 fail 树。它有非常好的性质,能够帮我们解决很多问题。

  • 性质 0:它是一棵 有根树,支持树剖,时间戳拍平,求 LCA 等各种树上路径或子树操作。
  • 性质 1:对于节点 p 及其对应字符串 tp,对于其子树内部所有节点 qsubtree(p),都有 tptq 的后缀,且 tptq 的后缀 当且仅当 qsubtree(p)。根据失配指针的定义易证。
  • 性质 2:若 p 是终止节点,则 p 的子树全部都是终止节点。根据 fail 指针的定义,容易发现对于在 fail 树上具有祖先 - 后代关系的点对 p,qtptq 的 Border,这意味着 tptq 的后缀。因此,若 tp 以某个单词结尾,则 tq 也一定以该单词结尾,得证。
  • 性质 3:定义 edp 表示作为 tp 后缀的单词数量。若单词互不相同,则 edp 等于 fail 树从 p 到根节点上单词节点的数量。若单词可以重复,则 edp 等于这些单词节点所对应的单词的出现次数之和。
  • 常用结论:一个单词在匹配串 S 中出现次数之和,等于它在 S所有前缀中作为后缀出现 的次数之和。

根据性质 3,有这样一类问题:单词有带修权值,多次询问对于某个给定的字符串 S,所有单词的权值乘以其在 S 中出现次数之和。根据常用结论,问题初步转化为 fail 树上带修点权,并对于 S 的每个前缀,查询该前缀所表示的状态到根的权值之和。

通常带修链求和要用到树剖,但查询具有特殊性质:一个端点是根。因此,与其单点修改链求和,不如 子树修改单点查询。实时维护每个节点的答案,这样修改一个点相当于更新子树,而查询时只需查单点。转化之前的问题需要树剖 + 数据结构 log2 维护,但转化后即可时间戳拍平 + 树状数组单 log 小常数解决。

补充:对于普通的链求和,只需差分转化为三个到根链求和也可以使用上述技巧。链加,单点查询 也可以通过转化变成 单点加,子树求和。只要包含一个单点操作,一个链操作,均可以将链操作转化为子树操作,即可将时间复杂度更大的树剖 BIT 换成普通 BIT。

  • 性质 4:把字符串 t 放在字典 s 的 AC 自动机上跑,得到的状态为 t 的最长后缀,满足它是 s 的前缀。

1.3 应用

大部分时候,我们借助 ACAM 刻画多模式串的匹配关系,求出文本串与字典的 最长匹配后缀。但 ACAM 也可以和动态规划结合:在利用动态规划思想构建的自动机上进行 DP,这是 DP 自动机 算法。

1.3.1 结合动态规划

ACAM 除了能够进行字符串匹配,还常与动态规划相结合,因为它精确刻画了文本串与 所有 模式串的匹配情况。同时,δ 函数自然地为动态规划的转移指明了方向。因此,当遇到形如 “不能出现若干单词” 的字符串 计数或最优化 问题,可以考虑在 ACAM 上 DP,将 ACAM 的状态写进 DP 的一个维度。

例如非常经典的 [JSOI2007]文本生成器。题目要求至少包含一个单词,补集转化相当于求 不包含任何一个单词 的长为 m 的字符串数量。考虑到我们只关心当前字符串的长度,和它与所有单词的匹配情况,设 fi,j 表示长为 i 且放到所有单词建出的 ACAM 上能够转移到状态 j 的字符串数量。转移即枚举下一个字符 c 是什么,fi,jfi+1,δ(j,c)。根据限制,需要保证 jδ(j,c) 都不是终止节点,最终答案即 26mqQqFfm,q。时间复杂度 O(nm|Σ||si|)

1.3.2 结合矩阵快速幂

在上一部分的基础上,若 |si| 很小而转移轮数非常多,可以将转移写成矩阵的形式。δ(p,c) 为我们提供了转移矩阵:添加一个字符后,从状态 p 转移到 q 的方案数为 c[δ(p,c)=q],即 Ai,j=c[δ(i,c)=j]

具体转移方式视题目而定。矩阵乘法也可以是广义矩阵乘法,如例 XII.

1.4 注意点

  • 建出字典树后不要忘记调用 build 建出 ACAM。
  • 注意模式串是否可以重复。
  • 在构建 ACAM 的过程中,不要忘记递推每个节点需要的信息。如 edpedfap 和状态 p 所表示的单词数量相加得到。

1.5 例题

I. P3808 【模板】AC 自动机(简单版)

本题相同编号的串多次出现仅算一次,因此题目相当于求:文本串 t 在模式串 si 建出的 ACAM 上匹配时经过的所有节点到根的路径的并上单词节点的个数。

设当前状态为 p,每次跳 p 的失配指针,加上经过节点表示的单词个数(单词可能相同)并标记,直到遇到标记节点 q,说明 q 到根都已经被考虑到。注意上述过程并不改变 p 本身。时间复杂度线性。

#include <bits/stdc++.h>
using namespace std;

const int N = 1e6 + 5;
const int S = 26;
int n, node, son[N][S], fa[N], ed[N];
string s;
void ins(string s) {
	int p = 0;
	for(char it : s) {
		if(!son[p][it - 'a']) son[p][it - 'a'] = ++node;
		p = son[p][it - 'a'];
	} ed[p]++;
}
void build() {
	queue <int> q;
	for(int i = 0; i < S; i++) if(son[0][i]) q.push(son[0][i]);
	while(!q.empty()) {
		int t = q.front(); q.pop();
		for(int i = 0; i < S; i++)
			if(son[t][i]) fa[son[t][i]] = son[fa[t]][i], q.push(son[t][i]);
			else son[t][i] = son[fa[t]][i];
	}
}
int main() {
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> s, ins(s);
	int p = 0, ans = 0; cin >> s, build();
	for(char it : s) {
		int tmp = p = son[p][it - 'a'];
		while(ed[tmp] != -1) ans += ed[tmp], ed[tmp] = -1, tmp = fa[tmp];
	} cout << ans << endl;
	return 0;
}

II. P2292 [HNOI2004] L 语言

首先我们有个显然的 DP:设 fi 表示 i 前缀能否理解,那么若 存在 fj=1t[j+1,i]D,则 fi=1。否则 fi=0。对 D 建出 ACAM,设 t[1,i] 跳到了状态 p,我们只需要知道 p 的哪些长度的后缀是单词,这样就可以 O(|t||s|) 回答单次询问,但不够快。

注意到 |s|20,因此考虑状压,设 mskp:若 p 的长度为 l 的后缀是单词,则 mskpl 位为 1。这样,再用 S 记录 fi20fi1 的状态,就可以通过位运算快速得到当前 fi 的结果,并更新 S

时间复杂度 O(n|s||Σ|+m|t|),其中 |Σ| 表示字符集大小。

*III. P2414 [NOI2011] 阿狸的打字机

由于删去一个字符和添加一个字符对字典树大小的影响均为 1,因此尽管单词长度之和可能很大,但建出的字典树大小仅有 m。设第 i 个单词在 trie 上的节点为 fi,根据应用 1,求 xy 中的出现次数可以在 y 到根的每个节点上打标记,查询 x 的子树内有标记的节点个数。

因此将询问离线,按 y 从小到大的顺序处理询问(为保证修改标记的总次数线性),套上 BIT 即可。时间复杂度线性对数。代码

IV. P5357 【模板】AC 自动机(二次加强版)

根据 fail 树的性质 1,文本串 S 在 AC 自动机上每经过一个节点就将其权值增加 1,则每个单词 TiS 中的出现次数即 Ti 在 fail 树上的子树节点权值和。时间复杂度线性对数。

*V. P4052 [JSOI2007]文本生成器

ACAM 与 DP 相结合的例题。

VI. P3041 [USACO12JAN]Video Game G

非常套路的 ACAM 上 DP:设 fi,j 表示长度为 i 且在 ACAM 上转移到状态 j 的字符串的最大权值,有转移 fi,j+edδ(j,c)fi+1,δ(j,c)。时间复杂度 O(nk|si||Σ|)

*VII. CF1202E You Are Given Some Strings...

还算有趣的一道题目。对于同时与两个字符串相关的问题,考虑 在拼接处计算贡献,即求出 fi 表示有多少单词是 t[1,i] 的后缀,gi 表示有多少单词是 t[i,n] 的前缀。figi 都可以用 ACAM 求出。最终答案为 i=2|t|fi1gi,时间复杂度线性。代码

VIII. CF163E e-Government

裸题。对 s 建出 ACAM,根据应用 1,使用性质 3 部分所给出的技巧:单点修改链上求和转化为子树修改单点求和(前提是一个端点为树根),BIT 维护即可。时间复杂度线性对数。代码

*IX. P7456 [CERC2018] The ABCD Murderer

由于单词可以重叠(否则就不可做了),我们只需求出对于每个位置 i,以 i 结尾的最长单词的长度 Li。因为对于相同的出现位置,用更短的单词去代替最长单词并不会让答案更优。使用 ACAM 即可求出 Li

最优化问题考虑 DP:设 fi 表示拼出 s[1,i] 的最小代价。不难得到转移 fi=minj=iLii1fj。特别的,若 Li 不存在(即没有单词在 s 中以 i 为结束位置出现)则 fi 为无穷大。若 fn 为无穷大则无解。可以线段树解决。

如果不想写线段树,还有一种方法:从后往前 DP。这样,每个位置可以转移到的地方是固定的(iLii1),所以用小根堆维护,懒惰删除即可。时间复杂度均为线性对数。

X. P3121 [USACO15FEB]Censoring G

非常经典的 AC 自动机题目。对 t 建出 SAM 加速匹配,每次加入一个字符,用栈在线维护字符串 s 即可。时间复杂度线性。

XI. P3715 [BJOI2017]魔法咒语

二合一屑题。考虑在 ACAM 上 DP,对于前 50% 的数据,由于 L 很小,所以可以暴力 DP,时间复杂度 O(L×|si|×|ti|)。对于后 50% 的数据,由于基本词汇长度 2,故直接把 fifi1 放到矩阵里面递推即可。时间复杂度 O((|ti|)3logL)

XII. CF696D Legen...

非常套路地设 fi,j 表示长度为 i 且 ACAM 上状态为 j 时的最大贡献,令 edi 表示状态 i 所有后缀对应的所有单词权值之和,即不停跳 fail 到达的所有节点权值之和,一个字典树上节点的权值为其所表示的所有单词权值之和。

显然有转移:fi,j+edδ(j,c)fi+1,δ(j,c),使用矩阵快速幂优化即可。时间复杂度 O((|si|)3logL)代码

*XIII. P5840 [COCI2015]Divljak

由于 T 的形态会改变,所以考虑对 S 建出 ACAM。根据 fail 树的性质,问题即转化为对给定节点 p (tp=Sx) 求存在多少个 PT 使得 p 的子树内存在 P 的每个前缀在 ACAM 上匹配到的节点。这相当于在添加 P 时,求出其依次匹配到的节点 q1,q2,,q|P|,在 fail 树上对所有 qi 到根的 链并 上的所有节点加 1

上述经典问题可以通过将 qi 按 dfs 序排序后,对 q1 到根执行链加,然后对于每个 qi (i>1),对 qilca(qi1,qi) 包含 qi 的儿子执行链加。

考虑使用 1.2 提到的技巧,将链加和单点查询转化为单点修改,子树查询,此时只需对所有 qi 加上 1,所有 lca(qi1,qi) (i>1) 减去 1 即可。时间复杂度线性对数。

2. 后缀自动机 SAM

后缀自动机全称 Suffix Automaton,简称 SAM,是一类极其有用但难以真正理解的字符串后缀结构(10 级)。它是笔者一年以前学习的算法,现在进行复习并重构学习笔记,看看能不能悟到一些新的东西。

2.1 基本定义与引理

SAM 相关的定义非常多,需要牢记并充分理解它们,否则学习 SAM 会非常吃力,因为符号化的语言相较于直观的图片和实例更难以理解。

首先,我们给出 SAM 的定义:一个长为 n 的字符串 s 的 SAM 是一个接受 s 的所有 后缀最小 的有限状态自动机。具体地,SAM 有 状态集合 Q,每个状态是有向无环图上的一个节点。从每个状态出发有若干条或零条 转移边,每条转移边都 对应一个字符(因此,一条路径表示一个 字符串),且从一个状态出发的转移互不相同。根据 DFA 的定义,SAM 还存在 终止状态集合 F,表示从初始状态 T 到任意终止状态的任意一条路径与 s 的一个 后缀 一一对应。

SAM 最重要,也是最基本的一个性质:从 T 到任意状态的所有路径与 s所有 子串 一一对应。我们称状态 p 表示字符串 tp,当且仅当存在一条 Tp 的路径使得该路径所表示的字符串为 tp。根据上述性质,tps 的子串。

  • 定义转移边 pq 表示的字符为 cp,q
  • 定义 δ(p,c) 表示状态 p 添加字符 c 转移到的状态。
  • 定义 前缀 状态集合 P 由所有前缀 s[1,i] 对应的状态组成。
  • SAM 的有向无环转移图也是有向无环单词图(DAWG, Directed Acyclic Word Graph)。

  • endpos(t)字符串 ts 中所有出现的 结束位置集合。例如,当 s="abcab" 时,endpos("ab")={2,5},因为 s[1:2]=s[4:5]="ab"
  • substr(p)状态 p 所表示的所有子串的 集合
  • shortest(p)状态 p 所表示的所有子串中,长度 最短 的那一个子串。
  • longest(p)状态 p 所表示的所有子串中,长度 最长 的那一个子串。
  • minlen(p)状态 p 所表示的所有子串中,长度 最短 的那一个子串的 长度minlen(i)=|shortest(i)|
  • len(i)状态 p 所表示的所有子串中,长度 最长 的那一个子串的 长度len(i)=|longest(i)|

两个字符串 t1,t2endpos 可能相等。例如当 s="abab" 时,endpos("b")=endpos("ab")。这样,我们可以将 s 的子串划分为若干 等价类,用一个状态表示。SAM 的每个状态对应若干 endpos 集合相同的子串。换句话说,tsubstr(p)endpos(t) 相等。因此,SAM 的状态数等于所有子串的等价类个数(初始状态对应空串)。

读者应该有这样的直观印象:SAM 的每个状态 p 都表示一个独一无二的 endpos 等价类,它对应着在 s 中出现位置相同的一些子串 substr(p)shortest(p),longest(p),minlen(p)len(p) 描述了 substr(p) 最短和最长的子串及其长度。

转移边与 substr 的联系:任意一条 Tp 的路径 P 所表示的字符串 tPsubstr(p)


在引出 SAM 的核心定义「后缀链接」前,我们需要证明关于上述概念的一些性质。下列引理的内容部分来自 OI-wiki,相关链接见 Part 2.4.

引理 1:考虑两个非空子串 uw(假设 |u||w|)。要么 endpos(u)endpos(w)=,要么 endpos(w)endpos(u),取决于 u 是否为 w 的一个后缀:

{endpos(w)endpos(u)if u is a suffix of wendpos(u)endpos(w)=otherwise

证明:若存在位置 i 满足 iendpos(u)iendpos(w),说明 uwi 为结束位置在 s 中出现。由于 |u||w|,所以 u 必然是 w 的后缀,因此 w 出现的位置 u 必然以 w 的后缀形式出现,即对于任意 iendpos(w)iendpos(u)。否则不存在这样的位置 i,即 endpos(u)endpos(w)=

引理 2:考虑一个状态 pp 所表示的所有子串长度连续,且 较短者总是较长者的后缀

证明:根据引理 1,若两个子串 endpos 相同(这也说明它们属于相同状态),则较短者总是较长者的后缀,后半部分得证。

对于前半部分考虑反证:假设 longest(p) 长为 L (minlen(p)<L<len(p)) 的后缀 tLsubstr(p)。由于 tLlongest(p)真后缀,故 endpos(longest(p))endpos(tL)。根据假设,endpos(longest(p))endpos(tL)。又因为 shortest(p)tL真后缀,故 endpos(tL)endpos(shortest(p)),因此 |endpos(longest(p))|<|endpos(tL)||endpos(shortest(p))|,这与 endpos(longest(p))=endpos(shortest(p)) 矛盾,证毕。

简单地说,对于一个子串 t 的所有后缀,其 endpos 集合大小随着后缀长度减小而单调不降。这很好理解:后缀越长,在 s 中出现的位置就越少

推论 1:对于子串 t 的所有后缀,其 endpos 集合大小随后缀长度减小而单调不降,且 较小的 endpos 集合包含于较大的 endpos 集合


引理 2 是非常重要的性质。有了它,我们就可以定义后缀链接了。

  • 定义状态 p后缀链接 link(p) 指向 longest(p) 最长 的一个后缀 w 满足 wsubstr(p) 所在的状态。换句话说,一个后缀链接 link(p) 连接到对应于 longest(p) 最长的处于另一个 endpos 等价类的后缀所在的状态。根据引理 2,minlen(i)=len(link(i))+1

引理 3:所有后缀链接形成一棵以 T 为根的树。

证明:对于任意不等于 T 的状态,沿着后缀链接移动总能达到一个所表示字符串更短的状态,直到 T

  • 定义 后缀路径 pq 表示在后缀链接形成的树上 pq 的路径。

引理 4:通过 endpos 集合构造的树(每个子节点的 subset 都包含在父节点的 subset 中)与通过后缀链接 link 构造的树相同。

根据推论 1 与后缀链接的定义容易证明。因此,后缀链接构成的树本质上是 endpos 集合构成的一棵树。

上图图源 OI-wiki。我们给出每个状态的 endpos 集合以便更好理解引理 4:endpos("a")={1}

endpos("ab")={2}endpos("abcb", "bcb", "cb")={4}endpos("b")={2,4}

endpos("abc")={3}endpos("abcbc", "bcbc", "cbc")={5}endpos("bc", "c")={3,5}

2.2 关键结论

我们还需要以下定理确保构建 SAM 的算法的正确性,并使读者对上述定义形成感性的直观的认知。

结论 1.1:从任意状态 p 出发跳后缀链接到 T 的路径,所有状态 qpT[minlen(q),len(q)] 不交,单调递减且并集形成 连续 区间 [0,len(p)]

证明:根据后缀链接的性质 len(link(p))+1=minlen(p) 即证。

结论 1.2:从任意状态 p 出发跳后缀链接到 T 的路径,所有状态 qpTsubstr(q) 的并集为 longest(p)所有后缀

证明:由结论 1.1 和后缀链接的定义易证。

结论 2.1tpsubstr(p),若存在 pq转移边,则 tp+cp,qsubstr(q)

证明:根据 substr 的定义可得。

结论 2.2tqsubstr(q),若存在 pq 的转移边,则 \existtpsubstr(p) 使得 tp+cp,q=tq

证明:结论 2.1 的逆命题。这很好理解,因为对于任意 tqsubstr(q),若不存在这样的 tp+cp,q=tq,那么就不存在 Tq 的路径使得其所表示字符串为 tp+cp,q,这与 tqsubstr(q) 矛盾。

结论 3.1:考虑状态 q,不存在转移 pq 使得 len(p)+1>len(q)

证明:显然。

结论 3.2:考虑状态 q,**唯一 **存在状态 p 和转移 pq 使得 len(p)+1=len(q)

证明:考虑反证法,若不存在这样的 p,说明 p,len(p)+1<len(q)。根据结论 2.2,substr(q) 中最长的一个串的长度为 maxtpsubstr(p)|tp|+1maxplen(p)+1。根据 len 的定义与 len(p)+1<len(q),推得 len(q)<len(q),矛盾。唯一性不难证明。

简单地说,若数集 T 由若干数集 S 的并加上 1 后得到,那么 maxsSs+1=maxtTt

结论 3.3:考虑状态 q唯一 存在转移 pq 使得 minlen(p)+1=minlen(q)

证明:同理。

  • 定义 maxtrans(q) 表示使得 len(p)+1=len(q) 且存在转移 pq 的唯一的 p
  • 定义 mintrans(q) 表示使得 minlen(p)+1=minlen(q) 且存在转移 pq 的唯一的 p

结论 4.1:考虑状态 q,若存在转移 pq,则 p 在后缀链接树上是 maxtrans(q) 或其祖先。

证明:由于所有 p 转移到相同状态 q,故所有 psubstr(p) 的并,短串为长串的后缀。根据 link 树的性质即证。

结论 4.2:考虑状态 q,若存在转移 pq,则 p 在后缀链接树上是 mintrans(q) 或其子节点。

证明:同理。

结论 4.3:考虑状态 q,若存在转移 pq,则所有这样的 plink 树上形成了一条 深度递减的链 maxtrans(q)mintrans(q)

证明:结合结论 4.1 与结论 4.2 易证。

可以发现上述性质大都与后缀链接有关,因为后缀链接是 SAM 所提供的最重要的核心信息。我们甚至可以抛弃 SAM 的 DAWG,仅仅使用后缀链接就可以解决大部分字符串相关问题。

  • 扩展定义:substr(pq) 表示后缀路径 pq 上所有状态的 substr 的并。

2.3 构建 SAM

铺垫了这么多,我们终于有足够的性质来建造 SAM 了。之前的长篇大论可能让读者认为它是一个非常复杂的算法:是,但不完全是。至少在代码实现方面,它比同级的 LCT 简单到不知道到哪里去了。

SAM 的构建核心思想是 增量法。我们在 s[1,i1] 的 SAM Ai1 的基础上进行更新,从而得到 s[1,i] 的 SAM Ai。因此,该算法是 在线 算法。它主要分为三个步骤:

  1. 打开 SAM。
  2. 把字符插进去。
  3. 关上 SAM。

s[1,i1]Ai1 上的状态为 las,当前状态数量为 cntlascnt 的初始值均为 1,表示初始状态 T=1。不要忘记初始化 lascnt

新建初始状态 curcnt+1,并令 cnt 自增 1 表示状态数量增加 1curs[1,i]Ai 上对应的状态。endpos(cur)={i}。令变量 plas 防止接下来的操作改变 las

接下来我们考虑如何连指向 cur 的转移边:由于 lasT 的后缀路径上的所有状态表示了所有 s[1,i1] 的后缀,因此若 p 没有 si 的转移边,就新建 pcur 字符为 si 的转移,并令 plink(p) 表示跳后缀链接。直到遇到路径上第一个有 si 出边的状态 p,此时就应该 停止 了,因为再连下去 Tpδ(p,si)Tpcur 会表示相同字符串,使相同出边指向两个不同节点,与 SAM 的性质相违背。此时需要分三种情况讨论:


Case 1:不存在 p。即后缀路径 lasT 上的所有状态都没有字符 si 的转移边。

容易发现这种情况仅在 si 未在 s[1:i1] 中出现过时发生。我们只需令 link(cur)T 即可。


Case 2:存在 p,令 q=δ(p,si)len(p)+1=len(q)

link(cur)q 即可,原因如下:设 lasT 后缀路径上 p 的前一个状态为 p。根据操作,可知 pcur 有一条转移边。则此时 minlen(cur)=minlen(p)+1=(len(p)+1)+1=len(q)+1,说明 q 恰好与 cur 的后缀链接的定义相匹配。

可以证明 substr(qT)s[1,i] 所有长度 len(q) 的后缀:由于 substr(lasT)s[1,i1] 的所有后缀,又因为 plasT 上,所以 longest(p)s[1,i1] 长为 len(p) 的后缀。而 pq 存在字符为 si 的转移边,故 longest(q)s[1,i] 长为 len(p)+1=len(q) 的后缀。再根据结论 1.2 得证。这同时也证明了 link(cur)q 这一操作的正确性。

图源 hihocoder。上图中,在插入 s5=a 时,状态 p=las=4 没有字符 a 的转移,因此令 δ(4,a)=cur=6,然后 plink(p)=5。状态 5 也没有字符 a 的转移,因此令 δ(5,a)=6,然后 plink(p)=T,也就是图中的 S

δ(T,a) 存在,此时 p=T,q=δ(T,a)=1。因为 len(T)+1=len(1),所以令 link(6)1 即可。

注意状态 4,5,6 所表示的子串,可以发现 (substr(4)substr(5))+a=substr(6)。这很好地验证了结论 2.1 和结论 2.2。


Case 3:存在 p,令 q=δ(p,si)len(p)+1len(q)

此时 len(p)+1<len(q),我们需要将 q 拆成两个状态 q1q2,将 substr(q) 分成长度小于等于 len(p)+1 和大于 len(p)+1 两部分。具体地,先令 cntcnt+1,然后新建一个状态 clcnt 表示将 substr(q) 长度 len(p)+1 的部分丢给 cl

  • minlen(cl) 等于原来的 minlen(q)
  • len(cl) 等于 len(p)+1
  • 新的 minlen(q) 等于 len(cl)+1

考虑 cl 如何继承 q 这一状态:首先,q 的所有转移要原封不动地存下来,故对于每个字符 c 都要 δ(cl,c)δ(q,c)。此外,由于 minlen(cl) 等于原来的 minlen(q),因此 link(cl) 原来的 link(q)。同时,新的 minlen(q) 等于 len(cl)+1 也即 len(p)+1,所以 link(q),link(cur)cl

此外,根据结论 4.3,我们知道后缀路径 pT 上转移到 q 的状态一定是路径的一段前缀,对于前缀上的所有节点 p,我们需要把 δ(p,si) 从本来的 q 改成 cl,因为我们把 substr(q) 长度 len(p)+1 的串丢给了状态 cl,所以对于原本能转移到 q 的所有 lenlen(p) 的状态(显然也是 pT 路径的前缀),都需要将字符 si 的转移 重定向cl

上图中,我们把 q=3 的不大于 len(p=T)+1=1 的所有子串提出来,丢给一个新建的状态 cl=5,然后 link(cur=4)cl=5。内部 link(q=3)cl=5,同时 link(cl=5)p=T,即原来的 link(q)

然后,从 p=T 往上跳后缀连接直到不存在连向 q=3 的路径或到达根节点 T,表示对于 pT 的一段前缀,满足前缀上所有状态添加字符 si 能够转移到 q=3,将它们字符为 si 的转移重定向至 cl=5(当然,上例只有 T 一个点,不过并不一定会跳到 T,因为可能跳到中间的某个状态 p 时就没有转移 (p,q=3) 了),即 (T,3) 变为了 (T,5)


上述分类讨论结束后,令 lascur 表示添加字符 si+1s[1,i]Ai 对应状态 cur。在实现中,我们通常在连接转移边之前执行该操作。构建 SAM 的代码如下:

const int N = 1e5 + 5;
const int S = 26;
int cnt = 1, las = 1, son[N][S], fa[N], len[N];
void ins(char s) {
	int it = s - 'a', p = las, cur = ++cnt;
	len[cur] = len[p] + 1, las = cur; // 计算 len[cur],更新 las
	while(!son[p][it]) son[p][it] = cur, p = fa[p]; // 添加转移边
	if(!p) return fa[cur] = 1, void(); // case 1 
	int q = son[p][it];
	if(len[p] + 1 == len[q]) return fa[cur] = q, void(); // case 2
	int cl = ++cnt; cpy(son[cl], son[q], S); // 新建节点,cl 继承 q 的所有转移
	len[cl] = len[p] + 1, fa[cl] = fa[q], fa[q] = fa[cur] = cl; // 计算 len[cl] 以及 cl, q, cur 的后缀链接,注意 fa[cl] = fa[q] 要在 fa[q] = cl 之前
	while(son[p][it] == q) son[p][it] = cl, p = fa[p]; // 修改后缀路径 p -> T 的一段前缀
}

当字符集 Σ 非常大的时候,时空复杂度均无法接受,因此需要使用平衡树维护每个状态的所有转移边,可以用 map 代替。

2.4 时间复杂度证明

下设字符串 s 长度为 n,证明大部分摘自 OI wiki。

2.4.1 状态数上界

构建后缀自动机的算法本身就已经证明了其 SAM 状态数不超过 2n1:插入 s1,s2 时分别产生一个状态,后续插入每个 si 时最多产生两个状态,因此当 n>1 时状态数不超过 2n2,形如 abbbb 的字符串达到上界。当 n=1 时状态数为 2n1

2.4.2 转移数上界

len(p)+1=len(q) 的转移 (p,q) 为连续的,显然,从一个非终止状态 p 出发 有且仅有 一条连续转移 (p,q),对于 q 也有且仅有一个对应的 p。因此,连续转移总数不超过 2n2。对于不连续的转移,找到从根节点 Tp 的一条连续路径,设其所表示字符串为 u;找到从 q 到任意一个终止节点 fF 的一条连续路径,设其所表示字符串为 v。对于不同的 p,qsp,q=u+cp,q+v 互不相同:若两个转移 (p,q)(p,q) 出现 sp,q=sp,q 的情况,由于不同路径所表示字符串不同,因此 (p,q)(p,q) 在同一条路径,这与 TpqF 连续矛盾。又因为 sp,qs 的真后缀(s 对应的路径转移显然连续),因此不连续的转移数量不超过 n1。这样,我们得到了转移数上界 3n3

由于最大的状态数量仅在形如 abbbb 的字符串中达到,此时转移数量小于 3n3。形如 abbbbc 的字符串达到了 3n4 的上界。

2.4.3 操作次数上界

该部分 OI Wiki 上讲得较为简略,因此笔者自行证明了这一结论。在构建 SAM 的过程中,有且仅有将 pq 的转移边改为 pcl 的操作 不新建 转移边。因此,基于 转移数线性 这一结论,其它操作的时间复杂度均为线性。

定义 depth(p) 表示 plink 树上的 深度。引理:若 pq 存在转移边,则 depth(p)depth(q)。证明:

  • 考虑后缀路径 qT 上的任意两个不同状态 q1,q2 (q1q2)。设 p1 为任意能转移到 q1 的状态,p2 为任意能转移到 q2 的状态。因为 substr(q1),substr(q2) 均为 longest(q) 的后缀,因此 substr(p1),substr(p2) 均为 longest(p) 的后缀。所以 p1,p2 均在后缀路径 pT 上。
  • p1=p2,则 p1 通过同一字符能转移到不同状态,矛盾。因此 p1p2。故能转移到 qT任意 状态 q 的所有状态 p 均在 pT 上且 互不相同。由于对于每个 q 至少存在一个与之对应的 p(可能存在多个),因此 |qT||pT|,即 depth(p)depth(q)。证毕。
  • 可结合下图以更好理解,其中 ii1 的边表示一条后缀链接,其余边表示转移边。
    H7pPnU.png

假设我们从 p 一直跳到 p,并将 pp 路径上所有状态指向 q 的转移边改为指向 cl。设 q=δ(link(p),si),容易证明 link(q) link(cl)=q。设 d=depth(p)depth(p),即从 p 开始跳 link 的次数。根据上述引理,我们有 depth(q)depth(p)=depth(p)ddepth(las)1d

同时,根据 link(cur)=cllink(cl)=q 可知 depth(cur)2depth(las)1d,即 ddepth(las)depth(cur)+1,这一不等式通过精确分析还可以更紧。因此,该部分操作的总时间复杂度可用 cur 相对于 las深度减少量之和 来估计。同时,若进入 Case 1 或 Case 2,则因为 lascur 存在转移边,由引理得 depth(cur)depth(las),若进入 Case 3,则根据上述不等式有 depth(cur)depth(las)+1。因此,势能分析得到 d 的级别为线性。

2.5 应用

2.5.1 求本质不同子串个数

根据 SAM 的性质,每个子串唯一对应一个状态,因此答案即 len(i)len(link(i))

2.5.2 字符串匹配

用文本串 ts 的 SAM 上跑匹配时,我们可以得到对于 t 的每个 前缀 t[1,i],其作为 s 的子串出现的 最长后缀 Li:若当前状态 p(即 t[iLi1,i1] 所表示的状态)不能匹配 ti(即 δ(p,ti) 不存在),就跳后缀链接令 plink(p) 并实时更新 Li=len(p) 直到 p=Tδ(p,ti) 存在,对于后者令 pδ(p,ti)Li 还需再加上 1。若能匹配,则直接令 pδ(p,ti) 并令 LiLi1+1。综合一下,我们得到如下代码:

for(int i = 1, p = 1, L = 0; i <= n; i++) {
	while(p > 1 && !son[p][t[i] - 'a']) L = len[p = fa[p]];
	if(son[p][t[i] - 'a']) L = min(L + 1, len[p = son[p][t[i] - 'a']]);
}

2.6 广义 SAM

广义 SAM,GSAM,全称 General Suffix Automaton,相对于普通 SAM 它支持对多个字符串进行处理。它可以看做对 trie 建后缀自动机。

一般的写法是每插入一个字符串前将 las 指针置为 T,非常方便。一个细节:构建单串 SAM 时,δ(las,si) 一定不存在,但对于多串 SAM 可能存在。这说明当前字符串 si 前缀是某个已经添加过的字符串的子串。我们需要进行以下特判,否则会出现这种情况:https://www.luogu.com.cn/discuss/322224

  1. q=δ(las,si) 存在,且 len(las)+1=len(q) 时,令 lasq 并直接返回。
  2. q=δ(las,si) 存在,且 len(las)+1len(q) 时,我们会新建节点 cl,并进行复制。此时,令 lascl 而非 cur。这是因为 len(cur)=len(las)+1len(cl)=len(las)+1,又因为 link(cur)=cl,所以这说明 substr(cur)=,即 节点 cur 是空壳,真正的信息在 cl 上面。为此,我们舍弃掉这个 cur,并用 cl 代替它。
int ins(int p, int it) {
	if(son[p][it] && len[son[p][it]] == len[p] + 1) return son[p][it]; // 如果节点已经存在,且 len 值相对应,即 (p, son[p][it]) 是连续转移,则直接转移。
	int cur = ++cnt, chk = son[p][it]; len[cur] = len[p] + 1;
	while(!son[p][it]) son[p][it] = cur, p = fa[p];
	if(!p) return fa[cur] = 1, cur;
	int q = son[p][it];
	if(len[p] + 1 == len[q]) return fa[cur] = q, cur;
	int cl = ++cnt; cpy(son[cl], son[q], S);
	len[cl] = len[p] + 1, fa[cl] = fa[q], fa[q] = fa[cur] = cl;
	while(son[p][it] == q) son[p][it] = cl, p = fa[p];
	return chk ? cl : cur; // 如果 len[las][it] 存在,则 cur 是空壳,返回 cl 即可
}

上述方法本质相当于对匹配串建出 trie 后进行 dfs 构建 SAM。部分特殊题目会直接给出 trie 而非模板串,此时模板串长度之和的级别为 O(|S|2),因此只能 bfs 构建 SAM:设 Pp 表示 trie 树上状态 p 在 SAM 上对应的位置,若 trie 树 T 上的转移 q=δT(p,c) 存在,其中 cpq 所表示字符,那么以 Pp 作为 las,插入字符 c 后新的 lasPq。此时 不需要 像上面一样特判,因为 δ(Pp,c) 必然不存在,这是由于 bfs 使得 len(Pp) 单调不降。模板题 P6139 代码:

#include <bits/stdc++.h>
using namespace std;

#define ll long long
#define cpy(x, y, s) memcpy(x, y, sizeof(x[0]) * (s))

const int N = 2e6 + 5;
const int S = 26;

ll n, ans, cnt = 1;
string s;
int len[N], fa[N], son[N][S];
int ins(int p, int it) {
	int cur = ++cnt; len[cur] = len[p] + 1;
	while(!son[p][it]) son[p][it] = cur, p = fa[p];
	if(!p) return fa[cur] = 1, cur;
	int q = son[p][it];
	if(len[p] + 1 == len[q]) return fa[cur] = q, cur;
	int cl = ++cnt; cpy(son[cl], son[q], S);
	len[cl] = len[p] + 1, fa[cl] = fa[q], fa[q] = fa[cur] = cl;
	while(son[p][it] == q) son[p][it] = cl, p = fa[p];
	return cur;
}

int node = 1, pos[N], tr[N][S];
void ins(string s) {
	int p = 1;
	for(char it : s) {
		if(!tr[p][it - 'a']) tr[p][it - 'a'] = ++node;
		p = tr[p][it - 'a'];
	}
}
void build() {
	queue <int> q; q.push(pos[1] = 1);
	while(!q.empty()) {
		int t = q.front(); q.pop();
		for(int i = 0, p; i < S; i++) if(p = tr[t][i])
			pos[p] = ins(pos[t], i), q.push(p);
	}
}
int main() {
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> s, ins(s);
	build();
	for(int i = 2; i <= cnt; i++) ans += len[i] - len[fa[i]];
	cout << ans << endl;
	return 0;
}

2.7 常用技巧与结论

2.7.1 线段树合并维护 endpos 集合

对于部分题目,我们需要维护每个状态的 endpos 集合,以刻画每个子串在字符串中所有出现位置的信息。

为此,我们在 s[1,i] 对应状态的 endpos 集合里插入位置 i,再根据 endpos 集合构造出来的树本质上就是后缀链接树这一事实,在 link 树上进行 线段树合并 即可得到每个状态的 endpos 集合。这是一个非常有用且常见的技巧。

注意,线段树合并时会破坏原有线段树的结构,因此若需要在线段树合并后保留每个状态的 endpos 集合对应的线段树的结构,需要在线段树合并时 新建节点。即 可持久化线段树合并。SAM 相关问题的线段树合并通常均需要可持久化。

特别的,如果仅为了得到 endpos 集合大小,那么只需求出每个状态在 link 树上的子树有多少个表示 s 的前缀的状态。前缀状态即所有曾作为 cur 的节点。对此,有两种解决方法:直接建图 dfs,以及 ——

2.7.2 桶排确定 dfs 顺序

显然后缀链接树上父亲的 len 值一定小于儿子,但千万不能认为编号小的节点 len 值也小。因此,对所有节点按照 len 值从大到小进行桶排序,然后按顺序合并每个状态及其父亲是正确的,并且常数比建图 + dfs 小不少,代码见例题 I.

2.7.3 快速定位子串

给定区间 [l,r],求 sl,r 在 SAM 上的对应状态:在构建 SAM 时容易预处理 s1,i 所表示的状态 posi。从 posr 开始在 link 树上倍增找到最浅的,lenrl+1 的状态 p​ 即为所求。

2.7.4 其它结论

  1. link 树上,若 pq 的祖先,则 substr(p) 中所有字符串在 longest(q)(下记为 s)中出现次数与出现位置相同。具体证明见 CF700E 题解区

2.8 注意点总结

  • 做题时不要忘记初始化 lascnt
  • 第二个 while 不要写成 son[p][it] = cur,应为 son[p][it] = cl
  • SAM 开两倍空间
  • 对于多串 SAM,如果每插入一个新字符串时令 lasT,且插入字符时不特判 δ(las,si) 是否存在,会导致出现空状态,从而父节点的 len不一定严格小于 子节点,使得桶排失效。对此要格外注意。

2.9 例题

I. P3804 【模板】后缀自动机 (SAM)

s 建出 SAM,对于每个状态 p 求出其 endpos 集合大小。根据题目限制,答案即 |endpos(p)|2len(p)×|endpos(p)|。视字符集大小为常数,时间复杂度线性。

#include <bits/stdc++.h>
using namespace std;

#define ll long long
#define cpy(x, y, s) memcpy(x, y, sizeof(x[0]) * (s))

const int N = 2e6 + 5; // 不要忘记开两倍空间
const int S = 26;

char s[N];
int cnt = 1, las = 1;
int son[N][S], len[N], fa[N];
int ed[N], buc[N], id[N];
ll n, ans;
void ins(char s) {
	int it = s - 'a', cur = ++cnt, p = las;
	las = cur, len[cur] = len[p] + 1, ed[cur] = 1;
	while(!son[p][it]) son[p][it] = cur, p = fa[p];
	if(!p) return fa[cur] = 1, void();
	int q = son[p][it];
	if(len[p] + 1 == len[q]) return fa[cur] = q, void();
	int cl = ++cnt; cpy(son[cl], son[q], S);
	len[cl] = len[p] + 1, fa[cl] = fa[q], fa[q] = fa[cur] = cl;
	while(son[p][it] == q) son[p][it] = cur, p = fa[p];
}
int main()  {
	scanf("%s", s + 1), n = strlen(s + 1);
	for(int i = 1; i <= n; i++) ins(s[i]);
	for(int i = 1; i <= cnt; i++) buc[len[i]]++;
	for(int i = 1; i <= n; i++) buc[i] += buc[i - 1];
	for(int i = cnt; i; i--) id[buc[len[i]]--] = i;
	for(int i = cnt; i; i--) ed[fa[id[i]]] += ed[id[i]];
	for(int i = 1; i <= cnt; i++) if(ed[i] > 1) ans = max(ans, 1ll * ed[i] * len[i]);
	cout << ans << endl;
	return 0;
}

II. P4070 [SDOI2016]生成魔咒

非常裸的 SAM,插入每个字符后新增的子串个数为 len(cur)len(link(cur)),求和即可。由于字符集太大,需要使用 map 存转移数组。时间复杂度线性对数。

*III. P4022 [CTSC2012]熟悉的文章

首先二分答案 m,考虑设 fi 表示文章的 i 前缀最长的符合限制的匹配长度。根据应用 2.5.2 我们可以求出文章的每个前缀作为字典子串出现的最长后缀长度 Li,则 fi=maxj[iLi,im]fj+(ij)。显然,LiLi1+1,因此 iLi 单调不降,故可以使用单调队列优化。时间复杂度线性对数。

IV. P5546 [POI2000]公共串

建出 GSAM 后,设 mski 表示 substr(i) 在哪些串中出现过,以状压形式存储,直接在 link 树上合并即可。

V. P3346 [ZJOI2015]诸神眷顾的幻想乡

由于叶子节点仅有 20 个,因此从每个叶子节点开始,整棵树都会形成一个字典树。将这 20 棵 Trie 树拼在一起求 GSAM 就做完了。

VI. P3181 [HAOI2016]找相同字符

建出两个串的 GSAM,设 ed1,i 表示状态 i 关于 s1endpos 集合大小,ed2,i 同理。答案显然为 ed1,i×ed2,i×(len(i)len(link(i)))

VII. P5341 [TJOI2019]甲苯先生和大中锋的字符串

建出 s 的 SAM 后容易得到所有出现 k 次的子串状态。每个符合题意的状态的子串长度是一段区间,差分即可。时间复杂度线性。

VIII. P4341 [BJWC2010]外星联络

SAM 的转移函数刻画了一个字符串 s 的所有子串,因此直接在该 DAG 上贪心遍历即可。贪心指优先走字符小的出边。

*IX. P3975 [TJOI2015]弦论

根据一条路径表示一个子串的性质,考虑求出从每个节点开始的路径条数 di=1+δ(i,c)dδ(i,c) 帮助跳过不可能的分支,然后在 SAM 的 DAG 上模拟跑一遍即可。对于 t=1 只需将上式中的 1 改为 edi

*X. H1079 退群杯 3rd E.

给定字符串 s,多次询问求 scd 有多少个子串包含 sab|s|,q2×105

L=ba+1。我们对每个位置 p[c+L1,d],求出有多少个左端点 lc 使得 slp 包含 sab。考虑找到 p 前面 sab 的最后一次出现位置 q,则贡献显然为 max(0,(qL+1)c+1)

转化贡献形式,考虑每个落在 [c+L1,d]sab 的出现位置 q 对答案的贡献。为方便说明,我们不妨假设 sabd+1 处出现。考虑 sabq 之后的下一次出现 q,则对于 p[q,q1],贡献均为 (qL+1)c+1。注意到 2cL 均与询问有关,与 q 无关,因此提出。则贡献可写为 q×(qq)。即每个位置的下标值乘以和下一次出现之间的距离。线段树维护区间出现位置最小值,最大值即可维护该信息。

2cL 的贡献次数为 d(minq)+1,因为所有 [q,q1] 的区间并起来形成了区间 [minq,d]。对 endpos 集合 可持久化 线段树合并,再使用 2.7.3 的技巧,即可做到 log 时间内回答每个询问。时间复杂度线性对数。代码

XI. CF316G3 Good Substrings

对所有串建出 GSAM,求出每个状态所表示的串在 s 和每个模式串中出现了多少次,若合法则统计答案即可。时间复杂度线性。

如果用先建出字典树再建 GSAM 的方法,空间开销会比较大,需要用 unsigned short 卡空间。

XII. SP8222 NSUBSTR - Substrings

这就属于 SAM 超级无敌大水题了吧。

XIII. 某模拟赛 一切的开始

给定字符串 s,求其两个 不相交 子串的长度乘积最大值,满足其中一个子串为另一个子串的子串。|s|105

s 建出 SAM,对于每个状态 i,我们只关心其第一次出现 a 和最后一次出现的位置 b,因为这样最优,反证法可证。若前者是后者的子串,那么后者显然取满 [a+1,n],前者长度即 L=min(len(i),ba)。若后者是前者的子串,则后者一定尽量长,长度为 L,那么前者取满 [1,bL] 最优,长度即 bL

综上,答案即 maxiL×max(na,bL)。时间复杂度线性。

*XIV. CF1037H Security

考虑直接在后缀自动机的 DAWG 上贪心。使用线段树合并判断当前字符串是否作为 [l,r] 的子串出现过,时间复杂度 O(|Σ|nlogn)代码

*XV. CF700E Cool Slogans

容易发现 si1si 中一定同时以前缀和后缀的形式出现,否则调整法证明可以做到更优。我们使用 si1si 中作为后缀的性质,考虑直接在 link 树上 DP。

再根据 2.7.4 的结论一(实际上这个结论是笔者做本题时才遇到的),我们可以设 fp 表示 longest(p) 的答案,以及 gp 表示 p 的祖先中答案取到 fp 的深度最小的状态,因为我们要让串长尽可能小,这样出现次数更多。转移即检查 longest(glink(p))longest(p) 中是否出现了至少两次,这相当于检查 longest(glink(p)) 是否在 longest(p) 的某个出现位置 pos 之前的一段区间 [poslen(p)+len(glink(p)),pos1] 处出现,容易用线段树合并维护 endpos 集合做到。若是,则令 fp=flink(p)+1gp=p。否则 fp=flink(p)gp=glink(p)

maxfp 即为答案,时空复杂度线性对数。代码

*XVI. CF666E Forensic Examination

SAM 各种常用技巧结合版。首先对 sti 一并建出 GSAM,线段树维护每个节点对应的子串在每个 ti 中出现的次数,即线段树 Tp 的位置 i 上记录着 p 所表示的所有串在 ti 中的出现次数。由于题目还需求最小编号,所以线段树维护区间最大出现次数以及对应最小编号。

使用线段树合并,预处理 link 的倍增数组以快速定位子串,单次询问只需倍增到 s[pl,pr] 的对应状态 p,查询 Tp[l,r] 的信息即可。时空复杂度均为线性对数。代码

2.10 相关链接与资料

3. 回文自动机 PAM

省选前两周填坑。之所以不是省选之后是因为担心省选考这玩意。

posted @   qAlex_Weiq  阅读(7220)  评论(24编辑  收藏  举报
编辑推荐:
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示