使用 AC 自动机构建 fail 树,通过 Trie 前缀计数与拓扑逆序自底向上聚合子树权值,在线性时间内统计多模式串出现次数。
OJ: roj
题目 ID: 1482
难度:提高
标签:字符串AC自动机拓扑排序
创建: 2026-09-30 14:00
更新: 2026-10-07 11:01
形式化题目
给定
定义这篇“论文”由这
其中
正解
思路
一个子串
1. 朴素跳 fail 链的瓶颈
如果将所有模式串拼接后在 AC 自动机上匹配,并在每个前缀位置暴力沿着 fail 指针向上追溯所有匹配成功的模式串,在最坏情况(例如所有串都是相同字符构成的重复串 a, aa, aaa…)下,单次前缀可能需要回溯
2. fail 树与拓扑聚合优化
若将 AC 自动机中所有节点的 fail 指向视作有向边
在 fail 树中:
- 节点
是节点 的祖先,当且仅当节点 所代表的字符串是节点 所代表字符串的后缀。 - 如果我们在 Trie 树上统计每个节点被所有串的前缀经过的次数
,那么一个模式串 (对应节点 )的总出现次数,恰好等于 fail 树上以 为根的整棵子树中所有节点的初始权值之和。
graph TD
subgraph Trie 树前缀匹配路径
R[0: 根] --> A["1: 'a' (cnt=3)"]
A --> AA["2: 'aa' (cnt=2)"]
AA --> AAA["3: 'aaa' (cnt=1)"]
end
subgraph fail 树拓扑汇聚方向
F3["3: 'aaa' (cnt=1)"] -->|"fail 指向"| F2["2: 'aa' (cnt=2+1=3)"]
F2 -->|"fail 指向"| F1["1: 'a' (cnt=3+3=6)"]
end
由于 fail 指针总是从深度大的节点指向深度较小的节点,BFS 构建 AC 自动机时节点出队的先后顺序天然构成了 fail 树的一个拓扑序。
因此,我们无需显式建立 fail 树反向边做树形 DFS,直接按 BFS 出队顺序的逆序(自底向上)执行状态转移:
遍历完成后,
代码
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 14:00
# update_at: 2026-09-30 14:04
import sys
from collections import deque
def build_ac(words: list[str]) -> tuple[list[int], list[int], list[int]]:
"""构建 AC 自动机并返回 (各前缀经过计数, 各模式串对应节点, 广搜拓扑序)。"""
trie: list[dict[str, int]] = [{}]
cnt: list[int] = [0]
pos: list[int] = []
for w in words:
u = 0
for ch in w:
if ch not in trie[u]:
trie[u][ch] = len(trie)
trie.append({})
cnt.append(0)
u = trie[u][ch]
cnt[u] += 1 # 记录所有模式串前缀在该节点的覆盖次数
pos.append(u)
fail = [0] * len(trie)
q = deque(trie[0].values())
order: list[int] = []
while q:
u = q.popleft()
order.append(u)
for ch, v in trie[u].items():
f = fail[u]
while f and ch not in trie[f]:
f = fail[f]
fail[v] = trie[f][ch] if ch in trie[f] else 0
q.append(v)
return cnt, fail, order, pos
def count_occurrences(cnt: list[int], fail: list[int], order: list[int], pos: list[int]) -> list[int]:
"""按 fail 树自底向上聚合子树计数,返回每个模式串在所有串中的总出现次数。"""
for u in reversed(order):
cnt[fail[u]] += cnt[u]
return [cnt[p] for p in pos]
def solve() -> None:
data = iter(sys.stdin.buffer.read().split())
n = int(next(data))
words = [next(data).decode() for _ in range(n)]
cnt, fail, order, pos = build_ac(words)
ans = count_occurrences(cnt, fail, order, pos)
print('\n'.join(map(str, ans)))
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:45
* update_at: 2026-10-05 03:45
*/
// AC 自动机 + fail 树子树聚合统计多模式串出现次数。
#include <cstdio>
#include <cstring>
#include <string>
typedef long long ll;
const int MAXNODE = 1000005; // 节点数不超过所有单词长度和 + 1
const int SIGMA = 26;
int ch[MAXNODE][SIGMA]; // Trie 转移:ch[u][c] = 节点 u 沿字符 c 的儿子
int fail[MAXNODE]; // fail 指针,构成以根为祖先的 fail 树
ll cnt[MAXNODE]; // cnt[u] = 所有模式串的前缀经过节点 u 的次数(后为子树聚合值)
int pos[205]; // pos[i] = 第 i 个单词在 Trie 上对应的终止节点
int order[MAXNODE]; // BFS 出队顺序,天然是 fail 树的拓扑序(父先于子)
int total_node = 1; // 节点总数,0 号为根,1 号预留给第一个新建节点
// 把所有单词插进 Trie,并在每个经过的节点上累计前缀覆盖次数
void insert_word(const std::string &s, int idx) {
int u = 0;
for (unsigned int i = 0; i < s.size(); ++i) {
int c = s[i] - 'a';
if (ch[u][c] == 0) {
ch[u][c] = total_node++;
}
u = ch[u][c];
cnt[u]++; // 这个前缀被该模式串经过了一次
}
pos[idx] = u;
}
// BFS 建 fail 指针,同时记录出队顺序
void build_fail() {
static int q[MAXNODE];
int head = 0, tail = 0;
for (int c = 0; c < SIGMA; ++c) {
if (ch[0][c] != 0) {
fail[ch[0][c]] = 0;
q[tail++] = ch[0][c];
}
}
while (head < tail) {
int u = q[head++];
order[head - 1] = u; // 出队顺序即拓扑序
for (int c = 0; c < SIGMA; ++c) {
int v = ch[u][c];
if (v != 0) {
fail[v] = ch[fail[u]][c];
q[tail++] = v;
} else {
ch[u][c] = ch[fail[u]][c]; // 路径压缩成转移自动机
}
}
}
}
int main() {
int n;
if (scanf("%d", &n) != 1) return 0;
for (int i = 0; i < n; ++i) {
static char word[2000005]; // 单个单词最长可达总长度 10^6,用静态区避免爆栈
scanf("%s", word);
insert_word(word, i);
}
build_fail();
// 按拓扑序的逆序(深度大到深度小)自底向上聚合 fail 树子树权值:
// 节点 u 的所有出现都会贡献给它的后缀 fail[u]
for (int i = total_node - 2; i >= 0; --i) {
int u = order[i];
cnt[fail[u]] += cnt[u];
}
for (int i = 0; i < n; ++i) {
printf("%lld\n", cnt[pos[i]]);
}
return 0;
}复杂度
- 时间复杂度:
。建立 Trie 树、广搜构建 fail 指针以及逆序拓扑累加权值均对每个节点处理常数次,总运行时间与所有单词的总长度之和呈严格线性关系。 - 空间复杂度:
。Trie 树的节点数量不超过 ,每个节点维护转移指针和整型计数变量,空间开销为线性阶。
总结
本题是 AC 自动机上经典利用 fail 树子树聚合 优化多模式串统计的范例。通过将前缀覆盖标记直接记录在 Trie 节点上,并沿 fail 树的拓扑逆序自底向上求和,彻底避免了暴力跳 fail 链的二次退化,实现了线性的时空复杂度。