后缀数组(SA)
💡 核心思想
后缀数组是将字符串的所有后缀按字典序排序后得到的数组。SA[i] 表示排名第 i 的后缀的起始位置。LCP(最长公共前缀)数组记录相邻后缀的最长公共前缀长度。通过后缀数组 + LCP,可以在 O(log n) 时间内完成任意子串的比较,在 O(n) 时间内完成多种字符串统计问题。
后缀数组是字符串竞赛的高级武器。DA(倍增)算法是后缀数组的标准构造方法,代码短且稳定。理解后缀数组的关键是:任意子串都是某个后缀的前缀,所以子串相关问题可以转化为后缀的前缀问题。LCP 数组更是神器,配合 RMQ 可以在 O(1) 时间内求任意两个后缀的 LCP。
🎯 直觉理解
想象你把一本书的所有可能的开头段落都列出来,然后按字典序排序。"后缀数组"就是这个排序后的列表。比如 "banana" 的后缀有:banana, anana, nana, ana, na, a。排序后:a, ana, anana, banana, na, nana。LCP 数组记录相邻两个后缀有多长的公共开头。
📝 算法流程
- 倍增法构造 SA:
- 第一轮按单个字符排序
- 第 k 轮按长度为 2^k 的子串排序(用上一轮排名作为关键字)
- 用计数排序或基数排序,复杂度 O(n log n)
- 构造 LCP 数组(Height):
- 按排名顺序,利用 h[i] >= h[i-1] - 1 的性质 O(n) 构造
$$SA[i] = \text{排名第 }i\text{ 的后缀的起始位置}$$
$$LCP(i,j) = \min_{k=rank[i]+1}^{rank[j]} Height[k]$$
📊 复杂度分析
| 指标 | 复杂度 |
|---|---|
| 时间 | $O(n \log n)$ 构造 SA / $O(n)$ 构造 LCP |
| 空间 | $O(n)$ |
💻 参考实现(C++)
C++ (C++17)
#include
using namespace std;
// 后缀数组(倍增法)
struct SuffixArray {
string s;
vector sa, rank, height;
int n;
SuffixArray(const string& str) : s(str + '$'), n(s.size()) {
sa.resize(n); rank.resize(n); height.resize(n);
build();
}
void build() {
// 初始按单个字符排序
vector cnt(256, 0);
for (char c : s) cnt[(unsigned char)c]++;
for (int i = 1; i < 256; i++) cnt[i] += cnt[i-1];
for (int i = n - 1; i >= 0; i--) sa[--cnt[(unsigned char)s[i]]] = i;
rank[sa[0]] = 0;
for (int i = 1; i < n; i++)
rank[sa[i]] = rank[sa[i-1]] + (s[sa[i]] != s[sa[i-1]]);
// 倍增
for (int k = 1; rank[sa[n-1]] < n - 1; k <<= 1) {
auto cmp = [&](int i, int j) {
if (rank[i] != rank[j]) return rank[i] < rank[j];
int ri = (i + k < n) ? rank[i + k] : -1;
int rj = (j + k < n) ? rank[j + k] : -1;
return ri < rj;
};
sort(sa.begin(), sa.end(), cmp);
vector newRank(n);
newRank[sa[0]] = 0;
for (int i = 1; i < n; i++)
newRank[sa[i]] = newRank[sa[i-1]] + cmp(sa[i-1], sa[i]);
rank = newRank;
}
// 构造 Height (LCP)
int h = 0;
for (int i = 0; i < n - 1; i++) {
int j = sa[rank[i] - 1];
while (i + h < n && j + h < n && s[i + h] == s[j + h]) h++;
height[rank[i]] = h;
if (h > 0) h--;
}
}
};
int main() {
SuffixArray sa("banana");
for (int i = 0; i < sa.n; i++)
cout << sa.sa[i] << " " << sa.s.substr(sa.sa[i]) << endl;
return 0;
} ⚠️ 常见坑点
排序稳定性——基数排序要注意稳定性
h 数组的含义——height[i] 是 SA[i] 和 SA[i-1] 的 LCP
字符串末尾要加哨兵——避免越界
排序比较器的效率——倍增法用 sort 是 O(n log² n),用基数排序可到 O(n log n)
📚 相关题目
| 题目 | 来源 | 难度 | 备注 |
|---|---|---|---|
| P3809 后缀排序 | 洛谷 | 提高组 | 后缀数组模板题 |
| P2852 牛奶模式 | 洛谷 | 提高组 | 后缀数组 + 二分 |
| P4248 差异 | 洛谷 | 提高组 | 后缀数组 + 单调栈 |