土法炼钢兴趣小组的算法知识备份

后缀数组:比后缀树更实用的选择

目录

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 个内部节点。每个节点至少需要存储:

即使做了极致优化,每个字符平均也需要 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$
后缀数组与 LCP 数组示意图

后缀数组的存储极其简单:仅需一个整数数组。如果 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)。其核心思想是:

  1. 首先按每个后缀的第一个字符排序,得到初始排名 rank_1[i];
  2. 利用 rank_1 按前 2 个字符排序:将后缀 i 的排序键设为 (rank_1[i], rank_1[i+1]),排序后得到 rank_2[i];
  3. 利用 rank_2 按前 4 个字符排序:键为 (rank_2[i], rank_2[i+2]);
  4. 一般地,第 k 轮用键 (rank_{k-1}[i], rank_{k-1}[i + 2^{k-1}]) 排序;
  5. 当所有排名互不相同时停止,此时 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 的递归分解点。算法的宏观思路是:

  1. 对 LMS 后缀排序(通过递归);
  2. 利用 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 的方法是在二分搜索中维护两个值:

当考察 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 往往就足够好了。原因是:

  1. 对于短模式串(m < 100),O(m log n) 和 O(m + log n) 差别不大;
  2. 现代 CPU 的 SIMD 指令使得 strncmp 极快(memcmp 在 AVX2 下每周期可比较 32 字节);
  3. 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
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

几个关键观察:

  1. libdivsufsort 是 Yuta Mori 在 2006 年实现的后缀数组构造库,基于 SA-IS 的变体(DivSufSort 算法),在实践中是最快的单线程后缀数组构造器。它的代码经过极致优化,常数因子极小。

  2. 后缀树的构造时间几乎是 SA-IS 的两倍,而内存占用是 5 倍。更重要的是,62GB 的峰值内存意味着在 32GB 内存的机器上根本无法运行,而 SA-IS 只需 12.5GB。

  3. 搜索时,后缀数组的缓存未命中次数约为后缀树的 40%,这直接反映为 2 倍以上的速度优势。后缀树的每次搜索都要频繁解引用指针,导致大量缓存未命中。

  4. 倍增法虽然内存占用与 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 的工作流程:

  1. 对参考基因组(如人类基因组 GRCh38,约 3.1GB)构建后缀数组;
  2. 利用后缀数组生成 BWT(BWT[i] = S[SA[i]-1],即后缀数组的前一个字符);
  3. 对 BWT 建立 FM-index(基于 BWT 的压缩全文索引);
  4. 使用 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。理由如下:

  1. libdivsufsort 经过十多年的优化和测试,代码质量极高;
  2. 它支持 32 位和 64 位版本,能处理超过 2GB 的文本;
  3. 它的 API 极其简洁:divsufsort(text, sa, n) 一行搞定;
  4. 在几乎所有基准测试中,它都是最快的单线程后缀数组构造器。
// 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 稀疏表

参考文献

  1. Manber, U. & Myers, G. (1993). “Suffix arrays: a new method for on-line string searches.” SIAM Journal on Computing, 22(5), 935-948.
  2. 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.
  3. 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.
  4. Abouelhoda, M.I., Kurtz, S. & Ohlebusch, E. (2004). “Replacing suffix trees with enhanced suffix arrays.” Journal of Discrete Algorithms, 2(1), 53-86.
  5. Li, H. & Durbin, R. (2009). “Fast and accurate short read alignment with Burrows-Wheeler transform.” Bioinformatics, 25(14), 1754-1760.
  6. Kärkkäinen, J., Sanders, P. & Burkhardt, S. (2006). “Linear work suffix array construction.” Journal of the ACM, 53(6), 918-936.
  7. Mori, Y. (2008). libdivsufsort. https://github.com/y-256/libdivsufsort
  8. Gusfield, D. (1997). Algorithms on Strings, Trees, and Sequences: Computer Science and Computational Biology. Cambridge University Press.

上一篇: Merkle 树与认证数据结构 下一篇: AC 自动机 相关阅读: - BWT 与 FM-index - 字符串哈希


By .