概述

后缀自动机(Suffix Automaton,简称 SAM)是一种用于处理字符串问题的强大数据结构。它是一个确定性有限自动机(DFA),接受给定字符串的所有后缀,并且是所有接受这些后缀的 DFA 中状态数最少的【1】

后缀自动机的核心思想是:将字符串的所有子串压缩成一个有限状态机,每个状态代表一组具有相同「出现位置集合」的子串(即 endpos 等价类)。这种压缩方式使得我们可以用 的时间复杂度和空间复杂度(实际实现中空间约为 个状态)来处理大多数字符串查询问题。

endpos 等价类

设字符串 的长度为 ,对于 的任意子串 ,定义 为所有结束位置的集合:

如果两个子串 满足 ,则称它们属于同一个 endpos 等价类

性质

endpos 等价类具有以下重要性质【1】

  1. 同一等价类中的子串长度连续:若等价类中子串的最大长度为 ,最小长度为 ,则该类包含所有长度为 的子串。
  2. 后缀链接关系:较短子串的后缀可能属于另一个等价类,这种关系构成一条链。

后缀链接是后缀自动机中连接不同状态的「指针」,又称 link

设状态 代表等价类 ,包含所有长度为 的子串(其中 是该类中最长子串的长度,)。则 的后缀链接 指向另一个状态 ,满足:

  • 代表的等价类包含所有 中子串的真后缀
  • 是满足这一条件的状态中 最大的(即最长后缀所在的等价类)

性质

  • 所有状态的后缀链接构成一棵树,称为 link 树
  • 根节点(初始状态)的
  • 从任意状态沿 link 向上走,会依次得到该状态代表子串的:后缀 → 后缀的后缀 → … → 空串

状态结构

后缀自动机的每个状态存储以下信息:

属性含义
len该状态代表的最长子串长度(即
link后缀链接指向的状态编号
next转移函数, 表示从该状态接受字符 后的目标状态

构造算法

后缀自动机采用在线算法构造,每次读入一个新字符 ,执行 extend(c) 操作。算法保持自动机的最小性,确保始终是正确的 DFA【1】

extend 操作

设当前字符串为 ,已构造好的自动机对应 的所有子串。现在要在末尾添加字符 ,得到新字符串

步骤如下:

  1. 创建新状态:新建状态 ,令 (其中 是添加字符前代表整个字符串 的状态)

  2. 沿后缀链接寻找插入位置:从 开始,沿 link 向上跳,找到第一个可以通过字符 转移的状态 ,即满足

  3. 建立转移:令

  4. 处理未找到 的情况:如果一直跳到根节点()仍未找到可转移的状态,则 (根状态)

  5. 处理找到 的情况:设

    • ,则
    • 否则需要克隆状态

状态克隆

时,需要创建克隆状态

  • (复制所有转移)
  • 然后将 的 link 都指向

克隆操作保证了自动机的最小性:将 分裂成两个状态,使得 值连续。

代码实现

#include <bits/stdc++.h>
using namespace std;
 
struct state {
    int len = 0;           // 最长字符串长度
    int link = -1;         // 后缀链接
    map<char, int> next;   // 转移
};
 
class SuffixAutomaton {
private:
    vector<state> st;
    int last = 0;          // 最后添加的状态
 
public:
    SuffixAutomaton(int maxLen = 0) {
        st.reserve(2 * maxLen);
        st.push_back(state());  // 初始化根状态
        st[0].len = 0;
        st[0].link = -1;
        last = 0;
    }
 
    void extend(char c) {
        int cur = st.size();
        st.push_back(state());
        st[cur].len = st[last].len + 1;
 
        int p = last;
        // 寻找可转移的位置
        while (p != -1 && !st[p].next.count(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 = st.size();
                st.push_back(st[q]);  // 复制 q 的所有信息
                st[clone].len = st[p].len + 1;
                // link 不变(在下一步被修改)
 
                // 调整 q 的后缀链接
                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;
    }
 
    // 查询:检查模式串是否为 S 的子串
    bool contains(const string& t) const {
        int v = 0;
        for (char c : t) {
            if (!st[v].next.count(c)) return false;
            v = st[v].next.at(c);
        }
        return true;
    }
 
    // 查询:S 中不同子串的个数
    long long countDistinctSubstrings() const {
        long long ans = 0;
        for (int i = 1; i < (int)st.size(); i++) {
            ans += st[i].len - st[st[i].link].len;
        }
        return ans;
    }
 
    // 查询:两字符串的最长公共子串长度
    int longestCommonSubstring(const string& t) const {
        int v = 0, l = 0, best = 0;
        for (char c : t) {
            while (v && !st[v].next.count(c)) {
                v = st[v].link;
                l = st[v].len;
            }
            if (st[v].next.count(c)) {
                v = st[v].next.at(c);
                l++;
            }
            best = max(best, l);
        }
        return best;
    }
};

时间复杂度分析

构造后缀自动机的总时间复杂度为 ,其中 为字符串长度。证明思路:

  • 每次 extend 操作中,沿 link 向上跳的总次数均摊为
  • 克隆操作不增加状态总数(最多 个状态)

应用场景

1. 子串查询

检查模式串 是否为原串 的子串,只需从初始状态出发,沿 的每个字符进行转移。若转移失败或最终不在合法状态,则 不是子串。

复杂度

2. 不同子串计数

根据自动机结构,状态 对应的 endpos 等价类包含长度为 的子串,共 【1】

对所有状态求和即可得到不同子串总数:

复杂度

3. 最长公共子串(Longest Common Substring)

给定两字符串 ,求它们的最长公共子串长度。

思路:对 构造后缀自动机,用 在自动机上运行,同时维护当前匹配长度 。当遇到无法继续的字符时,沿 link 上跳并调整 。整个过程中 的最大值即为答案。

复杂度

4. 字典树序第 小子串

在自动机的 DAG 上动态规划,可按字典序遍历所有子串。

5. 多个字符串的最长公共子串

对第一个字符串构造 SAM,其余字符串在自动机上运行,记录每个状态被各字符串覆盖的「深度」最小值。

与其他数据结构的比较

数据结构构造复杂度查询能力空间
后缀数组子串排名
KMP模式匹配
后缀自动机多种子串查询

后缀自动机的优势在于:一种结构,多种查询,且均为线性或常数复杂度。

模板题

参考


Last updated: 2026-04-05