后缀数组 (Suffix Array)

后缀数组 (Suffix Array) 完全指南

如果你只做 CRUD 业务,不需要后缀数组;但如果你从事基础设施、数据引擎或 AI Infra,它是核心武器。

在工程界,后缀数组(及其变体 FM-Index、BWT)主要活跃在以下领域:

  1. 大模型 (LLM) 数据清洗

    • 去重 (Deduplication):如何在 TB 级的 CommonCrawl 数据中发现并删除重复的段落?
    • 防污染 (Decontamination):如何确保评估集的题目没有泄露到训练集中?
    • 后缀数组支持高效的 "超长公共子串" 检测,是精确去重的基石(相比 MinHash 的模糊去重,它能提供精确边界)。
  2. 生物信息学 (Bioinformatics)

    • 这是后缀数组最大的工业应用场景。
    • DNA 序列(ATCG)本质上是超长字符串。基因组测序、序列比对 (Alignment) 极其依赖基于后缀数组的 FM-Index 技术(因为它极其节省内存,能在有限内存中塞进整个人类基因组索引)。
  3. 全文搜索与代码分析

    • 代码克隆检测 (Code Clone Detection):在 GitHub 规模的代码库中寻找复制粘贴的代码片段。
    • 正则/子串索引 :Lucene 等倒排索引擅长"分词"检索,但对于日志、代码等需要 "任意子串检索" (Substring Search) 的场景,后缀数组是标准解法。
  4. 数据压缩

    • Linux 下的 bzip2 压缩算法,核心就是 BWT (Burrows-Wheeler Transform),而 BWT 的构建依赖于后缀排序。

一、什么是后缀数组?

后缀数组的核心是将字符串的所有后缀进行 字典序排序

假设字符串 S="banana"S = \text{"banana"}S="banana",它有 6 个后缀:

  1. banana (index 1)
  2. anana (index 2)
  3. nana (index 3)
  4. ana (index 4)
  5. na (index 5)
  6. a (index 6)

1.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→ 原串下标。
  2. 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。
  3. 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(Mlog⁡N)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(N2log⁡N)O(N^2 \log N)O(N2logN),太慢。

主流使用 倍增法 (Doubling Algorithm) ,复杂度 O(Nlog⁡N)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=1sa[3] = 2, cnt[1]=2
    • i=3: id[3]=3, key=2sa[4] = 3, cnt[2]=3
    • i=2: id[2]=1, key=1sa[2] = 1, cnt[1]=1
    • i=1: id[1]=4, key=1sa[1] = 4, cnt[1]=0
  • 结果 : sa = [4, 1, 2, 3]。此时后缀已按前 2 个字符 (key1, key2) 排好序。

五、总结

学习后缀数组,本质上是在学习 "如何把复杂的字符串相似度问题,降维成数组上的数值比较问题"

它为你打开了通往高级字符串算法的大门。

  • 如果你想做 多模式匹配,学 AC 自动机。
  • 如果你想做 回文串,学 Manacher 或回文树。
  • 但如果你想解决 "子串统计"、"最长公共部分"、"字典序" 相关的问题,后缀数组是当之无愧的王者。
相关推荐
仰泳的熊猫1 小时前
题目1523:蓝桥杯算法提高VIP-打水问题
数据结构·c++·算法·蓝桥杯
汉克老师2 小时前
GESP2024年3月认证C++二级( 第三部分编程题(1) 乘法问题)
c++·算法·循环结构·gesp二级·gesp2级
juleskk2 小时前
2.18复试训练
算法
tankeven2 小时前
HJ94 记票统计
c++·算法
逆境不可逃2 小时前
LeetCode 热题 100 之 76.最小覆盖子串
java·算法·leetcode·职场和发展·滑动窗口
I_LPL2 小时前
day35 代码随想录算法训练营 动态规划专题3
java·算法·动态规划·hot100·求职面试
DeepModel2 小时前
【回归算法】多项式回归详解
算法·回归
雨翼轻尘2 小时前
2.1 链表1
数据结构·链表
百锦再2 小时前
Java中的日期时间API详解:从Date、Calendar到现代时间体系
java·开发语言·spring boot·struts·spring cloud·junit·kafka