从零掌握后缀自动机:OIer必备字符串处理神器
你还在为字符串匹配效率低而烦恼?面对重复子串查找、不同子串计数等问题时束手无策?本文将带你一文掌握后缀自动机(Suffix Automaton, SAM)的核心原理、实现步骤及实战应用,让你轻松突破字符串处理瓶颈,成为OI赛场上的字符串高手。读完本文,你将能够独立实现SAM、理解其线性复杂度的底层逻辑,并解决各类字符串经典问题。
什么是后缀自动机?
后缀自动机(SAM)是一种能高效表示字符串所有子串的数据结构,它以线性空间复杂度存储字符串的全部子串信息,并支持多种字符串操作的快速查询。对于长度为n的字符串,SAM仅需O(n)的空间和时间即可构造完成,这使得它在处理大规模文本时表现卓越。
SAM的核心价值在于其最小性——它是接受字符串所有后缀的最小确定性有限自动机(DFA)。与直接构建所有后缀的AC自动机相比,SAM通过合并等价状态将复杂度从O(n²)降至O(n),堪称字符串处理的"压缩神器"。
核心原理:从endpos集合到后缀链接树
endpos集合:状态的本质
SAM的每个状态对应一组具有相同结束位置集合(endpos) 的子串。例如对于字符串"abcbc",子串"bc"的endpos为{2,4},表示它在原字符串中结束于位置2和4。
关键性质:
- 两个子串的endpos相同当且仅当较短子串是较长子串的后缀
- 不同状态的endpos集合要么完全包含,要么互不相交
- 同一状态的子串长度构成连续区间[minlen, len]
后缀链接:状态间的父子关系
每个状态(除初始状态)都有一条后缀链接(link),指向对应最长子串的后缀中endpos不同的最长子串所在的状态。所有后缀链接构成一棵根向树,称为后缀链接树(parent树)。
后缀链接树性质:
- 祖先节点的endpos集合包含子孙节点
- 节点v的minlen = len(link(v)) + 1
- 从任意节点沿后缀链接遍历终将到达初始状态
线性构造算法:增量扩展的艺术
SAM采用在线增量构造,每次添加一个字符并更新自动机。核心步骤包括创建新状态、更新转移关系和调整后缀链接,整个过程保持线性复杂度。
核心步骤解析
- 创建新状态:添加字符c时,创建对应整个新字符串的状态cur
- 更新转移:从last状态沿后缀链接回溯,为缺失c转移的状态添加指向cur的转移
- 处理冲突状态:
- 情况1:未找到冲突状态,cur的link指向初始状态
- 情况2:找到的冲突状态q满足len(p)+1=len(q),cur的link指向q
- 情况3:否则分裂q为clone状态,调整相关转移和链接
实现代码示例
struct state {
int len, link;
std::map<char, int> next;
};
constexpr int MAXLEN = 100000;
state st[MAXLEN * 2];
int sz, last;
void sam_init() {
st[0].len = 0;
st[0].link = -1;
sz++;
last = 0;
}
void sam_extend(char c) {
int cur = sz++;
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 = sz++;
st[clone].len = st[p].len + 1;
st[clone].next = st[q].next;
st[clone].link = st[q].link;
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;
}
完整实现代码可参考OI-Wiki官方代码库。
实战应用:解决字符串经典问题
1. 不同子串计数
问题:计算字符串中不同子串的个数。
解法:利用后缀链接树的性质,每个状态贡献的子串数为len(v) - len(link(v)),求和即可得到总个数。
long long count_distinct_substrings() {
long long ans = 0;
for (int i = 1; i < sz; ++i) {
ans += st[i].len - st[st[i].link].len;
}
return ans;
}
2. 子串出现次数统计
问题:查询模式串在文本中出现的次数。
解法:通过对后缀链接树进行拓扑排序,计算每个状态的endpos集合大小:
void calc_occurrences() {
vector<int> order(sz), cnt(sz);
iota(order.begin(), order.end(), 0);
sort(order.begin(), order.end(), [&](int a, int b) {
return st[a].len > st[b].len;
});
for (int v : order) {
if (st[v].link != -1) {
cnt[st[v].link] += cnt[v];
}
}
}
3. 最长重复子串
问题:找出字符串中最长的重复子串。
解法:遍历所有状态,寻找len(v)最大且cnt[v] ≥ 2的状态,其len(v)即为答案。
性能分析:为何SAM如此高效?
SAM的高效性源于其精妙的状态设计:
- 状态数:最多2n-1个(n为字符串长度)
- 转移数:最多3n-4个
- 构造时间:O(n)(假设字符集大小为常数)
这种线性复杂度使得SAM能够轻松处理百万级长度的字符串,远超后缀数组等传统结构。
学习资源与进阶方向
- 官方文档:OI-Wiki后缀自动机详解
- 代码模板:SAM实现模板
- 进阶应用:结合后缀数组、线段树解决复杂字符串问题
- 推荐题目:
掌握SAM不仅能解决字符串问题,更能培养对复杂数据结构的设计思维。从endpos集合的等价类划分到后缀链接树的层级关系,SAM的每个细节都闪耀着算法设计的智慧光芒。现在就动手实现你的第一个SAM,开启字符串处理的高效之旅吧!
点赞收藏本文,关注后续字符串专题,解锁更多OI算法技巧!下一篇我们将深入探讨SAM与后缀树的关系,敬请期待。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



