查询词全部插入 Trie 建 AC 自动机,扫描文章只标记到达状态,再沿 fail 树自底向上合并命中,一次 O(|S|) 扫描统计出现过的查询词个数。
OJ: roj
题目 ID: 1479
难度:普及+/提高-
标签:AC自动机字符串Triepython
创建: 2026-09-30 13:20
更新: 2026-10-07 12:15
形式化题目
给定
输入样例:
1
5
she
he
say
shr
her
yasherhs输出样例:
3样例推演:文章 yasherhs 中出现的查询词是 she(位置 3–5)、he(位置 4–5)、her(位置 4–6),共 3 个;say、shr 没出现。
正解
思路
朴素做法。 对每个
把 she 的节点读到 r 时无路可走,而 yasherhs 里 sher 的正确后继是 her 的节点——回退信息缺失。
fail 指针补上回退——AC 自动机。 对每个节点定义 fail:它的最长真后缀中也是某个查询词前缀的那个节点(根为 0)。BFS 按层求 fail:处理节点
关键观察:只需标记状态,不必沿 fail 链逐次收集。 查询词 hit;再按 BFS 逆序(保证处理 hit 之和。每个词只在其结束节点计一次,天然处理了"一个词出现多次只算一次"和"重复查询词各算一次"(cnt 在插入时累加)。
下图是样例 5 个查询词建成的自动机(实线为 Trie 边,虚线为 fail 边,橙色为词尾节点),注意 she 的 fail 指向 he、her 的 fail 也指向 he:
flowchart LR
R((root)) --> S["s"]
S --> SH["sh"]
SH --> SHE["she<br/>词尾 cnt=1"]
SH --> SHR["shr<br/>词尾 cnt=1"]
R --> H["h"]
H --> HE["he<br/>词尾 cnt=1"]
HE --> HER["her<br/>词尾 cnt=1"]
S --> SA["sa"]
SA --> SAY["say<br/>词尾 cnt=1"]
SHE -. fail .-> HE
SHR -. fail .-> R
HER -. fail .-> R
SAY -. fail .-> R
style SHE fill:#ffe0b2
style HE fill:#ffe0b2
style HER fill:#ffe0b2
style SHR fill:#ffe0b2
style SAY fill:#ffe0b2
用样例走一遍扫描与传播,下表列出每个字符后的状态与当步命中的词:
| 已读入 | 当前状态 | 当步经 fail 收集到的词 | 累计命中 |
|---|---|---|---|
yash |
sh |
— | — |
yashe |
she |
she、he(fail 链 she → he) |
she, he |
yasher |
her |
her |
she, he, her |
yasherh |
h |
— | 同上 |
yasherhs |
sh |
— | 同上 |
最终在 she、he、her 三个词尾节点上 hit=1,say、shr 未被传播覆盖,答案 3。注意"当步经 fail 链收集"只是为理解服务的等价说法,实现里不做这一步——它被第二阶段的 fail 树逆序传播替代,这正是本解法把逐字符的链上行走压成线性总代价的关键。
代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-09-30 13:35
# update_at: 2026-09-30 13:35
import sys
from collections import deque
def build_automaton(words: list[bytes]) -> tuple[list[dict[int, int]], list[int], list[int], list[int]]:
"""插入查询词建 trie 并 BFS 求 fail,返回转移表 go、fail 数组、词尾计数 cnt 与 BFS 序。"""
go: list[dict[int, int]] = [{}] # 根是 0;每节点一个 dict,只存真实存在的边
cnt: list[int] = [0] # cnt[v]:以 v 结尾的查询词个数(重复词各计一次)
for w in words:
node = 0
for c in w:
nxt = go[node].get(c)
if nxt is None:
go[node][c] = len(go)
go.append({})
cnt.append(0)
nxt = len(go) - 1
node = nxt
cnt[node] += 1
fail: list[int] = [0] * len(go)
order: list[int] = [] # BFS 出队序:深度不减,任何节点都排在自己 fail 之后
queue = deque(go[0].values()) # 根的孩子 fail 默认就是根
while queue:
node = queue.popleft()
order.append(node)
for c, nxt in go[node].items():
f = fail[node] # 沿 fail 链找能接下字符 c 的祖先
while f and c not in go[f]:
f = fail[f]
fail[nxt] = go[f].get(c, 0)
queue.append(nxt)
return go, fail, cnt, order
def count_hits(article: bytes, go: list[dict[int, int]], fail: list[int], cnt: list[int], order: list[int]) -> int:
"""扫描文章标出到达过的状态,再沿 fail 树自底向上合并命中,返回出现过的查询词个数。"""
hit = bytearray(len(go))
node = 0
for c in article:
while node and c not in go[node]: # 没有对应边就沿 fail 链回退
node = fail[node]
node = go[node].get(c, 0)
hit[node] = 1
# 状态 v 到达 ⇒ v 的整个 fail 祖先链上的词都出现过;逆 BFS 序保证祖先后处理
for v in reversed(order):
if hit[v]:
hit[fail[v]] = 1
return sum(c for c, h in zip(cnt, hit) if h)
def solve() -> None:
data = iter(sys.stdin.buffer.read().split())
T = int(next(data))
out: list[str] = []
for _ in range(T):
n = int(next(data))
words = [next(data) for _ in range(n)]
article = next(data)
go, fail, cnt, order = build_automaton(words)
out.append(str(count_hits(article, go, fail, cnt, order)))
print('\n'.join(out))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-05 03:32
* update_at: 2026-10-05 03:32
*/
#include <cstdio>
#include <cstring>
typedef long long ll;
// 单组数据最多 1e4 个查询词、每词不超过 50 字符,Trie 节点数不超过 5e5+1
const int MAXNODE = 500005;
// 文章长度上限
const int MAXM = 1000005;
// 链式前向星存 Trie 边:只保存真实存在的边,不建 26 叉的完整转移表
int head[MAXNODE]; // head[u]:节点 u 的第一条出边编号,-1 表示无出边
int edge_to[MAXNODE]; // edge_to[e]:第 e 条边指向的子节点
char edge_ch[MAXNODE]; // edge_ch[e]:第 e 条边上的字符
int edge_next[MAXNODE]; // edge_next[e]:同一起点的下一条出边编号
int edge_cnt; // 已分配的边数
int fail_arr[MAXNODE]; // fail_arr[v]:v 的最长真后缀且同时是某个查询词前缀的节点
int word_cnt[MAXNODE]; // word_cnt[v]:以 v 结尾的查询词个数,重复词各计一次
int bfs_order[MAXNODE]; // bfs_order[]:BFS 出队序列,深度不减,用于逆序传播命中
char hit[MAXNODE]; // hit[v]:扫描文章时是否到达过状态 v,取值只有 0/1,用 char 省内存
int node_cnt; // 当前 Trie 的节点数
char article[MAXM]; // 文章
char word[64]; // 单个查询词,长度不超过 50
// 在节点 u 的出边中查找字符 c 指向的子节点,找不到返回 -1
int find_child(int u, int c) {
for (int e = head[u]; e != -1; e = edge_next[e]) {
if (edge_ch[e] == c) {
return edge_to[e];
}
}
return -1;
}
// 新建一条从 u 到 v、边上字符为 c 的 Trie 边
void add_edge(int u, int v, int c) {
edge_to[edge_cnt] = v;
edge_ch[edge_cnt] = c;
edge_next[edge_cnt] = head[u];
head[u] = edge_cnt;
edge_cnt++;
}
// 初始化一个空节点并返回它的编号
int new_node() {
int v = node_cnt;
head[v] = -1;
word_cnt[v] = 0;
fail_arr[v] = 0;
node_cnt++;
return v;
}
// 把所有查询词插入 Trie:状态含义是“当前文本后缀中,是某个查询词前缀的最长者”
void insert_word() {
int u = 0;
for (int i = 0; word[i] != '\0'; i++) {
int c = word[i];
int v = find_child(u, c);
if (v == -1) {
v = new_node();
add_edge(u, v, c);
}
u = v;
}
word_cnt[u] += 1; // 内容相同的查询词要各算一次,所以累加而不是置 1
}
// BFS 按层求 fail:处理节点 u 的孩子 v(字符 c)时,
// 从 fail_arr[u] 出发沿 fail 链找第一个有 c 边的祖先 f,则 fail_arr[v] 就是 f 的 c 边指向的节点
void build_fail() {
int qhead = 0;
int qtail = 0;
// 根的孩子没有更短的真后缀,fail 直接指向根
for (int e = head[0]; e != -1; e = edge_next[e]) {
int v = edge_to[e];
fail_arr[v] = 0;
bfs_order[qtail] = v;
qtail++;
}
while (qhead < qtail) {
int u = bfs_order[qhead];
qhead++;
for (int e = head[u]; e != -1; e = edge_next[e]) {
int v = edge_to[e];
int c = edge_ch[e];
int f = fail_arr[u];
int found = find_child(f, c);
while (f != 0 && found == -1) {
f = fail_arr[f];
found = find_child(f, c);
}
fail_arr[v] = (found == -1 ? 0 : found);
bfs_order[qtail] = v;
qtail++;
}
}
}
// 扫描文章标出到达过的状态,再按 BFS 逆序把命中沿 fail 边向上传播,最后统计词尾节点的命中
int scan_article() {
memset(hit, 0, node_cnt); // 只清理本组数据实际用到的节点范围
int u = 0;
for (int i = 0; article[i] != '\0'; i++) {
int c = article[i];
int found = find_child(u, c);
while (u != 0 && found == -1) { // Trie 上没有对应边就沿 fail 链回退
u = fail_arr[u];
found = find_child(u, c);
}
if (found != -1) {
u = found;
}
hit[u] = 1;
}
// 到达状态 v 说明 v 的整条 fail 祖先链上的查询词都以当前文本为后缀出现过;
// 逆 BFS 序保证处理 v 时它的 fail 祖先还没被传播,可以把命中继续往上传
for (int i = node_cnt - 2; i >= 0; i--) {
int v = bfs_order[i];
if (hit[v]) {
hit[fail_arr[v]] = 1;
}
}
int ans = 0;
for (int v = 1; v < node_cnt; v++) {
if (hit[v]) {
ans += word_cnt[v];
}
}
return ans;
}
int main() {
ll T;
scanf("%lld", &T);
while (T--) {
ll n;
scanf("%lld", &n);
edge_cnt = 0;
node_cnt = 0;
new_node(); // 根节点编号为 0
for (ll i = 0; i < n; i++) {
scanf("%s", word);
insert_word();
}
scanf("%s", article);
build_fail();
printf("%d\n", scan_article());
}
return 0;
}复杂度
- 时间复杂度:建自动机
(每节点只对自己的真实出边求 fail),扫描 (fail 链回退均摊 ),fail 树传播 ,总计 。 - 空间复杂度:
,每节点一个只存真实边的 dict,加 fail/cnt/hit三个线性数组。
总结
- 朴素做法逐词搜文章,
;合并扫描的关键是把所有查询词放进同一棵 Trie,一次遍历同时推进全部匹配。 - Trie 缺回退信息,AC 自动机用 fail 指针补上:BFS 按"父的 fail 链上找同字符边"求 fail,扫描时无边即回退,均摊
。 - 统计不必逐字符走 fail 链收集:查询词出现
某个到达状态位于其结束节点的 fail 子树中,于是先标记到达状态,再按 BFS 逆序沿 fail 向上传播,最后读词尾节点的 hit。 - 每词只在自己的结束节点计一次,重复查询词用
cnt累加,天然满足"出现多次算一次、内容相同的查询词各算一次"。