定义
后缀数组(Suffix Array)是对一个字符串的所有后缀按字典序排序后形成的数组。设字符串 长度为 ,则后缀数组 是一个长度为 的排列,满足:
其中 表示字符串 从位置 到 的子串(包含两端)。
示例
以字符串 为例:
| 后缀 | 起始位置 | 后缀内容 |
|---|---|---|
| 0 | ”abaab” | |
| 1 | ”baab” | |
| 2 | ”aab” | |
| 3 | ”ab” | |
| 4 | ”b” |
按字典序排序后:
| 排名 | 起始位置 | 后缀内容 |
|---|---|---|
| 0 | 2 | ”aab” |
| 1 | 3 | ”ab” |
| 2 | 0 | ”abaab” |
| 3 | 4 | ”b” |
| 4 | 1 | ”baab” |
因此该字符串的后缀数组为 。
后缀数组是处理字符串问题的有力工具,常与 KMP算法 和 字符串哈希 结合使用。
构造算法
朴素方法
最直接的做法是生成所有后缀,对它们直接排序。每个后缀长度为 ,比较两个后缀需要 时间,排序有 次比较,因此总时间复杂度为 。这种方法在小规模数据外不可接受。
倍增法
倍增法(Doubling Method)是构造后缀数组最经典的算法,其核心思想是利用已经计算过的信息来加速排序。
算法思想
对于每个位置 ,我们同时考虑以 开头、长度分别为 的前缀。设 为当前考虑的前缀长度,初始时 。
对每个位置 ,定义一个二元组:
当 较小时,我们可以使用基数排序对这个二元组进行排序。随着 不断倍增,当 时,所有后缀都可以完全区分,此时即得到完整的后缀数组。
算法步骤
设字符串 长度为 ,下标从 开始。
- 初始化:对每个字符进行排序,得到每个字符的排名;
- 迭代:对于 直到 :
- 按照 进行基数排序,得到新的排名 ;
- 如果所有排名均不同,则结束;
- 更新 。
正确性证明(简要)
我们证明算法结束时得到的 即为后缀的最终排名。
引理:当 足够大时(),每个后缀 可以唯一确定。
证明:此时每个后缀的前缀长度至少为 ,足以覆盖整个后缀,不同后缀必然不同。∎
引理:在第 轮迭代中,如果两个后缀 和 在 长度内的前缀不同,则 。
证明: 取决于二元组 。由于 已经区分了所有长度小于 的前缀,不同的前缀必然导致至少一个关键字不同,从而二元组不同,排名不同。∎
定理:倍增法最终得到的后缀数组是正确的。
证明:随着 不断倍增,存在某个 使得所有后缀在长度 内均可区分。根据引理,此后所有迭代的排名不再变化。由于最终排名与字典序一致(由基数排序保证),故算法正确。∎
C++ 实现
#include <bits/stdc++.h>
using namespace std;
// 后缀数组倍增法 O(n log n)
vector<int> buildSA(const string& s) {
int n = s.size();
vector<int> sa(n), rank(n), tmp(n);
// 初始化:单字符排序
for (int i = 0; i < n; i++) {
sa[i] = i;
rank[i] = s[i];
}
// 倍增
for (int k = 1; k < n; k <<= 1) {
// 按第二关键字排序(使用 tmp 作为临时数组)
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);
// 计算新的排名
tmp[sa[0]] = 0;
for (int i = 1; i < n; i++) {
tmp[sa[i]] = tmp[sa[i-1]] + (cmp(sa[i-1], sa[i]) ? 1 : 0);
}
for (int i = 0; i < n; i++) rank[i] = tmp[i];
// 如果所有排名均不同,则结束
if (rank[sa[n-1]] == n - 1) break;
}
return sa;
}上述实现中,我们使用 sort 进行排序,时间复杂度为 。为了达到严格的 ,可以使用基数排序代替 sort,但实现复杂度稍高。在竞赛中,上述实现通常足够高效。
LCP数组与Kasai算法
LCP数组的定义
最长公共前缀数组(Longest Common Prefix Array,记为 )存储了后缀数组中相邻后缀的最长公共前缀长度:
其中 表示字符串 与 的最长公共前缀长度。
Kasai算法
Kasai算法可以在 时间内由原字符串计算出 数组,无需显式构建后缀数组。
算法思想
考虑 与 之间的关系。设 ,则相邻后缀 与 有长度为 的公共前缀。
令 ,,不妨设 (否则交换)。则 ,且 (或已到达字符串末尾)。
考虑后缀 与 。它们分别是 与 去掉第一个字符后的结果。关键观察是:这两个后缀的公共前缀长度至少为 (除非 )。
这意味着如果我们按原字符串位置从左到右遍历,可以利用已计算的 值来加速后续计算。
C++ 实现
#include <bits/stdc++.h>
using namespace std;
// Kasai算法 O(n) 计算LCP数组
vector<int> buildLCP(const string& s, const vector<int>& sa) {
int n = s.size();
vector<int> rank(n), lcp(n);
// rank[sa[i]] = i,即后缀i在排序后的排名
for (int i = 0; i < n; i++) {
rank[sa[i]] = i;
}
// h = LCP(i, i-1) 的初始值
int h = 0;
for (int i = 0; i < n; i++) {
int r = rank[i];
if (r > 0) {
// 上一轮计算的 h
int j = sa[r - 1];
while (i + h < n && j + h < n && s[i + h] == s[j + h]) {
h++;
}
lcp[r] = h;
// h 至少减少1(如果 h > 0)
if (h > 0) h--;
} else {
lcp[r] = 0;
}
}
return lcp;
}该算法的时间复杂度分析:指针 从 到 单调递增,而 的值在每次循环中最多增加 、减少 ,因此总操作次数为 。
应用
子串查找
给定模式串 和文本串 ,如何判断 是否出现在 中?
一种预处理 的后缀数组的方法:首先对 构建后缀数组和 数组;然后通过二分查找在 中寻找以 开头的后缀。二分查找时,利用 数组快速比较 与 对应后缀的公共前缀长度。
时间复杂度为 ,适合多模式匹配场景。
比较任意两个子串
设需要比较 与 的字典序大小( 为闭区间)。设这两个子串分别是后缀 和 的前缀。
比较过程:
- 找到 和 在后缀数组中的排名 和 ;
- 假设 ,则子串 与 的字典序关系取决于 (即两后缀的公共前缀长度)与子串长度的关系:
- 若 ,则较短子串字典序更小;
- 否则,比较第一个不同字符即可。
利用 (区间最小值查询)在 时间内回答 查询,整体比较可在 (预处理后)完成。
不同子串计数
统计一个字符串中不同子串的数量,是后缀数组的经典应用。
公式推导:
字符串 的所有子串可以表示为所有后缀的前缀。设 ,后缀数组为 。
考虑排名第 的后缀 ,它贡献的新子串数量为:
其中 是该后缀的所有前缀数量,减去 个与前一个后缀重复的前缀。
因此,不同子串的总数为:
该公式利用了 的性质(虽然直接计算更直观)。
其他应用
- 最长重复子串:在后缀数组中找到 值最大的相邻后缀;
- 最长回文子串:将字符串与其反转拼接,利用后缀数组求解;
- 字符串压缩:利用不同子串数量评估字符串的压缩潜力。