C++ 后缀自动机(SAM):原理、实现与应用全解析

后缀自动机(Suffix Automaton, SAM)是处理字符串子串问题的高效数据结构,它能在 O(n) 时间 / 空间复杂度内构建字符串的压缩表示,支持子串存在性查询、子串出现次数统计、最长重复子串等经典问题。本文将从核心原理、结构定义、构建流程到实战应用,全面解析 SAM 的设计思想与 C++ 实现技巧。

一、SAM 的核心背景与优势

1.1 问题引入:传统方法的瓶颈

字符串子串问题(如统计所有不同子串数量、查找最长重复子串),传统方法(如后缀数组、暴力枚举)存在明显缺陷:

  • 暴力枚举:枚举所有子串需 O(n2) 时间,空间复杂度更高。
  • 后缀数组:构建需 O(nlogn) 时间,且部分查询操作(如子串出现次数)需额外处理。

SAM 的核心优势:

  • 线性构建:时间 / 空间复杂度均为 O(n)(n 为字符串长度)。
  • 压缩存储:通过状态转移和后缀链接,仅用 O(n) 状态表示所有子串(而非 O(n2))。
  • 高效查询:子串存在性、出现次数、最长匹配等操作均可在 O(len) 时间完成(len 为查询串长度)。

1.2 核心概念:子串的等价类

SAM 的本质是将字符串的所有子串按 "结束位置集合(endpos)" 分组:

  • endpos 定义 :对于子串 s,endpos(s) 是 s 在原字符串中所有结束位置的集合(位置从 0 或 1 开始,需统一)。例:字符串 ababa,子串 aba 的结束位置为 2、4 → endpos(aba) = {2,4}
  • 等价类 :若两个子串的 endpos 完全相同,则归为同一等价类(对应 SAM 中的一个 状态(State))。
  • 状态的 len 属性 :同一等价类中最长子串的长度(记为 len),最短子串长度为 link.len + 1link 为该状态的后缀链接)。

二、SAM 的核心结构

2.1 状态(State)的定义

SAM 的核心是状态节点和转移边,每个状态包含 4 个核心属性:

cpp

运行

复制代码
struct State {
    int len;        // 该状态对应最长子串的长度
    int link;       // 后缀链接(指向另一个状态)
    map<char, int> next;  // 转移边:字符 → 目标状态编号
    int cnt;        // 可选:该状态对应子串的出现次数(需额外统计)
};
  • len:当前等价类的最长子串长度。
  • link:后缀链接(核心!),指向更短的等价类状态,构成 "后缀链",用于处理子串的后缀关系。
  • next:转移边,模拟在当前子串末尾添加字符后的状态跳转。
  • cnt:扩展属性,记录该状态对应子串的出现次数(需在构建后通过拓扑排序统计)。

2.2 后缀链接(link)的意义

后缀链接是 SAM 的灵魂,满足两个核心性质:

  1. 长度递减link 指向的状态,其 len 严格小于当前状态的 len
  2. 后缀包含 :当前状态的所有子串的后缀,对应 link 状态的子串。例:状态 A(len=5)的 link 指向状态 B(len=3),则状态 A 中长度为 4、5 的子串的后缀(长度 ≤3)由状态 B 表示。

2.3 SAM 的整体结构

SAM 是一个 有向无环图(DAG)

  • 节点:状态(等价类)。
  • 边:转移边(字符驱动)。
  • 后缀链接:构成一棵反向树(根节点为初始状态,记为 size=1len=0link=-1)。

三、SAM 的构建算法(增量法)

SAM 的构建采用 增量法:从空串开始,逐个添加字符,动态维护状态和转移边。核心变量包括:

  • sa:存储所有状态的数组(初始包含根状态 sa[0])。
  • last:最后一个状态的编号(初始为 0,即根状态)。
  • size:状态总数(初始为 1)。

3.1 构建步骤(核心逻辑)

以字符串 s 为例,逐个添加字符 c = s[i]

步骤 1:创建新状态 cur

新状态 curlen = last.len + 1(对应以 i 为结束位置的最长子串),size++

步骤 2:从 last 沿后缀链遍历,添加转移边

last 出发,沿 link 向上遍历所有状态 p

  • p 无字符 c 的转移边,则添加 p.next[c] = cur,继续遍历 p.link
  • 若找到有字符 c 转移边的状态 p,记转移目标为 q,进入步骤 3。
  • 若遍历到根节点(p=-1)仍未找到,则设置 cur.link = 0,结束。
步骤 3:处理转移冲突(分裂状态 q

找到 p.next[c] = q 后,分两种情况:

  • 情况 3.1q.len == p.len + 1 → 直接将 cur.link = q,结束。
  • 情况 3.2q.len > p.len + 1 → 分裂 q 为新状态 clone
    1. clone 复制 q 的所有属性(len = p.len + 1next = q.nextlink = q.link)。
    2. p 沿后缀链遍历所有指向 q 的状态 p',将 p'.next[c] 改为 clone
    3. q.linkcur.link 均指向 clone
    4. size++,完成分裂。
步骤 4:更新 last

设置 last = cur,继续处理下一个字符。

3.2 C++ 完整实现(基础版)

cpp

运行

复制代码
#include <iostream>
#include <vector>
#include <map>
#include <algorithm>
using namespace std;

struct State {
    int len;        // 最长子串长度
    int link;       // 后缀链接
    map<char, int> next;  // 转移边
    State() : len(0), link(-1) {}
};

class SAM {
public:
    vector<State> sa;
    int last;       // 最后一个状态
    int size;       // 状态总数

    SAM() {
        sa.emplace_back();  // 根状态(编号0)
        last = 0;
        size = 1;
    }

    // 增量添加字符c
    void add(char c) {
        int cur = size++;  // 步骤1:创建新状态cur
        sa.emplace_back();
        sa[cur].len = sa[last].len + 1;

        int p = last;
        // 步骤2:沿后缀链添加转移边
        while (p != -1 && !sa[p].next.count(c)) {
            sa[p].next[c] = cur;
            p = sa[p].link;
        }

        if (p == -1) {
            // 遍历到根节点,cur的后缀链接为根
            sa[cur].link = 0;
        } else {
            int q = sa[p].next[c];
            if (sa[p].len + 1 == sa[q].len) {
                // 情况3.1:无需分裂
                sa[cur].link = q;
            } else {
                // 情况3.2:分裂q为clone
                int clone = size++;
                sa.emplace_back();
                sa[clone].len = sa[p].len + 1;
                sa[clone].next = sa[q].next;  // 复制转移边
                sa[clone].link = sa[q].link;  // 复制后缀链接

                // 更新所有指向q的转移边为clone
                while (p != -1 && sa[p].next[c] == q) {
                    sa[p].next[c] = clone;
                    p = sa[p].link;
                }
                // 更新q和cur的后缀链接
                sa[q].link = clone;
                sa[cur].link = clone;
            }
        }

        last = cur;  // 更新last为新状态
    }

    // 构建SAM(输入字符串)
    void build(const string& s) {
        for (char c : s) {
            add(c);
        }
    }
};

3.3 关键细节解析

  1. 分裂状态的必要性 :当 q.len > p.len + 1 时,q 对应的最长子串长度超过 p 加字符 c 的长度,说明 q 包含了 "不应该包含" 的子串,需分裂为 clone(对应 p.len+1 长度的子串)和原 q(对应更长的子串)。
  2. 后缀链接的维护:后缀链接确保所有子串的后缀关系被正确表示,是 SAM 能压缩存储所有子串的核心。
  3. 状态数量:理论上,SAM 的状态数不超过 2n−1(n 为字符串长度),因此空间复杂度为 O(n)。

四、SAM 的核心操作与扩展

4.1 基础操作:子串存在性查询

判断字符串 t 是否是原字符串的子串,只需模拟转移边遍历:

cpp

运行

复制代码
// 查询t是否是原字符串的子串
bool query(const string& t) {
    int p = 0;  // 从根状态开始
    for (char c : t) {
        if (!sa[p].next.count(c)) {
            return false;  // 无转移边,不存在
        }
        p = sa[p].next[c];
    }
    return true;
}

时间复杂度:O(len(t))。

4.2 扩展操作 1:统计子串出现次数

SAM 中状态的 cnt 表示对应子串的出现次数,需通过 拓扑排序 统计(按 len 降序遍历):

cpp

运行

复制代码
// 统计每个状态的出现次数
void countOccurrences() {
    // 步骤1:初始化cnt(仅包含终止状态的cnt=1)
    vector<int> cnt(size, 0);
    int p = last;
    while (p != 0) {
        cnt[p] = 1;
        p = sa[p].link;
    }

    // 步骤2:按len降序排序状态(拓扑序)
    vector<int> order(size);
    for (int i = 0; i < size; ++i) {
        order[i] = i;
    }
    sort(order.begin(), order.end(), [&](int a, int b) {
        return sa[a].len > sa[b].len;
    });

    // 步骤3:拓扑更新cnt
    for (int u : order) {
        if (sa[u].link != -1) {
            cnt[sa[u].link] += cnt[u];
        }
    }

    // 将cnt赋值给每个状态
    for (int i = 0; i < size; ++i) {
        sa[i].cnt = cnt[i];
    }
}

// 查询子串t的出现次数
int getOccurrence(const string& t) {
    int p = 0;
    for (char c : t) {
        if (!sa[p].next.count(c)) {
            return 0;  // 子串不存在
        }
        p = sa[p].next[c];
    }
    return sa[p].cnt;
}

核心逻辑:

  • 终止状态(last 沿后缀链的所有状态)的 cnt 初始化为 1(对应原字符串的后缀)。
  • len 降序遍历(拓扑序),将子状态的 cnt 累加到父状态(后缀链接指向的状态)。

4.3 扩展操作 2:统计所有不同子串数量

SAM 中每个状态贡献的不同子串数为 len - link.len,总和即为所有不同子串数量:

cpp

运行

复制代码
// 统计所有不同子串的数量
long long countDistinctSubstrings() {
    long long res = 0;
    for (int i = 1; i < size; ++i) {  // 跳过根状态
        res += sa[i].len - sa[sa[i].link].len;
    }
    return res;
}

例:字符串 ab 的不同子串为 a, b, ab → 总数 3,计算:

  • 状态 1(len=1, link=0):1-0=1(对应 a)。
  • 状态 2(len=2, link=0):2-0=2(对应 b, ab)。
  • 总和:1+2=3,正确。

4.4 扩展操作 3:查找最长重复子串

最长重复子串是出现次数 ≥2 的最长子串,只需遍历所有状态,找到 cnt≥2len 最大的状态:

cpp

运行

复制代码
// 查找最长重复子串的长度
int longestRepeatedSubstring() {
    int max_len = 0;
    for (int i = 1; i < size; ++i) {
        if (sa[i].cnt >= 2 && sa[i].len > max_len) {
            max_len = sa[i].len;
        }
    }
    return max_len;
}

五、SAM 的典型应用场景

5.1 单字符串问题

  • 统计所有不同子串的数量。
  • 查找最长重复子串、最长回文子串(需结合回文自动机或预处理)。
  • 子串出现次数统计、子串的第 k 小子串查询。

5.2 多字符串问题

  • 多字符串最长公共子串:构建多个字符串的 SAM,合并状态后查找公共转移路径的最长长度。
  • 字符串匹配:构建模式串的 SAM,在文本串中匹配所有出现位置。
  • 字典序第 k 小子串:对 SAM 的转移边按字符排序,结合状态的子串数量进行二分查找。

5.3 实战例题(LeetCode)

  • LeetCode 1044. 最长重复子串:用 SAM 可在 O(n) 时间解决(暴力法 O(n2) 超时)。
  • LeetCode 3045. 统计前后缀下标对 IV:结合 SAM 统计子串出现次数。

六、SAM 的优化与注意事项

6.1 性能优化

  • 转移边替换 :用 unordered_map 或数组代替 map(若字符集为小写字母,可直接用 int next[26]),减少时间开销:

    cpp

    运行

    复制代码
    // 字符集为小写字母时,优化转移边
    struct State {
        int len, link;
        int next[26];  // 代替map<char, int>
        State() : len(0), link(-1) {
            memset(next, -1, sizeof(next));  // 初始化为-1
        }
    };
  • 拓扑排序优化 :预先存储状态的 len,用桶排序代替快速排序,时间从 O(nlogn) 降至 O(n)。

6.2 常见错误

  • 结束位置的定义:需统一 endpos 的位置编号(0 或 1 开始),避免 len 计算错误。
  • 分裂状态的漏处理:未正确更新指向 q 的转移边,导致查询结果错误。
  • 字符集处理:若字符集包含大写字母、数字等,需确保转移边的键类型匹配。

七、总结

SAM 是字符串处理的 "瑞士军刀",核心优势是 线性时空复杂度高效的子串操作 。其设计思想的核心是通过 endpos 等价类和后缀链接,将 O(n2) 的子串压缩为 O(n) 的状态表示。

掌握 SAM 的关键:

  1. 理解 endpos 和等价类的定义(SAM 的理论基础)。
  2. 熟练掌握增量构建算法(尤其是分裂状态的逻辑)。
  3. 灵活运用后缀链接和拓扑排序扩展 SAM 的功能。

SAM 是竞赛和工业界处理大规模字符串问题的首选数据结构,结合后缀数组、AC 自动机等,可解决绝大多数字符串难题。

相关推荐
小白学大数据2 分钟前
Java 爬虫对百科词条分类信息的抓取与处理
java·开发语言·爬虫
Gold_Dino10 分钟前
agc011_e 题解
算法
zmzb010320 分钟前
C++课后习题训练记录Day56
开发语言·c++
编程小Y22 分钟前
C++ Insights
开发语言·c++
bubiyoushang88825 分钟前
基于蚁群算法的直流电机PID参数整定 MATLAB 实现
数据结构·算法·matlab
风筝在晴天搁浅33 分钟前
hot100 240.搜索二维矩阵Ⅱ
算法·矩阵
girl-072641 分钟前
2025.12.24代码分析
算法
Coder_Boy_1 小时前
Spring 核心思想与企业级最佳特性(实践级)事务相关
java·数据库·spring
王老师青少年编程1 小时前
csp信奥赛C++标准模板库STL案例应用5
c++·stl·set·集合·标准模板库·csp·信奥赛
永远睡不够的入1 小时前
直接插入排序、希尔排序、选择排序
数据结构·算法·排序算法