文章目录

AC 自动机

AC 自动机

AC 自动机(Aho-Corasick Automaton)是以 Trie 结构为基础,融合 KMP 失配思想构建的确定性有限状态自动机,是多模式串匹配问题的标准算法。它将所有模式串统一预处理,实现文本串单次扫描即可完成全部模式串的匹配,复杂度与模式串数量无关。


一、核心定位:从 KMP / Trie 到 AC 自动机

1.1 知识衔接

在学习 AC 自动机前,需掌握:

  • Trie:高效存储字符串集合,支持前缀查询,但无法直接完成子串匹配;

  • KMP:单模式串线性匹配,通过 next 指针实现失配回退,避免重复匹配。

AC 自动机本质是二者的结合:

AC 自动机 = Trie 树 + fail 失配指针

fail 指针就是 KMP 的 next 思想在 Trie 树上的推广。

1.2 KMP 与 AC 自动机对比

维度 KMP 算法 AC 自动机
处理场景 1 个模式串 + 1 个文本串 多个模式串 + 1 个文本串
核心结构 线性 next 数组 Trie 树结构
失配指针 \(\mathit{next}[i]\):当前前缀的最长 Border \(\mathit{fail}[u]\):当前状态的最长后缀状态
匹配复杂度 \(O\left(n+m\right)\) O\left(\sum

1.3 解决的核心问题

给定 \(n\) 个模式串 \(s_1,s_2,\dots,s_n\) 和一个文本串 \(T\),常见问题:

  1. 哪些模式串在 \(T\) 中出现过?

  2. 每个模式串在 \(T\) 中出现了多少次?

\(T\) 中有多少个子串是某个模式串的前缀?

暴力方案是对每个模式串单独跑 KMP,总复杂度 \(O\left(n\cdot|T|\right)\);AC 自动机可一次性处理所有模式串,文本串仅需扫描一次。


二、核心概念:fail 失配指针

2.1 严格定义

状态 \(u\) 的 fail 指针指向另一个状态 \(v\),满足:

\(v\) 对应的字符串是 \(u\) 对应字符串的最长真后缀

\(v\) 对应的字符串同时是某个模式串的前缀

通俗理解:匹配到状态 \(u\) 时若下一个字符失配,无需从头开始,直接跳转到 \(\mathit{fail}[u]\),复用已匹配的后缀信息,与 KMP 失配回退的思想完全一致。

2.2 fail 与 KMP next 的本质区别

维度 KMP next 指针 AC fail 指针
作用域 单个模式串内部 Trie 上所有状态
跳转范围 始终在同一个模式串内 可跳到另一个模式串的前缀状态
本质 单个串的最长相等前后缀 所有模式串前缀中,当前状态的最长后缀

关键差异:KMP 只有一个模式串,next 只在串内部跳转;AC 自动机有多个模式串,fail 可能跨串跳转,这是它能同时匹配多模式的核心原因。


三、构建流程

AC 自动机构建分为两步:插入所有模式串建 Trie  →  BFS 层序构建 fail 指针并补边形成字典图

3.1 第一步:构建基础 Trie

与普通字典树完全一致,将所有模式串逐字符插入。

const int MAXN = 2e6 + 10;
const int SIGMA = 26;

int ch[MAXN][SIGMA];
int fail[MAXN];
int cnt[MAXN];  // 以该节点结尾的模式串数量/编号
int tot;        // 节点分配器,根为0

void insert(string &s, int id = 1) {
    int u = 0;
    for (char ch : s) {
        int c = ch - 'a';
        if (!ch[u][c]) ch[u][c] = ++ tot;
        u = ch[u][c];
    }
    cnt[u] += id;
}

3.2 第二步:BFS 构建 fail 指针 + 字典图

构建思想

设节点 \(u\) 的父节点为 \(p\)\(p\) 经字符 \(c\) 指向 \(u\)。假设深度小于 \(u\) 的所有节点 fail 已求出:

  1. \(\mathit{ch}[\mathit{fail}[p]][c]\) 存在,则 \(\mathit{fail}[u] = \mathit{ch}[\mathit{fail}[p]][c]\)

  2. 若不存在,继续跳 \(\mathit{fail}[\mathit{fail}[p]]\) 重复判断,直到根节点;

  3. 若始终不存在,\(\mathit{fail}[u] = 0\)(指向根)。

字典图优化(Trie Graph)

上述朴素实现需要 while 循环跳 fail,效率较低。标准做法是补边优化
\(\mathit{ch}[u][c]\) 不存在,直接令 \(\mathit{ch}[u][c] = \mathit{ch}[\mathit{fail}[u]][c]\)
BFS 按层处理,处理 \(u\)\(\mathit{fail}[u]\) 的所有转移已计算完毕,因此可直接赋值,无需循环跳转。

补边后 Trie 升级为确定性有限状态自动机(DFA),每个状态对每个字符都有唯一出边,匹配时无需手动跳 fail,一步转移到位。

完整 build 代码

void build() {
    queue<int> q;
    // 根的直接子节点先入队,它们的fail天然指向根0
    for (int i = 0; i < SIGMA; i++) {
        if (ch[0][i]) q.push(ch[0][i]);
    }

    while (!q.empty()) {
        int u = q.front();
        q.pop();

        for (int i = 0; i < SIGMA; i++) {
            if (ch[u][i]) {
                // 子节点存在:fail指向fail[u]的对应转移
                fail[ch[u][i]] = ch[fail[u]][i];
                q.push(ch[u][i]);
            } else {
                // 子节点不存在:补边,直接继承fail的转移
                ch[u][i] = ch[fail[u]][i];
            }
        }
    }
}

注:不能将根节点入队,否则根的子节点 fail 会指向自身,造成逻辑错误。


四、多模式匹配

4.1 基础匹配写法

构建完成后,在字典图上逐字符扫描文本串即可。每到达一个状态,需要沿 fail 链向上遍历,统计所有匹配的模式串(因为后缀也可能是完整模式串)。

int query(string &t) {
    int u = 0, res = 0;
    for (char ch : t) {
        int c = ch - 'a';
        u = ch[u][c];  // 字典图一步转移,无需手动跳fail
        // 沿fail链统计所有匹配
        for (int j = u; j && cnt[j] != -1; j = fail[j]) {
            res += cnt[j];
            cnt[j] = -1;  // 标记已统计,避免重复计数
        }
    }
    return res;
}

4.2 复杂度分析

阶段 时间复杂度 说明
建 Trie $O\left(\sum s_i
build 构建 $O\left(\sum s_i
基础匹配 $O\left( T

当模式串数量多、嵌套匹配频繁时,内层 fail 链跳转成为性能瓶颈,需要拓扑排序优化。


五、效率优化:fail 树与拓扑排序

5.1 fail 树的核心性质

仅保留所有 fail 边,得到的图一定是一棵以根为根的树(fail 树)

  • 每个节点有且仅有一个 fail 指针;

  • fail 指针始终指向深度更浅的节点,不可能成环。

在 fail 树上,父节点是子节点的最长后缀状态。若文本串匹配到了子节点状态,则其所有祖先状态对应的后缀也被匹配到。

5.2 优化思路

  1. 匹配阶段:只在到达的状态上打计数标记,不跳 fail 链;

  2. 统计阶段:在 fail 树上按拓扑序(从叶子到根)将子节点的计数累加到父节点,一次性得到所有状态的匹配次数。

该优化将匹配复杂度降为严格 \(O\left(|T|\right)\),总复杂度与匹配次数无关,是竞赛大数据的标准写法。

5.3 完整实现

int in[MAXN];   // fail树的入度
int val[MAXN];  // 状态被经过的次数
int ans[MAXN];  // 每个模式串的最终出现次数

void build() {
    queue<int> q;
    for (int i = 0; i < SIGMA; i++) {
        if (ch[0][i]) q.push(ch[0][i]);
    }
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int i = 0; i < SIGMA; i++) {
            if (ch[u][i]) {
                fail[ch[u][i]] = ch[fail[u]][i];
                in[fail[ch[u][i]]]++;  // 统计fail树入度
                q.push(ch[u][i]);
            } else {
                ch[u][i] = ch[fail[u]][i];
            }
        }
    }
}

void query(string &t) {
    int u = 0;
    for (char ch : t) {
        int c = ch - 'a';
        u = ch[u][c];
        val[u]++;  // 只打标记,不跳fail
    }
}

void topo_sort() {
    queue<int> q;
    for (int i = 0; i <= tot; i++) {
        if (in[i] == 0) q.push(i);
    }
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        ans[u] = val[u];
        int v = fail[u];
        val[v] += val[u];  // 子节点计数累加到父节点
        if (--in[v] == 0) q.push(v);
    }
}

六、完整模板

6.1 基础版:统计出现过的模式串总数

对应题目:洛谷 P3808 AC 自动机(简单版)

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1e6 + 10;
const int SIGMA = 26;

int ch[MAXN][SIGMA];
int fail[MAXN];
int cnt[MAXN];
int tot;

void insert(string &s) {
    int u = 0;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) ch[u][v] = ++tot;
        u = ch[u][v];
    }
    cnt[u]++;
}

void build() {
    queue<int> q;
    for (int i = 0; i < SIGMA; i++)
        if (ch[0][i]) q.push(ch[0][i]);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int i = 0; i < SIGMA; i++) {
            if (ch[u][i]) {
                fail[ch[u][i]] = ch[fail[u]][i];
                q.push(ch[u][i]);
            } else ch[u][i] = ch[fail[u]][i];
        }
    }
}

int query(string &t) {
    int u = 0, res = 0;
    for (char c : t) {
        u = ch[u][c - 'a'];
        for (int j = u; j && cnt[j] != -1; j = fail[j]) {
            res += cnt[j];
            cnt[j] = -1;
        }
    }
    return res;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n; cin >> n;
    for (int i = 1; i <= n; i++) {
        string s; cin >> s;
        insert(s);
    }
    build();
    string t; cin >> t;
    cout << query(t) << endl;
    return 0;
}

6.2 进阶版:每个模式串出现次数(拓扑优化)

对应题目:洛谷 P5357【模板】AC 自动机

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 2e6 + 10;
const int SIGMA = 26;

int ch[MAXN][SIGMA];
int fail[MAXN];
int id[MAXN];   // 该节点对应第几个模式串,0表示无
int in[MAXN];
int val[MAXN];
int res[MAXN];  // 第i个模式串的答案
int tot, n;

void insert(string &s, int idx) {
    int u = 0;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) ch[u][v] = ++tot;
        u = ch[u][v];
    }
    id[u] = idx;
}

void build() {
    queue<int> q;
    for (int i = 0; i < SIGMA; i++)
        if (ch[0][i]) q.push(ch[0][i]);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int i = 0; i < SIGMA; i++) {
            if (ch[u][i]) {
                fail[ch[u][i]] = ch[fail[u]][i];
                in[fail[ch[u][i]]]++;
                q.push(ch[u][i]);
            } else ch[u][i] = ch[fail[u]][i];
        }
    }
}

void query(string &t) {
    int u = 0;
    for (char c : t) {
        u = ch[u][c - 'a'];
        val[u]++;
    }
}

void topo() {
    queue<int> q;
    for (int i = 0; i <= tot; i++)
        if (in[i] == 0) q.push(i);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        if (id[u]) res[id[u]] = val[u];
        int v = fail[u];
        val[v] += val[u];
        if (--in[v] == 0) q.push(v);
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n;
    for (int i = 1; i <= n; i++) {
        string s; cin >> s;
        insert(s, i);
    }
    build();
    string t; cin >> t;
    query(t);
    topo();
    for (int i = 1; i <= n; i++) cout << res[i] << '\n';
    return 0;
}

七、进阶应用:AC 自动机上 DP

AC 自动机是 DFA,天然支持动态规划,典型问题:求长度为 \(m\)、不包含任何给定模式串的字符串数量

核心思路

  1. 标记「危险状态」:自身是模式串结尾,或 fail 链上存在模式串结尾;

DP 状态:\(f\left[i\right][j]\) 表示长度为 \(i\)、当前在状态 \(j\) 的合法字符串方案数;

  1. 转移:枚举下一个字符,若目标状态不是危险状态则累加方案数。

危险状态标记

void mark_danger() {
    queue<int> q;
    for (int i = 0; i < SIGMA; i++)
        if (ch[0][i]) q.push(ch[0][i]);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        if (cnt[fail[u]]) cnt[u] = 1; // fail链有标记则自身也危险
        for (int i = 0; i < SIGMA; i++)
            if (ch[u][i]) q.push(ch[u][i]);
    }
}

八、多方案对比与选型

方案 预处理复杂度 匹配复杂度 适用场景 核心特点
KMP × n $O\left(\sum | s_i | \right)$ $O\left(n\cdot
AC 自动机 $O\left(\sum | s_i | \cdot \text{SIGMA}\right)$ s_i
后缀自动机 SAM $O\left( | T | \right)$ T
后缀数组 SA $O\left( T \log T
字符串哈希 $O\left(\sum s_i + T

九、总结

AC 自动机的核心可以用一句话概括:

以 Trie 为载体,用 KMP 失配思想构建 fail 指针,通过补边形成 DFA,实现文本单次扫描完成多模式匹配。

  • 基础流程:建 Trie → BFS 建 fail + 补边 → 文本扫描匹配

  • 优化核心:利用 fail 树拓扑排序,实现严格线性复杂度

  • 进阶延伸:作为 DFA 可扩展 DP、可持久化等高级应用

  • 选型建议:模式串数量少时用 KMP,多模式标准场景用 AC 自动机,文本固定查询多用 SAM

注:部分内容由 AI 润色

posted @ 2026-09-04 07:24  lvwangshu  阅读(0)  评论(0)    收藏  举报