1. 从一道题看字符串匹配的进阶玩法
DNA Sequence POJ - 2778 这道题,在算法竞赛圈子里算是字符串专题的一座小山头。第一次见到它的人,多半会愣一下:给一堆致病DNA片段,问长度为n的DNA串里有多少个不包含任何致病片段的串。n可以大到20亿,致病片段数量不超过10个,每个片段长度不超过10。这个数据规模一摆出来,暴力枚举4的n次方直接出局,动态规划按位推也会因为n太大而超时。真正上手做过之后你会发现,它把AC自动机和矩阵快速幂这两个看起来八竿子打不着的工具拧在了一起,形成了一套非常漂亮的解题范式。
这篇文章适合已经了解KMP或者Trie树基础、想往AC自动机方向进阶的读者,也适合刷题刷到瓶颈、想看看字符串和矩阵怎么联手的同学。我会从为什么这么设计讲起,把状态转移矩阵的构造过程一步步拆开,再补上实际编码时容易踩的坑。整篇内容基于我自己的做题和教学经验整理,代码用C++写,但思路对任何语言都通用。
核心关键词就三个:AC自动机、矩阵快速幂、状态转移。这三个词贯穿全文,理解了它们之间的关系,这道题就通了。
2. 问题本质与方案选型:为什么是AC自动机加矩阵快速幂
2.1 暴力思路为什么走不通
先明确题意。给定m个致病DNA片段,每个片段由A、T、C、G四种字符组成,长度不超过10。问长度为n的DNA串中,有多少个不包含任何致病片段作为子串。n最大到20亿,m不超过10。
最直接的想法是枚举所有长度为n的串,逐个检查是否包含致病片段。4的20亿次方这个数字大到没有意义,直接放弃。退一步,用动态规划:设dp[i][j]表示长度为i、当前匹配到AC自动机上节点j时的合法串数量。转移就是dp[i+1][next] += dp[i][j],前提是next节点不是致病终止节点。这个DP的时间复杂度是O(n × 节点数 × 4),节点数最多是m×10=100左右,n是20亿,算下来是8×10的11次方级别,照样超时。
问题的瓶颈在于n太大,而状态数很少。这种“状态少、步数多”的结构,天然适合用矩阵快速幂来加速线性递推。
2.2 AC自动机在这里扮演什么角色
AC自动机本质上是一棵Trie树加上失配指针。它解决的问题是:在一个文本串中同时匹配多个模式串。放到这道题里,我们把所有致病片段插入Trie树,构建失配指针,然后标记出所有“危险节点”——也就是那些本身是某个致病片段结尾的节点,以及通过失配指针能跳到危险节点的节点。
为什么需要AC自动机而不是普通的Trie树?因为DNA串在逐字符生成的过程中,匹配位置不是简单地沿着Trie树往下走。当当前字符在Trie树中没有对应子节点时,需要沿着失配指针回跳,找到最长的可行后缀继续匹配。这正是AC自动机的核心价值。没有失配指针,我们就无法正确判断“当前生成的串是否已经包含了某个致病片段”。
注意:标记危险节点时,不能只标记模式串结尾节点。如果一个节点的失配指针指向危险节点,那么这个节点也是危险的。这一步必须在BFS构建失配指针的过程中同步完成,否则会漏判。
2.3 矩阵快速幂为什么能加速
DP的转移方程可以写成:状态向量乘以一个转移矩阵,得到下一步的状态向量。设状态向量为V,其中V[j]表示当前在节点j的合法串数量。转移矩阵M是一个size×size的矩阵,M[j][k]表示从节点j能否一步转移到节点k(通过某个字符),能则值为1,否则为0。但这里要注意,从节点j通过不同字符可能转移到同一个节点k,所以M[j][k]的值应该是转移方案数,可能是0、1、2、3、4。
那么长度为n的串对应的状态向量就是初始向量乘以M的n次方。矩阵快速幂把O(n)的乘法次数降到O(log n),每次矩阵乘法是O(size³),size最多100左右,log n约31,总算下来大约3×10的7次方,完全可以在时限内跑完。
这个方案的精妙之处在于:它把“逐位递推”转化成了“矩阵幂运算”,利用了矩阵乘法满足结合律这一性质。你可以类比成:从A点到B点走n步,如果每步的走法固定,那总走法就是转移矩阵的n次方。这个类比虽然简化了细节,但核心思想是一致的。
3. 核心细节拆解:从Trie树到转移矩阵的完整链路
3.1 Trie树的构建与节点编号
先定义节点结构。每个节点有四个子节点指针(对应A、T、C、G),一个失配指针fail,一个布尔标记danger表示该节点是否危险。
struct Node { int next[4]; int fail; bool danger; Node() { memset(next, -1, sizeof(next)); fail = 0; danger = false; } };插入模式串时,从根节点出发,逐字符往下走,没有子节点就新建。最后一个字符对应的节点标记danger=true。这里有一个细节:字符到索引的映射。A→0,T→1,C→2,G→3,用一个map函数转换即可。
节点总数最多是m×L+1,m≤10,L≤10,所以最多101个节点。这个规模非常小,矩阵乘法的开销完全可以接受。
3.2 失配指针的构建与危险标记传播
失配指针的构建用BFS。根节点的所有存在的子节点入队,它们的fail指向根。然后逐层处理:对于当前节点u,遍历它的四个子节点。如果子节点v存在,那么v的fail等于u的fail对应的那个子节点(如果存在的话),否则继续沿着fail往上找。如果子节点不存在,通常有两种处理方式:一种是直接跳过,另一种是补全成Trie图。这道题里我推荐补全成Trie图,因为后续构造转移矩阵时会更方便。
补全Trie图的做法是:对于节点u的字符c,如果next[c]不存在,就令next[c] = fail的next[c]。这样每个节点都有四个出边,转移矩阵的构造就变成了直接遍历四个字符。
危险标记的传播:在BFS过程中,如果节点v的fail指向的节点是danger的,那么v也是danger的。这一步必须在BFS中完成,因为fail指针是逐层构建的,处理到v时,fail指向的节点的danger标记已经确定。
void build() { queue<int> q; for (int c = 0; c < 4; c++) { if (nodes[0].next[c] != -1) { nodes[nodes[0].next[c]].fail = 0; q.push(nodes[0].next[c]); } else { nodes[0].next[c] = 0; } } while (!q.empty()) { int u = q.front(); q.pop(); if (nodes[nodes[u].fail].danger) nodes[u].danger = true; for (int c = 0; c < 4; c++) { int v = nodes[u].next[c]; if (v != -1) { nodes[v].fail = nodes[nodes[u].fail].next[c]; q.push(v); } else { nodes[u].next[c] = nodes[nodes[u].fail].next[c]; } } } }提示:补全Trie图之后,原来的子节点指针会被覆盖。如果你后续还需要区分“真实子节点”和“补全边”,需要额外记录。但在这道题里不需要区分,因为转移矩阵只关心“从当前节点走某个字符会到哪个节点”,补全边正好满足这个需求。
3.3 转移矩阵的构造逻辑
转移矩阵的维度等于非危险节点的数量。为什么排除危险节点?因为一旦进入危险节点,就意味着当前生成的串已经包含了某个致病片段,后续无论怎么走都不合法。所以危险节点不参与状态转移。
构造方法:对于每个非危险节点u,遍历四个字符c,找到next[c]对应的节点v。如果v不是危险节点,那么矩阵M[u][v]加1。注意,这里u和v都是重新编号后的索引,不是原始节点编号。
// 收集所有非危险节点 vector<int> valid; for (int i = 0; i < nodeCount; i++) { if (!nodes[i].danger) valid.push_back(i); } int sz = valid.size(); // 建立原始编号到矩阵索引的映射 vector<int> id(nodeCount, -1); for (int i = 0; i < sz; i++) id[valid[i]] = i; // 构造矩阵 Matrix M(sz); for (int i = 0; i < sz; i++) { int u = valid[i]; for (int c = 0; c < 4; c++) { int v = nodes[u].next[c]; if (!nodes[v].danger) { M.a[i][id[v]]++; } } }这里有一个容易忽略的点:M[i][j]的值可能大于1。因为从节点u走不同字符可能到达同一个节点v。比如节点u的A边和T边都指向v,那么M[u][v]就是2。这个计数必须准确,否则最终答案会偏小。
3.4 矩阵快速幂的实现细节
矩阵快速幂的核心是二分幂。设矩阵为M,要求M的n次方。当n为偶数时,M^n = (M^(n/2))²;当n为奇数时,M^n = M × M^(n-1)。递归或迭代实现都可以。
struct Matrix { int sz; long long a[105][105]; Matrix(int s) : sz(s) { memset(a, 0, sizeof(a)); } Matrix operator*(const Matrix& other) const { Matrix res(sz); for (int i = 0; i < sz; i++) for (int k = 0; k < sz; k++) { if (a[i][k] == 0) continue; for (int j = 0; j < sz; j++) { res.a[i][j] = (res.a[i][j] + a[i][k] * other.a[k][j]) % MOD; } } return res; } }; Matrix power(Matrix base, int exp) { Matrix res(base.sz); for (int i = 0; i < base.sz; i++) res.a[i][i] = 1; while (exp > 0) { if (exp & 1) res = res * base; base = base * base; exp >>= 1; } return res; }取模的模数通常是100000,因为POJ 2778的答案要求对100000取模。这个模数不是质数,但矩阵快速幂不涉及除法,所以无所谓。
注意:矩阵乘法中的累加要用long long,因为两个小于100000的数相乘可能达到10的10次方,超出int范围。取模操作要在每次累加后立即执行,避免溢出。
3.5 初始向量与最终答案
初始状态是空串,对应根节点。所以初始向量V是一个长度为sz的向量,其中根节点对应的索引位置为1,其余为0。最终答案是V乘以M的n次方后,所有分量的和。
为什么是求和?因为长度为n的合法串可能停在任何一个非危险节点上,每一种停法都对应一个合法串。所以把所有非危险节点的计数加起来就是答案。
Matrix result = power(M, n); long long ans = 0; for (int i = 0; i < sz; i++) { ans = (ans + result.a[id[0]][i]) % MOD; }这里result.a[id[0]][i]表示从根节点出发走n步到达节点i的方案数。把所有i加起来就是总方案数。
4. 完整实操流程与编码实现
4.1 整体代码框架
把前面的模块拼起来,整体流程是:读入m和n,读入m个模式串,构建Trie树,构建失配指针并标记危险节点,收集非危险节点并构造转移矩阵,矩阵快速幂,求和输出。
#include <cstdio> #include <cstring> #include <queue> #include <vector> using namespace std; const int MOD = 100000; const int MAXN = 105; int charId(char c) { if (c == 'A') return 0; if (c == 'T') return 1; if (c == 'C') return 2; return 3; } struct Node { int next[4]; int fail; bool danger; Node() { memset(next, -1, sizeof(next)); fail = 0; danger = false; } } nodes[MAXN]; int nodeCount = 1; void insert(const char* s) { int u = 0; for (int i = 0; s[i]; i++) { int c = charId(s[i]); if (nodes[u].next[c] == -1) { nodes[u].next[c] = nodeCount++; } u = nodes[u].next[c]; } nodes[u].danger = true; } void build() { queue<int> q; for (int c = 0; c < 4; c++) { if (nodes[0].next[c] != -1) { nodes[nodes[0].next[c]].fail = 0; q.push(nodes[0].next[c]); } else { nodes[0].next[c] = 0; } } while (!q.empty()) { int u = q.front(); q.pop(); if (nodes[nodes[u].fail].danger) nodes[u].danger = true; for (int c = 0; c < 4; c++) { int v = nodes[u].next[c]; if (v != -1) { nodes[v].fail = nodes[nodes[u].fail].next[c]; q.push(v); } else { nodes[u].next[c] = nodes[nodes[u].fail].next[c]; } } } } struct Matrix { int sz; long long a[MAXN][MAXN]; Matrix(int s = 0) : sz(s) { memset(a, 0, sizeof(a)); } Matrix operator*(const Matrix& o) const { Matrix res(sz); for (int i = 0; i < sz; i++) for (int k = 0; k < sz; k++) { if (a[i][k] == 0) continue; for (int j = 0; j < sz; j++) { res.a[i][j] = (res.a[i][j] + a[i][k] * o.a[k][j]) % MOD; } } return res; } }; Matrix power(Matrix base, int exp) { Matrix res(base.sz); for (int i = 0; i < base.sz; i++) res.a[i][i] = 1; while (exp > 0) { if (exp & 1) res = res * base; base = base * base; exp >>= 1; } return res; } int main() { int m, n; while (scanf("%d%d", &m, &n) != EOF) { nodeCount = 1; for (int i = 0; i < MAXN; i++) nodes[i] = Node(); char buf[15]; for (int i = 0; i < m; i++) { scanf("%s", buf); insert(buf); } build(); vector<int> valid; for (int i = 0; i < nodeCount; i++) if (!nodes[i].danger) valid.push_back(i); int sz = valid.size(); vector<int> id(nodeCount, -1); for (int i = 0; i < sz; i++) id[valid[i]] = i; Matrix M(sz); for (int i = 0; i < sz; i++) { int u = valid[i]; for (int c = 0; c < 4; c++) { int v = nodes[u].next[c]; if (!nodes[v].danger) { M.a[i][id[v]]++; } } } Matrix R = power(M, n); long long ans = 0; for (int i = 0; i < sz; i++) { ans = (ans + R.a[id[0]][i]) % MOD; } printf("%lld\n", ans); } return 0; }4.2 关键步骤的参数计算与选择
节点数上限的计算:m≤10,每个模式串长度≤10,所以Trie树最多有10×10+1=101个节点。矩阵维度最多101,矩阵乘法的复杂度是101³≈10的6次方,log n≈31,总运算量约3×10的7次方,在POJ的时限内绰绰有余。
模数的选择:题目要求对100000取模。这个模数比较小,矩阵元素在累加过程中容易超过int范围,所以用long long存储。每次乘法后立即取模,保证数值始终在可控范围内。
n的范围:n可以到20亿,用int存储没问题,因为int上限约21亿。但为了保险,可以用long long读入n,再转成int传给power函数。如果n超过int范围,需要改用long long版本的快速幂,但POJ 2778的n不会超过int。
4.3 实操现场记录:从WA到AC的调试过程
我第一次写这道题的时候,WA了三次。第一次是因为忘记传播危险标记,只标记了模式串结尾节点,导致一些通过失配指针能跳到危险节点的节点没有被排除。第二次是因为矩阵乘法没有用long long,中间结果溢出。第三次是因为初始向量处理错了,把根节点的索引搞混了。
调试的时候,我建议先写一个暴力版本,用小数据对拍。比如n≤10的时候,暴力枚举所有4的n次方个串,逐个检查是否包含致病片段,和矩阵版本的结果对比。这样能快速定位是AC自动机的问题还是矩阵的问题。
还有一个实用的调试技巧:把转移矩阵打印出来,人工检查几行。比如根节点走A、T、C、G分别到哪个节点,这些转移是否符合预期。如果发现某个转移指向了危险节点但矩阵里还有值,说明危险标记有问题。
提示:POJ的输入可能有多组测试数据,所以要用while(scanf(...)!=EOF)循环处理。每次循环都要重置nodes数组和nodeCount,否则上一组的数据会污染下一组。
5. 常见问题与排查技巧实录
5.1 危险标记传播遗漏
这是最常见的错误。很多人只把模式串结尾节点标记为danger,忘记了在BFS构建失配指针时传播危险标记。考虑一个例子:模式串是"AT"和"TC"。Trie树中,"AT"的结尾节点是危险的,"TC"的结尾节点也是危险的。但是节点"T"本身不是任何模式串的结尾,然而从"T"出发走"C"会到达"TC"的结尾,所以"T"节点在匹配过程中如果遇到了"C",就会形成致病片段。更隐蔽的是,如果某个节点的失配指针指向危险节点,那么这个节点也是危险的。
正确的做法是在BFS的每一层,检查当前节点的fail指向的节点是否danger,如果是,把当前节点也标记为danger。这一步必须在处理当前节点的子节点之前完成,因为子节点的fail可能指向当前节点。
5.2 矩阵维度与节点编号的映射错误
收集非危险节点后,需要建立原始节点编号到矩阵索引的映射。这个映射容易搞混。比如原始节点编号是0、2、5、7,矩阵索引是0、1、2、3。构造矩阵时,M[i][j]中的i和j都是矩阵索引,不是原始编号。如果直接用原始编号去访问矩阵,会越界或者错位。
我的习惯是单独开一个vector id,id[原始编号]=矩阵索引。构造矩阵和最终求和时都用这个映射转换。这样不容易出错。
5.3 矩阵乘法溢出与取模时机
矩阵乘法的三重循环中,res.a[i][j] += a[i][k] * o.a[k][j]。a[i][k]和o.a[k][j]都是小于100000的数,乘积小于10的10次方,用int存储会溢出。必须用long long。而且每次累加后要立即取模,否则多次累加后long long也可能溢出。
另一个细节是,如果a[i][k]为0,可以跳过内层循环,节省时间。这个优化在矩阵稀疏的时候效果明显,但在本题中矩阵比较稠密,优化效果有限。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 答案偏大 | 危险节点未完全排除 | 打印危险节点列表,检查是否有遗漏 | 在BFS中传播危险标记 |
| 答案偏小 | 矩阵转移计数错误 | 打印转移矩阵,检查每行之和 | 确保M[i][j]累加而非赋值 |
| 运行超时 | 矩阵维度太大或快速幂实现低效 | 打印矩阵维度,检查循环 | 跳过零元素,用long long但及时取模 |
| 结果不稳定 | 多组数据未重置 | 检查每次循环是否重置nodes和nodeCount | 在循环开头重新初始化 |
| 编译错误 | 数组越界或类型不匹配 | 检查MAXN是否够大 | 节点数上限101,MAXN设为105足够 |
5.5 独家避坑技巧
第一个技巧:在构造转移矩阵之前,先把所有危险节点打印出来,人工确认一遍。特别是那些通过失配指针传播得到的危险节点,很容易漏掉。我通常会写一个debug函数,输出每个节点的编号、fail指针、danger标记,然后对着Trie树图检查。
第二个技巧:矩阵快速幂的指数n如果是0,应该返回单位矩阵。虽然本题n≥1,但养成处理边界条件的习惯没坏处。单位矩阵的对角线为1,其余为0。
第三个技巧:如果答案要求对100000取模,而最终结果是0,不要惊讶。这是完全可能的,因为模数很小。不要因为结果是0就怀疑代码有问题。
第四个技巧:POJ的评测机比较老,cin/cout可能比scanf/printf慢。建议用scanf读入字符串和整数,printf输出结果。字符串用char数组而不是string,避免不必要的开销。
6. 从这道题延伸出去的思考
这道题最值得玩味的地方,是它展示了“状态机+矩阵加速”这个范式的通用性。AC自动机本质上是一个有限状态自动机,状态就是Trie树上的节点,转移就是字符驱动的边。一旦把问题抽象成状态机上的路径计数,矩阵快速幂就自然而然地登场了。
同样的思路可以迁移到很多问题上。比如,给定一个图,问从起点走n步到终点的方案数,这就是邻接矩阵的n次方。再比如,某些动态规划问题中,状态转移只依赖于前一个状态,且转移规则固定,也可以用矩阵快速幂加速。识别这类问题的关键特征是:状态数少、步数多、转移规则不随步数变化。
我在实际做题时,遇到n特别大而状态数在100以内的DP,第一反应就是能不能写成矩阵。这个直觉帮我解决了不少看起来很难的题目。当然,矩阵快速幂不是万能的,如果转移规则随步数变化,或者状态数太大,就得另想办法。
另一个值得注意的点是AC自动机的Trie图补全。很多教程在构建失配指针时只处理存在的子节点,不补全不存在的边。这样做在匹配文本串时需要沿着fail指针回跳,效率较低。补全成Trie图后,每个节点都有完整的四个出边,匹配时不需要回跳,直接走就行。这个优化在本题中尤其重要,因为转移矩阵的构造依赖于“从当前节点走某个字符到哪个节点”这个信息,补全Trie图让这个信息变得直接可得。
最后说一个编码习惯上的建议:写这类涉及多个模块的题目时,先把每个模块单独测试通过,再拼在一起。比如先测试Trie树的插入和查询,再测试失配指针的构建,再测试矩阵乘法,最后组装。这样调试的时候能快速定位问题出在哪个模块。我见过太多人把所有代码写在一起,一运行就错,然后花几个小时找bug,其实如果分模块测试,可能十分钟就搞定了。