C++ 后缀数组(SA):原理、实现与应用全解析

标题

  • [C++ 后缀数组(SA):原理、实现与应用全解析](#C++ 后缀数组(SA):原理、实现与应用全解析)
    • 一、后缀数组的核心背景与优势
      • [1.1 问题引入:后缀处理的瓶颈](#1.1 问题引入:后缀处理的瓶颈)
      • [1.2 核心概念定义](#1.2 核心概念定义)
    • 二、后缀数组的构建:倍增法(最易实现)
      • [2.1 倍增法核心思想](#2.1 倍增法核心思想)
      • [2.2 关键:二元组排序](#2.2 关键:二元组排序)
      • [2.3 倍增法 C++ 实现(完整可运行)](#2.3 倍增法 C++ 实现(完整可运行))
      • [2.4 优化:基数排序(降低时间复杂度)](#2.4 优化:基数排序(降低时间复杂度))
    • 三、最长公共前缀(LCP)数组:高度数组
      • [3.1 LCP 数组的定义与性质](#3.1 LCP 数组的定义与性质)
      • [3.2 LCP 数组的构建(O(n) 算法)](#3.2 LCP 数组的构建(O(n) 算法))
      • [3.3 完整示例(SA + LCP)](#3.3 完整示例(SA + LCP))
    • 四、后缀数组的核心应用
      • [4.1 应用1:子串存在性查询(二分)](#4.1 应用1:子串存在性查询(二分))
      • [4.2 应用2:最长重复子串](#4.2 应用2:最长重复子串)
      • [4.3 应用3:统计不同子串的数量](#4.3 应用3:统计不同子串的数量)
      • [4.4 应用4:最长公共子串(两个字符串)](#4.4 应用4:最长公共子串(两个字符串))
    • 五、常见错误与最佳实践
      • [5.1 常见错误](#5.1 常见错误)
      • [5.2 最佳实践](#5.2 最佳实践)
    • 六、后缀数组与其他字符串结构的对比
    • 七、总结

C++ 后缀数组(SA):原理、实现与应用全解析

后缀数组(Suffix Array, SA)是处理字符串子串问题的核心数据结构,它将字符串的所有后缀按字典序排序并存储其起始位置,能在 (O(n \log n)) 时间复杂度内构建,支持最长公共前缀(LCP)、最长重复子串、子串排名等经典问题。本文将从核心原理、倍增法构建、LCP 数组计算到实战应用,全面解析后缀数组的设计思想与 C++ 实现技巧。

一、后缀数组的核心背景与优势

1.1 问题引入:后缀处理的瓶颈

字符串的后缀是子串问题的核心(如最长重复子串本质是两个后缀的最长公共前缀),直接存储所有后缀的时间/空间复杂度为 (O(n^2)),完全不可用:

  • 字符串 s = "ababc",其所有后缀为:
    • s[0:] = "ababc"
    • s[1:] = "babc"
    • s[2:] = "abc"
    • s[3:] = "bc"
    • s[4:] = "c"

后缀数组的核心价值:

  • 压缩存储:仅存储后缀的起始位置(而非完整后缀),空间复杂度 (O(n))。
  • 有序性:按字典序排序后,可通过二分快速查询子串是否存在,或计算 LCP。
  • 通用性:能解决几乎所有字符串子串问题,是竞赛/工业界的"通用工具"。

1.2 核心概念定义

给定字符串 (s[0...n-1]),定义:

  1. 后缀suffix(i) = s[i...n-1](以 (i) 为起始位置的后缀)。
  2. 后缀数组(sa)sa[k] = i 表示 suffix(i) 是字典序第 (k) 小的后缀((k) 从 0 开始)。
  3. 排名数组(rk)rk[i] = k 表示 suffix(i) 的字典序排名为 (k)(rk[sa[k]] = k,与 sa 互逆)。
  4. LCP 数组lcp[k] 表示 suffix(sa[k])suffix(sa[k-1]) 的最长公共前缀长度((k \geq 1))。

示例 :字符串 s = "ababc"(n=5)

后缀起始位置 i 后缀 排名 rk[i] sa[k] (k=rk[i])
0 ababc 0 sa[0] = 0
1 babc 3 sa[1] = 2
2 abc 1 sa[2] = 4
3 bc 4 sa[3] = 1
4 c 2 sa[4] = 3

对应的数组:

  • sa = [0, 2, 4, 1, 3]
  • rk = [0, 3, 1, 4, 2]
  • lcp = [0, 2, 0, 1, 0]lcp[1]=2 表示 suffix(0)suffix(2) 的 LCP 为 2,即 "ab")。

二、后缀数组的构建:倍增法(最易实现)

后缀数组的构建算法有多种(倍增法、DC3 法、SA-IS 法),其中倍增法实现简单、逻辑清晰,是新手首选(时间复杂度 (O(n \log n)))。

2.1 倍增法核心思想

将后缀的比较转化为"长度为 (2^k) 的前缀"的比较,逐步倍增长度直到覆盖整个字符串:

  1. 初始状态(k=0) :比较长度为 (1) 的前缀(即单个字符),得到初始排名 rk0
  2. 倍增迭代 :对每个 (k),比较长度为 (2^k) 的前缀(拆分为"前 (2^{k-1}) 字符" + "后 (2^{k-1}) 字符"),得到新排名 rk1
  3. 终止条件:当 (2^k \geq n) 时,排名稳定,得到最终 sa 数组。

2.2 关键:二元组排序

对于长度 (2^k) 的前缀,每个后缀 (i) 对应一个二元组:

  • 第一关键字:rk[i](前 (2^{k-1}) 字符的排名)。
  • 第二关键字:rk[i + 2^{k-1}](后 (2^{k-1}) 字符的排名,若超出字符串长度则为 -1)。

通过对二元组排序,得到新的排名数组。

2.3 倍增法 C++ 实现(完整可运行)

cpp 复制代码
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <cstring>
using namespace std;

// 构建后缀数组(sa)和排名数组(rk)
// s: 输入字符串,sa: 输出后缀数组,rk: 输出排名数组
void buildSA(const string& s, vector<int>& sa, vector<int>& rk) {
    int n = s.size();
    sa.resize(n);
    rk.resize(n);
    // 初始:按单个字符排序(k=0,长度1)
    for (int i = 0; i < n; ++i) {
        sa[i] = i;
        rk[i] = s[i];  // 初始排名为字符的ASCII值
    }

    // 倍增迭代:k 表示当前比较长度为 2^k
    for (int k = 1; k < n; k <<= 1) {
        // 排序规则:先按第一关键字(rk[i]),再按第二关键字(rk[i+k])
        auto cmp = [&](int i, int j) {
            if (rk[i] != rk[j]) return rk[i] < rk[j];
            // 超出长度的部分排名更小
            int ri = (i + k < n) ? rk[i + k] : -1;
            int rj = (j + k < n) ? rk[j + k] : -1;
            return ri < rj;
        };
        sort(sa.begin(), sa.end(), cmp);

        // 更新排名数组 rk
        vector<int> new_rk(n);
        new_rk[sa[0]] = 0;
        for (int i = 1; i < n; ++i) {
            // 若 sa[i] 和 sa[i-1] 的二元组相同,排名相同
            if (cmp(sa[i-1], sa[i])) {
                new_rk[sa[i]] = new_rk[sa[i-1]] + 1;
            } else {
                new_rk[sa[i]] = new_rk[sa[i-1]];
            }
        }
        rk.swap(new_rk);

        // 优化:若排名已唯一,提前终止
        if (rk[sa.back()] == n - 1) break;
    }
}

// 测试代码(基础SA构建)
int main() {
    string s = "ababc";
    vector<int> sa, rk;
    buildSA(s, sa, rk);

    cout << "字符串:" << s << endl;
    cout << "后缀数组 sa:";
    for (int x : sa) cout << x << " ";  // 输出:0 2 4 1 3
    cout << endl;

    cout << "排名数组 rk:";
    for (int x : rk) cout << x << " ";  // 输出:0 3 1 4 2
    cout << endl;

    return 0;
}

2.4 优化:基数排序(降低时间复杂度)

上述实现用 sort 排序,时间复杂度为 (O(n \log^2 n)),可通过基数排序优化为 (O(n \log n)):

cpp 复制代码
// 基数排序优化版 buildSA(字符集为ASCII)
void buildSA_Radix(const string& s, vector<int>& sa, vector<int>& rk) {
    int n = s.size(), m = 128;  // m 为字符集大小
    sa.resize(n);
    rk.resize(n);
    vector<int> cnt(m, 0), tmp(n);

    // 初始:按单个字符计数排序
    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[rk[i]]] = i;

    // 倍增迭代
    for (int k = 1; k < n; k <<= 1) {
        // 按第二关键字排序(sa 已经是按第一关键字排序的结果)
        int num = 0;
        for (int i = n - k; i < n; ++i) tmp[num++] = i;  // 无第二关键字的排在前面
        for (int i = 0; i < n; ++i) if (sa[i] >= k) tmp[num++] = sa[i] - k;

        // 按第一关键字基数排序
        fill(cnt.begin(), cnt.end(), 0);
        for (int i = 0; i < n; ++i) cnt[rk[tmp[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;
        num = 1;
        for (int i = 1; i < n; ++i) {
            int a = sa[i], b = sa[i-1];
            int a1 = (a + k < n) ? tmp[a + k] : -1;
            int b1 = (b + k < n) ? tmp[b + k] : -1;
            rk[a] = (tmp[a] == tmp[b] && a1 == b1) ? num - 1 : num++;
        }

        if (num == n) break;
        m = num;  // 更新字符集大小为当前排名数
    }
}

三、最长公共前缀(LCP)数组:高度数组

LCP 数组是后缀数组的"黄金搭档",几乎所有后缀数组的应用都依赖 LCP 数组。

3.1 LCP 数组的定义与性质

  • 定义lcp[i] 表示 suffix(sa[i])suffix(sa[i-1]) 的最长公共前缀长度((i \geq 1)),lcp[0] = 0
  • 核心性质(H 定理) :设 h[i] = lcp[rk[i]](表示 suffix(i) 和其前一个后缀的 LCP 长度),则:
    h [ i ] ≥ h [ i − 1 ] − 1 h[i] \geq h[i-1] - 1 h[i]≥h[i−1]−1
    该性质使得 LCP 数组可在 (O(n)) 时间内计算。

3.2 LCP 数组的构建(O(n) 算法)

cpp 复制代码
// 构建LCP数组(需先构建sa和rk)
void buildLCP(const string& s, const vector<int>& sa, const vector<int>& rk, vector<int>& lcp) {
    int n = s.size();
    lcp.resize(n, 0);
    int k = 0;  // 当前LCP长度
    for (int i = 0; i < n; ++i) {
        if (rk[i] == 0) continue;  // 排名第0的后缀无前驱
        if (k > 0) k--;  // 利用H定理优化
        int j = sa[rk[i] - 1];  // 前驱后缀的起始位置
        // 直接比较字符,扩展LCP长度
        while (i + k < n && j + k < n && s[i + k] == s[j + k]) {
            k++;
        }
        lcp[rk[i]] = k;  // lcp[rk[i]] 对应 h[i]
    }
}

3.3 完整示例(SA + LCP)

cpp 复制代码
int main() {
    string s = "ababc";
    vector<int> sa, rk, lcp;

    // 构建SA和RK
    buildSA_Radix(s, sa, rk);
    // 构建LCP
    buildLCP(s, sa, rk, lcp);

    cout << "字符串:" << s << endl;
    cout << "sa: "; for (int x : sa) cout << x << " "; cout << endl;  // 0 2 4 1 3
    cout << "rk: "; for (int x : rk) cout << x << " "; cout << endl;  // 0 3 1 4 2
    cout << "lcp: "; for (int x : lcp) cout << x << " "; cout << endl; // 0 2 0 1 0

    return 0;
}

四、后缀数组的核心应用

4.1 应用1:子串存在性查询(二分)

判断字符串 t 是否是 s 的子串,只需在 sa 数组中二分查找是否存在以 t 为前缀的后缀:

cpp 复制代码
// 二分查找子串t是否存在于s中
bool findSubstring(const string& s, const vector<int>& sa, const string& t) {
    int n = s.size(), m = t.size();
    if (m > n) return false;

    // 二分查找区间 [l, r]
    int l = 0, r = n - 1;
    while (l <= r) {
        int mid = (l + r) / 2;
        int pos = sa[mid];
        // 比较 s[pos..pos+m-1] 和 t
        int cmp = s.compare(pos, m, t);
        if (cmp == 0) return true;
        else if (cmp < 0) l = mid + 1;
        else r = mid - 1;
    }
    return false;
}

4.2 应用2:最长重复子串

最长重复子串是 LCP 数组中的最大值(需保证两个后缀不重叠,可选):

cpp 复制代码
// 查找最长重复子串的长度(可重叠)
int longestRepeatedSubstring(const vector<int>& lcp) {
    int max_len = 0;
    for (int x : lcp) {
        max_len = max(max_len, x);
    }
    return max_len;
}

// 查找最长不重叠重复子串
int longestNonOverlapRepeated(const string& s, const vector<int>& sa, const vector<int>& lcp) {
    int n = s.size(), max_len = 0;
    for (int i = 1; i < n; ++i) {
        if (lcp[i] <= max_len) continue;
        // 检查两个后缀是否重叠
        int a = sa[i], b = sa[i-1];
        if (abs(a - b) >= lcp[i]) {
            max_len = lcp[i];
        }
    }
    return max_len;
}

4.3 应用3:统计不同子串的数量

字符串的所有不同子串数量 = 总子串数 - 重复子串数:
总不同子串数 = n ( n + 1 ) 2 − ∑ i = 1 n − 1 l c p [ i ] 总不同子串数 = \frac{n(n+1)}{2} - \sum_{i=1}^{n-1} lcp[i] 总不同子串数=2n(n+1)−i=1∑n−1lcp[i]

cpp 复制代码
// 统计不同子串的数量
long long countDistinctSubstrings(int n, const vector<int>& lcp) {
    long long total = (long long)n * (n + 1) / 2;
    for (int i = 1; i < n; ++i) {
        total -= lcp[i];
    }
    return total;
}

4.4 应用4:最长公共子串(两个字符串)

将两个字符串拼接为 s = s1 + '#' + s2,构建 SA 和 LCP 数组,找到跨 # 的最大 LCP:

cpp 复制代码
// 查找s1和s2的最长公共子串长度
int longestCommonSubstring(const string& s1, const string& s2) {
    string s = s1 + '#' + s2;
    int n1 = s1.size(), n = s.size();
    vector<int> sa, rk, lcp;
    buildSA_Radix(s, sa, rk);
    buildLCP(s, sa, rk, lcp);

    int max_len = 0;
    for (int i = 1; i < n; ++i) {
        int a = sa[i], b = sa[i-1];
        // 检查是否跨#(一个在s1,一个在s2)
        bool a_in_s1 = (a < n1), b_in_s1 = (b < n1);
        if (a_in_s1 != b_in_s1) {
            max_len = max(max_len, lcp[i]);
        }
    }
    return max_len;
}

五、常见错误与最佳实践

5.1 常见错误

  1. 字符集处理 :未考虑特殊字符(如拼接字符串时的 #),导致排序错误 → 确保拼接字符不在原字符串中。
  2. 边界条件 :计算 LCP 时未处理 rk[i] == 0 的情况,导致数组越界 → 提前跳过排名为0的后缀。
  3. 倍增终止条件 :未检查 num == n,导致多余迭代 → 排名唯一时立即终止。
  4. LCP 与 h 数组混淆lcp[i] 对应 sa[i]h[i] 对应 rk[i],混淆会导致结果错误。

5.2 最佳实践

  1. 排序优化 :小规模字符串用 sort 实现(易调试),大规模用基数排序(效率高)。
  2. 字符集扩展 :若字符集包含中文/Unicode,先将字符串映射为整数数组(如 vector<int> s_int)。
  3. 空间优化 :sa/rk/lcp 均可用 vector<int> 存储,预先 reserve 空间避免扩容。
  4. 调试技巧 :打印 sa/rk/lcp 数组,结合手动计算的结果验证(如示例 ababc)。

六、后缀数组与其他字符串结构的对比

数据结构 构建时间 空间 核心优势 适用场景
后缀数组 (O(n \log n)) (O(n)) 通用,支持所有子串问题 多字符串比较、最长公共子串
后缀自动机 (O(n)) (O(n)) 线性时间构建,查询高效 单字符串子串统计、重复子串
AC 自动机 (O(\sum len)) (O(\sum len)) 多模式串匹配 敏感词过滤、日志关键词提取
KMP 算法 (O(n + m)) (O(m)) 单模式串匹配 子串查找、最小循环节

选择建议

  • 单字符串子串统计:优先用 SAM(线性时间)。
  • 多字符串比较(如最长公共子串):优先用后缀数组。
  • 多模式串匹配:用 AC 自动机。
  • 单模式串匹配:用 KMP。

七、总结

后缀数组是字符串处理的"万能工具",核心要点可总结为:

  1. 核心结构:sa(后缀起始位置排序)、rk(排名)、lcp(最长公共前缀),三者相辅相成。
  2. 构建方法:倍增法(易实现)+ 基数排序(高效),时间复杂度 (O(n \log n))。
  3. 核心性质:H 定理使得 LCP 数组可线性构建,是应用的关键。
  4. 应用场景:子串查询、最长重复子串、不同子串统计、最长公共子串等。

掌握后缀数组的关键:

  • 理解倍增法的二元组排序逻辑(核心是"分阶段比较")。
  • 区分 sa/rk/lcp 的定义与关系(互逆/关联)。
  • 熟练运用 LCP 数组解决各类子串问题。

后缀数组是 C++ 算法竞赛的必考知识点,也是工业界处理大规模字符串的核心工具,结合后缀自动机可解决几乎所有字符串难题。

相关推荐
ohoy2 小时前
RedisTemplate 使用之Set
java·开发语言·redis
hui函数2 小时前
如何解决 pip install 编译报错 ‘cl.exe’ not found(缺少 VS C++ 工具集)问题
开发语言·c++·pip
云栖梦泽2 小时前
易语言Windows桌面端「本地AI知识管理+办公文件批量自动化处理」双核心系统
开发语言
8***f3952 小时前
Spring容器初始化扩展点:ApplicationContextInitializer
java·后端·spring
r_oo_ki_e_2 小时前
java22--常用类
java·开发语言
AI小怪兽2 小时前
轻量、实时、高精度!MIE-YOLO:面向精准农业的多尺度杂草检测新框架 | MDPI AgriEngineering 2026
开发语言·人工智能·深度学习·yolo·无人机
码农小韩2 小时前
基于Linux的C++学习——循环
linux·c语言·开发语言·c++·算法
linweidong2 小时前
C++ 中避免悬挂引用的企业策略有哪些?
java·jvm·c++
用户93761147581612 小时前
并发编程三大特性
java·后端