定义

后缀数组(Suffix Array)是对一个字符串的所有后缀按字典序排序后形成的数组。设字符串 长度为 ,则后缀数组 是一个长度为 的排列,满足:

其中 表示字符串 从位置 的子串(包含两端)。

示例

以字符串 为例:

后缀起始位置后缀内容
0”abaab”
1”baab”
2”aab”
3”ab”
4”b”

按字典序排序后:

排名起始位置后缀内容
02”aab”
13”ab”
20”abaab”
34”b”
41”baab”

因此该字符串的后缀数组为

后缀数组是处理字符串问题的有力工具,常与 KMP算法字符串哈希 结合使用。

构造算法

朴素方法

最直接的做法是生成所有后缀,对它们直接排序。每个后缀长度为 ,比较两个后缀需要 时间,排序有 次比较,因此总时间复杂度为 。这种方法在小规模数据外不可接受。

倍增法

倍增法(Doubling Method)是构造后缀数组最经典的算法,其核心思想是利用已经计算过的信息来加速排序。

算法思想

对于每个位置 ,我们同时考虑以 开头、长度分别为 的前缀。设 为当前考虑的前缀长度,初始时

对每个位置 ,定义一个二元组

较小时,我们可以使用基数排序对这个二元组进行排序。随着 不断倍增,当 时,所有后缀都可以完全区分,此时即得到完整的后缀数组。

算法步骤

设字符串 长度为 ,下标从 开始。

  1. 初始化:对每个字符进行排序,得到每个字符的排名;
  2. 迭代:对于 直到
    • 按照 进行基数排序,得到新的排名
    • 如果所有排名均不同,则结束;
    • 更新

正确性证明(简要)

我们证明算法结束时得到的 即为后缀的最终排名。

引理:当 足够大时(),每个后缀 可以唯一确定。

证明:此时每个后缀的前缀长度至少为 ,足以覆盖整个后缀,不同后缀必然不同。∎

引理:在第 轮迭代中,如果两个后缀 长度内的前缀不同,则

证明 取决于二元组 。由于 已经区分了所有长度小于 的前缀,不同的前缀必然导致至少一个关键字不同,从而二元组不同,排名不同。∎

定理:倍增法最终得到的后缀数组是正确的。

证明:随着 不断倍增,存在某个 使得所有后缀在长度 内均可区分。根据引理,此后所有迭代的排名不再变化。由于最终排名与字典序一致(由基数排序保证),故算法正确。∎

C++ 实现

#include <bits/stdc++.h>
using namespace std;
 
// 后缀数组倍增法 O(n log n)
vector<int> buildSA(const string& s) {
    int n = s.size();
    vector<int> sa(n), rank(n), tmp(n);
    
    // 初始化:单字符排序
    for (int i = 0; i < n; i++) {
        sa[i] = i;
        rank[i] = s[i];
    }
    
    // 倍增
    for (int k = 1; k < n; k <<= 1) {
        // 按第二关键字排序(使用 tmp 作为临时数组)
        auto cmp = [&](int i, int j) {
            if (rank[i] != rank[j]) return rank[i] < rank[j];
            int ri = i + k < n ? rank[i + k] : -1;
            int rj = j + k < n ? rank[j + k] : -1;
            return ri < rj;
        };
        sort(sa.begin(), sa.end(), cmp);
        
        // 计算新的排名
        tmp[sa[0]] = 0;
        for (int i = 1; i < n; i++) {
            tmp[sa[i]] = tmp[sa[i-1]] + (cmp(sa[i-1], sa[i]) ? 1 : 0);
        }
        for (int i = 0; i < n; i++) rank[i] = tmp[i];
        
        // 如果所有排名均不同,则结束
        if (rank[sa[n-1]] == n - 1) break;
    }
    
    return sa;
}

上述实现中,我们使用 sort 进行排序,时间复杂度为 。为了达到严格的 ,可以使用基数排序代替 sort,但实现复杂度稍高。在竞赛中,上述实现通常足够高效。

LCP数组与Kasai算法

LCP数组的定义

最长公共前缀数组(Longest Common Prefix Array,记为 )存储了后缀数组中相邻后缀的最长公共前缀长度:

其中 表示字符串 的最长公共前缀长度。

Kasai算法

Kasai算法可以在 时间内由原字符串计算出 数组,无需显式构建后缀数组。

算法思想

考虑 之间的关系。设 ,则相邻后缀 有长度为 的公共前缀。

,不妨设 (否则交换)。则 ,且 (或已到达字符串末尾)。

考虑后缀 。它们分别是 去掉第一个字符后的结果。关键观察是:这两个后缀的公共前缀长度至少为 (除非 )。

这意味着如果我们按原字符串位置从左到右遍历,可以利用已计算的 值来加速后续计算。

C++ 实现

#include <bits/stdc++.h>
using namespace std;
 
// Kasai算法 O(n) 计算LCP数组
vector<int> buildLCP(const string& s, const vector<int>& sa) {
    int n = s.size();
    vector<int> rank(n), lcp(n);
    
    // rank[sa[i]] = i,即后缀i在排序后的排名
    for (int i = 0; i < n; i++) {
        rank[sa[i]] = i;
    }
    
    // h = LCP(i, i-1) 的初始值
    int h = 0;
    for (int i = 0; i < n; i++) {
        int r = rank[i];
        if (r > 0) {
            // 上一轮计算的 h
            int j = sa[r - 1];
            while (i + h < n && j + h < n && s[i + h] == s[j + h]) {
                h++;
            }
            lcp[r] = h;
            // h 至少减少1(如果 h > 0)
            if (h > 0) h--;
        } else {
            lcp[r] = 0;
        }
    }
    
    return lcp;
}

该算法的时间复杂度分析:指针 单调递增,而 的值在每次循环中最多增加 、减少 ,因此总操作次数为

应用

子串查找

给定模式串 和文本串 ,如何判断 是否出现在 中?

一种预处理 的后缀数组的方法:首先对 构建后缀数组和 数组;然后通过二分查找在 中寻找以 开头的后缀。二分查找时,利用 数组快速比较 对应后缀的公共前缀长度。

时间复杂度为 ,适合多模式匹配场景。

比较任意两个子串

设需要比较 的字典序大小( 为闭区间)。设这两个子串分别是后缀 的前缀。

比较过程:

  1. 找到 在后缀数组中的排名
  2. 假设 ,则子串 的字典序关系取决于 (即两后缀的公共前缀长度)与子串长度的关系:
    • ,则较短子串字典序更小;
    • 否则,比较第一个不同字符即可。

利用 (区间最小值查询)在 时间内回答 查询,整体比较可在 (预处理后)完成。

不同子串计数

统计一个字符串中不同子串的数量,是后缀数组的经典应用。

公式推导

字符串 的所有子串可以表示为所有后缀的前缀。设 ,后缀数组为

考虑排名第 的后缀 ,它贡献的新子串数量为:

其中 是该后缀的所有前缀数量,减去 个与前一个后缀重复的前缀。

因此,不同子串的总数为:

该公式利用了 的性质(虽然直接计算更直观)。

其他应用

  • 最长重复子串:在后缀数组中找到 值最大的相邻后缀;
  • 最长回文子串:将字符串与其反转拼接,利用后缀数组求解;
  • 字符串压缩:利用不同子串数量评估字符串的压缩潜力。

参考资料