概述
后缀自动机(Suffix Automaton,简称 SAM)是一种用于处理字符串问题的强大数据结构。它是一个确定性有限自动机(DFA),接受给定字符串的所有后缀,并且是所有接受这些后缀的 DFA 中状态数最少的【1】。
后缀自动机的核心思想是:将字符串的所有子串压缩成一个有限状态机,每个状态代表一组具有相同「出现位置集合」的子串(即 endpos 等价类)。这种压缩方式使得我们可以用 的时间复杂度和空间复杂度(实际实现中空间约为 个状态)来处理大多数字符串查询问题。
endpos 等价类
设字符串 的长度为 ,对于 的任意子串 ,定义 为所有结束位置的集合:
如果两个子串 和 满足 ,则称它们属于同一个 endpos 等价类。
性质
endpos 等价类具有以下重要性质【1】:
- 同一等价类中的子串长度连续:若等价类中子串的最大长度为 ,最小长度为 ,则该类包含所有长度为 的子串。
- 后缀链接关系:较短子串的后缀可能属于另一个等价类,这种关系构成一条链。
后缀链接(Suffix Link)
后缀链接是后缀自动机中连接不同状态的「指针」,又称 link。
设状态 代表等价类 ,包含所有长度为 的子串(其中 是该类中最长子串的长度,)。则 的后缀链接 指向另一个状态 ,满足:
- 代表的等价类包含所有 中子串的真后缀
- 是满足这一条件的状态中 最大的(即最长后缀所在的等价类)
性质
- 所有状态的后缀链接构成一棵树,称为 link 树
- 根节点(初始状态)的
- 从任意状态沿 link 向上走,会依次得到该状态代表子串的:后缀 → 后缀的后缀 → … → 空串
状态结构
后缀自动机的每个状态存储以下信息:
| 属性 | 含义 |
|---|---|
len | 该状态代表的最长子串长度(即 ) |
link | 后缀链接指向的状态编号 |
next | 转移函数, 表示从该状态接受字符 后的目标状态 |
构造算法
后缀自动机采用在线算法构造,每次读入一个新字符 ,执行 extend(c) 操作。算法保持自动机的最小性,确保始终是正确的 DFA【1】。
extend 操作
设当前字符串为 ,已构造好的自动机对应 的所有子串。现在要在末尾添加字符 ,得到新字符串 。
步骤如下:
-
创建新状态:新建状态 ,令 (其中 是添加字符前代表整个字符串 的状态)
-
沿后缀链接寻找插入位置:从 开始,沿 link 向上跳,找到第一个可以通过字符 转移的状态 ,即满足
-
建立转移:令
-
处理未找到 的情况:如果一直跳到根节点()仍未找到可转移的状态,则 (根状态)
-
处理找到 的情况:设
- 若 ,则
- 否则需要克隆状态 :
状态克隆
当 时,需要创建克隆状态 :
- (复制所有转移)
- 然后将 和 的 link 都指向
克隆操作保证了自动机的最小性:将 分裂成两个状态,使得 值连续。
代码实现
#include <bits/stdc++.h>
using namespace std;
struct state {
int len = 0; // 最长字符串长度
int link = -1; // 后缀链接
map<char, int> next; // 转移
};
class SuffixAutomaton {
private:
vector<state> st;
int last = 0; // 最后添加的状态
public:
SuffixAutomaton(int maxLen = 0) {
st.reserve(2 * maxLen);
st.push_back(state()); // 初始化根状态
st[0].len = 0;
st[0].link = -1;
last = 0;
}
void extend(char c) {
int cur = st.size();
st.push_back(state());
st[cur].len = st[last].len + 1;
int p = last;
// 寻找可转移的位置
while (p != -1 && !st[p].next.count(c)) {
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 = st.size();
st.push_back(st[q]); // 复制 q 的所有信息
st[clone].len = st[p].len + 1;
// link 不变(在下一步被修改)
// 调整 q 的后缀链接
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;
}
// 查询:检查模式串是否为 S 的子串
bool contains(const string& t) const {
int v = 0;
for (char c : t) {
if (!st[v].next.count(c)) return false;
v = st[v].next.at(c);
}
return true;
}
// 查询:S 中不同子串的个数
long long countDistinctSubstrings() const {
long long ans = 0;
for (int i = 1; i < (int)st.size(); i++) {
ans += st[i].len - st[st[i].link].len;
}
return ans;
}
// 查询:两字符串的最长公共子串长度
int longestCommonSubstring(const string& t) const {
int v = 0, l = 0, best = 0;
for (char c : t) {
while (v && !st[v].next.count(c)) {
v = st[v].link;
l = st[v].len;
}
if (st[v].next.count(c)) {
v = st[v].next.at(c);
l++;
}
best = max(best, l);
}
return best;
}
};时间复杂度分析
构造后缀自动机的总时间复杂度为 ,其中 为字符串长度。证明思路:
- 每次
extend操作中,沿 link 向上跳的总次数均摊为 - 克隆操作不增加状态总数(最多 个状态)
应用场景
1. 子串查询
检查模式串 是否为原串 的子串,只需从初始状态出发,沿 的每个字符进行转移。若转移失败或最终不在合法状态,则 不是子串。
复杂度:
2. 不同子串计数
根据自动机结构,状态 对应的 endpos 等价类包含长度为 到 的子串,共 个【1】。
对所有状态求和即可得到不同子串总数:
复杂度:
3. 最长公共子串(Longest Common Substring)
给定两字符串 和 ,求它们的最长公共子串长度。
思路:对 构造后缀自动机,用 在自动机上运行,同时维护当前匹配长度 。当遇到无法继续的字符时,沿 link 上跳并调整 。整个过程中 的最大值即为答案。
复杂度:
4. 字典树序第 小子串
在自动机的 DAG 上动态规划,可按字典序遍历所有子串。
5. 多个字符串的最长公共子串
对第一个字符串构造 SAM,其余字符串在自动机上运行,记录每个状态被各字符串覆盖的「深度」最小值。
与其他数据结构的比较
后缀自动机的优势在于:一种结构,多种查询,且均为线性或常数复杂度。
模板题
参考
Last updated: 2026-04-05