后缀自动机(SAM)& 后缀树 & 广义后缀自动机 学习笔记

定义

后缀自动机是一个 DAG,边带权(权值是一个字符),有一些节点称作结束节点,从根节点到结束节点的任意路径构成原字符串的所有后缀。由于后缀的前缀为子串,于是从根节点到任意节点的路径构成原字符串的所有子串。

后缀自动机上每个节点的每种边权的出边最多一条,这意味着后缀自动机上从根节点出发的每条路径与原字符串的每种本质不同的子串一一对应。

后缀自动机满足以上限制,且规模为 \(O(n)\)

Endpos 集合

对于一个子串,它的 Endpos 集合为它在原串中所有出现的末尾下标集合,我们用 \(E\) 表示 Endpos 集合。

有一些显而易见可以感受到的性质,有助于理解后面的过程:对于两个子串 \(a,b\) 满足 \(|a|<|b|\)

  • \(E_a=E_b\)\(a\)\(b\) 的后缀。
  • 同样的,若 \(a\)\(b\) 的后缀,则 \(E_a\subseteq E_b\)。若不是后缀,\(E_a\cap E_b=\varnothing\)

Endpos 等价类

所有 Endpos 相同的子串构成一个 Endpos 等价类。根据以上有一些性质:

  • 同一个等价类中,短的字符串是长的字符串的后缀。
  • 同一个等价类中,字符串的长度是连续的,即等价类中有一个最短的字符串,等价类中其他字符串都可以通过比它短 \(1\) 的另一个字符串往前加一个字符得到。

Endpos 等价类树

由以上,一个等价类中最长的字符串再往前加一个字符一定所属另一个等价类。且加的字符不同就属于不同等价类。

并且我们知道,加一个字符后的 Endpos 一定是当前 Endpos 的真子集,加不同字符后的 Endpos 不交。

于是根据 Endpos 的包含关系可以构建一棵 Endpos 等价类树,根节点是空串,其他每个节点代表一个 Endpos 等价类,对每个节点,其 Endpos 是所有儿子 Endpos 的并集,并且所有儿子的 Endpos 不交。

事实上,SAM 上的每个节点就代表一个 Endpos 等价类。

Link 则指在 SAM 上,一个节点代表的等价类中最短字符串 \(a\) 去掉左端字符后得到的串 \(b\) 所在的节点。我们知道 \(b\) 是其所在等价类的最长字符串。

显然后缀链接形成一个树形结构,且与 Endpos 等价类树的结构相同。

在一些资料中,Endpos 等价类树与 Link 构成的树也被称作 Parent 树。

构建 SAM

首先,构建 SAM 的时间复杂度是 \(O(n)\),这个我不讲。

现在我们知道,SAM 上每一个节点代表一个 Endpos 等价类。我们要使这个等价类中的子串最终走到这个点。

我们从一个空串节点开始(根节点),同时构建 Link 链接与 SAM 上的转移边。我们设根节点的标号为 \(0\),其 Link 标号为 \(-1\)(无意义)。

现在假设已经把原串的 \([1\dots i]\) 的 SAM 已经构造完了,考虑插入 \(c=[i+1]\) 这个字符。那么如何新建节点与连边?

  1. \(last\)\([1\dots i]\) 所在的节点,\(c\) 为插入的字符。
  2. 显然 \([1\dots i+1]\) 属于一个全新的等价类,所以新建一个节点 \(cur\)
    维护 \(len(x)\) 表示等价类中最长字符串的长度,那么 \(len(cur)=len(last)+1\)
  3. \(last\) 开始跳 Link 链接(包括根节点),检查每个点是否有 \(c\) 的转移,令检查的点为 \(p\)
    • 如果 \(p=-1\) 则说明 \(c\) 是一个从未出现过的字符,那么使 \(Link(cur)=0\)
    • 如果没有 \(c\) 的转移,使 \(trans(p,c)=cur\),继续跳。
    • 如果有 \(c\) 的转移,需要检查是否需要分裂节点(步骤 4 的 Case 1 和 Case 2)。
  4. 现在看是否要分裂节点,令 \(q=trans(p,c)\)
    • Case 1:\(len(q)=len(p)+1\)
      说明 \(q\) 仅包含 \(p+c\),直接使 \(Link(cur)=q\)
    • Case 2:\(len(q)\ne len(p)+1\)(实际上只可能是 \(>\))。
      说明 \(q\) 的等价类不仅包含 \(p+c\),还包含更长子串,此时需要分裂 \(q\)
      1. 新建一个节点 \(nw\),将 \(trans(nw)\) 复制为 \(trans(q)\),令 \(len(nw)=len(p)+1,Link(nw)=Link(q)\)
      2. \(Link(q)=nw,Link(cur)=nw\)
      3. 对于 \(p\)\(p\) 在 Parent 树的祖先,将所有指向 \(q\)\(c\) 转移指向 \(nw\),需要从 \(p\) 开始跳 Link。
  5. 更新 \(last=cur\)

在最后从 \(last\) 开始跳 Link,每个跳到的不为根的节点都为结束节点。

代码实现(每次在原串后插入一个字符)

const int N=1e5+5;
struct Sam {
	int len,link,isend;
	int tr[26];
}sam[N*2];
int lst,tot;
void build(int c) {
	int cur=++tot;
	sam[cur].len=sam[lst].len+1;
	while(lst>=0&&!sam[lst].tr[c]) 
		sam[lst].tr[c]=cur, lst=sam[lst].link;
	if(lst<0) {sam[cur].link=0; lst=cur; return;}
	int v=sam[lst].tr[c];
	if(sam[v].len==sam[lst].len+1) sam[cur].link=v;
	else {
		int copy=++tot;
		sam[copy]=sam[v], sam[copy].len=sam[lst].len+1;
		sam[v].link=sam[cur].link=copy;
		while(lst>=0&&sam[lst].tr[c]==v) sam[lst].tr[c]=copy, lst=sam[lst].link;
	}
	lst=cur;
}
char a[N],n;
int main() {
    sam[0].link=-1; // 初始化根节点的 link
    scanf("%s",a+1);
    n=strlen(a+1);
    for(int i=1;i<=n;++i) {
        build(a[i]-'a');
    }
    while(lst>0) sam[lst].isend=1, lst=sam[lst].link; // 标记结束节点
}

原理

步骤 3:

  • 为什么要跳 Link:新字符 \(c\) 会扩展所有 \(last\) 后缀, 生成新的子串。所以我们从 \(last\) 开始跳 Link,根据 Link 的定义,这就遍历了 \(last\) 的所有后缀。
  • 为什么要指向 \(cur\):对于状态 \(p\),如果它没有 \(c\) 的转移,那么对于 \(p\) 等价类内的所有子串 \(s\)\(s+c\) 是全新的子串,否则不是。它是全新的子串则它的 Endpos 集合为 \(\{i+1\}\)(当前插入的位置)。因此则 \(trans(p,c)=cur\)

Case 1:

  • \(len(q)=len(p)+1\) 意味着什么:根据 \(len\) 的定义,说明 \(q\) 等价类内最长的子串是 \(p\) 内最长子串拼接 \(c\)
  • Endpos 等价类的完整性:这说明对于 \(p\) 中所有子串 \(s\)\(s+c\) 都被 \(q\) 表示了,无需拆分。换句话说 \(q\) 中所有子串的 Endpos 集合都增加了 \(i+1\)(当前插入位置)这个元素,符合等价类的定义,无需拆分。
  • 为什么 Link 连向 \(q\):此时 \(cur\) 的最长属于不同等价类的后缀可以感受到,是 \(p\) 的最长子串拼接 \(c\),而 \(q\) 已经表示,所以 Link 直接连向 \(q\)
  • 为什么不用继续跳 \(p\) 的 Link:首先继续跳不会影响 \(Link(cur)\)。其次继续跳到 \(p\) 的更短后缀,这些后缀不用再连 \(c\) 转移,因为这些转移已经被覆盖了。

Case 2:

  • 要分裂:此时 \(p\) 中子串拼接 \(c\) 形成的子串的 Endpos 增加了 \(i+1\),与 \(q\) 中其他子串的 Endpos 不同,等价类不符合定义。
  • 复制转移:由于分裂,要保留原来通过 \(s+c\) 再拼接字符组成的子串,因此要保留转移。
  • \(Link(q)=nw\):因为 \(nw\) 是原来 \(q\) 的子集。
  • 修正 \(p\) 及其祖先的 \(c\) 转移:显然这些转移现在应归属 \(nw\)

我不会更加严谨且详细的证明了,有需要可以去翻论文,我想大多数人是不会想翻的。

例题(SAM 的建立 & 子串出现次数)

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

建出 SAM 后,从根节点跑记忆化搜索,求出每个等价类中的串在原串中的出现次数(每个等价类中的串出现次数相同),其实就是对于每个点,求从它开始走到结束节点的路径数。

为什么是走到结束节点的路径数:

  • 每个结束节点对应原串的一些连续的后缀,一个结束节点中的后缀的 Endpos 相同。
  • 从原点走到一个结束节点的路径数就是这个结束节点中的后缀个数。
  • 从一个点走到结束节点的路径数就是以这个结束节点中的后缀的前缀为子串的个数,即子串的出现次数。

当出现次数大于 \(1\),乘上节点的 \(len\) 并更新答案即可。记忆化搜索的代码实现如下:

int mem[N*2];
ll ans;
int dfs(int x) {
	if(isend[x]) mem[x]=1;
	fu(i,0,26) if(tr[x][i]) {
		if(mem[tr[x][i]]) mem[x]+=mem[tr[x][i]];
		else mem[x]+=dfs(tr[x][i]);
	}
	if(mem[x]>1) ans=max(ans,(ll)mem[x]*len[x]);
	return mem[x];
}

应用

检查子串是否出现

从根节点开始跑子串,如果能一直转移下去则子串有出现,否则没有出现。

不同子串个数

上面我们提到过,从根节点出发的每条路径与每种本质不同的子串一一对应。于是在 DAG 上 DP 即可。

但也可利用 Link 树的意义来做,一个节点的子串数量就是 \(len(x)-len(Link(x))\)

P4070 [SDOI2016] 生成魔咒(增量法求本质不同子串个数)

求每次添加一个字符后,字符串中的本质不同子串的个数。

我们利用一个节点的子串数量为 \(len(x)-len(Link(x))\) 来做,每次修改一个节点的 \(Link\) 或添加节点时需要更新答案。

由于这道题的字符集大小为 \(10^9\),所以使用 std::map 存储 SAM 上的树边即可。

P1368 【模板】最小表示法(字典序最小子串)

我不知道原题的官方解法是什么,但是这道题可以使用 SAM 线性做。

考虑先把原串复制一遍,然后就是求长为 \(n\) 的字典序最小的子串。建出 SAM 后,我们记忆化搜索求出每个点往后最多添加的字符数,这是为了保证后面跑贪心时保证答案长为 \(n\)。之后从根节点开始跑,贪心地从小往大遍历出边,同时要保证后续答案可以使得长为 \(n\)

这道题同样需要用 map 存储出边。

P3975 [TJOI2015] 弦论(字典序第 k 小子串)

建出 SAM 后我们可以求出每个节点开始的路径数,对于 \(t=0\) 的情况,只取这个点的方案数是 \(1\);而对于 \(t=1\) 的情况,只取这个点的方案数是这个点的子串的出现次数,用模板题中的做法即可。

然后从根节点贪心地走边,记递归函数 \((x,k)\) 表示当前走到 \(x\) 点,要走到从当前点出发的第 \(k\) 条路径。于是从根节点开始贪心地选即可。

SP1811 LCS - Longest Common Substring(两串的最长公共子串)

\(S,T\) 的最长公共子串长度,考虑对 \(S\) 造 SAM,然后用 \(T\) 中的字符挨个匹配。

维护一个当前匹配长度 \(cnt\)(注意长度并不是当前点的 \(len\)),如果有当前字符 \(c\) 的出边那么直接走,使 \(cnt\) 加一。

否则没有出边就一直跳 \(Link\),再看是否有出边,跳 \(Link\) 时需要将 \(cnt\) 重置为当前点的 \(len\),这是因为考虑 \(Link\) 的定义,为一个点的最短串去掉左端字符后的串所属的点。显然 \(Link\) 为当前串的最短串的最长真后缀所在点。

当然如果最后跳出根节点,就从根节点重新开始,并让 \(cnt\)\(0\)

复杂度是 \(O(n+m)\),因为考虑到往上跳 \(Link\) 的过程是均摊 \(O(m)\):我们只会往深处走 \(O(m)\) 次,当然也只会往浅处走 \(O(m)\) 次。

求每个点 Endpos 集合的大小

我们有:Endpos 集合大小 = 子串出现次数 = 走到结束节点的路径数

「Endpos 集合大小 = 子串出现次数」根据 Endpos 的定义可知,「子串出现次数 = 走到结束节点的路径数」在模板题的做法中提到过了。那么可以套用模板题的记忆化搜索做法。

另外也可以考虑每次插入一个前缀 \(i\) 的点(即不包括分裂部分时新建的点)后就会新贡献一个 Endpos 元素 \(i\),于是这个点初始 \(sz=1\),其他点(即分裂部分新建的点)初始 \(sz=0\),然后根据 Link 树的性质,祖先的 Endpos 是后代的超集,求子树的 \(sz\) 和即可。

求每个点 Endpos 集合中的极值

JZOJ8558 / 2025.04.08 NOI组模拟赛

类比求 Endpos 集合的大小,插入前缀 \(i\) 时,插入的点的极值初始赋为 \(i\)

我们建出 Link 树后递归求极值即可。

求每个点的 Endpos 集合

类比求 Endpos 集合的大小,插入前缀 \(i\) 时,插入的点的 Endpos 集合初始赋为 \(\{i\}\)

然后在 Link 树上线段树合并即可求出每个点的 Endpos 集合。

其他应用见 OI-Wiki

后缀树

考虑把一个串所有后缀插入到字典树上,这棵字典树有很好的性质,它可以表示所有子串。

然而在做 DP 时由于点数是 \(O(n^2)\) 的,这不好。考虑把所有只有一个儿子的点缩起来,新的树称作后缀树。

而一个串的后缀树实际上就是它反串建 SAM 后的 Parent 树。

广义后缀自动机

伪广义后缀自动机

把多个模式串建在同一张图里。两种方法:

  1. 用特殊符号链接多个串后建自动机。
  2. 对每个串在 SAM 上直接建立,每次建完后,将 \(last\) 指针初始化为根。

这两种方法建出的自动机可以跑匹配问题,但是不满足包含所有模式串的所有子串的最小 DFA 的要求。

对于第一种方法,自动机中包含了跨越特殊符号的子串。

对于第二种方法,可能出现空节点,具体来说当 \(lst\) 已有 \(c\) 边时,我们再尝试把 \(lst\)\(cur\) 连一条 \(c\) 边时会连不上,这时 \(cur\) 成为空节点。

真广义后缀自动机

我们先对所有模式串建字典树,在字典树上,我们 BFS 建广义 SAM。

每次对于一条边 \(u\to v\),我们把 \(lst\) 初始化为 \(cur_u\)\(u\) 的前缀节点,然后插入 \(v\) 的字符。

posted @ 2025-04-06 17:12  dengchengyu  阅读(39)  评论(0)    收藏  举报