【学习笔记】后缀自动机
# 0:【前言】
后缀自动机是我学OI以来遇到最难的知识点,去年暑假在ZR集训的时候死磕几天遂放弃
如今再次学习,终于强行理解了后缀自动机
# 1:【初识】
后缀自动机:可以接受字符串s的所有后缀的最小DFA
后缀自动机分为两部分:转移视图 & 失配视图(parent树,失配树,母树)
两个视图共用一套节点,各自有一组边,其中节点 \(u\) 代表 \(1->u\) 所有路径对应的字符串
以下是一个自动机,其中左边是转移视图,右边是失配视图

# 2:【endpos】
我们把一个字符串 \(s\) 在母串中所有结束位置构成的集合,称为结束位置集合,记作 \(endpos(s)\)
我们认为 \(endpos\) 相同的子串同属一个等价类

观察自动机不难发现以下性质
1.每个节点代表的字符串,其 \(endpos\) 全部相同,且满足后缀关系
2.失配树越往下,字符串越长,\(endpos\)逐渐缩小
3.后缀自动机蕴含了 \(s\) 的所有子串
# 3:【构建】
每次在字符串末尾新增一个字符,对自动机进行小范围重构,直到整个字符串构建完毕
构建后缀自动机时,我们会记录 \(len(u):u\) 节点表示的所有字符串的长度最大值

假如我们现在要加入字符,设上一次插入的节点为 \(last\)
3.1【加入字符此前从未出现】

转移视图:失配树 \(last->1\) 的节点都添加一条指向新节点 \(u\) 的边
失配树:添加一条根节点指向u的边
void insert(int c){
int p=last,u=++tot,len[u]=len[p]+1;
while(p&&!ch[p][c]){ ch[p][c]=u;p=fa[p]; }
if(!p) fa[u]=1;
}
3.2【加入字符此前出现】
设 \(p\) 是树链上第一个冲突节点,\(q\) 表 \(p\) 已经存在的 \(c\) 转移的节点
这里需要按照 \(q\) 是否为 \(u\) 的后缀来分讨
如何判断是否为后缀,这里有一个简单的方法,即判断是否 \(len[p]+1=len[q]\)
正确性看图感性理解

3.2.1【如果 \(q\) 是 \(u\) 的后缀】

可以直接在失配树上连接,就不用继续往上跳了
3.2.2【如果 \(q\) 不是 \(u\) 的后缀】

我们常新建节点来切割 \(q\)

int uu=++tot;len[uu]=len[p]+1;
复制 q 的转移给 uu
将 uu 作为连接 p,q,u 的中介
while(p&&ch[p][c]==q) ch[p][c]=uu;
# 4:【代码】
后缀自动机虽然构建方式非常非常难(晕),但是其代码巨简单,所以我选择直接背过
这里是后缀自动机的insert代码,也是后缀自动机最费解的一部分
int ch[N][30],fa[N],len[N];
int rt=1,lst=1,tot=1;
void insert(int c){
int p=lst,u=++tot;lst=u;
while(p&&!ch[p][c]){
ch[p][c]=u;
p=fa[p];
}
if(!p){
fa[u]=rt;
return ;
}
int q=ch[p][c];
if(len[p]+1==len[q]){
fa[u]=q;
return ;
}
int uu=++tot,len[uu]=len[p]+1;
memcpy(ch[uu],ch[q],sizeof ch[q]);
fa[uu]=fa[q];fa[q]=fa[u]=uu;
while(p&&ch[p][c]==q){
ch[p][c]=uu;
p=fa[p];
}
}
# 5:【结论】
5.1:【底层原理】
这两条定义了“节点是什么”和“边(fa)是什么”。
-
SAM 的 fa(后缀链接)完全等价于 AC 自动机的 fail 指针,即最长真后缀
-
节点 \(i\) 代表的子串长度范围:最短长度 \(len_{fa_i}+1\),最长长度 \(len_i\)
5.2:【高频结论】
-
拓扑排序直接按 \(len\) 排序
-
节点 \(i\) 含串数量:\(len_i-(len_{fa_i}+1)+1\)
-
一个子串的出现个数:其 \(endpos\) 集合大小
5.3:【内存分配】
- 开空间,节点数最多 \(2 \times n\)(\(n\) 为原串长度)。开数组时必须开 \(2 \times n + 5\) ;转移边数最多 \(3 \times n - 4\)。
5.4:【不常用】
- 求两个前缀的最长公共后缀,LCS(最长公共后缀):\(len_{LCA_{u,v}}\) (在 \(fa\) 树上)
# 6:【应用】
6.1【基础查找】
6.1.1 检查字符串是否出现
【问题】
给一个文本串 \(T\),问模式串 \(P\) 是不是 \(T\) 的子串。
【解法】
从 SAM 的根节点出发,拿着 \(P\) 的字符一个一个走 \(ch\) 边。如果能完整走完,就是子串;如果走到某个字符发现 \(ch[p][c] == 0\)(空指针),就不是子串。
6.2【统计计数】
所有这类问题,都必须先建好 SAM,并且按 \(len\) 排好序(基数排序),作为“拓扑序”。
6.2.1 本质不同子串个数
【问题】
字符串 S 有多少个不同的子串(空串不算)
【解法】
\(\sum(len_i-len_{fa_i})\),对 \(i = 2\) 到 \(tot\)(根节点 \(1\) 不算)求和。
在线插入时的动态维护(P4070 生成魔咒)
每插入一个字符(新建 \(cur\) 后,确定 \(fa[cur]\) 后),直接累加:
\(ans += len[cur] - len[ fa[cur] ]\);
注意:克隆节点 clone 产生时千万不要加这一句,因为 clone 代表的是旧串里已有的子串,不产生新贡献。
重点记忆:根节点不算。克隆节点不加。
6.2.2 所有不同子串的总长度
【问题】
所有不同子串的总长度
【解法】
对于节点 i,它代表的子串长度是连续区间 \([ L, R ]\),其中 \(L = len[fa[i]]+1,R = len[i]\) 。
这个区间的长度总和 \(= (L + R) * (R - L + 1) / 2\)(等差数列求和)。
答案 = 对每个节点算一遍,累加。
6.2.3 子串出现次数
【问题】
给定模式串 P,问它在文本串 T 中出现了多少次。
【解法】
预处理(只做一次):
建 \(SAM\) 时,每个新建的 \(cur\)(代表一个前缀)初始化 \(cnt[cur] = 1\)。
克隆节点 \(clone\) 初始化 \(cnt[clone] = 0\)。
建完后,按 \(len\) 从大到小(倒序)遍历所有节点,执行:
\(cnt[ fa[u] ] += cnt[u]\)。
现在,\(cnt[u]\) 就是节点 \(u\) 代表的所有子串的出现次数。
查询:拿 \(P\) 在 \(SAM\) 上走转移边,走到节点 \(u\)。答案就是 \(cnt[u]\)。如果中途断了,答案是 \(0\)。
为什么这样累加:因为 \(fa[u]\) 是 \(u\) 的后缀。\(u\) 出现一次,\(fa[u]\) 一定也出现一次。所以要把儿子的次数加给父亲(后缀),类似于AC自动机。
6.2.4 第一次出现的位置
【问题】
模式串 P 在文本串 T 中第一次出现的位置(起始下标)
【解法】
预处理 \(firstpos\):
新建 \(cur\) 时:\(firstpos[cur] = len[cur]\) (此时 \(len\) 与结束位置相等)
克隆 \(clone\) 时:必须继承 \(q\) 的位置,即 \(firstpos[clone] = firstpos[q]\)
(千万不要写成 firstpos[clone] = len[clone],那是错的!因为 clone 代表的短串早就出现过了,位置在 q 那里。)
查询:拿 \(P\) 走 \(SAM\) 走到节点 \(u\)。
起始位置 \(= firstpos[u] - len(P) + 1\)
6.2.5 所有出现的位置
【问题】
模式串 P 在文本串 T 中所有出现的位置。
【解法】
1.先找到 \(P\) 对应的节点 \(u\)。
2.在 Parent 树(fa 树)上,遍历 \(u\) 的整个子树。
3.输出子树中所有非克隆节点的 \(firstpos\)(减去长度 +1 得到起始位置)。
为什么只输出非克隆节点:
因为克隆节点是复制品,它代表的位置和它的父亲(或被克隆的 \(q\))是重叠的。如果输出克隆节点,会重复输出相同的位置。只输出“真正的前缀节点”(即新建的 \(cur\)),就能保证不重不漏。
实现方式:建好 SAM 后,把 fa 当成父亲,建一棵反图(或直接 DFS),从 u 往下搜。
重点记忆:搜子树,跳过克隆点,防止重复。
6.3 【字典序/贪心类】
主要在DAG转移图上做
6.3.1 字典序第 K 大子串
【问题】
找出所有子串中字典序第 K 大的那个。
【解法】
(1)第一步:DP 预处理路径数
在 DAG(ch 转移图)上跑记忆化搜索。
\(dp[u]\) 表示从节点 \(u\) 出发,能走出的路径总数(包含空路径)。
\(dp[u] = 1 + \sum dp[to]\)(1代表空路径)。
如果题目要求本质不同(去重):所有节点初始 \(dp[u] = 1\)。
如果题目要求出现位置不同(算重复):\(dp[u] = cnt[u] + \sum dp[to]\),其中 \(cnt[u]\) 是出现次数(应用 6.2.3 算出来的)。
(2)第二步:贪心查找
从根节点 \(rt\) 出发,\(K++\)(把空串也算上便于处理)。
对于当前节点 \(u\),从 \('a'\) 到 \('z'\) 枚举出边 \(v\)
如果 \(K > dp[v]\),说明答案不在这个分支,\(K -= dp[v]\),继续看下一个字符。
如果 \(K <= dp[v]\),说明答案在这个分支,输出这个字符,走到 \(v\),继续循环,直到 \(K\) 减到 \(0\)。
重点记忆:去重 dp=1,不去重 dp=cnt。贪心走边时用减法。
6.3.2 最小循环移位
【问题】
找到字符串 S 的所有循环移位中字典序最小的那个。
【解法】
1.令 \(T = S + S\)。
2.对 T 建 SAM。
3.从根节点出发,贪心地走最小的字符边,连续走 |S| 步。
4.走出来的字符串就是答案。
6.3.3 最短的没有出现的字符串
【问题】
找到一个最短的字符串,它不是 S 的子串。
【解法】
1.在 SAM 的 DAG(ch 图)上做 DP。
2.定义 \(dp[u]:\) 从节点 \(u\) 出发,需要再添加几个字符,才能得到一个“走不出”的字符串。
3.转移:
-
如果节点 \(u\) 没有某个字符 \(c\) 的出边,那么 \(dp[u] = 1\)(因为只要再走一个 \(c\) 就走不下去了)。
-
如果节点 \(u\) 对所有字符都有出边,那么 \(dp[u] = 1 + min( dp[to]\) )
4.答案就是 \(dp[rt]\)(从根出发)。
构造答案:从根开始,如果 \(dp[u] == 1\),直接输出一个缺失的字符结束;否则,挑 \(dp[to]\) 最小的那个分支走,循环。
6.4 【匹配/LCS 类】
这类问题主要通过跑自动机 + 跳 fa解决
6.4.1 两个字符串的最长公共子串(LCS)
【问题】
求字符串 S 和 T 的最长公共子串。
【解法】
6.4.2 多个字符串的最长公共子串
【问题】
求 k 个字符串的最长公共子串。
【解法】

浙公网安备 33010602011771号