恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
AC自动机+DP+高精度:多模式串计数问题实战解析
首页
资讯中心
/
AC自动机+DP+高精度:多模式串计数问题实战解析
AC自动机+DP+高精度:多模式串计数问题实战解析
发布时间:2026/10/12 21:45:22
1. 从标题拆解这道题到底在考什么1.1 三个关键词背后的知识地图看到“Censored! POJ - 1625 ac自动机dp高精度”这个标题有经验的选手基本能瞬间定位到它的知识坐标。这是一道经典的字符串计数类题目核心考点由三块拼图组成AC自动机负责处理多模式串的匹配与状态转移动态规划负责在状态空间上做计数高精度负责处理超出64位整数范围的大数运算。三者缺一不可任何一个环节薄弱都会导致这道题卡住。先把题目场景说清楚。题目的意思大致是给定若干个“禁忌词”也就是不希望出现的模式串再给定一个字符集和一个长度N要求计算出所有长度为N且不包含任何禁忌词的字符串有多少个。这个“不包含”指的是禁忌词不能作为子串出现在生成的字符串中。比如禁忌词是“ab”那么“aab”“abc”“xabx”都算违规而“acb”“ba”则合法。这类问题在现实中有很多对应场景。比如设计验证码时希望排除某些容易混淆或敏感的字符组合比如生成测试用例时需要避开特定的非法序列再比如生物信息学里统计不含特定片段的基因序列数量。所以它不只是一道竞赛题背后的建模思路是可以迁移的。1.2 为什么偏偏是这三个技术组合有人可能会问为什么不用KMP为什么不用简单的哈希原因在于模式串的数量可能很多。KMP只能处理单模式串匹配如果禁忌词有几十上百个逐个用KMP去判断效率极低。AC自动机本质上是Trie树加失配指针它能把所有模式串一次性建好然后在O(1)均摊时间内完成状态转移天然适合“多模式串逐字符扫描”的场景。那DP又是干什么的因为我们要统计的是“长度为N的字符串数量”这是一个典型的按位递推问题。每读入一个字符字符串就从一个状态转移到另一个状态。把AC自动机上的每个节点看作一个状态DP[i][j]表示长度为i、当前停在AC自动机节点j上的合法字符串数量转移就是枚举下一个字符看它跳到哪个节点如果跳到的节点不是禁忌节点就累加计数。高精度则是被数据范围逼出来的。长度N可能达到几十甚至上百字符集大小也有几十最终的方案数会是一个天文数字远超long long的范围。所以必须手写大整数加法或者用支持高精度的语言特性来处理。注意这三个技术点的组合不是随意拼凑的而是“多模式匹配计数大数”这条问题链的自然推导结果。理解了这个推导逻辑以后遇到类似题目就能自己判断该用什么工具。2. AC自动机构建的核心细节与避坑指南2.1 Trie树的插入与节点设计AC自动机的第一步是把所有禁忌词插入Trie树。每个节点需要维护几个关键信息子节点指针或者子节点数组、失配指针fail、以及一个标记表示该节点是否是某个禁忌词的结尾。在实际实现中我习惯用数组模拟而不是指针因为数组的缓存友好性更好调试也更方便。假设字符集是可见ASCII字符大小约为95那么每个节点的子节点数组开95就够了。如果字符集更大可以考虑用map或者哈希表来存子节点但那样转移会慢一些。对于POJ 1625这道题字符集大小通常不会太夸张直接开数组是最稳妥的选择。插入过程很直接从根节点出发逐个字符往下走没有子节点就新建最后在终止节点打上标记。这里有个细节需要注意如果某个禁忌词是另一个禁忌词的前缀比如禁忌词集合是{“ab”, “abc”}那么插入“abc”时经过的“ab”节点已经带了终止标记这没关系但“abc”的终止节点也要标记。后续在构建fail指针时如果一个节点的fail指向的节点是终止节点那么这个节点实际上也应该被视为终止节点因为它包含了禁忌词作为后缀。2.2 失配指针的构建与终止标记传播失配指针的构建用BFS逐层处理。根节点的所有直接子节点的fail指向根。对于其他节点假设当前节点是u它通过字符c指向子节点v那么v的fail应该指向u的fail节点通过字符c能到达的节点。如果那个节点不存在就继续沿着fail往上找直到根节点。这里有一个非常容易踩的坑终止标记的传播。在构建fail指针的过程中如果节点v的fail指向的节点带有终止标记那么v也必须标记为终止。原因很直观如果某个字符串的后缀是禁忌词那这个字符串本身就包含了禁忌词自然也是非法的。很多初学者会忘记这一步导致DP时把非法状态当成合法状态来计数最终答案偏大。我通常会在BFS的过程中顺便做这件事当处理节点v时检查fail[v]的终止标记如果为真就把v也标记为终止。这样一遍BFS下来所有需要传播的标记都处理完了。2.3 状态转移矩阵的预处理AC自动机构建完成后为了加速DP通常会预处理一个转移表trans[i][c]表示从节点i读入字符c后跳转到哪个节点。这个表的计算逻辑是如果节点i有字符c的子节点直接返回该子节点否则沿着fail指针往上找直到找到一个有字符c子节点的节点或者到达根节点。如果根节点也没有字符c的子节点就返回根节点本身。这个预处理看起来简单但有一个优化技巧可以在BFS构建fail指针的同时就计算好转移表。具体做法是对于根节点的子节点如果某个字符没有对应的子节点就把它的转移指向根节点对于其他节点如果某个字符没有子节点就把它的转移指向fail节点的对应转移。这样一遍BFS下来转移表就全部填好了后续DP时直接查表效率极高。实操心得转移表的预处理是AC自动机加速DP的关键。如果不做这个预处理每次转移都要沿着fail链往上找最坏情况下会退化到O(深度)整体复杂度会多一个数量级。我见过不少人在这一步偷懒结果在大数据量下超时。3. 动态规划的状态定义与转移方程3.1 DP状态的设计思路DP的状态定义直接决定了转移方程的形式。对于这道题最自然的定义是dp[i][j]表示长度为i、当前停在AC自动机节点j上的合法字符串数量。这里的“合法”指的是从根节点走到j的路径上没有经过任何被标记为终止的节点。为什么这样定义是合理的因为AC自动机的每个节点代表了一个“已匹配的后缀集合”。当我们读入一个新字符时只需要知道当前在哪个节点就能确定新字符会跳到哪个节点以及新节点是否合法。这正好满足动态规划的无后效性要求。初始状态是dp[0][0] 1表示长度为0时停在根节点方案数为1。最终答案是sum(dp[N][j])其中j遍历所有非终止节点。3.2 转移方程与边界处理转移方程很简洁对于每个状态dp[i][j]枚举字符集中的每个字符c计算next trans[j][c]如果next不是终止节点就执行dp[i1][next] dp[i][j]。但这里有几个边界情况需要特别注意。第一如果根节点本身就是终止节点比如某个禁忌词是空串那所有字符串都非法直接输出0。不过通常题目不会给空串但防御性编程总是好的。第二如果某个字符不在给定的字符集中枚举时应该跳过。第三如果next节点是终止节点直接跳过不累加。还有一个容易忽略的点字符集的确定。题目通常会给定一个字符集比如“所有可见ASCII字符”或者明确列出哪些字符可用。DP时只能枚举这些字符不能多也不能少。我见过有人直接用0到255枚举结果把不可用字符也算进去了答案偏大。3.3 滚动数组优化空间如果N很大比如达到1000那么dp数组开[1001][节点数]可能会爆内存。这时候可以用滚动数组只保留当前层和下一层。因为dp[i1]只依赖于dp[i]所以两层就够了。滚动数组的实现很简单用两个数组cur和nxt每轮结束后交换。但要注意每轮开始前把nxt清零。这个优化在节点数较多时效果显著能把空间复杂度从O(N*M)降到O(M)其中M是节点数。注意滚动数组虽然省空间但会丢失中间状态如果题目需要输出具体方案而不只是数量就不能用滚动数组。这道题只要求计数所以可以放心使用。4. 高精度加法的实现与性能考量4.1 为什么必须手写高精度前面提到方案数会超出long long的范围。具体能有多大假设字符集大小是50长度是100那么总方案数是50的100次方大约是10的170次方。long long最大约9.2×10的18次方差了150多个数量级。所以必须用高精度。有些语言自带大整数比如Java的BigInteger、Python的int用起来很方便。但如果用C就必须手写高精度加法。这道题只涉及加法不涉及乘除所以实现起来并不复杂。4.2 高精度加法的实现细节我通常用vector 或者数组来存大整数低位在前高位在后。加法就是从低位到高位逐位相加处理进位。为了方便可以写一个add函数把两个大整数相加的结果存到第一个参数里。有一个性能优化点避免频繁的内存分配。如果每次加法都新建一个vector在DP过程中会产生大量临时对象拖慢速度。我的做法是预分配好数组用固定长度的数组来存加法时直接修改目标数组。具体来说可以开一个二维数组dp[i][j][k]但这样内存太大。更好的做法是dp[j]是一个大整数用数组存加法时逐位操作。另一个优化是延迟进位。在DP过程中可以先用int数组存每一位的和不立即处理进位等所有加法做完后再统一进位。这样能减少进位操作的次数。不过这道题的数据量通常不会大到需要这种优化常规的逐位进位就够了。4.3 高精度与DP的结合方式把高精度和DP结合时dp数组的每个元素都是一个大整数。转移时的操作是dp[i1][next] dp[i][j]这就是一次高精度加法。如果节点数有100个长度有50那么总共要做50×100×字符集大小的加法操作。假设字符集大小是50那就是25万次高精度加法。每次加法如果大整数有200位那就是5000万次基本操作在C里大约需要零点几秒是可以接受的。但如果长度达到200节点数达到200字符集大小达到100那加法次数就是400万次每次200位总共8亿次操作可能会超时。这时候就需要考虑优化比如用更紧凑的存储方式或者用位运算加速。不过对于POJ 1625这道题常规实现是足够的。实操心得高精度加法的进位处理是易错点。我建议在写完后用小数据测试一下比如用两个已知的大整数相加看结果是否正确。另外大整数的输出要注意去掉前导零否则会格式错误。5. 完整实操流程与代码框架5.1 整体流程梳理把前面的模块串起来整个程序的流程是这样的读入字符集、禁忌词数量和长度N。把所有禁忌词插入Trie树标记终止节点。BFS构建fail指针同时传播终止标记并预处理转移表。初始化dp数组dp[0][0] 1。逐层DP枚举字符和状态执行高精度加法。统计第N层所有非终止节点的方案数之和。输出结果。这个流程看起来线性但每一步都有细节。比如第3步的BFS需要用一个队列来辅助。第5步的DP需要确定枚举顺序通常是外层枚举长度内层枚举节点最内层枚举字符。5.2 关键代码片段解析Trie树的插入和fail构建是核心。我用数组来模拟定义如下const int MAXN 105; // 最大节点数 const int MAXC 128; // 字符集大小 int trie[MAXN][MAXC]; int fail[MAXN]; bool terminal[MAXN]; int trans[MAXN][MAXC]; int nodeCnt 0;插入函数void insert(const string s) { int u 0; for (char c : s) { int idx (int)c; if (!trie[u][idx]) trie[u][idx] nodeCnt; u trie[u][idx]; } terminal[u] true; }BFS构建fail和转移表void build() { queueint q; for (int c 0; c MAXC; c) { if (trie[0][c]) { fail[trie[0][c]] 0; q.push(trie[0][c]); trans[0][c] trie[0][c]; } else { trans[0][c] 0; } } while (!q.empty()) { int u q.front(); q.pop(); terminal[u] | terminal[fail[u]]; // 传播终止标记 for (int c 0; c MAXC; c) { if (trie[u][c]) { fail[trie[u][c]] trans[fail[u]][c]; q.push(trie[u][c]); trans[u][c] trie[u][c]; } else { trans[u][c] trans[fail[u]][c]; } } } }高精度加法可以用一个结构体来封装struct BigInt { int d[500]; int len; BigInt() { memset(d, 0, sizeof(d)); len 1; } void add(const BigInt other) { int carry 0; for (int i 0; i max(len, other.len) || carry; i) { int sum d[i] (i other.len ? other.d[i] : 0) carry; d[i] sum % 10; carry sum / 10; if (i len) len i 1; } } };DP部分BigInt dp[2][MAXN]; dp[0][0].d[0] 1; dp[0][0].len 1; int cur 0, nxt 1; for (int i 0; i N; i) { for (int j 0; j nodeCnt; j) { dp[nxt][j] BigInt(); // 清零 } for (int j 0; j nodeCnt; j) { if (terminal[j]) continue; for (int c 0; c MAXC; c) { if (!validChar[c]) continue; int to trans[j][c]; if (terminal[to]) continue; dp[nxt][to].add(dp[cur][j]); } } swap(cur, nxt); }最后统计答案BigInt ans; for (int j 0; j nodeCnt; j) { if (!terminal[j]) ans.add(dp[cur][j]); } // 输出ans注意去掉前导零5.3 参数选择与复杂度分析节点数的上界是所有禁忌词长度之和加1。如果禁忌词有50个每个长度10那节点数最多501。字符集大小取决于题目通常是可见ASCII的95个字符。长度N可能达到50到100。时间复杂度是O(N × 节点数 × 字符集大小 × 高精度位数)。假设N50节点数500字符集95高精度位数200那么总操作数约为50×500×95×200 4.75亿。这个量级在C里需要一些优化才能过比如减少不必要的字符枚举、用更紧凑的高精度存储。空间复杂度方面用滚动数组后是O(节点数 × 高精度位数)大约500×200×4字节 400KB完全没问题。注意如果超时可以尝试只枚举实际出现在字符集中的字符而不是全部95个。这能显著减少内层循环次数。6. 常见问题排查与实战避坑6.1 答案偏大或偏小的典型原因答案偏大通常是因为终止标记没有正确传播。比如禁忌词是“ab”和“bc”如果只标记了“ab”和“bc”的终止节点没有把“abc”路径上的“bc”后缀标记传播到“abc”的终止节点那么“abc”会被误判为合法。解决方法是在BFS时执行terminal[u] | terminal[fail[u]]。答案偏小可能是因为转移表计算错误。比如某个字符在根节点没有子节点转移应该指向根节点但如果代码里返回了-1或者0以外的值就会导致状态丢失。检查方法是手动模拟一个小例子看转移是否正确。另一个常见问题是字符集处理错误。题目给定的字符集可能包含特殊字符比如空格、标点如果读入时用了cin s空格会被截断。这时候需要用getline或者逐个字符读入。6.2 高精度输出的格式问题高精度输出时如果最高位是0需要跳过。比如结果是100存储为[0,0,1]输出时要从最高位非零开始。如果结果本身就是0要输出一个0。我见过有人直接倒序输出整个数组结果前面带了一堆零导致格式错误。还有一个坑是大整数的比较。这道题不需要比较只需要加法和输出。但如果后续扩展需要比较要注意先比长度长度相同再从高位往低位比。6.3 内存与时间的平衡技巧如果节点数很多转移表开trans[MAXN][MAXC]可能会占不少内存。假设MAXN1000MAXC128那就是128000个int约512KB还好。但如果MAXN10000那就是5MB也还能接受。真正占内存的是dp数组的高精度部分。如果用滚动数组两层各500个节点每个节点200位那就是500×200×2×4字节 800KB没问题。时间优化方面除了减少字符枚举还可以把高精度加法写成内联函数减少函数调用开销。另外如果编译器支持可以开O2优化。6.4 常见问题速查表问题现象可能原因排查方法答案偏大终止标记未传播检查BFS中是否执行了terminal传播答案偏小转移表错误手动模拟小例子验证转移输出格式错误前导零未处理检查输出函数是否跳过前导零超时字符枚举过多只枚举有效字符减少内层循环内存超限dp数组未滚动改用滚动数组只保留两层读入错误特殊字符被截断改用getline或逐字符读入实操心得这道题我建议先用小数据测试比如字符集只有{a,b}禁忌词只有{“ab”}长度N3手动算出答案是5aaa, aab? 不对aab包含ab所以是aaa, aba? 也不对aba包含ab? 不aba的子串有ab吗a-b-a子串ab在位置0-1所以aba也非法。合法的是aaa, baa, bab, bba, bbb共5个。用这个例子验证代码能快速发现逻辑错误。7. 从这道题延伸出的通用解题框架7.1 多模式串计数问题的通用套路这道题代表了一类问题给定多个模式串统计不包含任何模式串的字符串数量。这类问题的通用解法就是AC自动机DP。AC自动机负责把模式串集合转化为状态机DP负责在状态机上计数。掌握了这个套路很多变种题都能迎刃而解。比如如果题目要求统计至少包含一个模式串的字符串数量可以用总数量减去不包含的数量。如果要求统计包含特定模式串组合的数量可以在DP状态里增加维度来记录已经匹配了哪些模式串。如果要求统计的字符串长度不固定可以加一个累加器每层都把当前层的方案数累加到答案里。7.2 高精度在计数问题中的通用处理高精度加法是计数类问题的常客。除了这道题很多组合计数问题也会遇到大数。我的建议是提前准备好一个高精度模板支持加法、减法、乘法、比较和输出。这样遇到需要高精度的题目时直接套模板不用每次重写。高精度模板的设计要注意几点一是存储方式低位在前还是高位在前我习惯低位在前因为加法从低位开始二是进位处理要处理好最高位的进位三是输出要跳过前导零四是性能尽量用数组而不是vector减少内存分配。7.3 状态压缩与滚动数组的适用场景滚动数组适用于DP只依赖于前一层的情况。如果DP依赖于前两层或者更多层就需要保留更多层。如果DP依赖于所有之前的状态那就不能用滚动数组只能老老实实开完整数组。状态压缩则是另一回事通常指把多个维度的状态压缩到一个整数里。比如在AC自动机DP中如果还需要记录已经匹配了哪些模式串可以用一个二进制位来表示这就是状态压缩。状态压缩能减少DP数组的维度但会增加每个状态的操作复杂度需要权衡。7.4 调试技巧与测试用例设计调试这类题目我通常分三步走。第一步用最小数据验证AC自动机构建是否正确可以打印每个节点的fail和终止标记。第二步用中等数据验证DP转移是否正确可以打印每一层的dp值。第三步用大数据验证高精度和性能。测试用例的设计要覆盖边界情况空字符集、空禁忌词集合、禁忌词包含所有字符、长度N0、禁忌词是单个字符等。这些边界情况往往能暴露代码中的隐藏bug。注意不要忽视N0的情况。如果N0答案应该是1空字符串但如果代码里没有处理可能会输出0或者崩溃。8. 个人实操体会与后续扩展方向这道题我前前后后做过好几遍每次都有新的收获。第一次做的时候终止标记传播忘了写答案偏大调了半天才发现。第二次做的时候高精度加法进位写错了导致结果不对。第三次做的时候超时了后来发现是字符枚举太多改成只枚举有效字符就过了。这些坑踩下来我对AC自动机和DP的结合有了更深的理解。如果要把这道题扩展一下可以增加一个要求输出字典序第K小的合法字符串。这就需要在DP的基础上增加一个“按位确定”的过程从高位到低位逐位尝试看当前字符能产生多少合法方案如果方案数小于K就跳过否则确定这一位。这个扩展能进一步锻炼对DP状态的理解。另一个扩展方向是支持动态增删禁忌词。这需要用到AC自动机的动态构建或者持久化技术难度会大不少但很有挑战性。最后分享一个小技巧在写AC自动机时可以把节点结构体化把子节点、fail、终止标记、转移表都放在一个结构体里这样代码更清晰调试也更方便。虽然会稍微增加一点内存但可读性的提升是值得的。