详解一下后缀自动机
Создано: 11 апреля 2026 г.
Отвечено с использованием GPT-5.4 Thinking от Chat01
Создано: 11 апреля 2026 г.
Отвечено с использованием GPT-5.4 Thinking от Chat01
详解一下后缀自动机
后缀自动机,通常写作 SAM(Suffix Automaton),是字符串算法里非常强的一种数据结构。它看起来叫“后缀”自动机,但实际上它能高效表示 一个串的所有子串。
你可以把它理解成:
用一个很紧凑的有向图,压缩表示某个字符串的全部子串信息。
它常用于这些题:
下面我按“直觉 → 定义 → 构造 → 为什么对 → 常见应用”的顺序讲。
设原串是 S。
后缀自动机是一个 DFA(确定有限自动机),满足:
S 的所有子串这里“最小”不是说代码短,而是 状态数最少。
这很厉害,因为一个长度为 n 的串有 O(n^2) 个子串,但 SAM 只需要 O(n) 个状态和边。
SAM 的每个状态,不是对应“某一个字符串”,而是对应 一类字符串。
更准确地说,一个状态对应一组 endpos 相同的子串。
对于字符串 t,它在 S 中可能出现多次。
endpos(t) 表示 t 在 S 中所有出现位置的“结束下标”集合。
例如 S = "ababa":
"a" 出现在位置 1, 3, 5 结束,所以endpos("a") = {1, 3, 5}"aba" 出现在区间 [1,3] 和 [3,5],结束位置是 3, 5endpos("aba") = {3, 5}如果两个子串的 endpos 集合一样,它们会被压到同一个状态里。
这就是 SAM 能压缩的根本原因。
通常每个状态会维护这几个东西:
len[v]状态 v 所表示的那一类字符串中,最长串的长度。
link[v]后缀链接,指向一个状态,表示:
把当前状态对应的最长串,去掉最前面若干字符后,得到的“最长真后缀”所在的状态。
它有点像 KMP 的 fail,也有点像 Trie 的后缀指针,但本质不完全一样。
next[v][c]从状态 v 出发,读入字符 c 后转移到哪个状态。
对每个状态 v,它其实代表了一段长度区间:
也就是说,这个状态里对应的字符串长度不是一个,而是一整段连续区间。
例如某状态 v:
len[v] = 10len[link[v]] = 6那么它表示的字符串长度就是:
并且这些长度对应的字符串,endpos 都相同,所以被归成同一类。
这个性质特别重要,很多计数公式都靠它。
因为它通常是按原串从左到右逐个加入字符构建的。
每次加入一个字符,都会让“当前整个前缀的所有后缀”得到更新。
换句话说:
所以名字里有“后缀”,用途上却覆盖了“子串”。
如果把状态看成点、转移看成边,那么:
因此:
“子串问题” 经常就变成了 “自动机上走路径的问题”。
SAM 可以在线构造。
每加入一个字符 c,用摊还 O(1) 时间更新,整个构造 O(n)。
构造时最核心的变量是:
last:表示当前整个串对应的状态初始只有一个根状态 0:
len[0] = 0link[0] = -1每次插入字符 c,创建一个新状态 cur,表示“新串的整个前缀”。
假设当前已经处理了字符串 S,现在加入一个字符 c,得到新串 S+c。
cur令:
len[cur] = len[last] + 1它代表新的整个前缀 S+c。
从 p = last 开始:
如果 p 没有字符 c 的转移,就把 next[p][c] = cur,然后 p = link[p] 继续往上跳。
这一步的含义是:
之前那些后缀后面接上
c,现在都能形成新的子串,所以要补边。
直到:
-1,或者c 的转移假设跳到某个状态 p,它已经有 c 转移,设
这时分三种情况。
如果 p == -1,说明一路都没找到已有的 c 转移。
那么直接设:
即新状态的后缀链接指向根。
这很好理解:新串的最长真后缀里,能落到已有状态的就是从根开始那部分。
q,而且正好“顺理成章”如果:
那说明 q 恰好就是我们要找的那个状态,不需要拆分。
直接:
这表示新状态的最长真后缀落在 q。
q,但长度不对,需要克隆如果:
这说明 q 这个状态“太大了”,它混合了两类本该分开的情况。
此时就要创建一个 克隆状态 clone。
设置:
len[clone] = len[p] + 1next[clone] = next[q]link[clone] = link[q]然后把:
link[q] = clonelink[cur] = clone并且沿着后缀链往上,把所有原本指向 q 的那条 c 转移,在符合条件时改成指向 clone。
这是 SAM 最难懂、但也是最核心的地方。
一句话解释:
之前的某个状态
q,同时代表了两批字符串;加入新字符后,发现这两批字符串的行为不再一致了,所以必须把它拆开。
而“克隆”就是一种高效的拆分方式:
q 的出边lenclone你可以把 clone 理解成:
“把原状态历史上的一部分语义,单独拎出来”
注意:
这是最常见的 C++ 写法骨架:
cppstruct SAM { static const int ALPHA = 26; struct State { int len, link; int next[ALPHA]; State() : len(0), link(-1) { memset(next, -1, sizeof(next)); } }; vector<State> st; int last; SAM(int max_len = 100000) { st.reserve(2 * max_len); st.push_back(State()); // root last = 0; } void extend(char ch) { int c = ch - 'a'; int cur = (int)st.size(); st.push_back(State()); st[cur].len = st[last].len + 1; int p = last; while (p != -1 && st[p].next[c] == -1) { st[p].next[c] = cur; p = st[p].link; } if (p == -1) { st[cur].link = 0; } else { int q = st[p].next[c]; if (st[p].len + 1 == st[q].len) { st[cur].link = q; } else { int clone = (int)st.size(); st.push_back(st[q]); // 拷贝 q st[clone].len = st[p].len + 1; // 只改 len while (p != -1 && st[p].next[c] == q) { st[p].next[c] = clone; p = st[p].link; } st[q].link = st[cur].link = clone; } } last = cur; } };
如果字符集不是 'a'~'z',把 next 改成 map 或 unordered_map 即可。
SAM 有两个经典结论:
2n - 1长度为 n 的串,SAM 的状态数是线性的。
在定长字符集下,整体也是 O(n) 级别。
因此:
O(n)O(n)这就是它能处理很长字符串的根本原因。
S = "ababa"先不追求所有细节,只看结果直觉。
构造后,你可以从根开始:
'a',得到子串 "a""ab",得到 "ab""aba",得到 "aba""bab",也能走到某条路径"ba",也可以总之,原串所有子串都会对应图上的某条路径。
而像 "aa" 这种不是子串的串,就走不出来。
这就是 SAM 最直观的用途:
它把“某串是否是子串”变成了“从根出发能否按字符一路走完”。
把原串建 SAM。
然后拿模式串 T 从根开始走:
T 不是子串T,说明它是子串时间复杂度:
这是 SAM 的经典公式。
每个状态 v 贡献的新子串个数为:
于是不同子串总数为:
前面说过,状态 v 对应的字符串长度区间是:
这个区间里的每种长度都对应一个不同子串。
区间长度就是:
把所有状态加起来即可。
这也是很常见的。
每次新建一个 cur(不是 clone),令:
表示这个状态对应的“新前缀”出现了一次。
len 从大到小拓扑统计把每个状态的 cnt 累加到它的 link 上。
即:
这样做完后:
v 的 cnt[v]如果你要查某个模式串 T 的出现次数:
vcnt[v]给 S 建 SAM,再拿另一个串 T 在上面跑。
维护当前匹配长度 l 和当前状态 v:
l++link 跳,直到能走或回到根l最终得到 S 和 T 的最长公共子串长度。
这个算法复杂度是:
比很多朴素方法高效得多。
这几个经常被一起提。
竞赛里很多“子串统计/匹配”题,SAM 都是很好用的选择。
不对。
一个状态对应的是一类字符串。
不对。
构造过程围绕后缀,但最终表示的是 所有子串。
link[v] 就是删掉第一个字符后的那个字符串不完全对。
它对应的是:
当前状态最长串的最长真后缀所在的状态
注意这里是“状态”,不是某个唯一字符串。
不是。
clone 是为了维护自动机最小化结构而引入的技术状态。
可以记住这句话:
某个状态原本把多种长度、但 endpos 相同的字符串压在一起;加入新字符后,发现它们的后继行为不同了,于是要把这个等价类拆分。
而 clone 正是“拆分等价类”的实现方式。
所以 clone 的本质不是“新出现了一个子串”,而是:
原来分得太粗,现在必须细分。
你可以这样记 SAM:
表示空串。
表示一个子串。
表示若干个“结束位置完全相同”的子串。
len这类子串里最长的长度。
link往“更短但本质最接近的后缀类”跳。
把一个状态拆成两份,避免“语义混住”。
这样会比死背定义更好记。
除了上面几种,SAM 还常做这些:
k 小k 的最长子串很多题的套路其实都是:
后缀自动机的本质是:
把一个串的所有子串,按
endpos等价类压缩成线性大小的自动机。
它最重要的几个关键词是:
如果你把这几个点想通,SAM 基本就算入门了。
下面这段是最值得背下来的“极简版”:
len[v]:状态 v 中最长串长度link[v]:最长串的最长真后缀所在状态v 代表长度区间
如果你愿意,我可以下一条直接给你继续讲:
“用一个具体字符串手动画出 SAM 的构造全过程”,或者
“后缀自动机模板 + 经典题套路总结”。