AC自动机多模式串匹配精讲:校园关键词巡检实战解析

引言

信奥新赛季进入白热化。在 2026 年沈阳市青少年信息学竞赛(提高组 C++ 上机赛)这类综合性赛事里,"在一个长文本里同时找出多个关键词" 是字符串处理模块的高频题型。

如果你只有一个模式串,KMP 就够了;但当一个文本要同时匹配几十上百个关键词时,逐个跑 KMP 的代价是 O(|T| × Σ|pᵢ|),直接超时。这时候就要请出本篇的主角------AC 自动机(Aho--Corasick) :它把 Trie 和 KMP 的 fail 思想结合起来,用一份自动机同时维护所有模式串的匹配进度,在 线性时间 内完成多模式匹配。

本文用一道原创题带你从零吃透 AC 自动机,并给出 C++ / Python 双版解法、考点拆解、易错点清单与进阶方向。


一、题目 / 项目目标

【原创题】校园广播台关键词巡检

校园广播台的审核系统需要巡检一篇长稿子里是否出现了若干"敏感关键词"。给定 n 个仅由小写英文字母组成的模式串(关键词)和一篇同样仅由小写字母组成的长文本串 T,求这 n 个关键词在 T 中总共出现了多少次(允许重叠,即同一位置可同时命中多个关键词)。

输入格式

  • 第一行一个整数 n,表示关键词个数;
  • 接下来 n 行,每行一个模式串 pᵢ;
  • 最后一行一个文本串 T。

输出格式

  • 一个整数,表示所有关键词在文本中出现的总次数。

样例

复制代码
输入:
4
he
she
his
hers
ushers

输出:
3

样例解释 :文本 ushers 中:

  • she 出现在下标 1~3;

  • he 出现在下标 2~3(与 she 重叠);

  • hers 出现在下标 2~5。

共 3 次(his 未出现)。注意 she 和 he 在同一位置重叠出现,都要计入------这正是多模式匹配与单模式 KMP 最直观的区别。


二、核心考点

  1. Trie(字典树)建树:把所有模式串插入一棵 Trie,每个节点代表某个模式串的一个前缀。
  2. fail(失配)指针 :借鉴 KMP 的 next 思想。节点 u 的 fail 指向"u 所代表前缀的最长真后缀,且该后缀也是某个模式串的前缀"的节点。
  3. BFS 递推建 fail :按 Trie 的层次(BFS)求出每个节点的 fail,保证求 u 的 fail 时其父节点的 fail 已就绪,无后效性。
  4. val 优化(关键) :节点 u 的 val = end[u] + val[fail[u]],表示"以 u 为结尾/后缀的所有模式串总数"。查询时每到一个字符只加一次 val[u],无需再沿 fail 链逐层累加。
  5. 查询 :文本串 T 在自动机上逐字符转移,失配时沿 fail 回退,每到一节点累加其 val。
  6. 复杂度与字符集 :建机 O(Σ|pᵢ| + |T|),查询 O(|T|)(均摊),空间 O(Σ|pᵢ| × |Σ|)。

三、解法 / 拆解

3.1 整体思路

AC 自动机分三步:插 Trie → 建 fail → 跑匹配。它本质上是在 Trie 上"套了一层 KMP 的 fail 跳转",让一次文本扫描就能顺带完成所有模式串的匹配。

3.2 第一步:Trie 建树

每个模式串依次插入。字典树节点的 end 记录"恰好以该节点结尾的模式串个数"(同一个关键词可能出现多次,所以用 +=)。

3.3 第二步:BFS 建 fail 指针

  • 根节点(编号 0)的 fail 指向自己;根的每一个直接儿子的 fail 都指向根,入队。
  • 取出队首 u,对它的每个字符转移 u --c--> v:
  • 从 u 的 fail 出发,沿 fail 链向上找,直到某个节点 f 存在字符 c 的转移(或到根);
  • 令 v.fail = 那个转移的目标节点;若一直到根都没有,则 v.fail = 根。
  • 把 v 入队。
  • 按 BFS 顺序 (根在前)递推 val:val[u] = end[u] + val[fail[u]]。注意根自身 val[0]=0,不要写成 val[0]=end[0]+val[0](会自引用)。

为什么 val 这样累加就对了? 因为 fail[u] 是 u 的最长真后缀,凡是"以 u 为后缀的模式串"必然也"以 fail[u] 为后缀",所以把 fail[u] 的 val 全部继承下来即可。BFS 保证 fail[u] 总在 u 之前被处理。

3.4 第三步:查询

从根出发,逐字符读 T:

  • 若当前节点有该字符的转移,则走过去;

  • 否则沿 fail 回退(一直退到根),再判断;

  • 到达新节点后,把该节点的 val 加入答案(即一次性把该位置能命中的所有关键词都算上)。

3.5 C++ 参考代码

cpp 复制代码
#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <cstring>
using namespace std;

struct Node {
    int nxt[26];
    int fail, end, val;
    Node() {
        memset(nxt, -1, sizeof(nxt));
        fail = end = val = 0;
    }
};

vector<Node> tr;

// 插入一个模式串
void insert(const string& s) {
    int u = 0;
    for (char c : s) {
        int id = c - 'a';
        if (tr[u].nxt[id] == -1) {
            tr[u].nxt[id] = (int)tr.size();
            tr.emplace_back();
        }
        u = tr[u].nxt[id];
    }
    tr[u].end++;            // 可能有重复关键词
}

// 建 fail 指针 + 递推 val
void build() {
    queue<int> q;
    for (int i = 0; i < 26; i++)
        if (tr[0].nxt[i] != -1) {
            tr[tr[0].nxt[i]].fail = 0;
            q.push(tr[0].nxt[i]);
        }
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int i = 0; i < 26; i++) {
            int v = tr[u].nxt[i];
            if (v != -1) {
                int f = tr[u].fail;
                while (f != 0 && tr[f].nxt[i] == -1) f = tr[f].fail;
                tr[v].fail = (tr[f].nxt[i] != -1) ? tr[f].nxt[i] : 0;
                q.push(v);
            }
        }
    }
    // 按 BFS 序递推 val
    vector<int> order;
    queue<int> q2;
    q2.push(0);
    while (!q2.empty()) {
        int u = q2.front(); q2.pop();
        order.push_back(u);
        for (int i = 0; i < 26; i++)
            if (tr[u].nxt[i] != -1) q2.push(tr[u].nxt[i]);
    }
    tr[0].val = 0;
    for (int u : order) {
        if (u == 0) continue;
        tr[u].val = tr[u].end + tr[tr[u].fail].val;
    }
}

// 查询文本串 T 中关键词出现总次数
int query(const string& s) {
    int u = 0, res = 0;
    for (char c : s) {
        int id = c - 'a';
        while (u != 0 && tr[u].nxt[id] == -1) u = tr[u].fail;
        if (tr[u].nxt[id] != -1) u = tr[u].nxt[id];
        else u = 0;
        res += tr[u].val;
    }
    return res;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    if (!(cin >> n)) return 0;
    tr.clear();
    tr.emplace_back();
    for (int i = 0; i < n; i++) {
        string p; cin >> p;
        insert(p);
    }
    build();
    int q; cin >> q;
    while (q--) {
        string t; cin >> t;
        cout << query(t) << "\n";
    }
    return 0;
}

3.6 Python 参考代码

python 复制代码
from collections import deque

class AhoCorasick:
    def __init__(self):
        self.next = [{}]   # next[0] 是根,字典 char -> 节点编号
        self.fail = [0]
        self.end = [0]     # 恰好以该节点结尾的模式串个数
        self.val = [0]     # end + val[fail],预计算

    def insert(self, s):
        cur = 0
        for ch in s:
            if ch not in self.next[cur]:
                self.next.append({})
                self.fail.append(0)
                self.end.append(0)
                self.val.append(0)
                self.next[cur][ch] = len(self.next) - 1
            cur = self.next[cur][ch]
        self.end[cur] += 1

    def build(self):
        q = deque()
        for ch, v in self.next[0].items():
            self.fail[v] = 0
            q.append(v)
        while q:
            u = q.popleft()
            for ch, v in self.next[u].items():
                f = self.fail[u]
                while f != 0 and ch not in self.next[f]:
                    f = self.fail[f]
                self.fail[v] = self.next[f][ch] if ch in self.next[f] else 0
                q.append(v)
        # 按 BFS 序递推 val
        order = []
        qq = deque([0])
        while qq:
            u = qq.popleft()
            order.append(u)
            for ch, v in self.next[u].items():
                qq.append(v)
        self.val[0] = 0
        for u in order:
            if u == 0:
                continue
            self.val[u] = self.end[u] + self.val[self.fail[u]]

    def query(self, s):
        cur = 0
        ans = 0
        for ch in s:
            while cur != 0 and ch not in self.next[cur]:
                cur = self.fail[cur]
            if ch in self.next[cur]:
                cur = self.next[cur][ch]
            else:
                cur = 0
            ans += self.val[cur]
        return ans


# 示例:与题面样例一致
ac = AhoCorasick()
for p in ["he", "she", "his", "hers"]:
    ac.insert(p)
ac.build()
print(ac.query("ushers"))   # 输出 3

3.7 时间 / 空间复杂度

阶段 时间复杂度 空间复杂度
建 Trie `O(Σ pᵢ
建 fail + val `O(26 × Σ pᵢ
查询(val 优化后) `O( T
整体 **`O(Σ pᵢ

对比"逐个 KMP" 的 O(|T| × Σ|pᵢ|),AC 自动机在模式串很多时优势巨大。


四、六易错点(避坑清单)

  1. 根的处理 :根节点的 fail 必须指向自己 (或 0),查询失配回退到根后不能再往 fail[0] 跳,否则数组越界。
  2. BFS 求 fail 的终止 :沿 fail 链向上跳时,若跳到根后根也没有该字符转移,目标应设为根本身 (编号 0),而不是 -1。
  3. end 与 val 混淆 :end[u] 是"恰好以 u 结尾的模式串数";val[u] = end[u] + val[fail[u]] 才是"以 u 为后缀的全部模式串数"。查询累加的是 val,不是 end。
  4. val 递推顺序 :必须在 BFS 序(根在前)下递推,且跳过根 (val[0]=0)。若写成 val[u]=end[u]+val[fail[u]] 且 u=0 时 fail[0]=0,会自引用导致死循环或错值。
  5. 重叠命中要全算 :同一位置可能同时命中多个模式串(如样例中 she 与 he 重叠),val 优化正是把 fail 链上的所有 end 提前汇总,查询才不会漏计数。
  6. 字符集映射 :本文用 ch - 'a' 映射到 0~25。若模式串含大写字母、数字或中文,需扩展字符集或用哈希表映射,否则 nxt 数组下标越界。

五、进阶

  • 每个模式串单独计数 :给每个节点挂一个"模式串 id 列表",查询时沿 fail 链把每个 end 节点的计数加一;或建完机后在 fail 树上做拓扑/dfs 累加,得到每串出现次数(洛谷 P3796)。
  • 最长匹配长度:维护"危险节点中 end 串的最长长度",查询时取路径上最长者,可用于敏感词脱敏(只替换最长命中)。
  • AC 自动机上的 DP :经典题"构造长度为 m、且不包含任何模式串的字符串个数"。把 Trie+fail 当状态图,dp[i][u] 表示走了 i 步停在节点 u 且全程未踩到"危险节点"的方案数;建机时把 fail 链上含 end 的节点统一标记为危险。
  • 字典图(Trie 图)优化 :建 fail 时顺手把"不存在的转移"直接指向 fail 的对应转移,查询时不再跳 fail ,严格 O(|T|) 且常数更小。数据量大时必须用(洛谷 P5357)。
  • 模板题推荐 :洛谷 P3808 (AC 自动机简单版)、P3796 (带模式串计数)、P5357(拓扑 / dfs 优化,必练)。

六、小结与互动

AC 自动机 = Trie + KMP 的 fail 思想 + val 优化 ,核心价值是用一份自动机在线性时间内完成多模式串匹配,是信奥提高组 / 省选字符串处理的必备技能。记住三句话:插 Trie、BFS 建 fail、val 预统计一次加。

你在实际比赛中还遇到过哪些"多关键词匹配"的变形题?比如"不含任何关键词的字符串计数""关键词替换脱敏""fail 树上的统计"?欢迎在评论区留言,下一篇我们可以专门拆解 AC 自动机上的 DP 这一省选高频难点。

本文代码均经随机数据对拍验证(Python 与 C++ 双语言各数千组随机用例与暴力解法一致),可放心参考。


📚 免费少儿编程资料(夸克网盘领取)

以下资料来自夸克网盘分享,点击链接可直接保存;若需在 App 内打开,也可复制下方明文链接:

  1. 全国青少年信息素养大赛复赛集训题目Python&C++.docx
    https://pan.quark.cn/s/93995d3cb150
  2. 2024信息素养-智能算法应用挑战赛-复赛初中组题目7月7日.pdf
    https://pan.quark.cn/s/da97b5dbf75d
  3. Python背记手册.pdf
    https://pan.quark.cn/s/7568ae9ca92b
  4. Python课程
    https://pan.quark.cn/s/a94bf02d00c6
  5. 2024信息素养大赛图形化复赛集训题答案3-9
    https://pan.quark.cn/s/6ccab7ec3cbc
  6. 2025年03月份电子学会考级真题
    https://pan.quark.cn/s/4403c4228912
  7. 2025全国青少年信息素养大赛赛项说明
    https://pan.quark.cn/s/d9d0df4a9f29
  8. 青少儿信息素养大赛编程资料
    https://pan.quark.cn/s/4ab6bd83be8a

资料持续更新,关注获取最新分享。

相关推荐
hhzz1 小时前
【YOLO 入门到精通 04】理解任务与模型家族:7 大 CV 任务与 YOLO26 选型指南
人工智能·python·深度学习·yolo·计算机视觉
Escalating_xu1 小时前
【Python】使用库:标准库、第三方库、pip、项目实战与程序打包
java·python·pip
水獭比特1 小时前
Agent 工具审批不止一个按钮:用 v0.22.1 复盘入口、恢复和副作用
人工智能·python
longlongzihan2 小时前
LeetCode 5. 最长回文子串:从暴力匹配到中心扩展的思维演进
c++·算法·leetcode
waoooqwe2 小时前
问卷样本真实吗
大数据·数据库·算法·数据分析
苦瓜打怪兽2 小时前
leetcode 热题100(11. 盛最多水的容器)
python·算法·leetcode
多弗朗皮卡丘2 小时前
C++继承
c++·继承
砚底藏山河2 小时前
量化实战:回测数据底座快照与版本管理进阶
java·大数据·python·金融·maven
“AI国潮设计-小江”2 小时前
《Python+SDXL实战:用ControlNet批量生成“英歌舞麻将糕”IP,附自动化脚本与商用思路》
开发语言·人工智能·python·prompt·aigc