【学习笔记】后缀自动机

# 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 个字符串的最长公共子串。

【解法】

posted @ 2026-04-25 16:16  Aistyr  阅读(33)  评论(0)    收藏  举报