1. 从一道题看字符串匹配与计数的完整技术链路
字符串匹配类的算法题里,有一类特别有意思的题型:给定一个字符集,要求统计长度为某个值的、不包含任何"禁用词"的字符串有多少个。这类问题表面上看是计数问题,实际上它把多模式串匹配和动态规划这两块知识缝合在了一起,再加上结果可能大到爆long long,还得叠一层高精度运算。三个技术点串成一条线,任何一个环节掉链子都过不了。
"Censored!"这道题就是这类题型的典型代表。它的核心设定是:给你一个字符集(注意,字符集不一定是小写字母,可能是任意可见字符),再给你若干个"禁忌单词",问你在长度为L的所有字符串中,有多少个字符串不包含任何一个禁忌单词作为子串。答案要对一个大数取模?不,这道题不取模,答案可能极其巨大,所以必须用高精度。
为什么这道题值得单独拿出来讲?因为它把AC自动机(Aho-Corasick Automaton)的状态转移特性用到了极致。AC自动机本身是用来做多模式匹配的,但在这道题里,它的每一个节点代表"当前已经匹配了哪些前缀"的状态,而DP的过程就是在AC自动机的状态图上走L步,统计不经过任何"危险节点"的路径条数。这个思路一旦理解透了,类似的题目(比如统计不含某些子串的字符串数量、DNA序列计数等)都能一通百通。
这篇文章适合谁看?如果你已经学过AC自动机的基本构建,但对"AC自动机+DP"的组合还不太熟练,或者你对高精度运算在算法题中的应用场景不太清楚,那这篇内容会帮你把整条链路打通。如果你是完全的新手,建议先补一下Trie树和KMP的基本概念,否则后面的状态转移部分会有点吃力。我会尽量用生活化的类比来解释,但该有的技术细节一个都不会少。
接下来我会从整体设计思路开始拆,然后逐个环节讲实现细节,再给出一套可以直接参考的代码框架,最后把我踩过的坑和常见问题整理出来。整篇内容基于我自己的实操经验,不是教科书式的复述。
2. 整体方案设计与核心思路拆解
2.1 为什么是AC自动机而不是KMP或后缀自动机
先想清楚一个问题:我们要统计的是"不包含任何禁忌单词"的字符串数量。禁忌单词有多个,而且长度不一。如果只用KMP,每次只能匹配一个模式串,多个模式串就得跑多遍,而且DP的状态没法统一表示"当前匹配到了哪些模式串的什么位置"。
AC自动机的优势在于,它把所有模式串建成一棵Trie树,然后通过fail指针把树变成了图。在这张图上,每个节点代表一个"匹配状态"——你当前扫描到的字符串,对应着Trie树上的某个节点,这个节点隐含了"当前后缀匹配了哪些模式串的前缀"这个信息。换句话说,AC自动机的节点天然就是DP的状态。
那后缀自动机(SAM)行不行?SAM更适合处理单个字符串的所有子串问题,对于多模式串的匹配场景,AC自动机更直接。而且这道题需要的是"路径计数",AC自动机的状态转移图正好是一个确定性的自动机,每个状态读入一个字符后跳转到唯一的下一个状态,非常适合做DP。
这里有个关键认知:AC自动机不只是一个匹配工具,它本质上是一个DFA(确定性有限自动机)。DFA的每个状态在给定输入字符后都有唯一的转移目标,这个性质是DP能够成立的前提。
2.2 DP状态的定义与转移方程
定义dp[i][j]表示:长度为i的字符串,当前在AC自动机上处于状态j时,不包含任何禁忌单词的方案数。
转移方程很直观:
dp[i+1][next_state] += dp[i][j]其中next_state = trie[j].next[c],c是字符集中的某个字符。但有一个前提:next_state不能是"危险节点"。
什么是危险节点?两类:
- 本身就是某个禁忌单词的结尾节点
- 通过fail指针间接指向了禁忌单词结尾的节点(因为fail指针指向的是当前状态的最长后缀,如果那个后缀是禁忌单词,那当前状态也"脏"了)
所以构建完AC自动机后,需要做一遍危险标记的下传:如果某个节点的fail指向的节点是危险的,那这个节点也是危险的。这一步在BFS构建fail指针的时候顺便就能做。
最终答案是sum(dp[L][j]),其中j遍历所有非危险节点。
2.3 高精度为什么必须上
题目中字符串长度L可以到很大(具体上限看题目数据范围),字符集大小也可能不小。即使字符集只有26个字母,L=50的时候,26^50已经是天文数字了,远超unsigned long long的范围(大约1.8×10^19)。所以必须用高精度。
高精度在这里的实现方式有两种选择:
- 用大数类(比如Java的BigInteger,或者C++手写高精度)
- 用数组模拟大整数加法
因为DP的转移只涉及加法,不涉及乘法,所以高精度只需要实现加法就够了。这大大简化了实现难度。每次转移就是两个高精度数相加,写一个bigAdd函数即可。
实操心得:很多人一看到高精度就头疼,觉得要写乘法、除法、取模。但这道题只需要加法,你甚至可以用
vector<int>存每一位,写一个20行的加法函数就搞定了。不要被"高精度"三个字吓到。
3. 核心细节解析与实操要点
3.1 字符集的处理:不是所有题都是小写字母
这道题的第一个坑就是字符集。很多AC自动机的模板题默认字符集是小写字母a-z,数组开26就够了。但"Censored!"的字符集是给定的,可能包含大写字母、数字、甚至符号,而且字符的ASCII码不一定连续。
处理方式:先把所有出现的字符收集起来,做一个字符到编号的映射。比如字符集是{A, C, G, T},那就建立映射A->0, C->1, G->2, T->3。Trie树的next数组大小就是字符集大小,而不是256或26。
map<char, int> charId; string charset; // 输入的字符集 for (int i = 0; i < charset.size(); i++) { charId[charset[i]] = i; }这样做的好处是节省空间,而且转移的时候直接用编号,不需要每次都查ASCII表。坏处是如果字符集很大(比如全部256个字符),那每个节点的next数组就会很大,内存消耗高。这时候可以用map或者哈希来代替数组,但会慢一些。对于这道题的数据规模,数组通常够用。
3.2 Trie树的构建细节
Trie树的每个节点需要存:
next[]:转移数组fail:失配指针danger:是否是危险节点
构建过程就是标准的Trie插入。每插入一个禁忌单词,在最后一个字符对应的节点上标记danger = true。
struct Node { int next[MaxChar]; int fail; bool danger; Node() { memset(next, 0, sizeof(next)); fail = 0; danger = false; } };注意根节点是0号节点,初始化的时候所有next指向0(也就是根),这表示"没有这个转移"。
注意事项:如果你的字符集编号从0开始,那
next[c]=0会有歧义——它既表示"转移到根节点",又表示"没有这个转移"。解决办法是让根节点编号为0,但真正的转移目标从1开始编号,或者用一个单独的exist数组标记转移是否存在。我个人的习惯是把根节点设为0,所有不存在的转移也指向0,然后在BFS构建fail的时候特殊处理根节点的子节点。
3.3 Fail指针的构建与危险标记下传
Fail指针的构建用BFS。核心逻辑:
- 根节点的所有存在的子节点的fail指向根节点,入队。
- 对于队列中的每个节点u,遍历它的所有转移c:
- 如果
u.next[c]存在,那u.next[c].fail = u.fail.next[c] - 如果不存在,直接让
u.next[c] = u.fail.next[c](路径压缩,这是AC自动机变成DFA的关键一步) - 如果
u.next[c]是危险节点(它自己danger或者它fail指向的节点danger),标记为危险
- 如果
void build() { queue<int> q; for (int c = 0; c < charSize; c++) { if (trie[0].next[c]) { trie[trie[0].next[c]].fail = 0; q.push(trie[0].next[c]); } } while (!q.empty()) { int u = q.front(); q.pop(); // 危险标记下传 if (trie[trie[u].fail].danger) { trie[u].danger = true; } for (int c = 0; c < charSize; c++) { int v = trie[u].next[c]; if (v) { trie[v].fail = trie[trie[u].fail].next[c]; q.push(v); } else { trie[u].next[c] = trie[trie[u].fail].next[c]; } } } }这里有个细节:危险标记的下传必须在BFS的过程中做,因为fail指针是指向层数更小的节点,BFS保证了处理当前节点时,它的fail指向的节点已经被处理过了。
实操心得:路径压缩(
trie[u].next[c] = trie[trie[u].fail].next[c])这一步非常关键。它让AC自动机从一棵树变成了一张图,每个状态对每个字符都有确定的转移。这样DP的时候就不需要沿着fail链跳来跳去了,直接O(1)转移。很多人写AC自动机的时候不做路径压缩,DP的时候每次都要跳fail,复杂度直接爆炸。
3.4 DP的初始化与边界条件
DP数组dp[i][j],i从0到L,j从0到节点总数。
初始化:dp[0][0] = 1,其余为0。意思是长度为0的字符串,在根节点,方案数为1。
转移:对于每个i从0到L-1,对于每个非危险节点j,如果dp[i][j] > 0,则对于字符集中的每个字符c,计算nj = trie[j].next[c],如果nj不是危险节点,则dp[i+1][nj] += dp[i][j]。
最终答案:sum(dp[L][j]),j遍历所有非危险节点。
注意事项:如果根节点本身是危险节点(比如某个禁忌单词是空串),那答案直接是0。但通常题目不会给空串,所以可以忽略。不过严谨起见,还是判断一下。
3.5 高精度加法的实现
因为只需要加法,实现非常简单。用一个vector<int>存每一位,低位在前。加法就是从低位到高位逐位相加,处理进位。
vector<int> bigAdd(vector<int>& a, vector<int>& b) { vector<int> res; int carry = 0; int n = max(a.size(), b.size()); for (int i = 0; i < n || carry; i++) { int sum = carry; if (i < a.size()) sum += a[i]; if (i < b.size()) sum += b[i]; res.push_back(sum % 10); carry = sum / 10; } return res; }在DP中,每次转移就是一次bigAdd。为了效率,可以用一个三维数组dp[i][j],每个元素是一个vector<int>。但这样内存消耗大,而且vector的拷贝开销高。优化方式是滚动数组,只保留当前层和下一层。
实操心得:高精度加法虽然简单,但在DP中频繁调用会有性能问题。我的做法是预估答案的最大位数,然后用固定大小的数组(比如
int dp[2][MaxNode][MaxDigits])来存,避免vector的动态分配。最大位数可以估算:字符集大小^L,取对数就能算出位数上限。比如26^50大约是10^70,那就开100位足够了。
4. 完整实操流程与核心环节实现
4.1 输入解析与字符集映射
先读入字符集和禁忌单词数量,然后读入每个禁忌单词。注意字符集可能包含空格,所以读入的时候要用getline或者逐个字符读。
int N, L; string charset; cin >> N >> L; cin >> charset; int charSize = charset.size(); map<char, int> charId; for (int i = 0; i < charSize; i++) { charId[charset[i]] = i; }然后读入N个禁忌单词,插入Trie树。
for (int i = 0; i < N; i++) { string word; cin >> word; insert(word); }插入函数:
void insert(string& s) { int u = 0; for (char ch : s) { int c = charId[ch]; if (!trie[u].next[c]) { trie[u].next[c] = ++tot; } u = trie[u].next[c]; } trie[u].danger = true; }4.2 AC自动机构建与危险节点标记
按照前面讲的BFS方式构建fail指针,同时做路径压缩和危险标记下传。这一步是整个算法的核心,写错了后面全错。
void build() { queue<int> q; for (int c = 0; c < charSize; c++) { int v = trie[0].next[c]; if (v) { trie[v].fail = 0; q.push(v); } } while (!q.empty()) { int u = q.front(); q.pop(); if (trie[trie[u].fail].danger) { trie[u].danger = true; } for (int c = 0; c < charSize; c++) { int v = trie[u].next[c]; if (v) { trie[v].fail = trie[trie[u].fail].next[c]; q.push(v); } else { trie[u].next[c] = trie[trie[u].fail].next[c]; } } } }注意:危险标记下传必须在处理当前节点的子节点之前做,因为子节点的fail可能指向当前节点。如果当前节点是危险的,子节点也会被标记为危险。这个顺序不能乱。
4.3 DP转移与高精度加法
用滚动数组优化空间。dp[0]表示当前层,dp[1]表示下一层。每轮结束后交换。
// 初始化 dp[0][0] = {1}; // 高精度数1 int cur = 0; for (int i = 0; i < L; i++) { // 清空下一层 for (int j = 0; j <= tot; j++) { dp[1-cur][j].clear(); } for (int j = 0; j <= tot; j++) { if (trie[j].danger) continue; if (dp[cur][j].empty()) continue; for (int c = 0; c < charSize; c++) { int nj = trie[j].next[c]; if (trie[nj].danger) continue; dp[1-cur][nj] = bigAdd(dp[1-cur][nj], dp[cur][j]); } } cur = 1 - cur; }最后统计答案:
vector<int> ans = {0}; for (int j = 0; j <= tot; j++) { if (!trie[j].danger) { ans = bigAdd(ans, dp[cur][j]); } } // 输出ans,从高位到低位4.4 高精度输出的细节
高精度数存的时候是低位在前,输出的时候要反过来。而且要注意去掉前导零(但至少保留一位)。
void printBig(vector<int>& a) { if (a.empty()) { cout << 0; return; } for (int i = a.size() - 1; i >= 0; i--) { cout << a[i]; } }实操心得:高精度加法中,如果两个数都是0,结果应该是0,但我的
bigAdd实现会返回一个空的vector或者只含一个0的vector。输出的时候要处理这种情况。另外,如果答案是0(比如所有路径都被禁忌单词堵死了),也要正确输出0。
4.5 完整代码框架
把上面的片段拼起来,就是一个完整的解法。我把它整理成一个可以直接参考的框架:
#include <bits/stdc++.h> using namespace std; const int MAXN = 105; // 最大节点数 const int MAXD = 200; // 最大位数 struct Node { int next[60]; int fail; bool danger; Node() { memset(next, 0, sizeof(next)); fail = 0; danger = false; } } trie[MAXN]; int tot = 0; map<char, int> charId; int charSize; void insert(string& s) { int u = 0; for (char ch : s) { int c = charId[ch]; if (!trie[u].next[c]) { trie[u].next[c] = ++tot; } u = trie[u].next[c]; } trie[u].danger = true; } void build() { queue<int> q; for (int c = 0; c < charSize; c++) { int v = trie[0].next[c]; if (v) { trie[v].fail = 0; q.push(v); } } while (!q.empty()) { int u = q.front(); q.pop(); if (trie[trie[u].fail].danger) { trie[u].danger = true; } for (int c = 0; c < charSize; c++) { int v = trie[u].next[c]; if (v) { trie[v].fail = trie[trie[u].fail].next[c]; q.push(v); } else { trie[u].next[c] = trie[trie[u].fail].next[c]; } } } } vector<int> bigAdd(const vector<int>& a, const vector<int>& b) { vector<int> res; int carry = 0; int n = max(a.size(), b.size()); for (int i = 0; i < n || carry; i++) { int sum = carry; if (i < a.size()) sum += a[i]; if (i < b.size()) sum += b[i]; res.push_back(sum % 10); carry = sum / 10; } return res; } int main() { int N, L; string charset; cin >> N >> L; cin >> charset; charSize = charset.size(); for (int i = 0; i < charSize; i++) { charId[charset[i]] = i; } for (int i = 0; i < N; i++) { string word; cin >> word; insert(word); } build(); vector<vector<int>> dp[2]; dp[0].resize(tot + 1); dp[1].resize(tot + 1); dp[0][0] = {1}; int cur = 0; for (int i = 0; i < L; i++) { for (int j = 0; j <= tot; j++) { dp[1-cur][j].clear(); } for (int j = 0; j <= tot; j++) { if (trie[j].danger || dp[cur][j].empty()) continue; for (int c = 0; c < charSize; c++) { int nj = trie[j].next[c]; if (trie[nj].danger) continue; dp[1-cur][nj] = bigAdd(dp[1-cur][nj], dp[cur][j]); } } cur = 1 - cur; } vector<int> ans = {0}; for (int j = 0; j <= tot; j++) { if (!trie[j].danger) { ans = bigAdd(ans, dp[cur][j]); } } for (int i = ans.size() - 1; i >= 0; i--) { cout << ans[i]; } cout << endl; return 0; }这个框架可以直接用,只需要根据题目的数据范围调整MAXN和MAXD。
5. 常见问题与排查技巧实录
5.1 答案总是偏小或者为0
这是最常见的问题。原因通常是危险节点标记不完整。检查以下几点:
- 插入禁忌单词时,最后一个节点的
danger是否设为true? - BFS构建fail时,是否做了危险标记下传?也就是
if (trie[trie[u].fail].danger) trie[u].danger = true; - 路径压缩后,
trie[u].next[c]可能指向一个危险节点,DP转移时是否检查了trie[nj].danger?
我遇到过一种情况:禁忌单词A是禁忌单词B的前缀,比如A="ab",B="abc"。插入"ab"时标记了节点2为危险,插入"abc"时节点3也是危险。但如果没有做危险标记下传,节点3的fail指向节点2(因为"abc"的最长后缀"bc"不在Trie中,再跳fail到"c",也不在,最后到根;实际上"abc"的fail应该指向"bc"对应的节点,如果"bc"不存在则指向根)。这种情况下节点3自己就是危险的,所以没问题。但如果B="xabc",A="abc",那B的某个前缀节点可能通过fail指向A的结尾节点,这时候就必须靠危险标记下传来标记了。
5.2 高精度加法结果不对
高精度加法的常见错误:
- 进位处理遗漏:循环条件要包含
carry,否则最高位的进位会丢失。 - 前导零问题:输出时从高位到低位,但最高位可能是0(比如结果就是0),需要特殊处理。
- 数组越界:如果预估的位数不够,加法结果会截断。建议开大一点,比如200位。
避坑技巧:可以在本地用Python的BigInteger验证小数据的结果,对比C++高精度的输出。比如L=10,字符集大小=2,禁忌单词1个,手动算一下或者用Python跑一下,看看结果是否一致。
5.3 内存超限或运行超时
如果节点数很多(比如禁忌单词总长度很大),dp数组会很大。优化方式:
- 用滚动数组,只保留两层。
- 高精度数用固定大小数组而不是vector,减少动态分配。
- 如果字符集很大,
next数组用map或者哈希,但会慢一些。权衡一下,通常数组更快。
另一个超时原因是DP转移时对每个状态都遍历所有字符。如果字符集大小是60,节点数是100,L是50,那总转移次数是50×100×60=300000次,每次还要做高精度加法。高精度加法如果位数是200,那每次加法是200次操作,总共6000万次操作,勉强能过。如果超时,可以优化高精度加法,比如用int数组存多位(压位),一次处理9位。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 答案为0 | 危险节点标记过多 | 检查根节点是否被标记为危险 | 确保空串不被视为禁忌单词 |
| 答案偏小 | 危险标记下传遗漏 | 打印每个节点的danger状态 | 在BFS中补充下传逻辑 |
| 答案偏大 | 危险节点未被排除 | 检查DP转移时的danger判断 | 转移前检查trie[nj].danger |
| 高精度结果截断 | 位数预估不足 | 计算字符集大小^L的位数 | 开更大的MAXD |
| 运行超时 | 高精度加法太慢 | 统计加法调用次数 | 压位优化或减少状态数 |
| 编译错误 | 数组大小不够 | 检查MAXN和字符集大小 | 根据题目范围调整 |
5.5 独家避坑技巧
第一个技巧:在本地用小数据暴力验证。写一个暴力枚举所有长度为L的字符串,检查是否包含禁忌单词,统计数量。然后和AC自动机+DP的结果对比。这个方法能快速定位是AC自动机的问题还是DP的问题。
第二个技巧:打印AC自动机的状态转移图。对于小规模的字符集和禁忌单词,把每个节点的next、fail、danger都打印出来,肉眼检查一遍。很多时候错误就藏在某个节点的fail指针上。
第三个技巧:高精度加法用Python验证。把DP的转移过程用Python重写一遍,用Python的int直接算,对比C++高精度的输出。如果Python的结果和C++不一致,那就是高精度实现有问题;如果一致但和暴力不一致,那就是DP或AC自动机的问题。
第四个技巧:注意字符集的读入。有些题目的字符集可能包含空格或者特殊字符,用cin >> charset可能会读不全。建议用getline或者逐个字符读入。我在这道题上踩过坑,字符集里有个空格,结果读入的时候被截断了,导致后面映射出错。
6. 从这道题延伸出去的知识链路
6.1 AC自动机+DP的通用套路
这道题的核心套路可以总结为:在DFA上做路径计数。AC自动机构建出来的就是一张DFA,每个状态代表匹配进度,每条边代表读入一个字符。DP的过程就是在DFA上走L步,统计不经过禁止状态的路径数。
这个套路可以推广到很多问题:
- 统计不含某些子串的字符串数量(就是这道题)
- 统计DNA序列中不含某些模式的数量
- 统计密码中不含某些敏感词的数量
- 甚至可以用矩阵快速幂优化,当L特别大的时候
如果L达到10^9级别,DP的O(L×状态数×字符集)就扛不住了。这时候可以把DFA的转移矩阵建出来,用矩阵快速幂加速。但矩阵中的元素是高精度数,矩阵乘法会非常慢。通常这种题会要求取模,那就简单了。
6.2 高精度在算法题中的常见场景
高精度运算在算法题中出现,通常是因为答案超出了基本数据类型的范围。常见场景:
- 组合数学:大数的阶乘、组合数
- 动态规划:方案数计数,且不取模
- 数论:大数的幂运算、大数的GCD
这道题属于DP计数不取模的情况。处理方式就是手写高精度加法。如果涉及乘法,就要写高精度乘法,复杂度会高很多。所以遇到高精度题,先看运算类型,如果只有加法,那难度直接降一半。
6.3 状态压缩与滚动数组的取舍
DP数组如果开dp[L+1][tot+1],当L=50,tot=100时,是5100个高精度数。每个高精度数如果200位,用int数组存就是5100×200×4字节≈4MB,可以接受。但如果L=1000,那就是80MB,可能超内存。这时候滚动数组就必要了。
滚动数组的代价是丢失了中间状态,无法回溯路径。但这道题只需要最终答案,不需要回溯,所以滚动数组是安全的。如果题目要求输出具体的方案(比如输出所有合法字符串),那就不能滚动,必须保留完整DP表,或者用其他方式记录路径。
我个人在实际操作中的体会是:先写非滚动版本,确保逻辑正确,然后再改成滚动数组优化。直接写滚动版本容易在数组下标上出错,调试起来很痛苦。先正确再优化,这个顺序不能反。
6.4 字符集大小对算法的影响
字符集大小直接影响两个地方:Trie树的next数组大小,和DP转移的循环次数。如果字符集是26,那next[26],转移循环26次。如果字符集是256,那next[256],每个节点占1KB,100个节点就是100KB,还好。但如果字符集是1000,那每个节点4KB,100个节点就是400KB,加上DP数组,内存就紧张了。
这时候可以考虑用map<int,int>来存转移,只存存在的边。但map的查询是O(log n),比数组的O(1)慢。权衡一下,如果字符集大但实际用到的字符少,用map更省内存;如果字符集大且大部分字符都用到,那还是用数组,内存换时间。
这道题的字符集大小通常不会太大(题目会给一个合理的范围),所以直接用数组就行。但知道这个权衡,遇到类似问题时能做出正确的选择。
6.5 危险节点标记的另一种思路
除了在BFS中下传危险标记,还有一种思路:在DP转移的时候,沿着fail链检查是否有危险节点。但这样每次转移都要跳fail,复杂度高。所以还是推荐在构建阶段就把危险标记下传好,DP的时候直接O(1)判断。
如果题目要求统计的是"包含至少一个禁忌单词"的字符串数量,那可以用总数量减去不包含的数量。总数量是字符集大小^L,也是一个高精度数,需要高精度减法和幂运算。这就比这道题复杂了。所以看清楚题目要求的是"不包含"还是"包含",这决定了算法的方向。
6.6 实际调试中的经验总结
调试这类题目,我通常分三步走:
第一步,用最小的数据(L=1,字符集大小=2,禁忌单词1个)手动推演,确认AC自动机的构建和DP的转移都正确。
第二步,用稍大的数据(L=5,字符集大小=3,禁忌单词2个)写暴力程序对比,确认答案一致。
第三步,用大数据测试性能,确认不超时、不超内存。
这三步走下来,基本能覆盖所有问题。最怕的是一上来就写完整代码,然后发现答案不对,又不知道错在哪。分步调试虽然看起来慢,但实际上更快。
另外,代码的模块化很重要。把Trie插入、AC自动机构建、高精度加法、DP转移分成独立的函数,每个函数单独测试。这样出错的时候能快速定位是哪个模块的问题。我见过很多人把所有逻辑写在一个main函数里,出了错只能一行行看,效率极低。
最后再分享一个小技巧:如果实在找不到bug,可以把AC自动机的每个节点的next、fail、danger打印出来,和手动构建的结果对比。很多时候问题就出在某个节点的fail指针指向了错误的位置,导致危险标记下传错误,最终答案偏大或偏小。这个方法虽然笨,但非常有效。