第41章 后缀数组与后缀树:从字符串匹配到基因序列分析

说实话,字符串处理这块,很多初学者觉得不就是找找子串、比对比对嘛。但当你真正面对一个几百万字符的文本,或者要处理DNA序列这种超长字符串时,传统方法就彻底歇菜了。这时候,后缀数组和后缀树就是你的救命稻草。

我个人最早接触后缀数组是在做基因序列比对的项目里。当时要在一段长达千万级的基因组中快速查找某个模式串,暴力匹配跑了一整天都没出结果。后来换了后缀数组,配合LCP,几分钟就搞定了。嗯,从那以后我就再也没小看过这个数据结构。

后缀数组的定义

先说说什么是后缀数组。说白了,就是把一个字符串的所有后缀按字典序排个序,然后记录下每个后缀在原串中的起始位置。

举个例子,字符串 "banana" 的后缀有:

banana
anana
nana
ana
na
a

按字典序排序后变成:

a        → 起始位置 5
ana      → 起始位置 3
anana    → 起始位置 1
banana   → 起始位置 0
na       → 起始位置 4
nana     → 起始位置 2

所以后缀数组就是 [5, 3, 1, 0, 4, 2]。

核心思想:后缀数组本身不存字符串,只存索引。通过索引可以快速定位到任意后缀,配合二分查找就能高效完成模式匹配。

倍增法构造后缀数组

构造后缀数组最经典的方法就是倍增法。为什么叫倍增?因为它每次利用已经排好的长度为 k 的排名,去推长度为 2k 的排名。你想想看,这就像滚雪球一样,越滚越大。

我当年第一次看倍增法代码时,觉得这玩意儿太绕了。后来自己手写了一遍才明白,其实就是个基数排序的变种。

// 倍增法构造后缀数组
void build_sa(int *s, int *sa, int n, int m) {
    int *rk = new int[n], *tmp = new int[n];
    int *cnt = new int[m + 1];
    
    // 第一轮:按单个字符排序
    for (int i = 0; i < m; i++) cnt[i] = 0;
    for (int i = 0; i < n; i++) cnt[rk[i] = s[i]]++;
    for (int i = 1; i < m; i++) cnt[i] += cnt[i - 1];
    for (int i = n - 1; i >= 0; i--) sa[--cnt[s[i]]] = i;
    
    // 倍增过程
    for (int k = 1; k < n; k *= 2) {
        // 按第二关键字排序
        int p = 0;
        for (int i = n - k; i < n; i++) tmp[p++] = i;
        for (int i = 0; i < n; i++)
            if (sa[i] >= k) tmp[p++] = sa[i] - k;
        
        // 按第一关键字排序
        for (int i = 0; i < m; i++) cnt[i] = 0;
        for (int i = 0; i < n; i++) cnt[rk[i]]++;
        for (int i = 1; i < m; i++) cnt[i] += cnt[i - 1];
        for (int i = n - 1; i >= 0; i--) 
            sa[--cnt[rk[tmp[i]]]] = tmp[i];
        
        // 更新排名
        swap(rk, tmp);
        rk[sa[0]] = 0;
        p = 1;
        for (int i = 1; i < n; i++)
            rk[sa[i]] = (tmp[sa[i]] == tmp[sa[i - 1]] &&
                        tmp[sa[i] + k] == tmp[sa[i - 1] + k]) ? p - 1 : p++;
        if (p >= n) break;
        m = p;
    }
    
    delete[] rk; delete[] tmp; delete[] cnt;
}

我的经验:倍增法的时间复杂度是 O(n log n),空间复杂度 O(n)。对于大多数场景已经够用了。但如果你要处理上亿级别的数据,可以考虑 DC3 算法,它是 O(n) 的,不过常数比较大。

DC3算法:线性时间的构造

DC3(Difference Cover Mod 3)算法,也叫 skew 算法。它把后缀分成三组,递归处理,最后合并。说实话,这个算法我很少在实际项目中手写,因为实现起来太复杂了。但理解它的思想很重要——分治策略在字符串处理中的妙用。

DC3的核心步骤:

  1. 将后缀按起始位置 mod 3 分成三组:S0、S1、S2
  2. 递归构造 S1∪S2 的后缀数组
  3. 利用 S1∪S2 的结果构造 S0 的后缀数组
  4. 合并三个数组

避坑指南:我曾经在实现 DC3 时踩过一个坑——递归调用时字符集大小没处理好,导致排序结果全乱了。记住,递归时要把三元组映射成新的字符,字符集大小要重新计算。

LCP与RMQ:后缀数组的灵魂搭档

光有后缀数组还不够,很多时候我们需要知道两个后缀的最长公共前缀(LCP)。比如你要找两个字符串的最长公共子串,或者做模式匹配时的加速,都离不开 LCP。

LCP 数组的定义很简单:lcp[i] 表示排名第 i 的后缀和排名第 i-1 的后缀的最长公共前缀长度。

计算 LCP 有个经典算法——Kasai 算法,O(n) 搞定:

void build_lcp(int *s, int *sa, int *rk, int *lcp, int n) {
    for (int i = 0; i < n; i++) rk[sa[i]] = i;
    int k = 0;
    for (int i = 0; i < n; i++) {
        if (rk[i] == 0) { k = 0; continue; }
        int j = sa[rk[i] - 1];
        while (i + k < n && j + k < n && s[i + k] == s[j + k]) k++;
        lcp[rk[i]] = k;
        if (k > 0) k--;
    }
}

有了 LCP 数组,再配合 RMQ(区间最小值查询),就能在 O(1) 时间内回答任意两个后缀的 LCP。你想想看,这意味着什么?意味着你可以快速比较任意两个子串的大小关系。

实用技巧:我一般用稀疏表(Sparse Table)来实现 RMQ,预处理 O(n log n),查询 O(1)。对于静态的 LCP 数组,这是最优选择。

后缀树:从数组到树形结构

后缀树是后缀数组的"升级版"。它把字符串的所有后缀压缩成一棵树,每个叶子节点对应一个后缀,每条边对应一段子串。

为什么需要后缀树?因为有些操作在后缀数组上做比较麻烦,比如查找一个模式串的所有出现位置。在后缀树上,你只需要沿着树边走,找到对应节点,然后遍历它的子树就能得到所有匹配位置。

我画了一张图,帮你理解后缀数组和后缀树的关系:

后缀数组 vs 后缀树 后缀数组(SA) 字符串: "banana" 后缀列表(排序后): a → SA[0]=5 ana → SA[1]=3 anana → SA[2]=1 banana → SA[3]=0 na → SA[4]=4 nana → SA[5]=2 LCP数组: lcp[1]=1, lcp[2]=3, lcp[3]=0 lcp[4]=0, lcp[5]=2 后缀树 a b n $ na anana$ a$ ana$ 5 3 0 4 2 叶子节点 = 后缀起始位置 每条边 = 一段连续子串 从根到叶子的路径 = 一个后缀 后缀数组是后缀树的"扁平化"表示,两者可以互相转换

后缀树的实际应用

后缀树的应用场景非常广泛,我挑几个常见的说说:

  • 模式匹配:在 O(m) 时间内查找模式串,其中 m 是模式串长度。比 KMP 还快,而且支持同时查找多个模式串。
  • 最长重复子串:在后缀树上找最深的内节点,对应的路径就是最长重复子串。我在做日志分析时用过这个功能,快速定位重复出现的错误信息。
  • 最长公共子串:把两个字符串拼起来,中间加个特殊字符,建后缀树。然后找同时属于两个字符串的最深节点。
  • DNA序列比对:这是后缀树的大杀器。基因序列动辄上亿字符,后缀树能高效完成序列比对、变异检测等任务。

我的建议:如果你只是做算法题或者中小规模的数据处理,后缀数组完全够用。但如果你要处理超大规模文本,或者需要频繁进行模式匹配,后缀树更合适。不过后缀树的内存开销比较大,每个节点要存 26 个(或更多)子节点指针,实际使用时要注意优化。

总结一下

后缀数组和后缀树是字符串处理领域的两大神器。后缀数组简洁高效,配合 LCP 和 RMQ 能解决大部分问题。后缀树功能更强大,但实现复杂、内存开销大。我个人建议:先从后缀数组入手,把倍增法和 Kasai 算法写熟练了,再考虑后缀树。

记住一句话:没有银弹。选哪个取决于你的具体场景。数据量小用暴力也不怕,数据量大就得靠这些高级数据结构了。


公众号:蓝海资料掘金营,微信deep3321