后缀数组 (Suffix Array) 完全指南
如果你只做 CRUD 业务,不需要后缀数组;但如果你从事基础设施、数据引擎或 AI Infra,它是核心武器。
在工程界,后缀数组(及其变体 FM-Index、BWT)主要活跃在以下领域:
-
大模型 (LLM) 数据清洗:
- 去重 (Deduplication):如何在 TB 级的 CommonCrawl 数据中发现并删除重复的段落?
- 防污染 (Decontamination):如何确保评估集的题目没有泄露到训练集中?
- 后缀数组支持高效的 "超长公共子串" 检测,是精确去重的基石(相比 MinHash 的模糊去重,它能提供精确边界)。
-
生物信息学 (Bioinformatics):
- 这是后缀数组最大的工业应用场景。
- DNA 序列(ATCG)本质上是超长字符串。基因组测序、序列比对 (Alignment) 极其依赖基于后缀数组的 FM-Index 技术(因为它极其节省内存,能在有限内存中塞进整个人类基因组索引)。
-
全文搜索与代码分析:
- 代码克隆检测 (Code Clone Detection):在 GitHub 规模的代码库中寻找复制粘贴的代码片段。
- 正则/子串索引 :Lucene 等倒排索引擅长"分词"检索,但对于日志、代码等需要 "任意子串检索" (Substring Search) 的场景,后缀数组是标准解法。
-
数据压缩:
- Linux 下的
bzip2压缩算法,核心就是 BWT (Burrows-Wheeler Transform),而 BWT 的构建依赖于后缀排序。
- Linux 下的
一、什么是后缀数组?
后缀数组的核心是将字符串的所有后缀进行 字典序排序。
假设字符串 S="banana"S = \text{"banana"}S="banana",它有 6 个后缀:
banana(index 1)anana(index 2)nana(index 3)ana(index 4)na(index 5)a(index 6)
1.1 三大核心数组
我们不直接存储这些字符串(太占空间),而是存它们的 起始下标。
-
SA 数组 (Suffix Array) :"排第 iii 名的是谁?"
- SA[1]=6SA[1] = 6SA[1]=6 ("a")
- SA[2]=4SA[2] = 4SA[2]=4 ("ana")
- SA[3]=2SA[3] = 2SA[3]=2 ("anana")
- SA[4]=1SA[4] = 1SA[4]=1 ("banana")
- ...
- 存储的是:排名 →\to→ 原串下标。
-
Rank 数组 :"你在原串里的位置排第几名?"
- Rank[6]=1Rank[6] = 1Rank[6]=1 (后缀 "a" 排第 1)
- Rank[1]=4Rank[1] = 4Rank[1]=4 (后缀 "banana" 排第 4)
- 性质:SA[Rank[i]]=iSA[Rank[i]] = iSA[Rank[i]]=i。
-
Height 数组 (LCP 数组) :"第 iii 名和第 i−1i-1i−1 名的后缀,它们的最长公共前缀 (LCP) 有多长?"
- 这是后缀数组的 灵魂。
- 它将字符串之间的"相似度"量化为了数字。
- 重要性质 :任意两个后缀 i,ji, ji,j 的 LCP,等于 HeightHeightHeight 数组在它们排名区间 [Rank[i]+1,Rank[j]][Rank[i]+1, Rank[j]][Rank[i]+1,Rank[j]] 上的 最小值 (RMQ)。
二、它能解决什么?(思维方式)
后缀数组的核心思维是:将字符串问题转化为 LCP 问题,再转化为 RMQ 问题。
2.1 模式串匹配 (O(MlogN)O(M \log N)O(MlogN))
问题 :判断字符串 PPP 是否在 SSS 中出现。
思考 :因为后缀是有序的,所有以 PPP 开头的后缀一定聚集在一起。
解法 :在 SASASA 数组上 二分查找。
2.2 最长公共前缀 (LCP)
问题 :求任意两个后缀的最长公共前缀。
解法 :配合 ST 表,查询 HeightHeightHeight 数组的区间最小值 (RMQ)。
2.3 最长重复子串
问题 :求 SSS 中出现至少两次的最长子串。
思考 :既然出现了两次,那它必然是某两个后缀的公共前缀。
解法 :HeightHeightHeight 数组里的最大值。就这么简单。
2.4 本质不同的子串个数
问题 :求 SSS 中有多少个不相同的子串。
思考:
- 每个后缀 iii 贡献了 Len(suffixi)Len(suffix_i)Len(suffixi) 个子串。
- 但其中有 Height[i]Height[i]Height[i] 个是和前一名重复的。
- 公式 :∑(Len(suffixi)−Height[i])\sum (Len(suffix_i) - Height[i])∑(Len(suffixi)−Height[i])。
2.5 出现至少 K 次的最长子串
解法 :在 HeightHeightHeight 数组中找连续 K−1K-1K−1 个数,使得它们的最小值最大。这可以用单调队列或二分答案解决。
三、怎么计算?(倍增法)
朴素排序是 O(N2logN)O(N^2 \log N)O(N2logN),太慢。
主流使用 倍增法 (Doubling Algorithm) ,复杂度 O(NlogN)O(N \log N)O(NlogN)。
(虽然有 O(N)O(N)O(N) 的 DC3 和 SA-IS 算法,但倍增法代码短、常数小,竞赛最常用)。
3.1 倍增思想:从抽象到具体
倍增法的核心逻辑是:利用"长度为 kkk 的排序结果",快速生成"长度为 2k2k2k 的排序结果"。
3.1.1 具体案例推导
假设 S="aabaaa"S = \text{"aabaaa"}S="aabaaa" (N=6N=6N=6)。
初始状态 (k=1k=1k=1) :
只看每个后缀的第 1 个字符。
a(index 1, 2, 4, 5, 6)b(index 3)- Rank 数组 :
[1, 1, 2, 1, 1, 1](注意:此时有大量重复排名)
第一轮倍增 (k=1→2k=1 \to 2k=1→2) :
我们要比较长度为 2 的子串。
- 对于后缀 iii,它的"前半段"排名是 Rankk[i]Rank_k[i]Rankk[i],"后半段"排名是 Rankk[i+k]Rank_k[i+k]Rankk[i+k]。
- Pair (第一关键字, 第二关键字) :
- Suffix 1 ("aa"): (1,1)(1, 1)(1,1)
- Suffix 2 ("ab"): (1,2)(1, 2)(1,2)
- Suffix 3 ("ba"): (2,1)(2, 1)(2,1)
- Suffix 4 ("aa"): (1,1)(1, 1)(1,1)
- Suffix 5 ("aa"): (1,1)(1, 1)(1,1)
- Suffix 6 ("a$"): (1,0)(1, 0)(1,0) (越界补 0)
- 排序后 Rank :
[2, 3, 4, 2, 2, 1](重复排名减少了)- 排第 1: Suffix 6 ("a")
- 排第 2: Suffix 1, 4, 5 ("aa")
- 排第 3: Suffix 2 ("ab")
- 排第 4: Suffix 3 ("ba")
第二轮倍增 (k=2→4k=2 \to 4k=2→4) :
比较长度为 4 的子串。
- Suffix 1 ("aaba"): Pair (Rank2[1],Rank2[3])=(2,4)(Rank_2[1], Rank_2[3]) = (2, 4)(Rank2[1],Rank2[3])=(2,4)
- Suffix 4 ("aaaa"): Pair (Rank2[4],Rank2[6])=(2,1)(Rank_2[4], Rank_2[6]) = (2, 1)(Rank2[4],Rank2[6])=(2,1)
- ...以此类推
- 排序后,所有 Rank 都互不相同,算法结束。
3.1.2 为什么和 Height 数组没关系?
关键误区澄清:
- SA 的构建过程(倍增排序)完全不需要 Height 数组。
- Height 数组是在 SA 构建完成后,单独计算的。
- 倍增排序只负责排出名次。排好名次后,我们再利用 Kasai 算法在 O(N)O(N)O(N) 时间内算出 Height 数组。
所以,流程是:原始字符串 →倍增法\xrightarrow{\text{倍增法}}倍增法 SA 数组 →Kasai 算法\xrightarrow{\text{Kasai 算法}}Kasai 算法 Height 数组。
3.2 Kasai 算法:从 Rank 到 Height 的直观推导
如果暴力计算每个后缀与其前一名的 LCP,复杂度是 O(N2)O(N^2)O(N2)。
Kasai 算法利用了一个惊人的性质,将复杂度降为 O(N)O(N)O(N)。
3.2.1 核心不等式
Height[Rank[i]]≥Height[Rank[i−1]]−1Height[Rank[i]] \ge Height[Rank[i-1]] - 1Height[Rank[i]]≥Height[Rank[i−1]]−1
这个公式看着吓人,翻译成人话就是:
"后缀 iii 和它前一名的相似度,至少比 后缀 i−1i-1i−1 和它前一名的相似度,少 1。"
3.2.2 直观证明(移位法)
假设 后缀 i−1i-1i−1 (记为 Si−1S_{i-1}Si−1) 排在第 kkk 名。
它的前一名是 后缀 ppp (记为 SpS_pSp),即 Rank[p]=k−1Rank[p] = k-1Rank[p]=k−1。
假设它们的 LCP 长度为 HHH。即 Si−1S_{i-1}Si−1 和 SpS_pSp 的前 HHH 个字符相同。
例如:
- Si−1=banana‾...S_{i-1} = \underline{\text{banana}}...Si−1=banana...
- Sp=bandana‾...S_p = \underline{\text{bandana}}...Sp=bandana...
- LCP=3LCP = 3LCP=3 ("ban")
现在我们来看 后缀 iii (SiS_iSi)。它是 Si−1S_{i-1}Si−1 去掉首字符得到的。
同理,后缀 p+1p+1p+1 (Sp+1S_{p+1}Sp+1) 是 SpS_pSp 去掉首字符得到的。
- Si=anana‾...S_i = \underline{\text{anana}}...Si=anana...
- Sp+1=andana‾...S_{p+1} = \underline{\text{andana}}...Sp+1=andana...
- 显而易见,SiS_iSi 和 Sp+1S_{p+1}Sp+1 的 LCP 至少是 3−1=23 - 1 = 23−1=2 ("an")。
关键点来了 :
在排序列表中,Sp+1S_{p+1}Sp+1 的排名一定在 SiS_iSi 之前(因为 "and..." < "ana...")。
虽然 Sp+1S_{p+1}Sp+1 未必 恰好是 SiS_iSi 的 紧邻前一名 (中间可能插了别的字符串),但根据 LCP 的性质(Min-Range 性质):
SiS_iSi 和它紧邻前一名的 LCP,一定 ≥Si\ge S_i≥Si 和更前面某位 (Sp+1S_{p+1}Sp+1) 的 LCP。
所以:Height[Rank[i]]≥LCP(Si,Sp+1)=Height[Rank[i−1]]−1Height[Rank[i]] \ge LCP(S_i, S_{p+1}) = Height[Rank[i-1]] - 1Height[Rank[i]]≥LCP(Si,Sp+1)=Height[Rank[i−1]]−1。
3.2.3 算法流程
利用这个性质,我们在计算 Height[Rank[i]]Height[Rank[i]]Height[Rank[i]] 时,不需要从头开始匹配,而是从 Height[Rank[i−1]]−1Height[Rank[i-1]] - 1Height[Rank[i−1]]−1 的长度开始继续往后匹配。
指针 kkk 最多减 NNN 次(每次 iii 增加时减 1),最多加 NNN 次(匹配成功时加 1),总复杂度 O(N)O(N)O(N)。
四、代码模板 (Java - 倍增法)
java
import java.util.Arrays;
public class SuffixArray {
String s;
int n;
int[] sa, rk, height;
public SuffixArray(String s) {
this.s = s;
this.n = s.length();
this.sa = new int[n + 1];
this.rk = new int[n + 1];
this.height = new int[n + 1];
buildSA();
buildHeight();
}
// === 核心算法:倍增法构建 SA (O(N log N)) ===
private void buildSA() {
int m = 128; // 桶的大小 (最大排名值)
int[] cnt = new int[Math.max(n + 1, m)];
int[] id = new int[n + 1]; // id[i]: 按第二关键字排好序的第 i 个后缀
int[] px = new int[n + 1]; // px[i]: 后缀 id[i] 的第一关键字排名
int[] oldRk = new int[n + 1];
// 1. 初始排序 (k=1):按单字符进行基数排序
for (int i = 1; i <= n; i++) { rk[i] = s.charAt(i - 1); cnt[rk[i]]++; }
for (int i = 1; i < m; i++) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; i--) sa[cnt[rk[i]]--] = i;
// 2. 倍增循环 (w: 当前比较长度的一半)
for (int w = 1; w < n; w <<= 1) {
int p = 0;
// --- A. 对第二关键字排序 (利用上一轮 SA 直接生成,O(N)) ---
// 规则:后缀 i 的第二关键字是 rk[i+w]。
// 技巧:直接按顺序收集。越界(i+w>n)的视作 0,最先入队。
for (int i = n; i > n - w; i--) id[++p] = i; // 越界部分
for (int i = 1; i <= n; i++) { // 有效部分
if (sa[i] > w) id[++p] = sa[i] - w;
}
// --- B. 对第一关键字排序 (基数排序,O(N)) ---
// 核心:利用基数排序的"稳定性",在第一关键字相同时,保持第二关键字的顺序(id数组)。
// (详细下标变换请参考代码下方的"算法流程可视化"部分)
Arrays.fill(cnt, 0);
for (int i = 1; i <= n; i++) px[i] = rk[id[i]]; // 获取第一关键字
for (int i = 1; i <= n; i++) cnt[px[i]]++;
for (int i = 1; i < m; i++) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; i--) sa[cnt[px[i]]--] = id[i]; // 逆序填充保证稳定性
// --- C. 更新 Rank 数组 (去重并重新排名) ---
System.arraycopy(rk, 0, oldRk, 0, n + 1);
p = 0;
for (int i = 1; i <= n; i++) {
// 若两个后缀的第一、第二关键字都相同,则排名相同
if (oldRk[sa[i]] == oldRk[sa[i - 1]] &&
oldRk[sa[i] + w] == oldRk[sa[i - 1] + w]) {
rk[sa[i]] = p;
} else {
rk[sa[i]] = ++p;
}
}
m = p + 1; // 下一轮桶大小 = 当前最大排名
if (p == n) break; // 所有后缀排名均不同,提前结束
}
}
// === 核心算法:Kasai 计算 Height (O(N)) ===
// 性质:h[rk[i]] >= h[rk[i-1]] - 1
private void buildHeight() {
int k = 0;
// 必须按原串下标顺序遍历,以利用继承性质
for (int i = 1; i <= n; i++) {
if (rk[i] == 1) continue; // 第一名没有前驱
if (k > 0) k--; // 核心:去头继承,从 k-1 开始匹配
int j = sa[rk[i] - 1]; // 获取排名前一位的后缀
while (i + k <= n && j + k <= n &&
s.charAt(i + k - 1) == s.charAt(j + k - 1)) {
k++;
}
height[rk[i]] = k;
}
}
}
算法流程可视化 (Trace)
为了理解 id, px, sa 等数组下标的变换,我们以字符串 s = "aaba" (n=4) 为例,演示 w=1 这一轮的倍增过程。
1. 初始状态 (w=1 开始前)
假设初始 rk (按单字符排序): [1, 1, 2, 1] (分别对应 'a','a','b','a')。
此时 sa 为 [1, 2, 4, 3] (下标 1,2,4 都是 'a',保持原序; 3 是 'b')。
2. 构建 id 数组 (按第二关键字排序)
目标:我们希望对二元组 (rk[i], rk[i+1]) 排序。首先按第二关键字 rk[i+1] 对后缀进行排序,结果存入 id。
i > n-w的后缀 (后缀 4): 第二关键字为 0 (最小),最先入队 ->id[1] = 4。- 其他后缀: 遍历
sa,如果sa[i] > 1,说明sa[i]可以作为某个后缀sa[i]-1的第二关键字部分。sa[1]=1: 不大于 1,跳过。sa[2]=2:id[2] = 2-1 = 1。 (后缀 1 的第二关键字是后缀 2)sa[3]=4:id[3] = 4-1 = 3。 (后缀 3 的第二关键字是后缀 4)sa[4]=3:id[4] = 3-1 = 2。 (后缀 2 的第二关键字是后缀 3)
- 结果 :
id = [4, 1, 3, 2]。这就是按第二关键字排好序的后缀下标。
3. 基数排序 (按第一关键字排序)
现在利用 cnt 数组对第一关键字 px[i] = rk[id[i]] 进行稳定排序。
id:[4, 1, 3, 2]px(对应rk值):[rk[4], rk[1], rk[3], rk[2]]=[1, 1, 2, 1]cnt统计:1出现 3 次,2出现 1 次。前缀和后cnt[1]=3,cnt[2]=4。- 倒序填充 sa :
i=4:id[4]=2,key=1。sa[3] = 2,cnt[1]=2。i=3:id[3]=3,key=2。sa[4] = 3,cnt[2]=3。i=2:id[2]=1,key=1。sa[2] = 1,cnt[1]=1。i=1:id[1]=4,key=1。sa[1] = 4,cnt[1]=0。
- 结果 :
sa = [4, 1, 2, 3]。此时后缀已按前 2 个字符(key1, key2)排好序。
五、总结
学习后缀数组,本质上是在学习 "如何把复杂的字符串相似度问题,降维成数组上的数值比较问题"。
它为你打开了通往高级字符串算法的大门。
- 如果你想做 多模式匹配,学 AC 自动机。
- 如果你想做 回文串,学 Manacher 或回文树。
- 但如果你想解决 "子串统计"、"最长公共部分"、"字典序" 相关的问题,后缀数组是当之无愧的王者。