
标题
- [C++ 后缀数组(SA):原理、实现与应用全解析](#C++ 后缀数组(SA):原理、实现与应用全解析)
-
- 一、后缀数组的核心背景与优势
-
- [1.1 问题引入:后缀处理的瓶颈](#1.1 问题引入:后缀处理的瓶颈)
- [1.2 核心概念定义](#1.2 核心概念定义)
- 二、后缀数组的构建:倍增法(最易实现)
-
- [2.1 倍增法核心思想](#2.1 倍增法核心思想)
- [2.2 关键:二元组排序](#2.2 关键:二元组排序)
- [2.3 倍增法 C++ 实现(完整可运行)](#2.3 倍增法 C++ 实现(完整可运行))
- [2.4 优化:基数排序(降低时间复杂度)](#2.4 优化:基数排序(降低时间复杂度))
- 三、最长公共前缀(LCP)数组:高度数组
-
- [3.1 LCP 数组的定义与性质](#3.1 LCP 数组的定义与性质)
- [3.2 LCP 数组的构建(O(n) 算法)](#3.2 LCP 数组的构建(O(n) 算法))
- [3.3 完整示例(SA + LCP)](#3.3 完整示例(SA + LCP))
- 四、后缀数组的核心应用
-
- [4.1 应用1:子串存在性查询(二分)](#4.1 应用1:子串存在性查询(二分))
- [4.2 应用2:最长重复子串](#4.2 应用2:最长重复子串)
- [4.3 应用3:统计不同子串的数量](#4.3 应用3:统计不同子串的数量)
- [4.4 应用4:最长公共子串(两个字符串)](#4.4 应用4:最长公共子串(两个字符串))
- 五、常见错误与最佳实践
-
- [5.1 常见错误](#5.1 常见错误)
- [5.2 最佳实践](#5.2 最佳实践)
- 六、后缀数组与其他字符串结构的对比
- 七、总结
C++ 后缀数组(SA):原理、实现与应用全解析
后缀数组(Suffix Array, SA)是处理字符串子串问题的核心数据结构,它将字符串的所有后缀按字典序排序并存储其起始位置,能在 (O(n \log n)) 时间复杂度内构建,支持最长公共前缀(LCP)、最长重复子串、子串排名等经典问题。本文将从核心原理、倍增法构建、LCP 数组计算到实战应用,全面解析后缀数组的设计思想与 C++ 实现技巧。
一、后缀数组的核心背景与优势
1.1 问题引入:后缀处理的瓶颈
字符串的后缀是子串问题的核心(如最长重复子串本质是两个后缀的最长公共前缀),直接存储所有后缀的时间/空间复杂度为 (O(n^2)),完全不可用:
- 字符串
s = "ababc",其所有后缀为:s[0:] = "ababc"s[1:] = "babc"s[2:] = "abc"s[3:] = "bc"s[4:] = "c"
后缀数组的核心价值:
- 压缩存储:仅存储后缀的起始位置(而非完整后缀),空间复杂度 (O(n))。
- 有序性:按字典序排序后,可通过二分快速查询子串是否存在,或计算 LCP。
- 通用性:能解决几乎所有字符串子串问题,是竞赛/工业界的"通用工具"。
1.2 核心概念定义
给定字符串 (s[0...n-1]),定义:
- 后缀 :
suffix(i) = s[i...n-1](以 (i) 为起始位置的后缀)。 - 后缀数组(sa) :
sa[k] = i表示suffix(i)是字典序第 (k) 小的后缀((k) 从 0 开始)。 - 排名数组(rk) :
rk[i] = k表示suffix(i)的字典序排名为 (k)(rk[sa[k]] = k,与 sa 互逆)。 - LCP 数组 :
lcp[k]表示suffix(sa[k])和suffix(sa[k-1])的最长公共前缀长度((k \geq 1))。
示例 :字符串 s = "ababc"(n=5)
| 后缀起始位置 i | 后缀 | 排名 rk[i] | sa[k] (k=rk[i]) |
|---|---|---|---|
| 0 | ababc | 0 | sa[0] = 0 |
| 1 | babc | 3 | sa[1] = 2 |
| 2 | abc | 1 | sa[2] = 4 |
| 3 | bc | 4 | sa[3] = 1 |
| 4 | c | 2 | sa[4] = 3 |
对应的数组:
sa = [0, 2, 4, 1, 3]rk = [0, 3, 1, 4, 2]lcp = [0, 2, 0, 1, 0](lcp[1]=2表示suffix(0)和suffix(2)的 LCP 为 2,即 "ab")。
二、后缀数组的构建:倍增法(最易实现)
后缀数组的构建算法有多种(倍增法、DC3 法、SA-IS 法),其中倍增法实现简单、逻辑清晰,是新手首选(时间复杂度 (O(n \log n)))。
2.1 倍增法核心思想
将后缀的比较转化为"长度为 (2^k) 的前缀"的比较,逐步倍增长度直到覆盖整个字符串:
- 初始状态(k=0) :比较长度为 (1) 的前缀(即单个字符),得到初始排名
rk0。 - 倍增迭代 :对每个 (k),比较长度为 (2^k) 的前缀(拆分为"前 (2^{k-1}) 字符" + "后 (2^{k-1}) 字符"),得到新排名
rk1。 - 终止条件:当 (2^k \geq n) 时,排名稳定,得到最终 sa 数组。
2.2 关键:二元组排序
对于长度 (2^k) 的前缀,每个后缀 (i) 对应一个二元组:
- 第一关键字:
rk[i](前 (2^{k-1}) 字符的排名)。 - 第二关键字:
rk[i + 2^{k-1}](后 (2^{k-1}) 字符的排名,若超出字符串长度则为 -1)。
通过对二元组排序,得到新的排名数组。
2.3 倍增法 C++ 实现(完整可运行)
cpp
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <cstring>
using namespace std;
// 构建后缀数组(sa)和排名数组(rk)
// s: 输入字符串,sa: 输出后缀数组,rk: 输出排名数组
void buildSA(const string& s, vector<int>& sa, vector<int>& rk) {
int n = s.size();
sa.resize(n);
rk.resize(n);
// 初始:按单个字符排序(k=0,长度1)
for (int i = 0; i < n; ++i) {
sa[i] = i;
rk[i] = s[i]; // 初始排名为字符的ASCII值
}
// 倍增迭代:k 表示当前比较长度为 2^k
for (int k = 1; k < n; k <<= 1) {
// 排序规则:先按第一关键字(rk[i]),再按第二关键字(rk[i+k])
auto cmp = [&](int i, int j) {
if (rk[i] != rk[j]) return rk[i] < rk[j];
// 超出长度的部分排名更小
int ri = (i + k < n) ? rk[i + k] : -1;
int rj = (j + k < n) ? rk[j + k] : -1;
return ri < rj;
};
sort(sa.begin(), sa.end(), cmp);
// 更新排名数组 rk
vector<int> new_rk(n);
new_rk[sa[0]] = 0;
for (int i = 1; i < n; ++i) {
// 若 sa[i] 和 sa[i-1] 的二元组相同,排名相同
if (cmp(sa[i-1], sa[i])) {
new_rk[sa[i]] = new_rk[sa[i-1]] + 1;
} else {
new_rk[sa[i]] = new_rk[sa[i-1]];
}
}
rk.swap(new_rk);
// 优化:若排名已唯一,提前终止
if (rk[sa.back()] == n - 1) break;
}
}
// 测试代码(基础SA构建)
int main() {
string s = "ababc";
vector<int> sa, rk;
buildSA(s, sa, rk);
cout << "字符串:" << s << endl;
cout << "后缀数组 sa:";
for (int x : sa) cout << x << " "; // 输出:0 2 4 1 3
cout << endl;
cout << "排名数组 rk:";
for (int x : rk) cout << x << " "; // 输出:0 3 1 4 2
cout << endl;
return 0;
}
2.4 优化:基数排序(降低时间复杂度)
上述实现用 sort 排序,时间复杂度为 (O(n \log^2 n)),可通过基数排序优化为 (O(n \log n)):
cpp
// 基数排序优化版 buildSA(字符集为ASCII)
void buildSA_Radix(const string& s, vector<int>& sa, vector<int>& rk) {
int n = s.size(), m = 128; // m 为字符集大小
sa.resize(n);
rk.resize(n);
vector<int> cnt(m, 0), tmp(n);
// 初始:按单个字符计数排序
for (int i = 0; i < n; ++i) cnt[rk[i] = s[i]]++;
for (int i = 1; i < m; ++i) cnt[i] += cnt[i-1];
for (int i = n-1; i >= 0; --i) sa[--cnt[rk[i]]] = i;
// 倍增迭代
for (int k = 1; k < n; k <<= 1) {
// 按第二关键字排序(sa 已经是按第一关键字排序的结果)
int num = 0;
for (int i = n - k; i < n; ++i) tmp[num++] = i; // 无第二关键字的排在前面
for (int i = 0; i < n; ++i) if (sa[i] >= k) tmp[num++] = sa[i] - k;
// 按第一关键字基数排序
fill(cnt.begin(), cnt.end(), 0);
for (int i = 0; i < n; ++i) cnt[rk[tmp[i]]]++;
for (int i = 1; i < m; ++i) cnt[i] += cnt[i-1];
for (int i = n-1; i >= 0; --i) sa[--cnt[rk[tmp[i]]]] = tmp[i];
// 更新排名
swap(rk, tmp);
rk[sa[0]] = 0;
num = 1;
for (int i = 1; i < n; ++i) {
int a = sa[i], b = sa[i-1];
int a1 = (a + k < n) ? tmp[a + k] : -1;
int b1 = (b + k < n) ? tmp[b + k] : -1;
rk[a] = (tmp[a] == tmp[b] && a1 == b1) ? num - 1 : num++;
}
if (num == n) break;
m = num; // 更新字符集大小为当前排名数
}
}
三、最长公共前缀(LCP)数组:高度数组
LCP 数组是后缀数组的"黄金搭档",几乎所有后缀数组的应用都依赖 LCP 数组。
3.1 LCP 数组的定义与性质
- 定义 :
lcp[i]表示suffix(sa[i])和suffix(sa[i-1])的最长公共前缀长度((i \geq 1)),lcp[0] = 0。 - 核心性质(H 定理) :设
h[i] = lcp[rk[i]](表示suffix(i)和其前一个后缀的 LCP 长度),则:
h [ i ] ≥ h [ i − 1 ] − 1 h[i] \geq h[i-1] - 1 h[i]≥h[i−1]−1
该性质使得 LCP 数组可在 (O(n)) 时间内计算。
3.2 LCP 数组的构建(O(n) 算法)
cpp
// 构建LCP数组(需先构建sa和rk)
void buildLCP(const string& s, const vector<int>& sa, const vector<int>& rk, vector<int>& lcp) {
int n = s.size();
lcp.resize(n, 0);
int k = 0; // 当前LCP长度
for (int i = 0; i < n; ++i) {
if (rk[i] == 0) continue; // 排名第0的后缀无前驱
if (k > 0) k--; // 利用H定理优化
int j = sa[rk[i] - 1]; // 前驱后缀的起始位置
// 直接比较字符,扩展LCP长度
while (i + k < n && j + k < n && s[i + k] == s[j + k]) {
k++;
}
lcp[rk[i]] = k; // lcp[rk[i]] 对应 h[i]
}
}
3.3 完整示例(SA + LCP)
cpp
int main() {
string s = "ababc";
vector<int> sa, rk, lcp;
// 构建SA和RK
buildSA_Radix(s, sa, rk);
// 构建LCP
buildLCP(s, sa, rk, lcp);
cout << "字符串:" << s << endl;
cout << "sa: "; for (int x : sa) cout << x << " "; cout << endl; // 0 2 4 1 3
cout << "rk: "; for (int x : rk) cout << x << " "; cout << endl; // 0 3 1 4 2
cout << "lcp: "; for (int x : lcp) cout << x << " "; cout << endl; // 0 2 0 1 0
return 0;
}
四、后缀数组的核心应用
4.1 应用1:子串存在性查询(二分)
判断字符串 t 是否是 s 的子串,只需在 sa 数组中二分查找是否存在以 t 为前缀的后缀:
cpp
// 二分查找子串t是否存在于s中
bool findSubstring(const string& s, const vector<int>& sa, const string& t) {
int n = s.size(), m = t.size();
if (m > n) return false;
// 二分查找区间 [l, r]
int l = 0, r = n - 1;
while (l <= r) {
int mid = (l + r) / 2;
int pos = sa[mid];
// 比较 s[pos..pos+m-1] 和 t
int cmp = s.compare(pos, m, t);
if (cmp == 0) return true;
else if (cmp < 0) l = mid + 1;
else r = mid - 1;
}
return false;
}
4.2 应用2:最长重复子串
最长重复子串是 LCP 数组中的最大值(需保证两个后缀不重叠,可选):
cpp
// 查找最长重复子串的长度(可重叠)
int longestRepeatedSubstring(const vector<int>& lcp) {
int max_len = 0;
for (int x : lcp) {
max_len = max(max_len, x);
}
return max_len;
}
// 查找最长不重叠重复子串
int longestNonOverlapRepeated(const string& s, const vector<int>& sa, const vector<int>& lcp) {
int n = s.size(), max_len = 0;
for (int i = 1; i < n; ++i) {
if (lcp[i] <= max_len) continue;
// 检查两个后缀是否重叠
int a = sa[i], b = sa[i-1];
if (abs(a - b) >= lcp[i]) {
max_len = lcp[i];
}
}
return max_len;
}
4.3 应用3:统计不同子串的数量
字符串的所有不同子串数量 = 总子串数 - 重复子串数:
总不同子串数 = n ( n + 1 ) 2 − ∑ i = 1 n − 1 l c p [ i ] 总不同子串数 = \frac{n(n+1)}{2} - \sum_{i=1}^{n-1} lcp[i] 总不同子串数=2n(n+1)−i=1∑n−1lcp[i]
cpp
// 统计不同子串的数量
long long countDistinctSubstrings(int n, const vector<int>& lcp) {
long long total = (long long)n * (n + 1) / 2;
for (int i = 1; i < n; ++i) {
total -= lcp[i];
}
return total;
}
4.4 应用4:最长公共子串(两个字符串)
将两个字符串拼接为 s = s1 + '#' + s2,构建 SA 和 LCP 数组,找到跨 # 的最大 LCP:
cpp
// 查找s1和s2的最长公共子串长度
int longestCommonSubstring(const string& s1, const string& s2) {
string s = s1 + '#' + s2;
int n1 = s1.size(), n = s.size();
vector<int> sa, rk, lcp;
buildSA_Radix(s, sa, rk);
buildLCP(s, sa, rk, lcp);
int max_len = 0;
for (int i = 1; i < n; ++i) {
int a = sa[i], b = sa[i-1];
// 检查是否跨#(一个在s1,一个在s2)
bool a_in_s1 = (a < n1), b_in_s1 = (b < n1);
if (a_in_s1 != b_in_s1) {
max_len = max(max_len, lcp[i]);
}
}
return max_len;
}
五、常见错误与最佳实践
5.1 常见错误
- 字符集处理 :未考虑特殊字符(如拼接字符串时的
#),导致排序错误 → 确保拼接字符不在原字符串中。 - 边界条件 :计算 LCP 时未处理
rk[i] == 0的情况,导致数组越界 → 提前跳过排名为0的后缀。 - 倍增终止条件 :未检查
num == n,导致多余迭代 → 排名唯一时立即终止。 - LCP 与 h 数组混淆 :
lcp[i]对应sa[i],h[i]对应rk[i],混淆会导致结果错误。
5.2 最佳实践
- 排序优化 :小规模字符串用
sort实现(易调试),大规模用基数排序(效率高)。 - 字符集扩展 :若字符集包含中文/Unicode,先将字符串映射为整数数组(如
vector<int> s_int)。 - 空间优化 :sa/rk/lcp 均可用
vector<int>存储,预先reserve空间避免扩容。 - 调试技巧 :打印 sa/rk/lcp 数组,结合手动计算的结果验证(如示例
ababc)。
六、后缀数组与其他字符串结构的对比
| 数据结构 | 构建时间 | 空间 | 核心优势 | 适用场景 |
|---|---|---|---|---|
| 后缀数组 | (O(n \log n)) | (O(n)) | 通用,支持所有子串问题 | 多字符串比较、最长公共子串 |
| 后缀自动机 | (O(n)) | (O(n)) | 线性时间构建,查询高效 | 单字符串子串统计、重复子串 |
| AC 自动机 | (O(\sum len)) | (O(\sum len)) | 多模式串匹配 | 敏感词过滤、日志关键词提取 |
| KMP 算法 | (O(n + m)) | (O(m)) | 单模式串匹配 | 子串查找、最小循环节 |
选择建议:
- 单字符串子串统计:优先用 SAM(线性时间)。
- 多字符串比较(如最长公共子串):优先用后缀数组。
- 多模式串匹配:用 AC 自动机。
- 单模式串匹配:用 KMP。
七、总结
后缀数组是字符串处理的"万能工具",核心要点可总结为:
- 核心结构:sa(后缀起始位置排序)、rk(排名)、lcp(最长公共前缀),三者相辅相成。
- 构建方法:倍增法(易实现)+ 基数排序(高效),时间复杂度 (O(n \log n))。
- 核心性质:H 定理使得 LCP 数组可线性构建,是应用的关键。
- 应用场景:子串查询、最长重复子串、不同子串统计、最长公共子串等。
掌握后缀数组的关键:
- 理解倍增法的二元组排序逻辑(核心是"分阶段比较")。
- 区分 sa/rk/lcp 的定义与关系(互逆/关联)。
- 熟练运用 LCP 数组解决各类子串问题。
后缀数组是 C++ 算法竞赛的必考知识点,也是工业界处理大规模字符串的核心工具,结合后缀自动机可解决几乎所有字符串难题。