后缀自动机(Suffix Automaton, SAM)是处理字符串子串问题的高效数据结构,它能在 O(n) 时间 / 空间复杂度内构建字符串的压缩表示,支持子串存在性查询、子串出现次数统计、最长重复子串等经典问题。本文将从核心原理、结构定义、构建流程到实战应用,全面解析 SAM 的设计思想与 C++ 实现技巧。
一、SAM 的核心背景与优势
1.1 问题引入:传统方法的瓶颈
字符串子串问题(如统计所有不同子串数量、查找最长重复子串),传统方法(如后缀数组、暴力枚举)存在明显缺陷:
- 暴力枚举:枚举所有子串需 O(n2) 时间,空间复杂度更高。
- 后缀数组:构建需 O(nlogn) 时间,且部分查询操作(如子串出现次数)需额外处理。
SAM 的核心优势:
- 线性构建:时间 / 空间复杂度均为 O(n)(n 为字符串长度)。
- 压缩存储:通过状态转移和后缀链接,仅用 O(n) 状态表示所有子串(而非 O(n2))。
- 高效查询:子串存在性、出现次数、最长匹配等操作均可在 O(len) 时间完成(len 为查询串长度)。
1.2 核心概念:子串的等价类
SAM 的本质是将字符串的所有子串按 "结束位置集合(endpos)" 分组:
- endpos 定义 :对于子串 s,
endpos(s)是 s 在原字符串中所有结束位置的集合(位置从 0 或 1 开始,需统一)。例:字符串ababa,子串aba的结束位置为 2、4 →endpos(aba) = {2,4}。 - 等价类 :若两个子串的
endpos完全相同,则归为同一等价类(对应 SAM 中的一个 状态(State))。 - 状态的 len 属性 :同一等价类中最长子串的长度(记为
len),最短子串长度为link.len + 1(link为该状态的后缀链接)。
二、SAM 的核心结构
2.1 状态(State)的定义
SAM 的核心是状态节点和转移边,每个状态包含 4 个核心属性:
cpp
运行
struct State {
int len; // 该状态对应最长子串的长度
int link; // 后缀链接(指向另一个状态)
map<char, int> next; // 转移边:字符 → 目标状态编号
int cnt; // 可选:该状态对应子串的出现次数(需额外统计)
};
- len:当前等价类的最长子串长度。
- link:后缀链接(核心!),指向更短的等价类状态,构成 "后缀链",用于处理子串的后缀关系。
- next:转移边,模拟在当前子串末尾添加字符后的状态跳转。
- cnt:扩展属性,记录该状态对应子串的出现次数(需在构建后通过拓扑排序统计)。
2.2 后缀链接(link)的意义
后缀链接是 SAM 的灵魂,满足两个核心性质:
- 长度递减 :
link指向的状态,其len严格小于当前状态的len。 - 后缀包含 :当前状态的所有子串的后缀,对应
link状态的子串。例:状态 A(len=5)的link指向状态 B(len=3),则状态 A 中长度为 4、5 的子串的后缀(长度 ≤3)由状态 B 表示。
2.3 SAM 的整体结构
SAM 是一个 有向无环图(DAG):
- 节点:状态(等价类)。
- 边:转移边(字符驱动)。
- 后缀链接:构成一棵反向树(根节点为初始状态,记为
size=1,len=0,link=-1)。
三、SAM 的构建算法(增量法)
SAM 的构建采用 增量法:从空串开始,逐个添加字符,动态维护状态和转移边。核心变量包括:
sa:存储所有状态的数组(初始包含根状态sa[0])。last:最后一个状态的编号(初始为 0,即根状态)。size:状态总数(初始为 1)。
3.1 构建步骤(核心逻辑)
以字符串 s 为例,逐个添加字符 c = s[i]:
步骤 1:创建新状态 cur
新状态 cur 的 len = last.len + 1(对应以 i 为结束位置的最长子串),size++。
步骤 2:从 last 沿后缀链遍历,添加转移边
从 last 出发,沿 link 向上遍历所有状态 p:
- 若
p无字符c的转移边,则添加p.next[c] = cur,继续遍历p.link。 - 若找到有字符
c转移边的状态p,记转移目标为q,进入步骤 3。 - 若遍历到根节点(
p=-1)仍未找到,则设置cur.link = 0,结束。
步骤 3:处理转移冲突(分裂状态 q)
找到 p.next[c] = q 后,分两种情况:
- 情况 3.1 :
q.len == p.len + 1→ 直接将cur.link = q,结束。 - 情况 3.2 :
q.len > p.len + 1→ 分裂q为新状态clone:clone复制q的所有属性(len = p.len + 1,next = q.next,link = q.link)。- 从
p沿后缀链遍历所有指向q的状态p',将p'.next[c]改为clone。 - 将
q.link和cur.link均指向clone。 size++,完成分裂。
步骤 4:更新 last
设置 last = cur,继续处理下一个字符。
3.2 C++ 完整实现(基础版)
cpp
运行
#include <iostream>
#include <vector>
#include <map>
#include <algorithm>
using namespace std;
struct State {
int len; // 最长子串长度
int link; // 后缀链接
map<char, int> next; // 转移边
State() : len(0), link(-1) {}
};
class SAM {
public:
vector<State> sa;
int last; // 最后一个状态
int size; // 状态总数
SAM() {
sa.emplace_back(); // 根状态(编号0)
last = 0;
size = 1;
}
// 增量添加字符c
void add(char c) {
int cur = size++; // 步骤1:创建新状态cur
sa.emplace_back();
sa[cur].len = sa[last].len + 1;
int p = last;
// 步骤2:沿后缀链添加转移边
while (p != -1 && !sa[p].next.count(c)) {
sa[p].next[c] = cur;
p = sa[p].link;
}
if (p == -1) {
// 遍历到根节点,cur的后缀链接为根
sa[cur].link = 0;
} else {
int q = sa[p].next[c];
if (sa[p].len + 1 == sa[q].len) {
// 情况3.1:无需分裂
sa[cur].link = q;
} else {
// 情况3.2:分裂q为clone
int clone = size++;
sa.emplace_back();
sa[clone].len = sa[p].len + 1;
sa[clone].next = sa[q].next; // 复制转移边
sa[clone].link = sa[q].link; // 复制后缀链接
// 更新所有指向q的转移边为clone
while (p != -1 && sa[p].next[c] == q) {
sa[p].next[c] = clone;
p = sa[p].link;
}
// 更新q和cur的后缀链接
sa[q].link = clone;
sa[cur].link = clone;
}
}
last = cur; // 更新last为新状态
}
// 构建SAM(输入字符串)
void build(const string& s) {
for (char c : s) {
add(c);
}
}
};
3.3 关键细节解析
- 分裂状态的必要性 :当
q.len > p.len + 1时,q对应的最长子串长度超过p加字符c的长度,说明q包含了 "不应该包含" 的子串,需分裂为clone(对应p.len+1长度的子串)和原q(对应更长的子串)。 - 后缀链接的维护:后缀链接确保所有子串的后缀关系被正确表示,是 SAM 能压缩存储所有子串的核心。
- 状态数量:理论上,SAM 的状态数不超过 2n−1(n 为字符串长度),因此空间复杂度为 O(n)。
四、SAM 的核心操作与扩展
4.1 基础操作:子串存在性查询
判断字符串 t 是否是原字符串的子串,只需模拟转移边遍历:
cpp
运行
// 查询t是否是原字符串的子串
bool query(const string& t) {
int p = 0; // 从根状态开始
for (char c : t) {
if (!sa[p].next.count(c)) {
return false; // 无转移边,不存在
}
p = sa[p].next[c];
}
return true;
}
时间复杂度:O(len(t))。
4.2 扩展操作 1:统计子串出现次数
SAM 中状态的 cnt 表示对应子串的出现次数,需通过 拓扑排序 统计(按 len 降序遍历):
cpp
运行
// 统计每个状态的出现次数
void countOccurrences() {
// 步骤1:初始化cnt(仅包含终止状态的cnt=1)
vector<int> cnt(size, 0);
int p = last;
while (p != 0) {
cnt[p] = 1;
p = sa[p].link;
}
// 步骤2:按len降序排序状态(拓扑序)
vector<int> order(size);
for (int i = 0; i < size; ++i) {
order[i] = i;
}
sort(order.begin(), order.end(), [&](int a, int b) {
return sa[a].len > sa[b].len;
});
// 步骤3:拓扑更新cnt
for (int u : order) {
if (sa[u].link != -1) {
cnt[sa[u].link] += cnt[u];
}
}
// 将cnt赋值给每个状态
for (int i = 0; i < size; ++i) {
sa[i].cnt = cnt[i];
}
}
// 查询子串t的出现次数
int getOccurrence(const string& t) {
int p = 0;
for (char c : t) {
if (!sa[p].next.count(c)) {
return 0; // 子串不存在
}
p = sa[p].next[c];
}
return sa[p].cnt;
}
核心逻辑:
- 终止状态(
last沿后缀链的所有状态)的cnt初始化为 1(对应原字符串的后缀)。 - 按
len降序遍历(拓扑序),将子状态的cnt累加到父状态(后缀链接指向的状态)。
4.3 扩展操作 2:统计所有不同子串数量
SAM 中每个状态贡献的不同子串数为 len - link.len,总和即为所有不同子串数量:
cpp
运行
// 统计所有不同子串的数量
long long countDistinctSubstrings() {
long long res = 0;
for (int i = 1; i < size; ++i) { // 跳过根状态
res += sa[i].len - sa[sa[i].link].len;
}
return res;
}
例:字符串 ab 的不同子串为 a, b, ab → 总数 3,计算:
- 状态 1(
len=1,link=0):1-0=1(对应a)。 - 状态 2(
len=2,link=0):2-0=2(对应b, ab)。 - 总和:
1+2=3,正确。
4.4 扩展操作 3:查找最长重复子串
最长重复子串是出现次数 ≥2 的最长子串,只需遍历所有状态,找到 cnt≥2 且 len 最大的状态:
cpp
运行
// 查找最长重复子串的长度
int longestRepeatedSubstring() {
int max_len = 0;
for (int i = 1; i < size; ++i) {
if (sa[i].cnt >= 2 && sa[i].len > max_len) {
max_len = sa[i].len;
}
}
return max_len;
}
五、SAM 的典型应用场景
5.1 单字符串问题
- 统计所有不同子串的数量。
- 查找最长重复子串、最长回文子串(需结合回文自动机或预处理)。
- 子串出现次数统计、子串的第 k 小子串查询。
5.2 多字符串问题
- 多字符串最长公共子串:构建多个字符串的 SAM,合并状态后查找公共转移路径的最长长度。
- 字符串匹配:构建模式串的 SAM,在文本串中匹配所有出现位置。
- 字典序第 k 小子串:对 SAM 的转移边按字符排序,结合状态的子串数量进行二分查找。
5.3 实战例题(LeetCode)
- LeetCode 1044. 最长重复子串:用 SAM 可在 O(n) 时间解决(暴力法 O(n2) 超时)。
- LeetCode 3045. 统计前后缀下标对 IV:结合 SAM 统计子串出现次数。
六、SAM 的优化与注意事项
6.1 性能优化
-
转移边替换 :用
unordered_map或数组代替map(若字符集为小写字母,可直接用int next[26]),减少时间开销:cpp
运行
// 字符集为小写字母时,优化转移边 struct State { int len, link; int next[26]; // 代替map<char, int> State() : len(0), link(-1) { memset(next, -1, sizeof(next)); // 初始化为-1 } }; -
拓扑排序优化 :预先存储状态的
len,用桶排序代替快速排序,时间从 O(nlogn) 降至 O(n)。
6.2 常见错误
- 结束位置的定义:需统一 endpos 的位置编号(0 或 1 开始),避免 len 计算错误。
- 分裂状态的漏处理:未正确更新指向 q 的转移边,导致查询结果错误。
- 字符集处理:若字符集包含大写字母、数字等,需确保转移边的键类型匹配。
七、总结
SAM 是字符串处理的 "瑞士军刀",核心优势是 线性时空复杂度 和 高效的子串操作 。其设计思想的核心是通过 endpos 等价类和后缀链接,将 O(n2) 的子串压缩为 O(n) 的状态表示。
掌握 SAM 的关键:
- 理解
endpos和等价类的定义(SAM 的理论基础)。 - 熟练掌握增量构建算法(尤其是分裂状态的逻辑)。
- 灵活运用后缀链接和拓扑排序扩展 SAM 的功能。
SAM 是竞赛和工业界处理大规模字符串问题的首选数据结构,结合后缀数组、AC 自动机等,可解决绝大多数字符串难题。