「一本通 2.4 练习 6」文本生成器

GitHub跳转原题关系图返回列表

正难则反,答案 = 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

后置题目
luogu P14363[CSP-S 2025] 谐音替换B 直接复用了 A 教的关键观察:把「当前状态 fail 链上的终止节点」当成本次读入所新增的全部匹配,A 用它在 BFS 里下传词尾命中标记判定「已命中」,B 用它在扫描询问时枚举所有以位置 i 结尾的规则匹配,再叠加字符对建模、覆盖 [L,R] 拆末尾子问题、fail 树 DFS 序 + 树状数组按深度阈值离线计数这些新流程;A(提高)是简洁的 AC 自动机判定/状态基础题,B(省选/NOI-)在其上叠加多阶段流程,Δrank=2,方向与台阶性均成立。roj 1470「一本通 2.2 练习 4」CensoringB 的 main.py 逐字复用 A 在 BFS 建 fail 时沿 fail 链下传词尾信息这一步(A 下传 dead 命中标记、B 下传 end 词长),并同样照抄 A 的缺边补全 ch[u][c]=ch[fail[u]][c],让每字符 O(1) 转移,再在此之上叠加 A 未教的「字符栈 kept + 状态栈 states 命中后整段弹出并回退状态」这一新流程。shumeng CSP201509E最佳文章B 复用 A 在 BFS 建 fail 时沿 fail 链下传词尾信息这一步:A 下传 dead 命中标记,B 在同一处把 score 沿 fail 链累加,使落点的命中/得分信息完整,再叠加矩阵快速幂。shumeng CSP202506D月票发行B 的段内计数直接复用 A 的关键状态设计:同样把「已匹配模式前缀的最长后缀」当作自动机状态逐字符转移,并沿用 A 的「落点已命中模式则该路径不计入」得到 no_ccf/no_spark(对应 main.cpp 的 next_state 与 output_mask 过滤);在此之上叠加 # 切段、全局三状态顺序衔接、safe_order 计数与 Kitamasa 求长段递推项,A 的计数 DP 恰好是 B 每个自由段里的子过程。

形式化题目

给定 NN 个只含英文大写字母的单词 w1,w2,…,wNw_1, w_2, \ldots, w_N,以及一个固定长度 MM。

称一个大写字母串 ss 是可读的,当且仅当存在某个 ii 使得 wiw_i 是 ss 的子串。 求长度恰为 MM 的可读串个数,对 1000710007 取模。

约束:1⩽N⩽601 \leqslant N \leqslant 60,所有单词与文本的长度均不超过 100100,字符集为 2626 个大写字母。

样例。 N=2N = 2、M=2M = 2,单词为 A、B。长度 2 的串共 262=67626^2 = 676 个;既不含 A 也不含 B 的串每一位只能取其余 2424 个字母,共 242=57624^2 = 576 个。故可读串为 676−576=100676 - 576 = 100,与样例输出一致。这个减法正是全文的起点。

正解

思路

朴素做法为什么不可行。 最直接的办法是枚举全部 26M26^M 个串,对每个串逐位匹配 NN 个单词。当 M=100M = 100 时 2610026^{100} 无法枚举,连"列出所有候选"这一步都做不到。 所以必须换一种"不枚举串"的计数方式。

第一步:正难则反。 直接数"至少含一个单词"很麻烦——一个串可能同时命中多个单词, 要避免重复计数就得对 NN 个单词做容斥,而单词之间的重叠关系(谁是谁的子串、谁和谁 共享前缀)复杂到无法枚举。

反过来数它的补集则干净得多。"不可读"的定义是对所有 wiw_i 取否定:

S=#{ s∈ΣM:s 不含任何 wi },answer=(26M−S) mod 10007S = \#\{\, s \in \Sigma^M : s \text{ 不含任何 } w_i \,\}, \qquad \text{answer} = (26^M - S) \bmod 10007

“不含任何单词"在从左往右构造串的过程中是可以增量维护的,而"至少含一个"不是。 于是问题从计数题变成了"在自动机上数路径”。

第二步:判断"是否命中"只需要有限状态。 从左往右一位一位构造文章时,决定"接下来 会不会命中某个单词"的全部信息是:当前已读入串的最长可匹配后缀(即最长的、同时是 某个单词前缀的后缀)。整个前缀的其余部分再也用不上了。

这正是 AC 自动机(Aho–Corasick 自动机)的状态定义。把 NN 个单词插入一棵 trie,求出 fail 指针后,每个 trie 节点就代表"当前最长可匹配后缀"这一状态,于是"前缀有没有命中 单词"完全由节点决定。以样例的词 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]。它有两种来源:

  1. vv 本身就是某个单词的词尾;
  2. vv 的 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 中做一次下传:

dead[v]∣=dead[fail[v]]\texttt{dead}[v] \mathrel{|}= \texttt{dead}[\texttt{fail}[v]]

因为 fail[v]\texttt{fail}[v] 的深度严格更小、在 BFS 中一定先被处理完,所以一次下传就把 整条 fail 链上的词尾信息传到了 vv。

第四步:补全转移表。 如果只保留 trie 上真实存在的边,那么读一个字符时要沿 fail 链回退去找第一条该字符的边:

python
while node and c not in go[node]:
    node = fail[node]

最坏情况会退化(想象 100100 个字符全部相同、trie 被压成一条长链)。解决办法是在 BFS 求 fail 的同时补全转移表:对节点 vv 与字符 cc,若 vv 没有 cc 边,就令

go[v][c]←go[fail[v]][c]go[v][c] \leftarrow go[\texttt{fail}[v]][c]

根上缺的边指向根自己。这一步不改变语义:go[fail[v]][c]go[\texttt{fail}[v]][c] 正是"沿 vv 的最长 真后缀继续尝试读 cc"的结果,与上面的 while 循环逐位等价。补全之后,每次转移只需 一次查表,O(1)O(1)。

第五步:按长度递推计数。 状态就是自动机节点,令 dpk[v]dp_k[v] 表示"长度恰为 kk、 全程未命中任何单词、且构造完停在节点 vv"的串数。

  • 初值:dp0[0]=1dp_0[0] = 1,空串停在根。
  • 转移:枚举 vv 与 c∈Σc \in \Sigma,令 u=go[v][c]u = go[v][c]。若 uu 是死状态,这条路径 已经命中单词,直接丢弃;否则 dpk+1[u]+=dpk[v]dp_{k+1}[u] \mathrel{+}= dp_k[v]。
  • 由不变量"未经过死状态   ⟺  \iff 未含任何单词",S=∑v∉DdpM[v]S = \sum_{v \notin D} dp_M[v]。
  • 最终答案 (26M−S) mod 10007(26^M - S) \bmod 10007。

状态数只有 trie 的节点数(⩽101\leqslant 101),与 MM 无关,所以这个 DP 每层只要 26×∣V∣26 \times |V| 次转移。

样例 DP 表(N=2,M=2N=2, M=2,词 A、B)

26 个字母按从根读入后的去向分成三类:A 去节点 1、B 去节点 2、其余 24 个字母留在 根 0,而节点 1、2 都是死状态。于是"未命中"的串在 DP 里只能一直待在根节点:

kk dpk[0]dp_k[0] 根 dpk[1]dp_k[1] (死) dpk[2]dp_k[2] (死) 未命中串数 SkS_k
0 1 0 0 1
1 1×24=241 \times 24 = 24 0(落入即出局) 0(落入即出局) 24
2 24×24=57624 \times 24 = 576 0 0 576

第 1 行是初值 dp0[0]=1dp_0[0] = 1;第 2 行只有 24 条留在根的分支存活;第 3 行把 24 再乘 24 得 S2=576S_2 = 576。于是答案 =262−576=676−576=100= 26^2 - 576 = 676 - 576 = 100,与样例一致。

表格同时暴露了一个容易搞错的地方:死状态的 DP 值恒为 0,因为任何落入死状态的 转移都在当步被丢弃,不存在"先命中、后面又忘记"的可能。这正好对应子串语义——单词一旦 出现就永远算数。

代码

Python 版:

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++ 版(同一算法):

cpp
/**
 * 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;
}

复杂度

设 L=∑i=1N∣wi∣L = \sum_{i=1}^{N} |w_i|(题面保证 L⩽100L \leqslant 100),自动机节点数 ∣V∣⩽L+1|V| \leqslant L + 1。

阶段 时间 空间
建 trie O(L)O(L) O(26∣V∣)O(26 \lvert V \rvert) 转移表
BFS 求 fail、补全转移表、下传 dead O(26∣V∣)O(26 \lvert V \rvert) O(∣V∣)O(\lvert V \rvert) 辅助数组
MM 轮计数 DP O(26∣V∣⋅M)O(26 \lvert V \rvert \cdot M) O(∣V∣)O(\lvert V \rvert) 滚动数组

合计时间 O(26∣V∣(M+1))O(26 \lvert V \rvert (M + 1)),空间 O(26∣V∣)O(26 \lvert V \rvert)。代入选最坏值 ∣V∣≈101|V| \approx 101、M=100M = 100,转移次数约 2.6×1052.6 \times 10^5。

对照朴素枚举的 O(26M⋅M)O(26^M \cdot M):DP 的收益全部来自"用最长可匹配后缀代替整个前缀" 这一步状态压缩,把与 MM 成指数关系的枚举换成了与 MM 成线性关系的递推。

答案的正确性依赖两条不变量:dpk[v]dp_k[v] 中每个串读完后确实停在 vv(由转移表补全与 朴素回退的等价性保证);“未经过死状态"确实等价于"不含任何单词”(由 dead 沿 fail 链下传的完备性保证)。

正文的每个概念在 main.py 中的落点如下,方便对照阅读:

正文概念 main.py 中的位置
建 trie、求 fail、补全转移表、下传 dead build_automaton()
词尾标记 dead[node] = True build_automaton() 插入循环末尾
dead[v]∣=dead[fail[v]]\texttt{dead}[v] \mathrel{|}= \texttt{dead}[\texttt{fail}[v]] BFS 循环首行
go[v][c]←go[fail[v]][c]go[v][c] \leftarrow go[\texttt{fail}[v]][c] BFS 循环的 else 分支
dpdp 递推与 S=∑dpM[v]S = \sum dp_M[v] count_safe()
(26M−S) mod 10007(26^M - S) \bmod 10007 solve() 末两行

总结

这道题的关键不是 AC 自动机本身,而是先想清楚"命中的否定"才是可增量维护的量。

  • 数"至少含一个单词"要面对 NN 个单词的重叠容斥;数"一个都不含"则天然可加, 于是 26M−S26^M - S 把问题转成了自动机上的路径计数。
  • 判断"当前前缀是否已经命中"不需要记住整个前缀,只需记住它的最长可匹配后缀, 而这正是 AC 自动机的状态。这一步是整个解法成立的支点。
  • dead 必须沿 fail 链下传,否则会漏掉"刚读入的字符补全了一个更短的单词"这种情况。
  • 转移表要在 BFS 中补全,把"沿 fail 链回退"这个最坏 O(max⁡∣wi∣)O(\max|w_i|) 的动作摊到建表 阶段,扫描时每步 O(1)O(1)。

Python 在本数据范围下最慢用例约 0.240.24 秒、峰值内存约 1717 MB,无论时间还是空间都 有充足余量;同样的算法用 C++ 落地时,把转移表换成全局 int go[L + 5][26] 与 int dp[L + 5] 即可,写法完全一致。

图示解析

下图是整条解法的路线,从"不能枚举"出发,经过正难则反,落到自动机上的 DP:

text
输入: 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)和最终递推。图中每个 方框都对应正文中的一个小节,可以当作阅读顺序的索引。