AC自动机与动态规划结合:解决字符串安全过滤与计数问题 📅 发布时间:2026/8/29 10:07:37 👁 浏览次数: 1. 项目概述从一道经典题看字符串处理与动态规划的深度结合看到“poj1625”这个题号很多老ACM选手大概会心一笑。这是一道来自POJ北京大学在线评测系统的经典题目它像一座小小的技术熔炉把AC自动机、动态规划DP和大数运算这三块硬骨头巧妙地焊在了一起。题目本身描述的是一个“禁止词汇”过滤问题给定一个包含若干“坏词”的词典以及一个字母表规定了哪些字符是合法的和目标字符串长度N要求计算出所有长度为N、且不包含任何“坏词”作为子串的合法字符串有多少个。答案可能非常巨大需要用高精度大数来表示。这不仅仅是一道算法题它几乎是一个微型的、完整的字符串安全过滤系统的数学模型。在实际开发中无论是敏感词过滤、病毒特征码匹配还是基因序列分析其核心思想都是相通的在一个线性的序列字符串中高效地检测并规避一组预定义的“危险模式”。POJ1625将这个问题抽象到了极致迫使你同时考虑模式匹配的效率AC自动机、状态转移的计数动态规划和超大整数处理大数运算这三个层面。搞定它你对字符串处理类动态规划的理解会上一个大台阶。2. 核心思路拆解为什么是AC自动机DP大数2.1 问题本质与暴力法的死胡同最朴素的想法是暴力枚举生成所有长度为N、由给定字母表构成的字符串然后逐个检查是否包含“坏词”。假设字母表大小为M那么总共有 M^N 个字符串。对于每个字符串我们需要用KMP或直接遍历去匹配所有坏词。复杂度是 O(M^N * N * 总模式串长度)这在N稍大比如20时就是天文数字完全不可行。我们需要一个“聪明”的枚举方法。动态规划DP是处理“计数”类问题的利器其核心思想是将大问题分解为重叠子问题并存储中间结果以避免重复计算。对于本问题一个很自然的DP状态定义是dp[i][j]表示长度为i的字符串且以某种“状态”j结尾时合法的方案数。那么这个“状态”j应该是什么它必须能告诉我们当前字符串的末尾部分与所有“坏词”的匹配情况到了哪一步。因为我们需要确保整个字符串不包含坏词而坏词可能出现在任何位置。如果只用字符串的最后几个字符作为状态状态数会爆炸最多有 M^i 种。这时AC自动机登场了。2.2 AC自动机将模式匹配转化为状态转移图AC自动机Aho-Corasick Automaton是一种多模式串匹配算法。它把所有“坏词”构建成一棵Trie树并通过失败指针fail指针将树升级为一个确定有限状态自动机DFA。这个自动机的每个节点即Trie树节点代表一个“匹配状态”。关键洞察我们可以用AC自动机的节点编号作为DP状态jdp[i][j]的含义就变成了构建长度为i的字符串当匹配完这个字符串后AC自动机恰好停留在节点j时所有**从未经过任何“坏词终点标记节点”**的路径数。为什么这样可行状态压缩AC自动机的节点数最多是“所有坏词总字符数1”这通常远小于 M^N将指数级的状态空间压缩到了线性级。完整性在AC自动机上走任何一个字符串都对应一条从根节点状态0出发的路径。反之从根节点出发的任意一条路径也对应一个字符串。合法性判断我们可以在构建AC自动机时标记出所有代表“坏词结束”的节点以及通过fail指针传递标记后所有包含坏词后缀的节点。任何经过这些“危险节点”的路径对应的字符串都包含了坏词必须被排除。于是整个DP过程就变成了在AC自动机构建的状态转移图上进行“路径计数”。我们从初始状态根节点长度0开始每次考虑添加一个字母表中的合法字符看看会从当前状态转移到哪个新状态。如果新状态是“安全”的那么就可以将当前状态的方案数累加到新状态上。2.3 大数运算不可避免的精度问题由于N和M可能较大方案数dp[N][*]的总和很容易超过64位整数甚至长整型的表示范围。题目明确要求输出完整数字因此必须实现高精度整数大数的加法和乘法DP累加只需要加法但有些变种可能需要乘法。这是工程实现上的最后一个关键点确保计数准确无误。3. 核心细节解析与实现要点3.1 AC自动机的构建与危险标记传递构建AC自动机分为两步构建基本的Trie树然后构建fail指针。1. Trie树构建每个节点需要记录子节点指针数组大小为字母表大小、失败指针fail、是否为危险节点flag。插入每个“坏词”时从根节点0开始按字符走如果对应子节点不存在则创建。在单词结尾的节点将其标记为危险flagtrue。2. Fail指针构建与危险标记传递Fail指针指向当前节点所代表字符串的最长真后缀所在的节点。使用BFS进行层次遍历来构建。这是极易出错的一步当一个节点u的fail指针指向节点v时如果v是危险节点那么u也必须被标记为危险因为这意味着到达节点u的路径其末尾部分已经包含了某个坏词即v对应的坏词。例如坏词有“she”和“he”。当匹配到“she”时停在‘e’节点它是危险的。字符串“the”虽然本身不是坏词但它的后缀“he”是坏词。在AC自动机上匹配“the”会走到某个节点这个节点的fail链最终会指向“he”的终点节点因此该节点也必须被预标记为危险否则DP会漏判。// 伪代码BFS构建fail指针并传递危险标记 queueint q; for (每个根节点的直接子节点) { if (子节点存在) { 该子节点的fail指针 根节点; 如果根节点危险则该子节点也危险 // 实际上根节点不危险此处是通用逻辑 q.push(子节点); } else { 将该子节点指针指向根节点便于后续转移; } } while (!q.empty()) { int u q.front(); q.pop(); for (每个字符c) { int v tree[u].next[c]; if (v) { // 如果子节点存在 tree[v].fail tree[tree[u].fail].next[c]; tree[v].flag | tree[tree[v].fail].flag; // 关键传递危险标记 q.push(v); } else { tree[u].next[c] tree[tree[u].fail].next[c]; // 路径压缩优化转移 } } }3.2 动态规划的状态转移设dp[i][j]为高精度数大数表示长度为i且匹配后停留在自动机节点j的安全方案数。 初始化dp[0][0] 1空字符串在根节点其他为0。转移方程 对于所有i从 0 到 N-1对于所有安全节点j即tree[j].flag false对于字母表中的每个合法字符c计算从状态j输入字符c后到达的新状态nj tree[j].next[c]。如果新状态nj是安全的tree[nj].flag false则进行转移dp[i1][nj] dp[i][j]最终答案ans sum(dp[N][j])对所有安全节点j求和。注意事项字母表映射题目给出的字母表可能不是26个小写字母可能是任意字符集例如包含标点。需要先将合法字符映射到连续的整数索引0到M-1便于在AC自动机的next数组中使用。所有非法字符在输入时就应该被过滤或视为导致方案不可行根据题意。DP数组滚动由于dp[i1]只依赖于dp[i]可以使用滚动数组如dp[2][状态数]来节省内存这对于大数运算尤其重要因为大数对象本身比较耗内存。大数运算效率DP过程中要进行大量的大数加法。实现时可以采用压位如每9位十进制数用一个int存储来提升效率。如果使用Java或Python则可以直接使用内置的BigInteger或int。3.3 大数模板的实现要点虽然C没有原生的大数支持但实现一个适用于本题的加法模板是必要的。这里提供一个简单、清晰的思路struct BigInt { vectorint digits; // 低位在前高位在后每个元素存储0-9的一个数字 BigInt() {} BigInt(int num) { // 用int初始化 while (num) { digits.push_back(num % 10); num / 10; } if (digits.empty()) digits.push_back(0); } BigInt operator(const BigInt rhs) { int carry 0; for (size_t i 0; i max(digits.size(), rhs.digits.size()) || carry; i) { if (i digits.size()) digits.push_back(0); digits[i] carry (i rhs.digits.size() ? rhs.digits[i] : 0); carry digits[i] 10; if (carry) digits[i] - 10; } return *this; } string to_string() const { string s; for (auto it digits.rbegin(); it ! digits.rend(); it) s.push_back(*it 0); return s.empty() ? 0 : s; } }; // 使用示例dp[ni][nj] dp[i][j];注意这个实现是最基础的没有做压位优化。在极端情况下N很大M适中方案数可能极其巨大导致位数非常多加法操作会成为性能瓶颈。在实际竞赛或高性能场景中需要实现压位如以1e9为基的大数并考虑使用int64来存储中间结果以减少进位次数。4. 完整实现流程与代码框架下面我将结合代码框架一步步拆解如何将三者融合。假设字母表为52个大小写字母。#include iostream #include vector #include queue #include cstring #include string #include map using namespace std; const int MAXN 50; // 最大节点数约为模式串总长 const int ALPHABET_SIZE 256; // 初始字符集大小根据输入映射 int charMap[256]; // 字符到索引的映射 int M, N, P; // 字母表大小目标长度模式串数量 struct Node { int next[52]; // 映射后的字母表大小 int fail; bool danger; // 是否为危险节点 Node() { memset(next, 0, sizeof(next)); fail 0; danger false; } } tree[MAXN]; int nodeIndex 0; // 当前节点数根节点为0 // 大数类简化版未压位 class BigInt { /* 如前文所述 */ }; BigInt dp[2][MAXN]; // 滚动DP数组 void insertPattern(const string pattern) { int cur 0; for (char ch : pattern) { int idx charMap[(unsigned char)ch]; if (idx -1) return; // 如果字符不在字母表此模式串不可能出现可忽略或报错 if (!tree[cur].next[idx]) { tree[cur].next[idx] nodeIndex; } cur tree[cur].next[idx]; } tree[cur].danger true; // 标记单词结尾为危险 } void buildAC() { queueint q; // 初始化第一层节点的fail指针 for (int i 0; i M; i) { int child tree[0].next[i]; if (child) { tree[child].fail 0; // 如果fail指向的节点危险则当前节点也危险第一层的fail是根根不危险 q.push(child); } else { child 0; // 指向根节点自身便于转移 } } // BFS构建fail while (!q.empty()) { int u q.front(); q.pop(); // 传递危险标记如果fail节点危险则当前节点也危险 if (tree[tree[u].fail].danger) { tree[u].danger true; } for (int i 0; i M; i) { int v tree[u].next[i]; if (v) { tree[v].fail tree[tree[u].fail].next[i]; // 构建时再次传递危险标记 tree[v].danger | tree[tree[v].fail].danger; q.push(v); } else { v tree[tree[u].fail].next[i]; } } } } int main() { // 输入处理字母表、N、P string alphabet; cin M N P; // 本题输入顺序可能不同此处为示意 cin alphabet; // 初始化字符映射 memset(charMap, -1, sizeof(charMap)); for (int i 0; i M; i) { charMap[(unsigned char)alphabet[i]] i; } // 插入模式串 for (int i 0; i P; i) { string pattern; cin pattern; insertPattern(pattern); } // 构建AC自动机 buildAC(); // DP初始化 int cur 0; dp[cur][0] BigInt(1); // 空字符串 // DP转移 for (int i 0; i N; i) { int nxt cur ^ 1; for (int j 0; j nodeIndex; j) dp[nxt][j] BigInt(0); // 清空下一层 for (int j 0; j nodeIndex; j) { if (tree[j].danger) continue; // 当前状态不安全跳过 if (dp[cur][j].digits.size() 1 dp[cur][j].digits[0] 0) continue; // 方案数为0跳过 for (int c 0; c M; c) { int nj tree[j].next[c]; if (!tree[nj].danger) { dp[nxt][nj] dp[cur][j]; } } } cur nxt; } // 统计答案 BigInt ans; for (int j 0; j nodeIndex; j) { if (!tree[j].danger) { ans dp[cur][j]; } } // 输出答案 cout ans.to_string() endl; return 0; }5. 常见问题与调试技巧实录即使理解了算法实现时也处处是坑。下面是我在多次实现和调试中总结的几个关键点。5.1 危险标记传递不完整问题现象程序对某些测试用例输出结果比正确答案大似乎漏掉了一些包含坏词的字符串。根因分析这几乎总是因为危险标记danger没有通过fail指针正确传递。例如坏词有“abc”和“bc”。字符串“abbc”虽然不直接包含“abc”但包含“bc”。在AC自动机上匹配“abb”后可能停在某个节点然后输入‘c’转移到节点X。节点X的fail指针可能指向“bc”的终点节点如果这个危险标记没有在构建自动机时传递给X那么DP就会认为“abbc”是安全的。解决方案在BFS构建fail指针的循环中在设置好一个子节点v的fail指针后立即执行tree[v].danger | tree[tree[v].fail].danger;。同时在BFS处理队首节点u时也需要检查tree[u].danger | tree[tree[u].fail].danger;对于通过else路径压缩得到的虚拟节点其危险性与fail指向的节点相同这已在路径压缩时通过赋值next指针间接处理但显式标记更安全。5.2 字母表映射错误问题现象程序运行异常数组越界或结果全为0。根因分析题目给的字母表可能不是连续的ASCII码比如包含‘%’、‘’等字符。如果直接用字符的ASCII码作为数组索引会导致数组访问越界next数组定义太小或映射到错误位置。解决方案务必先用一个mapchar, int或一个大小为256的int charMap[256]来建立字符到[0, M-1]的映射。在插入模式串和DP转移时所有字符操作都必须经过这个映射。初始化时记得将charMap所有值设为-1便于检测非法字符输入。5.3 大数运算导致的超时或内存超限问题现象N较大如50时程序运行非常慢或者内存占用巨大。根因分析基础的大数实现每位存一个0-9的int在进行大量加法时效率低下。同时DP数组dp[2][MAXN]中每个元素都是一个BigInt对象如果BigInt内部使用vectorint且未预分配空间频繁的push_back和拷贝构造会带来巨大开销。优化策略压位这是最有效的优化。例如用vectorint存储每个元素代表0-9999万进制或0-99999999亿进制的数字。这样加法、乘法的次数大幅减少。预分配内存在BigInt的构造函数或operator中可以预先reserve一定的容量减少动态扩容。减少拷贝DP转移dp[nxt][nj] dp[cur][j];会触发大数的拷贝和加法。如果大数类实现了移动语义会有些帮助。也可以考虑用指针或引用操作。滚动数组清空清空下一行DP数组时避免对整个大数对象进行赋值清零而是直接调用其clear()方法或与一个临时清零对象交换。5.4 空字符串与零结果的判断问题现象输入N0时程序可能输出错误或崩溃。逻辑梳理根据题意长度为0的字符串空字符串只要不包含坏词显然不包含就是合法的且只有1种。所以dp[0][0]应该为1。最终答案应该是所有安全节点j上的dp[N][j]之和。如果所有可能路径都经过危险节点结果就是0。大数输出时需要能正确输出“0”和“1”。5.5 调试技巧从小规模数据开始构造微型测试字母表{‘a’, ‘b’}坏词{“aa”}N3。手工计算所有8个字符串aaa, aab, aba, abb, baa, bab, bba, bbb其中包含“aa”的有aaa, aab, baa。所以合法方案有5个。用程序跑看结果是否为5。输出中间状态在DP结束后打印出dp[N][j]对所有节点j的值。结合画出的AC自动机状态图手动模拟验证转移是否正确。验证大数将大数类暂时替换为long long并取模一个大素数用另一个写好的、正确的DP程序同样用long long对拍确保核心逻辑AC自动机构建、DP转移无误后再切换回大数验证最终结果。检查字符映射打印出字符映射表确保每个合法字符都正确映射到了0到M-1并且模式串中的字符都在映射表中。这道题之所以经典是因为它毫无冗余地将三个知识点捆绑在一起任何一个环节的薄弱都会导致失败。它考察的不仅仅是算法模板的背诵更是对AC自动机状态含义的深刻理解、对动态规划状态设计的洞察力以及扎实的工程实现能力。当你能够独立、流畅地写出这道题的代码并通过所有测试点时你对“字符串上的动态规划”这一大类问题的掌握就已经相当深入了。