后缀自动机(SAM):字符串处理的“万能瑞士军刀”

如果说KMP是解决单一模式串匹配的"精准手术刀",AC自动机是多模式串匹配的"战场扫描仪",那么后缀自动机就是处理所有子串问题的"万能瑞士军刀"------它用极致的数学对称性,将任意字符串的所有子串信息压缩到了一个精巧的图结构中。

引言

在编程竞赛和工业级文本处理中,我们总会遇到这样的困境:给你一个长度高达10^6的字符串,你需要回答"某个子串出现了多少次?""所有不同子串的总长度是多少?""多个字符串的最长公共子串是什么?"单看每个问题都有特定解法,但能否用一个数据结构通吃所有

传统的做法要么是建后缀数组(代码长、常数大),要么是建后缀树(空间开销大),而后缀自动机(Suffix Automaton, SAM) 提供了一种近乎完美的答案------它用**O(n)**的时间和空间,构造出一个包含原字符串所有子串信息的最小DFA(确定性有限状态自动机)。理解SAM,你就掌握了一把解决字符串问题的"万能钥匙"。

"如果说字符串是一幅藏宝图,那么SAM就是以最经济的方式,把图上所有路径(子串)都编码进了一个永不迷路的导航系统。"

前置知识

在开始之前,请确保你熟悉以下概念:

  1. 确定性有限状态自动机(DFA):由状态、转移边和终止状态组成,从起始状态出发,沿着字符边走,能唯一确定到达的状态。

  2. 字符串的子串与后缀:子串是连续的一段;后缀是以原串末尾结尾的特殊子串。

  3. 字典树(Trie):用于存储字符串集合的树形结构,共享公共前缀。

  4. KMP算法思想 :理解"失配指针"如何利用已匹配信息进行跳转(这对理解SAM的link指针至关重要)。

  5. 时间复杂度分析基础:理解均摊复杂度的概念。

第一章:从问题出发------为什么需要后缀自动机

1.1 一切子串问题的核心

假设我们有一个字符串 s = "abcbc"。我们想知道:

  • 子串 "bc" 出现了几次?(答案是2次)

  • 总共有多少个本质不同的子串?(答案是13个)

  • 是否存在一个子串 "cb" ?(答案是存在)

如果让你来设计一个数据结构解决所有这类问题,最朴素的想法是:把所有后缀插入到一棵Trie树中。这棵Trie树包含了所有子串(因为任何子串都是某个后缀的前缀),但它的大小是O(n²) 级别。对于n=10^5,这棵树的内存会直接爆炸。我们需要一种压缩方式。

1.2 从"集合"到"等价类"的思维飞跃

SAM的核心思想是:不需要存储每个子串的具体位置,只需要存储它们在所有后缀中出现位置的"规律" 。SAM利用一个叫 endpos 的概念(也叫right集合),把所有拥有相同出现位置集合的子串压缩成一个状态。

  • 例如,在"abcbc"中,子串"bc"出现在位置2和4(结束下标),子串"c"也出现在位置3和5。

  • 如果两个子串的endpos集合完全相同,那么它们在自动机中的"命运"就是一样的------它们可以被合并

通过这种合并,SAM的状态数被压缩到了2n ,转移边数压缩到了3n。这才是SAM既"万能"又"经济"的根本原因。

2.1 什么是endpos等价类

定义:对于一个子串tendpos(t) = ts中所有结束位置的集合(位置从1开始计数)。

例如,s = "ababa"

  • endpos("a") = {1, 3, 5}

  • endpos("ba") = {3, 5}

  • endpos("aba") = {3, 5}

注意,"ba""aba"endpos是相同的!它们属于同一个等价类。自动机会把它们压缩到同一个状态

这引出了一个重要结论:同一个状态里的所有子串,互为后缀,且长度连续 。也就是说,状态v代表了一系列长度在(len(link(v)), len(v)]之间的子串。

2.2 link指针------SAM的"失配"灵魂

如果说状态是SAM的"肉体",那么 link指针就是SAM的"灵魂"。

link(v)指向的是状态v中最长子串的后缀 中,属于另一个等价类的、长度最长的那个子串所在的状态。

如何理解link

你可以把link理解为KMP的next数组在自动机上的泛化。在KMP中,匹配失败时,我们利用next回退到最长的相同前后缀。在SAM中,当我们从一个状态顺着字符找不到转移时,我们就顺着link指针跳转,直到找到一个有目标字符转移的状态或回到根节点。

  • len[v]:状态v中最长子串的长度。

  • len[link[v]]:状态v中最短子串的长度减1。

关键性质 :顺着link指针从任意状态走到根节点,所经过的路径恰好覆盖了该状态中最长子串的所有不同后缀

第三章:构造算法------在线增量构建

3.1 算法流程的直观理解

SAM的构造是一个在线算法,即逐个向字符串末尾添加字符,动态维护整个自动机。代码极其精炼,通常不超过30行。

我们定义:

  • last:对应整个当前字符串的状态。

  • size:状态总数。

  • next:转移边(字典)。

  • link:后缀链接。

  • len:状态内最长子串长度。

核心代码模板及注释(C++)

cpp

复制代码
struct State {
    int len, link; // len: 最长子串长度, link: 后缀链接
    int next[26]; // 假设小写字母,实际可用map
};
State st[MAXN * 2]; // 2n空间
int sz, last;

void sam_init() {
    st[0].len = 0;
    st[0].link = -1;
    sz = 1;
    last = 0;
}

void sam_extend(char c) {
    int cur = sz++; // 新建当前字符对应的状态
    st[cur].len = st[last].len + 1;
    int p = last;
    // 步骤1: 从last开始,沿着link走,为没有'c'转移的状态添加指向cur的边
    while (p != -1 && !st[p].next[c]) {
        st[p].next[c] = cur;
        p = st[p].link;
    }
    if (p == -1) { // 情况1: 走到了根节点之上,link指向根
        st[cur].link = 0;
    } else { // 情况2: p有'c'转移
        int q = st[p].next[c];
        if (st[p].len + 1 == st[q].len) { // 情况A: 直接连接
            st[cur].link = q;
        } else { // 情况B: 需要分裂节点
            int clone = sz++; // 复制q
            st[clone].len = st[p].len + 1;
            memcpy(st[clone].next, st[q].next, sizeof(st[q].next)); // 复制转移
            st[clone].link = st[q].link;
            // 将p及其沿着link走的所有指向q的转移重定向到clone
            while (p != -1 && st[p].next[c] == q) {
                st[p].next[c] = clone;
                p = st[p].link;
            }
            st[q].link = st[cur].link = clone; // 更新q和cur的link
        }
    }
    last = cur; // 更新last
}

3.2 为什么需要"克隆"?

"克隆"是SAM构造中最烧脑的一步。简单来说,当我们添加新字符时,新状态cur需要一个正确的link。如果直接指向q不合法(因为q的长度太长了),我们就必须创建一个q的"副本"------clone

clone拥有q的全部转移边,但其len更小(等于st[p].len + 1),并且它的link指向q原来的link。这样做保证了自动机的状态数依然是线性的,并且所有状态的lenlink关系依然严格成立。

理解"克隆"的比喻

想象一家公司(状态q)业务扩张得太快,新人(cur)无法直接归入q部门。于是,公司把q的核心业务抽离出来,成立一个更具普适性的"子公司"(clone),让qcur都挂在clone下面。这样既保留了核心业务,又实现了灵活扩展。

第四章:经典例题精解------洛谷 P3804 【模板】后缀自动机 (SAM)

理论最终要服务于实战。我们以算法竞赛中最经典的模板题为例,展示SAM的完整落地。

4.1 题目呈现

题目来源 :洛谷 P3804

题目描述

给定一个只包含小写字母的字符串 SS(∣S∣≤106∣S∣≤106),请你求出 SS 的所有子串中,出现次数 ×× 该子串长度的最大值。

输入格式 :一行,一个只含小写字母的字符串。

输出格式:一行,一个整数,表示答案。

4.2 解题思路推导(如何求出现次数)

  1. 问题转化:我们要最大化 f(t)=出现次数(t)×∣t∣f(t)=出现次数(t)×∣t∣。

  2. 联系SAM状态 :SAM中每一个状态 vv 代表了一堆 长度连续且互为后缀 的子串(长度范围为 (len[link[v]], len[v]])。关键性质 :状态 vv 内所有子串的出现次数完全相同(因为它们拥有相同的 endpos 集合)!

  3. 求出现次数 :状态 vv 的 endpos 集合大小,就是该状态内子串的出现次数。

    • 初始时,我们将每次 extend 新建的 cur 节点(代表整个当前前缀)的 cnt 设为 1。

    • 克隆节点 clone 是复制出来的,并不代表某个具体的前缀,所以 cnt[clone] = 0

    • 构建完成后,沿着 link 指针从后向前累加cnt[link[v]] += cnt[v]。为什么?因为 link 指向的父节点是当前状态的最长严格后缀,父节点的 endpos 集合必然包含子节点的 endpos 集合。拓扑序由 len 决定------len 越大,在DAG中的层次越深。

  4. 统计答案 :遍历所有状态 vv(根节点0除外),当 cnt[v] > 1 时(出现多次的子串才有竞争力),计算 cnt[v] * len[v],取最大值。注意:我们只取 len[v],因为状态内长度最大的子串显然乘积最大,而其出现次数不变。

4.3 完整AC代码(C++17)

cpp

复制代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 1e6 + 5;

struct State {
    int len, link;
    int next[26];
} st[MAXN << 1]; // 开两倍空间

int sz, last;
ll cnt[MAXN << 1]; // 记录每个状态的endpos大小

void sam_init() {
    st[0].len = 0;
    st[0].link = -1;
    sz = 1;
    last = 0;
    memset(st[0].next, 0, sizeof(st[0].next));
}

void sam_extend(char ch) {
    int c = ch - 'a';
    int cur = sz++;
    st[cur].len = st[last].len + 1;
    cnt[cur] = 1; // 代表当前前缀的状态,出现次数初始为1
    memset(st[cur].next, 0, sizeof(st[cur].next));
    
    int p = last;
    while (p != -1 && !st[p].next[c]) {
        st[p].next[c] = cur;
        p = st[p].link;
    }
    if (p == -1) {
        st[cur].link = 0;
    } else {
        int q = st[p].next[c];
        if (st[p].len + 1 == st[q].len) {
            st[cur].link = q;
        } else {
            int clone = sz++;
            st[clone].len = st[p].len + 1;
            memcpy(st[clone].next, st[q].next, sizeof(st[q].next));
            st[clone].link = st[q].link;
            cnt[clone] = 0; // 克隆节点初始为0
            
            while (p != -1 && st[p].next[c] == q) {
                st[p].next[c] = clone;
                p = st[p].link;
            }
            st[q].link = st[cur].link = clone;
        }
    }
    last = cur;
}

char s[MAXN];
int b[MAXN << 1], topo[MAXN << 1]; // 基数排序辅助数组

int main() {
    scanf("%s", s);
    int n = strlen(s);
    sam_init();
    for (int i = 0; i < n; i++) sam_extend(s[i]);
    
    // 基数排序(计数排序)按len从小到大排序,等效于拓扑序
    int max_len = n;
    for (int i = 0; i < sz; i++) b[st[i].len]++;
    for (int i = 1; i <= max_len; i++) b[i] += b[i-1];
    for (int i = sz - 1; i >= 0; i--) topo[--b[st[i].len]] = i;
    
    ll ans = 0;
    // 逆序处理(len从大到小),累加endpos大小
    for (int i = sz - 1; i > 0; i--) {
        int v = topo[i];
        cnt[st[v].link] += cnt[v];
        if (cnt[v] > 1) ans = max(ans, cnt[v] * st[v].len);
    }
    printf("%lld\n", ans);
    return 0;
}

4.4 复杂度分析与解题心得

  • 时间复杂度:构造 O(n×∣Σ∣)O(n×∣Σ∣),其中 ∣Σ∣=26∣Σ∣=26(常数);基数排序和DP统计 O(n)O(n)。整体完美通过 106106 的数据规模。

  • 空间复杂度:状态数组 2n2n,转移数组 2n×262n×26,在 106106 下约占用 2×106×(4×27)≈216MB2×106×(4×27)≈216MB,需注意内存限制(通常512MB可通过)。

解题核心套路提炼

做SAM的题,心里要时刻挂着 link 。绝大多数的计数问题,都是先建SAM,然后利用 link 指针作为父边构建出 link 树,在树上做树形DP(或拓扑累加)。只要算出了每个状态的 cnt(即 endpos 大小),你就拥有了子串出现次数的上帝视角。

总结

后缀自动机是字符串处理领域一颗璀璨的明珠。它的美妙之处在于,用近乎完美的数学结构(endpos等价类)实现了对庞大子串信息的最小无损压缩。虽然其构造算法(尤其是克隆操作)初次接触时略显晦涩,但一旦理解了核心思想,它将为你打开一扇高效处理字符串问题的大门。

三个关键点

  1. 核心是压缩 :SAM通过endpos等价类,将O(n²)个子串信息无损压缩到O(n)个状态中。

  2. 灵魂是linklink指针是SAM的"失配"导航,是进行复杂匹配和DP的基础。

  3. 关键是DP :竞赛中90%的SAM题目,都是对link树进行拓扑DP,利用cnt累加计算出现次数。

"后缀自动机教会我们:真正的智慧不在于记录所有细节,而在于发现细节背后的规律,并将其提炼为最优雅的'等价'。"

参考文献与延伸阅读

  1. ACM-ICPC 算法实现与解析 - 陈志鹏(经典教程)

  2. 后缀自动机(Suffix Automaton)详解 - OI Wiki

  3. Blumer, A., et al. "The smallest automaton recognizing the subwords of a text." Theoretical Computer Science (1985). (SAM的原始论文)

  4. 洛谷 P3804 【模板】后缀自动机 (SAM) - 相关题解区

相关推荐
白白白小纯2 小时前
算法篇—链表的中间节点
c语言·数据结构·算法·leetcode
Frostnova丶3 小时前
(19)LeetCode 54. 螺旋矩阵
算法·leetcode·矩阵
Frostnova丶3 小时前
(18)LeetCode 73. 矩阵置零
算法·leetcode·矩阵
冻柠檬飞冰走茶3 小时前
PTA基础编程题目集 7-11 分段计算居民水费(C语言实现)
c语言·开发语言·算法
得物技术3 小时前
推荐系统体验的数字化突破:得物自动化评测平台的技术实践|AICon 文章整理
算法·架构·llm
VALENIAN瓦伦尼安教学设备3 小时前
转子/行星/平行轴齿轮箱综合故障模拟实验台可复现常见机械问题
大数据·数据库·人工智能·嵌入式硬件·算法
rannn_1113 小时前
【力扣hot100】哈希表专题——从两数之和到最长连续序列
java·算法·leetcode·哈希
小保CPP4 小时前
OpenCV C++基于极值区域滤波算法的场景文本检测(OCR)
c++·人工智能·opencv·算法·计算机视觉·ocr
来一碗刘肉面4 小时前
字符串模式匹配(朴素模式匹配算法与KMP算法)
数据结构·算法