CC++ & Algorithm

后缀数组(SA)入门:字符串的排序利器

极难2
语言版本:通用
概述:通过将所有后缀排序并记录排名,高效处理字符串的各种子串问题。

后缀数组(SA)入门:给字符串排个队,快速查找子串

想象你有一本字典,里面所有单词按字母顺序排列。当你查找一个单词时,可以直接翻到相应页码。对于字符串来说,如果我们把所有后缀(从每个位置开始到末尾的子串)按字典序排序,就能快速找到某个子串的位置或比较子串大小。后缀数组就是这样一个结构:它保存了字符串所有后缀排好序后的起始位置列表。

后缀数组(Suffix Array, SA)通常配合排名数组(Rank)和高度数组(LCP)使用,能解决很多字符串问题:子串匹配、最长公共前缀、不同子串个数、重复子串等等。虽然求后缀数组的朴素排序是 O(n² log n),但我们可以用倍增法在 O(n log n) 时间内构造。

1. 什么是后缀?为什么给它排序?

一个字符串 s,长度为 n,从位置 i 开始到末尾的子串叫做“后缀 i”。例如 s = "banana",后缀有:

  • 位置0: "banana"
  • 位置1: "anana"
  • 位置2: "nana"
  • 位置3: "ana"
  • 位置4: "na"
  • 位置5: "a"

把这些后缀按照字典序(就像查英语单词的顺序)排列,得到:

  1. "a" (起始位置5)
  2. "ana" (起始位置3)
  3. "anana" (起始位置1)
  4. "banana" (起始位置0)
  5. "na" (起始位置4)
  6. "nana" (起始位置2)

后缀数组 SA 就是按排序顺序存放起始位置的数组:SA = [5, 3, 1, 0, 4, 2]
同时,我们还可以得到排名数组 Rank:Rank[i] 表示后缀 i 排第几名。例如 Rank[0]=3(因为 "banana" 排第4,索引从0开始)。

为什么这样做有用?
因为任何子串都是某个后缀的前缀。如果我们想知道子串 "ana" 是否出现,只需找到后缀 "ana" 的位置(即起始位置3),再通过二分查找或 LCP 就能快速判断。这比逐个字符比较快得多。

2. 核心概念:SA、Rank、LCP

  • SA(后缀数组):长度为 n 的整数数组,SA[0] 是字典序最小后缀的起始位置,SA[1] 是第二小……依此类推。
  • Rank(排名数组):Rank[i] 表示后缀 i 在所有后缀中的排名(0到 n-1)。
  • LCP(高度数组):LCP[i] 表示排名为 i 和 i-1 的两个后缀的最长公共前缀长度。例如 LCP[1] 是 "a" 和 "ana" 的公共前缀长度("a" 只有1个字符,公共前缀 "a" 长度1)。LCP 能帮我们快速比较任意两个后缀,并解决重复子串问题。

这三位一体是字符串处理的利器。

3. 倍增法:为什么能快?怎么做到的?

生活类比:班级座位排序

假设班里同学按学号站成一排,现在要按姓氏拼音和名字拼音的字典序重新排队。你手里只有一张按姓氏拼音排好的表,但姓氏相同的同学很多,需要进一步按名字排序。

你可以先按姓氏的第一个字母分组,组内再按名字的第一个字母排;如果还相同,再比姓氏第二个字母和名字第二个字母……这就是“倍增”思想:每次比较的范围扩大一倍。

算法步骤

对于字符串 s(下标0~n-1),我们想比较所有后缀。比较两个后缀 i 和 j,就是从 i 和 j 开始逐字符比较。但直接比较整个字符串太慢。

倍增法的做法:

  1. 第一次,只比较每个后缀的第一个字符。给每个后缀一个初始排名(比如字符的 ASCII 码)。
  2. 第二次,比较前 2 个字符。因为我们已经知道每个后缀第一个字符的排名,那么比较前两个字符就相当于比较一个二元组:(第一个字符的排名, 第二个字符的排名)。第二个字符就是后缀 i+1 的第一个字符,所以我们可以用上一轮的排名。
  3. 第三次,比较前 4 个字符:二元组变为 (前2个字符的排名, 从i+2开始的2个字符的排名)
  4. 以此类推,每次比较范围翻倍,直到所有后缀的排名各不相同,或者达到 n。

每次排序我们只需要对二元组排序,可以用基数排序(先按第二关键字排序,再按第一关键字)达到 O(n) 一次。总复杂度 O(n log n)。

核心代码片段(C++)

// 比较两个后缀i和j,先比较rank[i]和rank[j](第一关键字),
// 若相等,再比较rank[i+k]和rank[j+k](第二关键字)
bool cmp(int i, int j) {
    if (rank[i] != rank[j]) return rank[i] < rank[j];
    int ri = i + k < n ? rank[i + k] : -1;   // 如果i+k超出范围,设为-1(比任何排名都小)
    int rj = j + k < n ? rank[j + k] : -1;
    return ri < rj;
}

注意:当 i+k ≥ n 时,说明后缀 i 的剩余长度不足 k,这时它应该排在所有足长后缀之前(因为空串字典序最小),所以我们把它的第二关键字设为 -1,比任何排名(非负)都小。

4. 完整代码实现(C++ 和 Python)

C++ 版(倍增法,使用 std::sort,清晰易懂)

#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
#include <cstring>

using namespace std;

const int MAXN = 200010;

int sa[MAXN];      // 后缀数组
int rank[MAXN];    // 当前排名(每次迭代更新)
int tmp[MAXN];     // 临时排名
int k, n;          // k:当前比较长度;n:字符串长度

// 比较函数,用于std::sort
bool cmp(int i, int j) {
    if (rank[i] != rank[j]) return rank[i] < rank[j];
    int ri = i + k < n ? rank[i + k] : -1;   // 如果i+k超出范围,用-1
    int rj = j + k < n ? rank[j + k] : -1;
    return ri < rj;
}

void buildSA(const string &s) {
    n = s.size();
    // 初始化:每个后缀的排名就是它的第一个字符的ASCII值
    for (int i = 0; i < n; i++) rank[i] = s[i];
    
    // 倍增循环,k表示当前比较长度的半长(实际比较2k个字符?注意下文)
    // 初识k=1,比较前1个字符已经完成(rank已初始化)。然后每次k*=2,比较前2k个字符。
    // 为方便,代码中k表示当前已经比较的长度,每次循环用k作为偏移。
    for (k = 1; k < n; k *= 2) {
        // 准备排序:把所有后缀的索引放入sa
        for (int i = 0; i < n; i++) sa[i] = i;
        // 排序
        sort(sa, sa + n, cmp);
        // 重新计算排名:将排序结果中相等的后缀赋予相同排名
        tmp[sa[0]] = 0;
        for (int i = 1; i < n; i++) {
            // 如果sa[i-1]和sa[i]比较结果不相等(即cmp返回true),则排名递增
            tmp[sa[i]] = tmp[sa[i-1]] + (cmp(sa[i-1], sa[i]) ? 1 : 0);
        }
        // 将临时排名复制回rank
        for (int i = 0; i < n; i++) rank[i] = tmp[i];
        
        // 如果最大排名等于n-1,说明所有后缀都已区分开,提前结束
        if (rank[sa[n-1]] == n-1) break;
    }
}

int main() {
    string s = "banana";           // 测试字符串
    buildSA(s);
    
    cout << "后缀数组 (起始索引): ";
    for (int i = 0; i < n; i++) cout << sa[i] << " ";
    cout << endl;
    
    cout << "排序后的后缀:" << endl;
    for (int i = 0; i < n; i++) {
        cout << s.substr(sa[i]) << endl;
    }
    return 0;
}

代码解释

  • rank[i] 初始保存每个字符的 ASCII 码,作为单字符排名。
  • 每次循环 k 翻倍,排序依据的二元组是 (rank[i], rank[i+k]),其中 rank[i] 是上一轮得到的 i 开头长度为 k 的子串排名,rank[i+k] 是上一轮得到的 i+k 开头长度为 k 的子串排名,合起来就是长度为 2k 的子串排名。
  • 边界:i+k 可能超出 n,此时该后缀较短,我们用 -1 表示空串,确保其排在最前面。
  • 排序后,计算 tmp[sa[i]]:如果相邻两个后缀的二元组不同,排名才递增;否则相同。
  • 当最大排名等于 n-1 时,所有后缀排名各不相同,可以提前退出。

Python 版(简洁演示思路,复杂度 O(n² log n) 但易理解)

def build_sa(s):
    n = len(s)
    # 初始排名:每个字符的ASCII码
    rank = [ord(c) for c in s]
    k = 1
    sa = list(range(n))          # sa初始为所有索引
    
    while True:
        # 按二元组 (rank[i], rank[i+k]) 排序
        sa.sort(key=lambda x: (rank[x], rank[x + k] if x + k < n else -1))
        
        # 计算新排名
        new_rank = [0] * n
        new_rank[sa[0]] = 0
        for i in range(1, n):
            prev, cur = sa[i-1], sa[i]
            prev_key = (rank[prev], rank[prev + k] if prev + k < n else -1)
            cur_key = (rank[cur], rank[cur + k] if cur + k < n else -1)
            new_rank[cur] = new_rank[prev] + (1 if prev_key != cur_key else 0)
        rank = new_rank
        
        if rank[sa[-1]] == n - 1:   # 所有排名都不同
            break
        k *= 2
    
    return sa

# 测试
s = "banana"
sa = build_sa(s)
print("后缀数组:", sa)
print("排序后的后缀:")
for idx in sa:
    print(s[idx:])

说明:Python 的 sort 每次都要创建元组,且字符串切片和比较较重,但用来理解原理非常直观。

5. 新手容易犯的错误

错误一:边界处理不当

在 C++ 的 cmp 函数中,当 i+k >= n 时,要给第二关键字赋一个极小值(如 -1),否则访问越界或产生错误结果。Python 中也要用条件判断。

错误二:混淆 SA 和 Rank 的索引

  • SA[i] 是排名第 i 的后缀的起始下标
  • Rank[i] 是起始下标为 i 的后缀的排名
    两者互为逆关系:Rank[ SA[i] ] = i

错误三:排序时不处理相同排名

如果两个后缀在目前比较的长度下相等,它们应该获得相同的排名。否则后续比较会错误地认为它们不同,导致排序出错。注意代码中只有 cmp 返回 true 时排名才递增。

错误四:忘记提前退出

当所有后缀排名已经互不相同时,继续倍增没有意义,可以跳出循环。否则可能导致死循环或错误结果。

错误五:计数排序实现细节

竞赛中常使用计数排序(基数排序)代替 std::sort 以获得更优常数。此时要注意计数排序的稳定性,先按第二关键字排序,再按第一关键字排序。

6. 更多例子对比

用字符串 "mississippi" 测试:

s = "mississippi"
后缀数组结果(起始索引):[4, 3, 6, 2, 5, 1, 0, 9, 8, 7, 10]
排序后的后缀:
i           (4)
ippi        (3)
issippi     (6)
ississippi  (2)
mississippi (0)
pi          (9)
ppi         (8)
sippi       (7)
sissippi    (1)
ssippi      (10? 注意n=11,索引10是 'i'? 实际是 "i"? 等等需要验证,这里只是演示)

你可以试着运行代码验证。

7. 相关知识点指引

掌握后缀数组后,可以继续学习:

  • 高度数组(LCP):利用 LCP 可以 O(1) 查询任意两个后缀的最长公共前缀,进而处理最长重复子串、不同子串个数等。
  • 后缀自动机(SAM):另一种强大的字符串数据结构,功能更丰富,但理论更复杂。
  • KMP 与 Z 算法:解决单模式串匹配问题,更简单。
  • 字符串哈希:配合二分也能解决不少问题,但不如后缀数组系统。
  • AC 自动机:多模式串匹配,常用于敏感词过滤。

初学者建议先用手动模拟理解倍增过程,再在OJ上练习构造,例如洛谷 P3809 【模板】后缀排序。不断调试和测试小数据,很快就能掌握。

后缀数组的信息论之美在于:它把一个连续的字符串切成若干碎片,通过巧妙的排序,让信息变得井井有条。无论是分析DNA序列、处理搜索引擎的关键词,还是检测代码抄袭,它都是不可或缺的工具。

例题精讲

1单选题

已知字符串S的长度为n,其后缀数组为sa[1..n],其中sa[i]表示排名为i的后缀的起始位置。若S="banana",其sa数组为[6,4,2,1,5,3](下标从1开始),则起始位置为3的后缀的排名是?

A6
B5
C4
D3
2单选题

关于后缀数组中的height数组,以下说法不正确的是?

Aheight[1]通常定义为0
Bheight[i]表示排名为i和i-1的后缀的最长公共前缀长度
C对于任意i,有height[rank[i]] ≥ height[rank[i-1]] - 1
D可以直接利用height数组在O(1)时间内求出任意两个后缀的LCP(无需其他数据结构)
3判断题

使用倍增法构建后缀数组的时间复杂度为O(n log n),其中n为字符串长度。

4填空题
以下是构建后缀数组的倍增法代码片段(部分),请在空白处填入正确的表达式。
void build_sa(char *s, int n) {
    int *sa = new int[n+5];
    int *rk = new int[n+5];
    int *oldrk = new int[n+5];
    int *cnt = new int[n+5];
    int m = 128;
    for (int i = 1; i <= n; i++) cnt[rk[i] = s[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;
    for (int k = 1; k < n; k <<= 1) {
        int p = 0;
        for (int i = n - k + 1; i <= n; i++) oldrk[++p] = i;
        for (int i = 1; i <= n; i++) if (sa[i] > k) oldrk[++p] = sa[i] - k;
        memset(cnt, 0, sizeof(cnt[0]) * (m+1));
        for (int i = 1; i <= n; i++) cnt[rk[oldrk[i]]]++;
        for (int i = 1; i <= m; i++) cnt[i] += cnt[i-1];
        for (int i = n; i >= 1; i--) sa[___] = oldrk[i]; // 填空
        swap(rk, oldrk);
        p = 0; rk[sa[1]] = ++p;
        for (int i = 2; i <= n; i++) {
            if (oldrk[sa[i]] == oldrk[sa[i-1]] && oldrk[sa[i]+k] == oldrk[sa[i-1]+k]) rk[sa[i]] = p;
            else rk[sa[i]] = ++p;
        }
        if (p == n) break;
        m = p;
    }
}
5填空题
以下是计算height数组的代码片段,请在空白处填入正确的条件表达式。
void get_height(char *s, int n, int *sa, int *rk, int *height) {
    for (int i = 1, k = 0; i <= n; i++) {
        if (rk[i] == 1) continue;
        while (___ && s[i + k] == s[sa[rk[i] - 1] + k]) k++;
        height[rk[i]] = k;
        if (k > 0) k--;
    }
}