一、前导知识
二、AC自动机分析
本题是多模式串,单长串的问题,所以将不允许出现的短串建\(AC\)自动机,在每个串末尾打一个标记。
三、动态规划分析
设置\(f[i][j]\)表示处理完文本串的前\(i\)个字符,匹配到\(AC\)自动机中的\(j\)号节点的方案中修改字符数量的最小值为多少。
来研究一下从当前状态,可以转化到哪些状态,同时携带过去什么样的信息。
按上面的描述方法,那么目前肯定是没有存在\(DNA\)病毒片断的。那么,目前的状态,可以通过引出的四条边向其它状态转移,当然,也可能是通过失配指针进行转移。
如果要转移去的状态,存在片断结束标识,那么是不能走的,因为走了就意味着包含了病毒片断。
不存在结束标识的结点,是可以走的,这里面还划分两种情况:
-
走的这条路与长串的当前字符一致。
啥也不用修改,代价也没有增加。 -
走的这条路与长串的当前字符不一致。
需要修改当前的字符,代价增加\(1\)。
四、经验总结
- 不包含的处理方法
cnt[p] |= cnt[fail[p]];
是因为本题要求的是不能包含模板串,
如果当前结点的失配指针结点有标识,那么当前结点也需要进行标识,表示:
abc
不可以,那么xxabcxx
也是不行的,否则就是存在病毒片断了~
说的再白一点,就是标准的AC自动机
只解决完整匹配的问题,这里要解决的是“不包含”,那么就需要增加这句话了!
这里只需要标记后缀就可以了,比如存在 xxabcxx
这样的路径,由于在处理到xxabc
时,就标识了xxabc
的cnt
值为
abc
的cnt
值,此结点的后续部分就不会被走到,因为它是一个树的形式,所以只需要标识后缀就可以了。
- AC自动机DP的套路
- 线性枚举每个长串的字符
- 枚举AC自动机的每个状态结点
- 枚举每个状态结点的出边
- 根据题意决策可否转移,转移的话成本是多高。
五、实现代码
#include <bits/stdc++.h>
using namespace std;
const int N = 1010; //50个DNA片断,每个片断的长度不超过20,那么所有的结点个数就是不超过1000
const int INF = 0x3f3f3f3f;
int n;
//Trie树
int tr[N][4], cnt[N], idx; //’A’,‘G’,‘C’,‘T’
//AC自动机
int q[N], fail[N];
string str;
//DP数组
/*类比前一道题,AcWing 1052. 设计密码
f[i][j]:密码已生成了i位,并且第i位匹配在子串中的位置为j时的方案数。
本题f[i][j]:前i个字母,并且当前走到了trie里面的第j个位置,所有操作方案中,最少修改的数量
AC自动机的题目,一般都可以从KMP的题目类比过来。
*/
int f[N][N];
//转换函数
int get(char c) {
if (c == 'A') return 0;
if (c == 'T') return 1;
if (c == 'G') return 2;
return 3;
}
//构建Trie树
void insert() {
int p = 0;
for (int i = 0; i < str.size(); i++) {
int t = get(str[i]);
if (!tr[p][t]) tr[p][t] = ++idx;
p = tr[p][t];
}
cnt[p] = 1;//标识有结尾单词
}
//构建AC自动机
void build() {
int hh = 0, tt = -1;
//一级结点入队列
for (int i = 0; i < 4; i++)
if (tr[0][i]) q[++tt] = tr[0][i];
while (hh <= tt) {
int t = q[hh++];//取出队列头
for (int i = 0; i < 4; i++) {//遍历所有可能的子节点:A-0,G-1,C-2,T-3
int p = tr[t][i];
if (!p) tr[t][i] = tr[fail[t]][i];
else {
fail[p] = tr[fail[t]][i];
q[++tt] = p;
/*
下面这句不是AC自动机标准模板的内容,是因为本题要求的是不能包含模板串,
如果当前结点的失配指针结点有标识,那么当前结点也需要进行标识,表示:
abc不可以,那么xxxxxabc也是不行的,否则就是存在病毒片断了~
说的再白一点,就是标准的AC自动机只解决完整匹配的问题,这里要解决的是“不包含”,那么就需要增加这句话了!
这里我们只需要标记后缀就可以了,比如存在 xxabcxx这样的路径,由于我们在处理到xxabc时,就标识了xxabc的cnt值为
abc的cnt值,此结点的后续部分就不会被走到,因为它是一个树的形式,所以我们只需要标识后缀就可以了。
*/
cnt[p] |= cnt[fail[p]];//这句话太重要了~!
}
}
}
}
int main() {
int T = 1;
while (cin >> n, n) {
//多组数据,每次清空
memset(tr, 0, sizeof tr);
memset(cnt, 0, sizeof cnt);
memset(fail, 0, sizeof fail);
memset(f, 0x3f, sizeof f);
idx = 0;
//构建Trie树
for (int i = 1; i <= n; i++) cin >> str, insert();
//构建AC自动机
build();
//读入:待修复DNA片段
cin >> str;
//开始DP
f[0][0] = 0; //一个字母都没有,状态显然是0
/*现在做完了第i个字母了,然后当前已经走到AC自动机的第j个位置,并且前面不包含任何一个病毒串的情况下
我们来枚举下一个位置该填充哪个字母,我们准备填充字母k*/
//下面三层循环的枚举步骤需要记忆一下,一般都是这样的三层循环,注意顺序不能反~
for (int i = 0; i < str.size(); i++) //枚举str长度
for (int j = 0; j <= idx; j++) //枚举AC自动机的结点
for (int k = 0; k < 4; k++) { //枚举当前位置选哪个字母,即边
int p = tr[j][k]; //可能到达的结点
//判断一下p这个位置是不是安全节点,如果大于0,表示存在以此结点为终点的致病片断,不能走这条路径
if (!cnt[p]) {
//当前这条路线,当前这步,是否与长串的当前位置一致,一致则没有代价,否则代价是1
int cost = get(str[i]) == k ? 0 : 1;
//当前状态,可以通过+cost的代价,到达下一个状态
f[i + 1][p] = min(f[i + 1][p], f[i][j] + cost);
}
}
//预求最小,先设最大
int res = INF;
//在走完str.size()个字母的情况下,当前走到了AC自动机中的第i个位置时的值最小
for (int i = 0; i <= idx; i++) res = min(res, f[str.size()][i]);
//如果没有被修改过,返回-1
if (res == INF) res = -1;
printf("Case %d: %d\n", T++, res);
}
return 0;
}