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 自动机等,可解决绝大多数字符串难题。

相关推荐
照物华1 小时前
MySQL 软删除 (Soft Delete) 与唯一索引 (Unique Constraint) 的冲突与解决
java·mysql
gihigo19981 小时前
一维光栅结构严格耦合波分析(RCWA)求解器
算法
海边夕阳20061 小时前
【每天一个AI小知识】:什么是人脸识别?
人工智能·经验分享·python·算法·分类·人脸识别
张np1 小时前
java基础-Vector(向量)
java
光头程序员1 小时前
学习笔记——常识解答之垃圾回收机制
java·笔记·学习
liu****1 小时前
13.数据在内存中的存储
c语言·开发语言·数据结构·c++·算法
我不会写代码njdjnssj1 小时前
贪心算法+动态规划
算法·贪心算法·动态规划
Unstoppable221 小时前
代码随想录算法训练营第 55 天 | 53. 寻宝(Prim + Kruskal)
数据结构·算法··kruskal·prim
橘颂TA1 小时前
【剑斩OFFER】算法的暴力美学——数青蛙
算法·leetcode·动态规划·结构与算法