引言
信奥新赛季进入白热化。在 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 最直观的区别。
二、核心考点
- Trie(字典树)建树:把所有模式串插入一棵 Trie,每个节点代表某个模式串的一个前缀。
- fail(失配)指针 :借鉴 KMP 的 next 思想。节点
u的 fail 指向"u所代表前缀的最长真后缀,且该后缀也是某个模式串的前缀"的节点。 - BFS 递推建 fail :按 Trie 的层次(BFS)求出每个节点的 fail,保证求
u的 fail 时其父节点的 fail 已就绪,无后效性。 - val 优化(关键) :节点
u的val = end[u] + val[fail[u]],表示"以u为结尾/后缀的所有模式串总数"。查询时每到一个字符只加一次val[u],无需再沿 fail 链逐层累加。 - 查询 :文本串
T在自动机上逐字符转移,失配时沿 fail 回退,每到一节点累加其val。 - 复杂度与字符集 :建机
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 自动机在模式串很多时优势巨大。
四、六易错点(避坑清单)
- 根的处理 :根节点的 fail 必须指向自己 (或 0),查询失配回退到根后不能再往
fail[0]跳,否则数组越界。 - BFS 求 fail 的终止 :沿 fail 链向上跳时,若跳到根后根也没有该字符转移,目标应设为根本身 (编号 0),而不是
-1。 - end 与 val 混淆 :
end[u]是"恰好以u结尾的模式串数";val[u] = end[u] + val[fail[u]]才是"以u为后缀的全部模式串数"。查询累加的是val,不是end。 - val 递推顺序 :必须在 BFS 序(根在前)下递推,且跳过根 (
val[0]=0)。若写成val[u]=end[u]+val[fail[u]]且u=0时fail[0]=0,会自引用导致死循环或错值。 - 重叠命中要全算 :同一位置可能同时命中多个模式串(如样例中
she与he重叠),val 优化正是把 fail 链上的所有end提前汇总,查询才不会漏计数。 - 字符集映射 :本文用
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 内打开,也可复制下方明文链接:
- 全国青少年信息素养大赛复赛集训题目Python&C++.docx
https://pan.quark.cn/s/93995d3cb150 - 2024信息素养-智能算法应用挑战赛-复赛初中组题目7月7日.pdf
https://pan.quark.cn/s/da97b5dbf75d - Python背记手册.pdf
https://pan.quark.cn/s/7568ae9ca92b - Python课程
https://pan.quark.cn/s/a94bf02d00c6 - 2024信息素养大赛图形化复赛集训题答案3-9
https://pan.quark.cn/s/6ccab7ec3cbc - 2025年03月份电子学会考级真题
https://pan.quark.cn/s/4403c4228912 - 2025全国青少年信息素养大赛赛项说明
https://pan.quark.cn/s/d9d0df4a9f29 - 青少儿信息素养大赛编程资料
https://pan.quark.cn/s/4ab6bd83be8a
资料持续更新,关注获取最新分享。