后缀自动机(Suffix Automaton,简称 SAM)是处理字符串问题最强大的数据结构之一。它能在 O(n) 时间内构造出一个状态数 O(n) 的自动机,之后可以在线性时间内回答大量字符串相关的问题:不同子串个数、最长重复子串、出现次数最多的子串、子串出现次数、最小表示法……在 CTF 的 Misc 和逆向题中,SAM 经常用来做字符串匹配、密码分析、文本去重等任务。
很多人觉得 SAM 难,其实核心就两条:endpos 等价类 和后缀链接 。把这两个概念搞懂了,剩下的都是工程细节。
1. 为什么需要 SAM 先想一个朴素问题:给一个长度为 n 的字符串,统计它有多少个不同的子串。
最暴力的做法是枚举所有起点和终点,插入一个 set,O(n²) 个串,每个串 O(n) 比较,总 O(n³)。优化一下用哈希,O(n²)。
但如果 n 是 10⁵ 呢?O(n²) 直接 TLE。
SAM 的做法是:构造 SAM 本身 O(n),构造完之后,不同子串个数 = 所有状态的 (len[v] - len[link[v]]) 之和 。O(n) 构造,O(n) 统计。这就是 SAM 的威力。
2. endpos 等价类 定义:对于字符串 S 的一个子串 t,endpos(t) 表示 t 在 S 中所有出现位置的右端点集合。
举个例子,S = “abcabx”:
子串 “ab” 出现在位置 1 和 4,endpos = {1, 4}(0-indexed 的话是 {1, 4})
子串 “b” 出现在位置 2 和 5,endpos = {2, 5}
子串 “bc” 出现在位置 3,endpos = {3}
关键观察:如果两个子串的 endpos 相同,那么它们要么互为后缀关系,要么一个不出现另一个就不出现。 换句话说,同一个 endpos 类里的所有子串,形成了一条从短到长的后缀链。
SAM 的每个状态代表一个 endpos 等价类。每个状态 v 记录:
len[v]:这个类中最长的子串长度
link[v]:后缀链接,指向另一个 endpos 类(代表更短的后缀)
next[v][c]:转移,加一个字符 c 后跳到哪个状态
同一个状态 v 中,所有子串的长度范围是 (len[link[v]], len[v]],且它们都是最长子串的后缀。所以这个状态代表的子串数量是 len[v] - len[link[v]]。
3. 构造过程:在线增量构造 SAM 的构造是在线 的——逐个字符加入,每次加入后维护好自动机。
设当前已经有一个 SAM,现在要加字符 c。我们维护一个 last 指针,指向整个字符串对应的状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 1. 创建新状态 cur,len[cur] = len[last] + 1 2. 从 last 开始沿后缀链接向上跳, 对每个没有 c 转移的状态 p,设置 next[p][c] = cur 3. 找到第一个有 c 转移的状态 p,设 q = next[p][c] 4. 如果不存在这样的 p: - link[cur] = 根节点(0),结束 5. 如果 len[q] == len[p] + 1: - link[cur] = q,结束 6. 否则需要拆分: - 创建 clone 状态 clone,复制 q 的转移和 link - len[clone] = len[p] + 1 - 从 p 开始沿后缀链接,把所有指向 q 的 c 转移改为指向 clone - link[q] = clone,link[cur] = clone 7. last = cur
上面的第 6 步是 SAM 最 tricky 的部分。为什么要拆?因为 q 这个状态里混了两类子串:一类长度 ≤ len[p]+1,它们的 endpos 和 clone 一样;另一类长度 > len[p]+1,它们的 endpos 不同。拆分就是把这两类分开。
4. 完整实现(Python) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 class SAM : def __init__ (self ): self .len = [0 ] self .link = [-1 ] self .next = [{}] self .last = 0 def extend (self, c ): cur = len (self .len ) self .len .append(self .len [self .last] + 1 ) self .link.append(0 ) self .next .append({}) self .cnt.append(1 ) p = self .last while p != -1 and c not in self .next [p]: self .next [p][c] = cur p = self .link[p] if p == -1 : self .link[cur] = 0 else : q = self .next [p][c] if self .len [p] + 1 == self .len [q]: self .link[cur] = q else : clone = len (self .len ) self .len .append(self .len [p] + 1 ) self .link.append(self .link[q]) self .next .append(self .next [q].copy()) self .cnt.append(0 ) while p != -1 and self .next [p].get(c, -1 ) == q: self .next [p][c] = clone p = self .link[p] self .link[q] = clone self .link[cur] = clone self .last = cur @classmethod def build (cls, s ): sam = cls() for c in s: sam.extend(c) return sam def count_distinct_substrings (self ): """不同子串个数""" total = 0 for v in range (1 , len (self .len )): total += self .len [v] - self .len [self .link[v]] return total def get_occurrence_count (self ): """统计每个状态对应子串的出现次数""" order = sorted (range (len (self .len )), key=lambda x: -self .len [x]) for v in order: if self .link[v] != -1 : self .cnt[self .link[v]] += self .cnt[v] return self .cnt
5. CTF 实战:不同子串个数 最基础的应用。给一个超长字符串(比如题目里 dump 出来的一段日志或密文),问有多少个不同的子串。
1 2 3 4 s = "ababa" * 100000 sam = SAM.build(s) print (sam.count_distinct_substrings())
6. CTF 实战:出现次数最多的子串 在密码分析题中,经常需要找出一段文本中出现频率最高的特定模式。SAM 可以帮你快速统计每个子串的出现次数。
1 2 3 4 5 6 7 8 9 10 sam = SAM.build(text) cnt = sam.get_occurrence_count() max_occur = max (cnt) for v in range (1 , len (sam.len )): if cnt[v] == max_occur: length_range = (sam.len [sam.link[v]], sam.len [v]] print (f"状态 {v} : 长度 {length_range} 的子串出现 {max_occur} 次" )
7. CTF 实战:子串匹配 给一个文本 T 和一个模式 P,判断 P 是否是 T 的子串。
1 2 3 4 5 6 7 8 9 10 11 def match (sam, pattern ): v = 0 for c in pattern: if c not in sam.next [v]: return False v = sam.next [v][c] return True sam = SAM.build("hello world" ) print (match (sam, "world" )) print (match (sam, "xyz" ))
这个方法 O(|P|),比 KMP 还简单。而且 SAM 一旦建好,多次匹配都是 O(|P|)。
8. CTF 实战:最长公共子串 给两个字符串 A 和 B,求它们的最长公共子串。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 def longest_common_substring (a, b ): sam = SAM.build(a) v = 0 cur_len = 0 best_len = 0 for c in b: while v != 0 and c not in sam.next [v]: v = sam.link[v] cur_len = sam.len [v] if c in sam.next [v]: v = sam.next [v][c] cur_len += 1 else : cur_len = 0 best_len = max (best_len, cur_len) return best_len print (longest_common_substring("abcde" , "bcdef" ))
9. 复杂度分析
空间 :状态数 ≤ 2n - 1,转移数 ≤ 3n - 4。对 n=10⁶ 来说,大概 200 万个状态,Python 里用 dict 存转移可能会有点慢,C++ 数组就很轻松。
时间 :构造 O(n),每个查询 O(|P|)。均摊线性。
常数 :Python 的 dict 转移常数偏大,如果题目卡常,可以考虑用数组代替 dict(把字符映射到 0~255)。
10. 常见坑
clone 状态的 cnt 初始化为 0 。clone 不是新前缀,它的出现次数要从子节点累加。
后缀链接跳的时候注意 -1 。根节点的 link 是 -1,循环终止条件是 p == -1。
字符映射 。如果是任意字节,记得把字符映射到 0~255,用数组存转移会快很多。
多测清空 。多组数据记得重新建 SAM,不要复用状态数组。
总结 SAM 是字符串处理的瑞士军刀。掌握了 endpos 等价类和后缀链接这两个核心概念,所有变种问题都能推导出来。在 CTF 中,它常用于:
超长文本的子串统计
密码分析中的频率统计
逆向题中的字符串匹配
Misc 题中的文本去重和模式发现
写 SAM 的代码量不大,但调试起来容易出微妙的 bug。建议先在小例子上手动画一遍状态转移图,理解每一步在干什么,再写代码。