正难则反,答案 = 26^M − 不含任何单词的串数;用 AC 自动机构造状态(补全转移表、沿 fail 链下传「已命中」标记),再按长度做滚动数组计数 DP,复杂度 O(26·Σ|w|·M)。
OJ: roj
题目 ID: 1485
难度:提高
标签:字符串AC自动机动态规划计数python
创建: 2026-09-30 14:19
更新: 2026-10-07 11:01
形式化题目
给定
称一个大写字母串
约束:
样例。 A、B。长度 2 的串共 A 也不含 B 的串每一位只能取其余
正解
思路
朴素做法为什么不可行。 最直接的办法是枚举全部
第一步:正难则反。 直接数"至少含一个单词"很麻烦——一个串可能同时命中多个单词,
要避免重复计数就得对
反过来数它的补集则干净得多。"不可读"的定义是对所有
“不含任何单词"在从左往右构造串的过程中是可以增量维护的,而"至少含一个"不是。 于是问题从计数题变成了"在自动机上数路径”。
第二步:判断"是否命中"只需要有限状态。 从左往右一位一位构造文章时,决定"接下来 会不会命中某个单词"的全部信息是:当前已读入串的最长可匹配后缀(即最长的、同时是 某个单词前缀的后缀)。整个前缀的其余部分再也用不上了。
这正是 AC 自动机(Aho–Corasick 自动机)的状态定义。把 A、B 为例,自动机只有 3 个节点:
graph LR R((0 根)) -- A --> A1((1 词尾 A<br/>dead)) R -- B --> B1((2 词尾 B<br/>dead)) R -- 其余 24 个字母 --> R A1 -. fail .-> R B1 -. fail .-> R
从根读 A 到节点 1、读 B 到节点 2、读其余 24 个字母都留在根 0。节点 1 和 2 是
两个单词的词尾,走到它们就说明命中了。
第三步:标记"死状态"。 我们把"到达该节点时已经命中某个单词"的节点称为死状态
dead[v]。它有两种来源:
本身就是某个单词的词尾; 的 fail 链上存在某个词尾——也就是说,当前后缀的某个真后缀是一个完整单词。
第 2 种情况很容易被漏掉。例如单词是 AB:
graph LR R0((0 根)) -- A --> N1((1)) N1 -- B --> N2((2 词尾 AB<br/>dead)) N2 -. fail .-> R0
节点 2 自己是词尾,dead[2] = True,这个图看着简单。但设想单词是 AB、CD,
读到 XAB 时最后一步落在节点 AB 上,命中;而如果单词是 AB、文本是 ABAB,
第二次的 AB 依然要在最后一步被抓住。关键在于:只要某个词尾在 fail 链上,就说明
已经命中,因为 fail 链恰好枚举了当前状态的全部"是某单词前缀"的真后缀。
实现上不需要在每次转移后沿 fail 链查一遍,只需在 BFS 中做一次下传:
因为
第四步:补全转移表。 如果只保留 trie 上真实存在的边,那么读一个字符时要沿 fail 链回退去找第一条该字符的边:
while node and c not in go[node]:
node = fail[node]最坏情况会退化(想象
根上缺的边指向根自己。这一步不改变语义:while 循环逐位等价。补全之后,每次转移只需
一次查表,
第五步:按长度递推计数。 状态就是自动机节点,令
- 初值:
,空串停在根。 - 转移:枚举
与 ,令 。若 是死状态,这条路径 已经命中单词,直接丢弃;否则 。 - 由不变量"未经过死状态
未含任何单词", 。 - 最终答案
。
状态数只有 trie 的节点数(
样例 DP 表( ,词 A、B)
26 个字母按从根读入后的去向分成三类:A 去节点 1、B 去节点 2、其余 24 个字母留在
根 0,而节点 1、2 都是死状态。于是"未命中"的串在 DP 里只能一直待在根节点:
| 未命中串数 |
||||
|---|---|---|---|---|
| 0 | 1 | 0 | 0 | 1 |
| 1 | 0(落入即出局) | 0(落入即出局) | 24 | |
| 2 | 0 | 0 | 576 |
第 1 行是初值
表格同时暴露了一个容易搞错的地方:死状态的 DP 值恒为 0,因为任何落入死状态的 转移都在当步被丢弃,不存在"先命中、后面又忘记"的可能。这正好对应子串语义——单词一旦 出现就永远算数。
代码
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:20
# update_at: 2026-09-30 14:20
import sys
from collections import deque
MOD = 10007 # 题面要求的结果模数
ALPHABET = 26 # 只可能出现英文大写字母
def build_automaton(words: list[bytes]) -> tuple[list[list[int]], list[bool]]:
"""插入所有单词建 trie 并 BFS 补全转移表,返回完整 goto 表 go 与危险标记 dead。
go[v][c] 是"在状态 v 读到字符 c 后该去哪";若 v 本身没有 c 边,就直接继承
go[fail[v]][c],根上缺的边指向根自己。补全后每步只查一次表,不必沿 fail 链回退。
dead[v] 表示"走到状态 v 就已经包含了至少一个完整单词":既包括 v 自己就是词尾,
也包括某个词尾是 v 的真后缀——后者刚好由 fail 链给出,顺着 BFS 顺序把父亲的
dead 往下传即可。
"""
trie: list[list[int]] = [[-1] * ALPHABET] # -1 表示这条边在 trie 上还不存在
dead: list[bool] = [False]
for w in words:
node = 0
for ch in w:
c = ch - 65
if trie[node][c] == -1: # 该前缀第一次出现,新建节点
trie[node][c] = len(trie)
trie.append([-1] * ALPHABET)
dead.append(False)
node = trie[node][c]
dead[node] = True # 词尾本身当然算"已包含单词"
fail = [0] * len(trie) # 根和根的孩子失配后都回到根
root = trie[0] # 根那一行,缺边时直接写 0
# 先处理根的孩子:它们缺的边一律指向根,同时入队等待做 BFS。
queue = deque()
for c in range(ALPHABET):
child = root[c]
if child == -1:
root[c] = 0
else:
queue.append(child)
while queue:
node = queue.popleft()
dead[node] |= dead[fail[node]] # fail 链上的词尾都算命中,向下传递
inherit = trie[fail[node]] # 缺边时整行继承的那一行
for c in range(ALPHABET):
child = trie[node][c]
if child == -1:
trie[node][c] = inherit[c] # 补全:等价于沿 fail 链继续尝试读 c
else:
fail[child] = inherit[c] # child 的真实失配边
queue.append(child)
return trie, dead
def count_safe(states: int, dead: list[bool], go: list[list[int]], length: int) -> int:
"""统计长度恰为 length、且完全不含任何单词的字符串个数(即"不可读文本"数)。
dp[v] 是扫完当前长度后停在状态 v 的文本数;每一步让每个非死状态向它的 26 条
出边转移,落在死状态上的文本直接丢弃。状态数 = trie 节点数,与文本长度无关。
"""
dp = [0] * states
dp[0] = 1 # 空串停在根
for _ in range(length):
nxt = [0] * states
for v in range(states):
ways = dp[v]
if ways:
for u in go[v]:
if not dead[u]: # 只有落点安全才保留这条转移
nxt[u] = (nxt[u] + ways) % MOD
dp = nxt
return sum(dp) % MOD
def solve() -> None:
it = iter(sys.stdin.buffer.read().split())
n = int(next(it)) # 单词数 N
m = int(next(it)) # 文本固定长度 M
words = [next(it) for _ in range(n)] # 紧随其后的 N 个单词
go, dead = build_automaton(words)
total = pow(ALPHABET, m, MOD) # 全部可能的文章数 26^m
readable = (total - count_safe(len(go), dead, go, m)) % MOD
print(readable)
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:57
* update_at: 2026-10-05 03:57
*/
// roj 1485「文本生成器」:AC 自动机 + 计数 DP(正难则反)
// 直接数"至少含一个单词"的串要做重叠容斥,很难;反过来数"一个单词都不含"的串
// 可以边构造边判断,于是 答案 = 26^M - (长度 M、不含任何单词的串数)。
// "当前前缀是否已命中单词"只需记住它的最长可匹配后缀,这正是 AC 自动机的状态:
// 建 trie -> BFS 求 fail 时补全转移表并把命中标记 dead 沿 fail 链下传 ->
// 按长度做滚动数组 DP 数路径。复杂度 O(26 * |V| * M)。
#include <cstdio>
typedef long long ll;
const int MOD = 10007; // 题面要求的结果模数
const int ALPHA = 26; // 字符集:26 个大写字母
const int MAXNODE = 6100; // 自动机节点数上界 1 + sum|w| <= 1 + 60 * 100
const int MAXLEN = 105; // 单个单词长度上界
int go[MAXNODE][ALPHA]; // go[v][c]:状态 v 读到字符 c 后的去向;BFS 时补全,缺边继承 fail
int fail[MAXNODE]; // fail[v]:状态 v 的最长真后缀节点
char dead[MAXNODE]; // dead[v] = 1 表示到达 v 时已经命中过某个单词(只有 0/1,用 char 省内存)
int node_cnt = 1; // 节点数,0 号是根
int dp[MAXNODE]; // dp[v]:当前长度、全程未命中且停在 v 的串数
int nxt[MAXNODE]; // 下一长度的 dp
int que[MAXNODE]; // BFS 队列
char word[MAXLEN]; // 当前读入的单词
// 把长度为 len 的单词 word 插入 trie,并在词尾打上命中标记
void insert_word(int len) {
int v = 0;
for (int i = 0; i < len; i++) {
int c = word[i] - 'A';
if (go[v][c] == 0) { // 0 号是根,真实边不会指回根,可用 0 表示"这条边还不存在"
go[v][c] = node_cnt;
node_cnt++;
}
v = go[v][c];
}
dead[v] = 1; // 节点本身是词尾,走到这里就命中了
}
// BFS 求 fail,同时补全转移表并把命中标记沿 fail 链下传
void build_automaton() {
int head = 0;
int tail = 0;
for (int c = 0; c < ALPHA; c++) {
if (go[0][c] != 0) { // 根的孩子失配后回到根
que[tail] = go[0][c];
tail++;
}
}
while (head < tail) {
int v = que[head];
head++;
if (dead[fail[v]]) { // fail 链上的词尾说明当前后缀已命中,向下传递
dead[v] = 1;
}
int f = fail[v]; // fail[v] 更浅,已处理完,它的转移表是完整的
for (int c = 0; c < ALPHA; c++) {
int u = go[v][c];
if (u != 0) {
fail[u] = go[f][c];
que[tail] = u;
tail++;
} else {
go[v][c] = go[f][c]; // 补全:等价于沿 fail 链回退直到能读 c
}
}
}
}
// 统计长度恰为 m、且完全不含任何单词的串数(模 MOD)
int count_safe(int m) {
for (int v = 0; v < node_cnt; v++) {
dp[v] = 0;
}
dp[0] = 1; // 空串停在根
for (int step = 1; step <= m; step++) {
for (int v = 0; v < node_cnt; v++) {
nxt[v] = 0;
}
for (int v = 0; v < node_cnt; v++) {
if (dp[v] == 0) {
continue;
}
for (int c = 0; c < ALPHA; c++) {
int u = go[v][c];
if (dead[u]) { // 落到死状态就已经命中,这条转移丢弃
continue;
}
nxt[u] += dp[v];
if (nxt[u] >= MOD) {
nxt[u] -= MOD;
}
}
}
for (int v = 0; v < node_cnt; v++) {
dp[v] = nxt[v];
}
}
int sum = 0;
for (int v = 0; v < node_cnt; v++) {
sum += dp[v];
if (sum >= MOD) {
sum -= MOD;
}
}
return sum;
}
// 快速幂求 base^exp mod MOD
int pow_mod(int base, int exp) {
int result = 1;
int b = base % MOD;
int e = exp;
while (e > 0) {
if (e & 1) {
result = result * b % MOD;
}
b = b * b % MOD;
e >>= 1;
}
return result;
}
int main() {
int n = 0;
int m = 0;
scanf("%d %d", &n, &m);
for (int i = 0; i < n; i++) {
scanf("%s", word);
int len = 0;
while (word[len] != '\0') {
len++;
}
insert_word(len);
}
build_automaton();
int total = pow_mod(ALPHA, m); // 全部文章数 26^m
int safe = count_safe(m); // 不可读文章数
int answer = (total - safe) % MOD;
if (answer < 0) {
answer += MOD;
}
printf("%d\n", answer);
return 0;
}复杂度
设
| 阶段 | 时间 | 空间 |
|---|---|---|
| 建 trie | ||
| BFS 求 fail、补全转移表、下传 dead | ||
合计时间
对照朴素枚举的
答案的正确性依赖两条不变量:dead 沿 fail
链下传的完备性保证)。
正文的每个概念在 main.py 中的落点如下,方便对照阅读:
| 正文概念 | main.py 中的位置 |
|---|---|
建 trie、求 fail、补全转移表、下传 dead |
build_automaton() |
词尾标记 dead[node] = True |
build_automaton() 插入循环末尾 |
| BFS 循环首行 | |
BFS 循环的 else 分支 |
|
count_safe() |
|
solve() 末两行 |
总结
这道题的关键不是 AC 自动机本身,而是先想清楚"命中的否定"才是可增量维护的量。
- 数"至少含一个单词"要面对
个单词的重叠容斥;数"一个都不含"则天然可加, 于是 把问题转成了自动机上的路径计数。 - 判断"当前前缀是否已经命中"不需要记住整个前缀,只需记住它的最长可匹配后缀, 而这正是 AC 自动机的状态。这一步是整个解法成立的支点。
dead必须沿 fail 链下传,否则会漏掉"刚读入的字符补全了一个更短的单词"这种情况。- 转移表要在 BFS 中补全,把"沿 fail 链回退"这个最坏
的动作摊到建表 阶段,扫描时每步 。
Python 在本数据范围下最慢用例约 int go[L + 5][26] 与
int dp[L + 5] 即可,写法完全一致。
图示解析
下图是整条解法的路线,从"不能枚举"出发,经过正难则反,落到自动机上的 DP:
输入: N 个单词 + 长度 M
|
v
枚举 26^M 个串? --- 不可行 (M<=100)
|
v
正难则反: 答案 = 26^M - S
|
v
S = 一个单词都不含的串数
|
v
判定"是否命中"只需有限状态
|
v
AC 自动机 (状态 = trie 节点)
|- 补全转移表 go[v][c] = go[fail[v]][c] -> 每步 O(1)
|- 下传 dead[v] |= dead[fail[v]] -> 命中即出局
|
v
dp_k[v]: 长度 k、未命中、停在 v 的串数
dp_{k+1}[go[v][c]] += dp_k[v] (落点非死)
|
v
S = sum(dp_M[v]) -> answer = (26^M - S) mod 10007自上而下读:上半部分说明为什么必须放弃枚举,中间两步是建模(正难则反 + 用自动机
压缩状态),下半部分是两个实现细节(补全转移表、下传 dead)和最终递推。图中每个
方框都对应正文中的一个小节,可以当作阅读顺序的索引。