☰
AC自动机+DP+高精度:统计不含禁用词的字符串数量
2026/10/12 16:11:12 网站建设 项目流程

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。核心逻辑:

  1. 根节点的所有存在的子节点的fail指向根节点,入队。
  2. 对于队列中的每个节点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指针指向了错误的位置,导致危险标记下传错误,最终答案偏大或偏小。这个方法虽然笨,但非常有效。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询