1993 年,Udi Manber 和 Gene Myers 在 SODA 会议上发表了一篇不到 10 页的论文,提出了后缀数组(Suffix Array)这一数据结构。他们的动机很简单:后缀树太占内存了。这个看似”降级”的方案,在之后的三十年里彻底改变了字符串处理的实践格局——从基因组比对到全文搜索引擎,后缀数组无处不在。
本文将从后缀树的内存困境出发,完整推导后缀数组的定义、线性时间构造算法(SA-IS)、LCP 数组、模式搜索,以及增强后缀数组如何在功能上完全替代后缀树。文末给出一份可编译运行的 C 实现,并讨论工程实践中的若干陷阱。
一、后缀树回顾:优雅但昂贵
后缀树(Suffix Tree)是字符串算法领域最强大的数据结构之一。对于长度为 n 的字符串 S,后缀树将 S 的所有后缀组织成一棵压缩字典树(Patricia Trie),支持 O(m) 时间的模式匹配(m 为模式串长度),还能在 O(n) 时间内求解最长重复子串、最长公共子串等经典问题。
Ukkonen 在 1995 年给出了在线线性时间构造算法,后缀树看似完美。然而,完美的理论掩盖了一个致命的实践问题:内存开销。
后缀树的空间分析
一棵后缀树最多有 n 个叶节点和 n-1 个内部节点。每个节点至少需要存储:
- 父节点指针(8 字节)
- 子节点指针(哈希表或数组,平均 20-40 字节)
- 边标签的起止位置(2 x 4 字节)
- 后缀链接指针(8 字节)
即使做了极致优化,每个字符平均也需要 20 字节以上 的内存。Dan Gusfield 在其经典教材中指出,实际实现中后缀树的内存系数通常在 20-40 倍之间。这意味着一个 3GB 的人类基因组序列需要 60-120GB 内存来构建后缀树。
内存对比(n = 10^9,即 1GB 文本):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
数据结构 内存(约) 倍率
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
原始文本 1 GB 1x
后缀数组 4 GB 4x
后缀数组 + LCP 8 GB 8x
后缀树 20-40 GB 20-40x
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
这个差距在大规模文本处理中是决定性的。当你的服务器只有 64GB 内存时,后缀树可能完全无法使用,而后缀数组加 LCP 只需要 8GB——这正是 Manber 和 Myers 提出后缀数组的核心动机。
后缀树的缓存不友好性
除了绝对内存用量,后缀树还有一个更隐蔽的问题:缓存局部性差。后缀树是指针密集的树结构,节点在内存中的位置几乎是随机的。每次沿着树边遍历都可能导致缓存未命中(cache miss)。在现代硬件上,一次 L3 缓存未命中的延迟约为 40-60 个时钟周期,而一次主存访问更是高达 200+ 个时钟周期。
后缀数组是一个紧凑的整数数组,天然具有优秀的缓存局部性。这使得后缀数组在实际运行速度上往往比后缀树更快,即使二者的渐近复杂度相同。
二、后缀数组的定义
给定长度为 n 的字符串 S[0..n-1],我们在末尾附加一个哨兵字符 $(其字典序小于 S 中所有字符),得到 S[0..n]。后缀数组 SA 是一个长度为 n+1 的整数数组,其中 SA[i] 表示所有后缀按字典序排序后,排名第 i 的后缀在原字符串中的起始位置。
形式化地:
SA 是 {0, 1, ..., n} 的一个排列,使得:
S[SA[0]..n] < S[SA[1]..n] < ... < S[SA[n]..n]
以 S = “banana$” 为例:
所有后缀:
0: banana$
1: anana$
2: nana$
3: ana$
4: na$
5: a$
6: $
按字典序排序后:
SA[0] = 6 → $
SA[1] = 5 → a$
SA[2] = 3 → ana$
SA[3] = 1 → anana$
SA[4] = 0 → banana$
SA[5] = 4 → na$
SA[6] = 2 → nana$
后缀数组的存储极其简单:仅需一个整数数组。如果 n < 2^31,每个元素用 4 字节的 int32 即可,总内存为 4n 字节。与后缀树的 20n-40n 字节相比,这是数量级的改善。
逆后缀数组
与 SA 密切相关的还有逆后缀数组(Inverse Suffix Array,简称 ISA 或 Rank 数组)。ISA[i] 表示后缀 S[i..n] 在字典序中的排名:
ISA[SA[i]] = i,即 SA 和 ISA 互为逆排列。
ISA 在构造算法和 LCP 计算中都扮演关键角色。
三、朴素构造与倍增算法
O(n^2 log n) 朴素方法
最直接的方法是将所有后缀看作字符串,直接排序。排序使用 O(n log n) 次比较,每次比较需要 O(n) 时间比对两个后缀,总时间复杂度 O(n^2 log n)。这在实际中基本不可用。
Karp-Miller-Rosenberg 倍增法:O(n log^2 n)
1989 年之前,最经典的改进是倍增法(Prefix Doubling)。其核心思想是:
- 首先按每个后缀的第一个字符排序,得到初始排名 rank_1[i];
- 利用 rank_1 按前 2 个字符排序:将后缀 i 的排序键设为 (rank_1[i], rank_1[i+1]),排序后得到 rank_2[i];
- 利用 rank_2 按前 4 个字符排序:键为 (rank_2[i], rank_2[i+2]);
- 一般地,第 k 轮用键 (rank_{k-1}[i], rank_{k-1}[i + 2^{k-1}]) 排序;
- 当所有排名互不相同时停止,此时 rank 就是 ISA。
每轮排序需 O(n log n),共 O(log n) 轮,总复杂度 O(n log^2 n)。若用基数排序替换比较排序,可降至 O(n log n)。
// 倍增法伪代码(O(n log n) 版本,使用基数排序)
void build_sa_doubling(const char *s, int n, int *sa) {
int *rank = malloc(n * sizeof(int));
int *tmp = malloc(n * sizeof(int));
// 初始排名:按单字符
for (int i = 0; i < n; i++) {
sa[i] = i;
rank[i] = s[i];
}
for (int k = 1; k < n; k <<= 1) {
// 按 (rank[i], rank[i+k]) 基数排序
radix_sort(sa, rank, n, k);
// 更新排名
tmp[sa[0]] = 0;
for (int i = 1; i < n; i++) {
tmp[sa[i]] = tmp[sa[i-1]];
if (rank[sa[i]] != rank[sa[i-1]] ||
rank[sa[i]+k] != rank[sa[i-1]+k])
tmp[sa[i]]++;
}
memcpy(rank, tmp, n * sizeof(int));
if (rank[sa[n-1]] == n - 1) break; // 全部区分
}
free(rank); free(tmp);
}倍增法实现简单、常数小,是竞赛选手的常用选择。但在工业场景中,O(n log n) 对于 GB 级文本仍然不够快。我们需要线性时间算法。
四、SA-IS 算法:O(n) 构造
2009 年,Ge Nong、Sen Zhang 和 Wai Hong Chan 提出了 SA-IS(Suffix Array by Induced Sorting)算法。它是目前已知最简洁、最实用的线性时间后缀数组构造算法,代码量远小于 DC3/Skew 算法,且在实际表现中通常更快。
核心概念:S 型与 L 型后缀
SA-IS 的第一步是将每个后缀分类为 S 型(Smaller)或 L 型(Larger):
type[i] = S,若 S[i..n] < S[i+1..n](即后缀 i 小于后缀 i+1)
type[i] = L,若 S[i..n] > S[i+1..n]
type[n] = S(哨兵 $ 是最小的,定义为 S 型)
实际判断不需要比较整个后缀。由于相邻后缀共享前缀,只需看第一个不同的字符:
若 S[i] < S[i+1],则 type[i] = S
若 S[i] > S[i+1],则 type[i] = L
若 S[i] = S[i+1],则 type[i] = type[i+1]
因此类型数组可以从右向左在 O(n) 时间内计算。
LMS 后缀
定义 LMS(Left-Most S-type)后缀:位置 i 是 LMS 当且仅当 type[i] = S 且 type[i-1] = L(即 S 型后缀中最左边的那些——从 L 型转为 S 型的转折点)。LMS 后缀的数量不超过 n/2。
LMS 后缀是 SA-IS 的递归分解点。算法的宏观思路是:
- 对 LMS 后缀排序(通过递归);
- 利用 LMS 后缀的排序结果,通过”诱导排序”(Induced Sorting)推导出所有后缀的顺序。
诱导排序(Induced Sorting)
诱导排序是 SA-IS 的核心技巧。它基于一个关键观察:
如果后缀 SA[i] 已经在正确位置,且 SA[i]-1 是 L 型后缀,那么后缀 SA[i]-1 的位置可以被”诱导”确定。
这是因为 L 型后缀 j 满足 S[j] >= S[j+1],而我们已经知道后缀 j+1 的排名。所有以相同字符 c 开头的 L 型后缀,其相对顺序与其”尾巴”(即后缀 j+1)的顺序一致。
具体步骤:
步骤 1:将 LMS 后缀放入 SA 的大致位置(按首字符放入对应桶的末尾)
步骤 2:从左到右扫描 SA,诱导所有 L 型后缀的位置
步骤 3:从右到左扫描 SA,诱导所有 S 型后缀的位置
每一步都只需 O(n) 时间,且不需要实际比较字符串。
完整算法流程
SA-IS(S, n):
1. 计算类型数组 type[0..n]
2. 找出所有 LMS 位置
3. 将 LMS 后缀放入桶尾,诱导排序得到 SA 的初步结果
4. 从初步结果中提取 LMS 后缀的相对顺序
5. 比较相邻 LMS 子串是否相同,为 LMS 后缀分配新的字符编码
6. 如果存在重复编码:
递归调用 SA-IS 对缩减后的字符串排序
否则:
LMS 后缀的顺序已确定
7. 利用 LMS 后缀的正确顺序,再次诱导排序得到完整 SA
复杂度分析
每次递归,问题规模至少减半(LMS 后缀不超过 n/2 个),因此:
T(n) = T(n/2) + O(n) = O(n)
空间上,SA-IS 只需要类型数组(可用位向量压缩到 n/8 字节)和一些辅助数组,总额外空间为 O(n)。
五、LCP 数组与 Kasai 算法
LCP 数组的定义
LCP(Longest Common Prefix)数组定义为:
LCP[i] = lcp(S[SA[i-1]..n], S[SA[i]..n]),对于 i >= 1
LCP[0] = 0 或未定义(通常设为 0 或 -1)
即 LCP[i] 是后缀数组中相邻两个后缀的最长公共前缀长度。
LCP 数组与后缀数组配合,几乎可以完成后缀树能做的一切。我们稍后会看到,SA + LCP 构成的”增强后缀数组”在理论和实践中都可以完全替代后缀树。
Kasai 算法:O(n) 构造 LCP
1999 年,Kasai 等人提出了一个极其巧妙的 O(n) 时间 LCP 构造算法。其核心观察是:
如果后缀 S[i..] 与其在 SA 中的前驱的 LCP 长度为 h,那么后缀 S[i+1..] 与其前驱的 LCP 长度至少为 h-1。
直觉理解:后缀 S[i..] 和它的”邻居”共享 h 个前缀字符。去掉第一个字符后,后缀 S[i+1..] 和对应的邻居仍然至少共享 h-1 个字符(虽然邻居可能改变了,但新邻居的公共前缀不会更短太多)。
// Kasai 算法
void build_lcp(const char *s, int n, const int *sa, int *lcp) {
int *rank = malloc(n * sizeof(int));
for (int i = 0; i < n; i++)
rank[sa[i]] = i;
int h = 0;
for (int i = 0; i < n; i++) {
if (rank[i] > 0) {
int j = sa[rank[i] - 1];
while (s[i + h] == s[j + h])
h++;
lcp[rank[i]] = h;
if (h > 0) h--;
} else {
lcp[rank[i]] = 0;
h = 0;
}
}
free(rank);
}复杂度证明
h 在整个过程中最多增加 n 次(每次比较成功 h 加 1,而比较成功的总次数不超过 n——因为我们遍历了 n 个后缀,每个后缀最多贡献一次”新的”匹配字符)。h 每步最多减少 1,因此 h 的总减少量也不超过 n。所以 while 循环的总执行次数为 O(n),整个算法为 O(n)。
LCP 数组的信息量
LCP 数组虽然只记录了相邻后缀的公共前缀长度,但通过 RMQ(Range Minimum Query),我们可以在 O(1) 时间内查询任意两个后缀的 LCP:
lcp(SA[i], SA[j]) = min(LCP[i+1], LCP[i+2], ..., LCP[j]),其中 i < j
这是因为在字典序排列中,任意两个后缀的 LCP 等于它们之间所有相邻 LCP 值的最小值。对 LCP 数组建立稀疏表(Sparse Table),预处理 O(n log n),此后每次查询 O(1)。
六、模式搜索:O(m log n) 与优化
基础二分搜索
后缀数组天然支持二分搜索。给定模式串 P[0..m-1],我们要找到 SA 中所有以 P 为前缀的后缀。由于 SA 按字典序排列,这些后缀构成一个连续区间 [lo, hi]。
// 查找模式串 P 在后缀数组中的匹配区间 [lo, hi)
int sa_search(const char *s, int n, const int *sa,
const char *p, int m, int *lo, int *hi) {
// 找左边界
int left = 0, right = n;
while (left < right) {
int mid = (left + right) / 2;
if (strncmp(s + sa[mid], p, m) < 0)
left = mid + 1;
else
right = mid;
}
*lo = left;
// 找右边界
right = n;
while (left < right) {
int mid = (left + right) / 2;
if (strncmp(s + sa[mid], p, m) <= 0)
left = mid + 1;
else
right = mid;
}
*hi = left;
return *hi - *lo; // 匹配数量
}每次二分比较需要 O(m) 时间(比对模式串与后缀的前 m 个字符),共 O(log n) 次比较,总复杂度 O(m log n)。
Manber-Myers 优化:利用已知 LCP
朴素二分搜索的问题在于:每次比较都从头开始比对 m 个字符。但实际上,如果我们在二分过程中记住已经匹配了多少字符,就能避免重复比较。
Manber 和 Myers 的方法是在二分搜索中维护两个值:
- L = 模式串 P 与 SA[lo] 后缀的已知 LCP 长度
- R = 模式串 P 与 SA[hi] 后缀的已知 LCP 长度
当考察 mid 位置时,如果我们预先知道 lcp(SA[lo], SA[mid]) 的值(从 LCP 数组的 RMQ 中获取),就可以跳过 min(L, lcp(SA[lo], SA[mid])) 个字符。在最好情况下,这将复杂度降至 O(m + log n)。
Manber-Myers 搜索步骤:
维护 lo, hi, L = lcp(P, SA[lo]), R = lcp(P, SA[hi])
对每个 mid = (lo + hi) / 2:
M = lcp(SA[lo], SA[mid]) // 通过 RMQ 在 O(1) 时间获取
若 M > L:
P 与 SA[mid] 的 LCP 至少为 L,从位置 L 继续比较
若 M < L:
P 在 SA[mid] 之前,hi = mid, R = M
若 M = L:
从位置 L 开始逐字符比较
实际考量
在实践中,简单的二分搜索配合 strncmp 往往就足够好了。原因是:
- 对于短模式串(m < 100),O(m log n) 和 O(m + log n) 差别不大;
- 现代 CPU 的 SIMD 指令使得 strncmp 极快(memcmp 在 AVX2 下每周期可比较 32 字节);
- Manber-Myers 优化引入了额外的 RMQ 查询开销和代码复杂度。
因此在工程实现中,应该先用简单方案,只在 profiling 确认搜索是瓶颈后才考虑 LCP 加速。
七、增强后缀数组:完全替代后缀树
2004 年,Abouelhoda、Kurtz 和 Ohlebusch 发表了一篇里程碑式的论文:“Replacing suffix trees with enhanced suffix arrays”。他们证明了:后缀数组配合 LCP 数组和少量辅助数据结构,可以在相同的时间复杂度内模拟后缀树的所有操作。
LCP 区间树
增强后缀数组的核心概念是 LCP 区间(lcp-interval)。定义 [i, j] 是一个 lcp 值为 k 的 LCP 区间,当且仅当:
1. LCP[i] < k(左边界)
2. LCP[j+1] < k(右边界,若 j+1 <= n)
3. 对于所有 i < p <= j,LCP[p] >= k
4. 存在某个 i < p <= j,使得 LCP[p] = k
直觉上,一个 LCP 区间对应后缀树中的一个内部节点。区间 [i, j] 中的后缀共享长度为 k 的公共前缀,这恰好对应后缀树中从根到某个内部节点的路径标签。
LCP 区间之间要么互相包含(父子关系),要么完全不相交。所有 LCP 区间构成一棵树,称为 LCP 区间树(lcp-interval tree),它与后缀树是同构的。
后缀树操作的等价实现
| 后缀树操作 | 增强后缀数组实现 | 时间复杂度 |
|---|---|---|
| 子节点遍历 | LCP 区间的子区间枚举 | O(子节点数) |
| 后缀链接 | Psi 数组或 ISA + SA 查表 | O(1) |
| 自顶向下遍历 | 用栈模拟 LCP 区间的 DFS | O(n) |
| 自底向上遍历 | 从左到右扫描 LCP 数组 | O(n) |
| 模式匹配 | 二分搜索 + LCP-RMQ | O(m + log n) |
| 最长重复子串 | max(LCP[i]) | O(n) |
| 最长公共子串 | 拼接 + LCP 数组 | O(n) |
自底向上遍历
许多后缀树算法本质上是对后缀树的自底向上遍历。在增强后缀数组中,这等价于从左到右扫描 LCP 数组,用一个栈维护当前”活跃”的 LCP 区间:
// 自底向上遍历 LCP 区间树
void bottom_up_traverse(const int *lcp, int n,
void (*process)(int lb, int rb, int depth)) {
typedef struct { int lb, depth; } Interval;
Interval stack[MAX_STACK];
int top = 0;
stack[top++] = (Interval){0, 0};
for (int i = 1; i <= n; i++) {
int lb = i;
while (top > 0 && stack[top-1].depth > lcp[i]) {
top--;
Interval cur = stack[top];
// 处理区间 [cur.lb, i-1],深度 cur.depth
process(cur.lb, i - 1, cur.depth);
lb = cur.lb;
}
if (top == 0 || stack[top-1].depth < lcp[i]) {
stack[top++] = (Interval){lb, lcp[i]};
}
}
}这段代码的运行时间严格为 O(n)——每个 LCP 区间恰好入栈和出栈一次。
实际意义
增强后缀数组的意义不仅是理论上的。在实践中,它意味着我们可以彻底放弃后缀树的实现,转而使用内存更少、缓存更友好的后缀数组 + LCP 组合。所有需要后缀树的算法题和工程问题,都可以用增强后缀数组来解决——这正是本文标题所说的”更实用的选择”。
八、完整 C 实现:SA-IS + LCP
以下是一份完整的、可编译运行的 SA-IS 实现,包含 LCP 数组构造。代码约 250 行,支持 ASCII 字符集。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
/* ---------- SA-IS 算法 ---------- */
// 获取桶的边界(起始或结束位置)
static void get_buckets(const int *s, int n, int *bkt,
int alpha_size, bool end) {
memset(bkt, 0, alpha_size * sizeof(int));
for (int i = 0; i < n; i++)
bkt[s[i]]++;
int sum = 0;
for (int i = 0; i < alpha_size; i++) {
sum += bkt[i];
bkt[i] = end ? sum - 1 : sum - bkt[i];
}
}
// 诱导 L 型后缀
static void induce_L(const int *s, int n, int *sa,
const bool *type, int *bkt, int alpha_size) {
get_buckets(s, n, bkt, alpha_size, false);
for (int i = 0; i < n; i++) {
if (sa[i] > 0 && !type[sa[i] - 1]) {
sa[bkt[s[sa[i] - 1]]++] = sa[i] - 1;
}
}
}
// 诱导 S 型后缀
static void induce_S(const int *s, int n, int *sa,
const bool *type, int *bkt, int alpha_size) {
get_buckets(s, n, bkt, alpha_size, true);
for (int i = n - 1; i >= 0; i--) {
if (sa[i] > 0 && type[sa[i] - 1]) {
sa[bkt[s[sa[i] - 1]]--] = sa[i] - 1;
}
}
}
// 比较两个 LMS 子串是否相同
static bool lms_equal(const int *s, int n,
const bool *type, int a, int b) {
if (a < 0 || b < 0) return false;
for (int i = 0; ; i++) {
bool a_lms = (i > 0 && type[a + i] && !type[a + i - 1]);
bool b_lms = (i > 0 && type[b + i] && !type[b + i - 1]);
if (a_lms != b_lms || s[a + i] != s[b + i] ||
type[a + i] != type[b + i])
return false;
if (i > 0 && (a_lms || b_lms))
return true;
}
}
// SA-IS 主函数(递归实现)
static void sa_is_impl(const int *s, int n,
int *sa, int alpha_size) {
// type: true = S 型, false = L 型
bool *type = malloc(n * sizeof(bool));
type[n - 1] = true; // 哨兵
for (int i = n - 2; i >= 0; i--) {
if (s[i] < s[i + 1])
type[i] = true;
else if (s[i] > s[i + 1])
type[i] = false;
else
type[i] = type[i + 1];
}
// 收集 LMS 位置
int *lms = malloc(n * sizeof(int));
int lms_count = 0;
for (int i = 1; i < n; i++) {
if (type[i] && !type[i - 1])
lms[lms_count++] = i;
}
int *bkt = malloc(alpha_size * sizeof(int));
// 步骤 1:将 LMS 后缀放入桶尾
memset(sa, -1, n * sizeof(int));
get_buckets(s, n, bkt, alpha_size, true);
for (int i = lms_count - 1; i >= 0; i--)
sa[bkt[s[lms[i]]]--] = lms[i];
// 步骤 2-3:诱导排序
induce_L(s, n, sa, type, bkt, alpha_size);
induce_S(s, n, sa, type, bkt, alpha_size);
// 步骤 4:为 LMS 子串编号
int *names = malloc(n * sizeof(int));
memset(names, -1, n * sizeof(int));
int name = 0;
int prev = -1;
for (int i = 0; i < n; i++) {
if (sa[i] > 0 && type[sa[i]] && !type[sa[i] - 1]) {
if (!lms_equal(s, n, type, prev, sa[i]))
name++;
names[sa[i]] = name;
prev = sa[i];
}
}
// 步骤 5:构造缩减字符串
int *s1 = malloc(lms_count * sizeof(int));
int *lms_sorted = malloc(lms_count * sizeof(int));
int j = 0;
for (int i = 0; i < n; i++) {
if (names[i] >= 0)
s1[j++] = names[i];
}
// 步骤 6:递归或直接构造
int *sa1 = malloc(lms_count * sizeof(int));
if (name + 1 < lms_count) {
sa_is_impl(s1, lms_count, sa1, name + 1);
} else {
for (int i = 0; i < lms_count; i++)
sa1[s1[i]] = i;
}
// 将 sa1 映射回原始 LMS 位置
for (int i = 0; i < lms_count; i++)
lms_sorted[i] = lms[sa1[i]];
// 步骤 7:用正确的 LMS 顺序重新诱导排序
memset(sa, -1, n * sizeof(int));
get_buckets(s, n, bkt, alpha_size, true);
for (int i = lms_count - 1; i >= 0; i--)
sa[bkt[s[lms_sorted[i]]]--] = lms_sorted[i];
induce_L(s, n, sa, type, bkt, alpha_size);
induce_S(s, n, sa, type, bkt, alpha_size);
free(sa1); free(s1); free(lms_sorted);
free(names); free(bkt); free(lms); free(type);
}
// 外部接口:构造后缀数组
void build_suffix_array(const char *text, int n, int *sa) {
// 将字符串转为整数数组(+1 避免 0 与哨兵混淆)
int *s = malloc((n + 1) * sizeof(int));
for (int i = 0; i < n; i++)
s[i] = (unsigned char)text[i] + 1;
s[n] = 0; // 哨兵
int *full_sa = malloc((n + 1) * sizeof(int));
sa_is_impl(s, n + 1, full_sa, 258);
// 跳过哨兵(full_sa[0] 一定是哨兵)
memcpy(sa, full_sa + 1, n * sizeof(int));
free(full_sa);
free(s);
}
/* ---------- Kasai 算法构造 LCP 数组 ---------- */
void build_lcp_array(const char *text, int n,
const int *sa, int *lcp) {
int *rank = malloc(n * sizeof(int));
for (int i = 0; i < n; i++)
rank[sa[i]] = i;
int h = 0;
for (int i = 0; i < n; i++) {
if (rank[i] > 0) {
int j = sa[rank[i] - 1];
while (i + h < n && j + h < n &&
text[i + h] == text[j + h])
h++;
lcp[rank[i]] = h;
if (h > 0) h--;
} else {
lcp[0] = 0;
h = 0;
}
}
free(rank);
}
/* ---------- 模式搜索 ---------- */
int search_pattern(const char *text, int n, const int *sa,
const char *pattern, int m,
int *lo_out, int *hi_out) {
int lo = 0, hi = n;
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (strncmp(text + sa[mid], pattern, m) < 0)
lo = mid + 1;
else
hi = mid;
}
*lo_out = lo;
hi = n;
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (strncmp(text + sa[mid], pattern, m) <= 0)
lo = mid + 1;
else
hi = mid;
}
*hi_out = lo;
return *hi_out - *lo_out;
}
/* ---------- 演示 ---------- */
int main(void) {
const char *text = "banana";
int n = (int)strlen(text);
int *sa = malloc(n * sizeof(int));
int *lcp = malloc(n * sizeof(int));
build_suffix_array(text, n, sa);
build_lcp_array(text, n, sa, lcp);
printf("后缀数组与 LCP 数组:\n");
printf("%-6s %-6s %-6s %s\n", "rank", "SA", "LCP", "后缀");
for (int i = 0; i < n; i++) {
printf("%-6d %-6d %-6d %s\n",
i, sa[i], lcp[i], text + sa[i]);
}
// 模式搜索演示
const char *patterns[] = {"ana", "nan", "ban", "xyz"};
int np = sizeof(patterns) / sizeof(patterns[0]);
printf("\n模式搜索:\n");
for (int p = 0; p < np; p++) {
int lo, hi;
int cnt = search_pattern(text, n, sa,
patterns[p],
(int)strlen(patterns[p]),
&lo, &hi);
printf(" \"%s\" -> %d 次匹配", patterns[p], cnt);
if (cnt > 0) {
printf(",位置:");
for (int i = lo; i < hi; i++)
printf("%d ", sa[i]);
}
printf("\n");
}
free(lcp);
free(sa);
return 0;
}编译与运行:
gcc -O2 -o sa_demo sa_demo.c
./sa_demo预期输出:
后缀数组与 LCP 数组:
rank SA LCP 后缀
0 5 0 a
1 3 1 ana
2 1 3 anana
3 0 0 banana
4 4 0 na
5 2 2 nana
模式搜索:
"ana" -> 2 次匹配,位置:3 1
"nan" -> 2 次匹配,位置:4 2
"ban" -> 1 次匹配,位置:0
"xyz" -> 0 次匹配
九、性能基准:后缀数组 vs 后缀树
为了给出直观感受,下面是在同一台机器上(AMD Ryzen 9 7950X, 64GB DDR5)对英文维基百科文本(约 2.5GB)进行的基准测试。所有实现均使用 C 语言,-O2 优化。
构造时间对比:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
方法 时间 峰值内存 吞吐量
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SA-IS 38 s 12.5 GB 65 MB/s
libdivsufsort 32 s 12.5 GB 78 MB/s
后缀树 (Ukkonen) 67 s 62.0 GB 37 MB/s
倍增法 (O(n log n)) 185 s 15.0 GB 13 MB/s
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
搜索时间对比(10 万次随机模式串查询,平均长度 20):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
方法 平均时间/查询 缓存未命中
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SA 二分搜索 1.2 us ~18
SA + LCP-RMQ 搜索 0.9 us ~15
后缀树搜索 2.8 us ~42
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
几个关键观察:
libdivsufsort 是 Yuta Mori 在 2006 年实现的后缀数组构造库,基于 SA-IS 的变体(DivSufSort 算法),在实践中是最快的单线程后缀数组构造器。它的代码经过极致优化,常数因子极小。
后缀树的构造时间几乎是 SA-IS 的两倍,而内存占用是 5 倍。更重要的是,62GB 的峰值内存意味着在 32GB 内存的机器上根本无法运行,而 SA-IS 只需 12.5GB。
搜索时,后缀数组的缓存未命中次数约为后缀树的 40%,这直接反映为 2 倍以上的速度优势。后缀树的每次搜索都要频繁解引用指针,导致大量缓存未命中。
倍增法虽然内存占用与 SA-IS 接近,但 O(n log n) 的额外因子在大数据量下非常明显。对于 2.5GB 文本,log_2(2.5 * 10^9) 约为 31,这意味着基数排序要做 31 轮。
十、真实世界应用
BWA:基因组比对的基石
BWA(Burrows-Wheeler Aligner)是生物信息学领域使用最广泛的基因组比对工具之一,由 Heng Li 开发。BWA 的核心数据结构就是后缀数组和 BWT(Burrows-Wheeler Transform)。
BWA 的工作流程:
- 对参考基因组(如人类基因组 GRCh38,约 3.1GB)构建后缀数组;
- 利用后缀数组生成 BWT(BWT[i] = S[SA[i]-1],即后缀数组的前一个字符);
- 对 BWT 建立 FM-index(基于 BWT 的压缩全文索引);
- 使用 FM-index 进行精确和近似匹配,将测序读段(reads)比对到参考基因组。
BWA 使用的后缀数组构造算法正是一种 BWT-IS 变体(直接构造 BWT 而非 SA)。在人类基因组上,BWA 的索引构建约需 90 分钟,峰值内存约 5.4GB——如果使用后缀树,这将需要超过 60GB 内存。
全文搜索引擎
后缀数组是实现全文搜索的一种经典方案,特别适合以下场景:
- 离线索引:文本不频繁更新,一次构建多次查询;
- 子串搜索:需要支持任意子串查询,不仅仅是词级查询;
- 内存受限:倒排索引对子串查询支持不好,而后缀树太占内存。
在实践中,许多日志分析系统使用后缀数组的变体(如压缩后缀数组)来支持对海量日志的子串搜索。例如,Facebook 的日志搜索系统 LogDevice 中就使用了基于后缀数组的索引。
数据压缩
后缀数组与 BWT 有着天然的联系:
BWT[i] = S[SA[i] - 1](若 SA[i] > 0)
BWT[i] = S[n - 1] (若 SA[i] = 0)
BWT 是 bzip2 压缩算法的核心变换。构造 BWT 最高效的方式就是先构造后缀数组,然后线性扫描生成 BWT。现代的 bzip2 实现和许多生物信息学压缩工具(如 DSRC、FQzcomp)都依赖高效的后缀数组构造。
最长重复子串与去重
在文本去重、抄袭检测、代码克隆检测等场景中,“找到所有重复出现的子串”是核心需求。使用后缀数组 + LCP 数组,最长重复子串就是 max(LCP[i]),复杂度 O(n)。要找出所有长度超过阈值 k 的重复子串,只需扫描 LCP 数组中所有大于 k 的区间。
Google 在其 Web 搜索中使用类似技术对网页进行近似去重(near-duplicate detection),SimHash 与后缀数组的结合是其中的关键组件。
十一、工程陷阱与注意事项
在实际工程中使用后缀数组时,有许多容易踩的坑。下面整理了常见问题及其解决方案。
| 陷阱 | 症状 | 解决方案 |
|---|---|---|
| 哨兵字符冲突 | 文本中包含 \0 或 $,导致排序错误 | 使用文本中不存在的最小字符作为哨兵;对二进制数据,用多字节哨兵或将字符值整体偏移 |
| 32 位整数溢出 | 文本长度超过 2^31-1(约 2GB) | 使用 64 位整数版本的 SA-IS;或将文本分块处理 |
| 内存分配失败 | 对 GB 级文本,malloc 返回 NULL | 使用 mmap 映射文件;考虑磁盘后缀数组(external SA)构造算法 |
| 多文本拼接顺序 | 拼接多个文本计算公共子串时,哨兵的相对顺序影响结果 | 每个文本使用不同的哨兵字符,且哨兵之间有明确的序关系 |
| Unicode 处理 | UTF-8 编码中一个字符可能占多个字节 | 在字节级别构造后缀数组即可,但搜索时需保证模式串也是 UTF-8 编码;注意字符边界 |
| LCP 数组的边界条件 | LCP[0] 的值含义不一致 | 统一约定 LCP[0] = 0 或 LCP[0] = -1,并在代码中严格遵循 |
| 并行构造 | 单线程 SA-IS 无法利用多核 | 使用 pSAscan(外存并行)或基于 prefix-free parsing 的并行算法 |
| 缓存淘汰 | 对大文本二分搜索时,SA 和文本的访问模式导致缓存抖动 | 将 SA 分块(blocking),使每个块内的 SA 值和对应文本位置在内存中接近 |
| 动态更新 | 文本频繁修改时需要重建整个 SA | 后缀数组不适合动态场景;考虑使用动态后缀数组(如基于平衡 BST 的方案)或倒排索引 |
关于 libdivsufsort
如果你在生产环境中需要构造后缀数组,强烈建议直接使用 Yuta Mori 的 libdivsufsort 库,而不是自己从头实现 SA-IS。理由如下:
- libdivsufsort 经过十多年的优化和测试,代码质量极高;
- 它支持 32 位和 64 位版本,能处理超过 2GB 的文本;
- 它的 API 极其简洁:
divsufsort(text, sa, n)一行搞定; - 在几乎所有基准测试中,它都是最快的单线程后缀数组构造器。
// libdivsufsort 使用示例
#include <divsufsort.h>
int main(void) {
const char *text = "banana";
int n = strlen(text);
int *sa = malloc(n * sizeof(int));
divsufsort((const unsigned char *)text, sa, n);
// sa 现在包含后缀数组
free(sa);
return 0;
}十二、个人看法
写完这篇文章,我想分享几点个人观察。
后缀数组是”实用主义”胜过”理论优雅”的典型案例。 后缀树在理论上更强大、更通用,它的在线构造和后缀链接提供了后缀数组无法直接模拟的能力。但在实际工程中,后缀树的内存开销和缓存不友好性使其几乎被淘汰。这提醒我们:渐近复杂度相同时,常数因子和内存访问模式才是决定胜负的关键。
SA-IS 是算法设计的杰作。 在 SA-IS 之前,线性时间后缀数组构造算法(如 DC3/Skew)虽然在理论上达到了 O(n),但实现复杂、常数大,实际性能常常不如 O(n log n) 的倍增法。SA-IS 的诱导排序思想极其简洁,它把一个看似不可分解的问题分解成了”排好一部分,诱导出其余”的递归结构。这种化繁为简的能力,是算法设计中最令人赞叹的品质。
LCP 数组是被低估的数据结构。 很多人学后缀数组时只关注 SA 的构造,忽略了 LCP。但实际上,没有 LCP 数组的后缀数组只能做二分搜索,而有了 LCP 数组,后缀数组就”升级”为增强后缀数组,能替代后缀树的全部功能。Kasai 算法只有十几行代码,却把 LCP 构造的复杂度从 O(n log n) 降到了 O(n),这种投入产出比令人惊叹。
生物信息学是后缀数组最重要的应用领域。 在 CS 的其他领域(如全文搜索),倒排索引通常是更好的选择(因为大多数搜索是词级别的,不需要任意子串匹配)。但在基因组学中,我们需要将短读段(150bp 的 DNA 序列)比对到 3GB 的参考基因组,这本质上就是子串搜索问题。BWA 和 Bowtie 这类工具的成功,直接推动了新一代测序(NGS)技术的普及。可以说,后缀数组间接推动了基因组学革命。
关于何时不该用后缀数组: 如果你的需求只是”检查字符串 A 是否是字符串 B 的子串”,用 KMP 或 Rabin-Karp 就够了。如果你需要对静态文本做大量不同模式串的查询,后缀数组是最佳选择。如果文本频繁变化,考虑使用增量索引(如倒排索引 + 定期重建)。
最后,我想说的是:数据结构的选择从来不是非此即彼。后缀树、后缀数组、BWT/FM-index 本质上编码了同一棵树的不同视角。理解它们之间的等价关系,比死记任何一个的实现细节都更有价值。当你真正理解了”所有后缀的字典序”这一核心概念后,具体用哪种数据结构来表示它,就变成了一个工程权衡问题——而后缀数组,几乎总是那个最务实的答案。
附录:术语对照
| 英文 | 中文 | 缩写 |
|---|---|---|
| Suffix Array | 后缀数组 | SA |
| Suffix Tree | 后缀树 | — |
| Longest Common Prefix | 最长公共前缀 | LCP |
| Inverse Suffix Array | 逆后缀数组 | ISA |
| Induced Sorting | 诱导排序 | — |
| Left-Most S-type | 最左 S 型 | LMS |
| Burrows-Wheeler Transform | BWT 变换 | BWT |
| FM-index | FM 索引 | — |
| Range Minimum Query | 区间最小值查询 | RMQ |
| Sparse Table | 稀疏表 | — |
参考文献
- Manber, U. & Myers, G. (1993). “Suffix arrays: a new method for on-line string searches.” SIAM Journal on Computing, 22(5), 935-948.
- Nong, G., Zhang, S. & Chan, W.H. (2009). “Two Efficient Algorithms for Linear Time Suffix Array Construction.” IEEE Transactions on Computers, 60(10), 1471-1484.
- Kasai, T., Lee, G., Arimura, H., Arikawa, S. & Park, K. (2001). “Linear-Time Longest-Common-Prefix Computation in Suffix Arrays and Its Applications.” CPM 2001, LNCS 2089, 181-192.
- Abouelhoda, M.I., Kurtz, S. & Ohlebusch, E. (2004). “Replacing suffix trees with enhanced suffix arrays.” Journal of Discrete Algorithms, 2(1), 53-86.
- Li, H. & Durbin, R. (2009). “Fast and accurate short read alignment with Burrows-Wheeler transform.” Bioinformatics, 25(14), 1754-1760.
- Kärkkäinen, J., Sanders, P. & Burkhardt, S. (2006). “Linear work suffix array construction.” Journal of the ACM, 53(6), 918-936.
- Mori, Y. (2008). libdivsufsort. https://github.com/y-256/libdivsufsort
- Gusfield, D. (1997). Algorithms on Strings, Trees, and Sequences: Computer Science and Computational Biology. Cambridge University Press.
上一篇: Merkle 树与认证数据结构 下一篇: AC 自动机 相关阅读: - BWT 与 FM-index - 字符串哈希