摆烂很久了,康复训练到kmp和ac自动机的时候突然发现很容易就能理解其中的原理(之前甚至没写过ac自动机)。果然算法也是需要时间沉淀的东西,其中的原理网上有很多优质的博文,这里就不献丑了。要看懂本篇请先看看网上其他大神写的全面的内容,这里只说明重点核心部分,浅浅讲下我的理解供复习用。
kmp
算法讲解中反复提到的核心:nextp[]数组,和一个反复出现的关键字:回溯。
首先要清楚回溯的对象是模板p,一直遍历的是s,而next[]是用来回溯p的。
浅谈一下算法原理:
-
设i遍历s,j遍历p
-
i和j只要一致就不断遍历下去,当出现不一致的时候就匹配失败(显然的),这个时候回溯的是p而不是s,问题就来到了 为什么只回溯p?,观察下面的图,当C-D匹配失败后,i的位置不动,j的位置向前移动(注意!!效果上其实是p向右移动)四个单位后i对应C,j也对应C,C-C匹配,ij又可以不断遍历下去直到j遍历完。
-
理解2的过程之后,就能发现kmp的一个关键回溯原理,p移动四个单位之后,前面还有两个字符“AB”根本就没有去管它,却神奇的能和s匹配成功,这一原理大佬叫它 “最长前缀后缀”,解释起来很复杂,大概说的就是字符串存在一个后缀它和它的前缀一模一样,观察上面的图,C-D匹配失败后,"ABCDAB"这个字符的前缀"AB"同时也是后缀,在C-D之前,后缀"AB"能匹配成功,那么前缀"AB"一定也能匹配成功,所以就不用管"AB"了。
-
kmp就是像上面介绍的那样,匹配的时候下标就+1,不匹配就只让j回溯。
-
最后来解释怎么计算p该位移多远,实际上我们不是真的位移,而是计算”跳跃“的位置(效果一样),这里就是构造next[]的过程。上图中 下标从0开始,j=6的时候匹配失败,匹配成功的"ABCDAB"的最长前后缀长度是2,那么next[6]=2; 下标从1开始的话就是next[7]=3;
-
计算next[]数组,牢记next[]是针对p构造的,根据最长前后缀得到的。
ne[1]=0; for(int i=1,j=0;i<=n;i++) { j=ne[i];//j要知道当前p应该到哪个位置了,此时i-1和j-1是匹配过的,ne[i-1]=j-1,p[i-1]=p[j-1](j>0), while(j&&p[i]!=p[j]) j=ne[j];//当i和j不匹配时,j回溯,直到匹配成功或者j=0; ne[i+1]=(p[i]==p[j])? j+1:0;//0可以用j替换 //等价于:if(p[i]==p[j]) j++;ne[i+1]=j; //这里最难理解,细说:假设p[i]!=p[j],那么j一定等于0,那么i+1一定回溯到0这个位置; //假设p[i]==p[j],那么i+1就应该回溯到j+1这个位置,即ne[i+1]=j+1,因为p[i]==p[j],所以要在下一层循环i+1时判断p[i+1]是否匹配j+1,匹配的话j再次+1,不匹配j+1就回溯到匹配为止 }
-
然后这是下标从1开始的另一种写法,让i和j+1匹配,也许更好理解:
ne[1]=0; for(int i=2,j=0;i<=n;i++) { j=ne[i-1];//j要知道上一个循环p遍历到个位置了,此时i-1和j是匹配过的,p[i-1]=p[j],ne[i-1]=j while(j&&p[i]!=p[j+1]) j=ne[j];//i和j+1进行匹配 ne[i]=(p[i]==p[j+1])?j+1:0;//假设p[i]==p[j+1],那么i回溯到j+1的位置,否则j一定是等于0 }
-
完整算法:
char p[N],s[M]; int ne[N]; int main() { int n,m; cin >> n >> p+1 >> m >> s+1;//下标从1开始 ne[1]=0; for(int i=2,j=0;i<=n;i++)//这是求next数组的,上文已解释过 { j=ne[i-1]; while(j>0&&p[i]!=p[j+1]) j=ne[j]; ne[i]=(p[i]==p[j+1])?j+1:0; } for(int i=1,j=0;i<=m;i++)//这里求匹配位置的下标 { while(j>0&&s[i]!=p[j+1]) j=ne[j];//同next,i和j+1匹配,不匹配则回溯 if(s[i]==p[j+1]) j++; if(j==n) cout<<i-n<<' ';//完成匹配,输出起点坐标 } return 0; }
ac自动机
ac自动机是kmp的升级版,可以在一个文本串中找到多个不同的模式串。
kmp通过查找p对应的next[]数组实现快速匹配,如果把所有p做成一个字典树,然后匹配的时候查找p对应的next[]数组,就可以实现快速匹配的效果。
trie
O
s/ \h
O O
h/ \a \e
O O O
e/ \r \y \r
O O O O
类比 KMP 的 next[i] trie里的next数组代表最长的trie"相同前后缀",如果不存在相同前缀则next[i]指向0
比如 she 中在trie树中最长存在相同前缀的后缀为he,其前缀和her的he
此时 she 的 e 通过next指向 her的e
KMP中 由于while(j && p[i] != p[j+1]) j = next[j];
直到 if(p[i]==p[j+1]) j++;
next[i] = j;
所以while这一行开始的时候的j是上一轮赋给next[i-1]时的j
所以KMP:
先求next[0] -> next[i-1]:前i-1组的信息
在前i-1组的信息上再去求next[i]
for(i=2;i<=m;i++)
{
int j = next[i-1];
while(j && p[i]!=p[j+1]) j = next[j];
if(s[i]==p[j+1]) j++;
next[i] = j;
}
类比KMP:
自动机在trie中则是先求前i-1层的信息,再求第i层的信息
那么逐层向下就可以用BFS来做
while(hh<=tt)
{
t = q[hh++];//取队列头节点 其值为自动机第i-1层某个结点的idx -> t对应的是next[i-1]中的i-1
//遍历头节点t所有的儿子
26个字母
for(i=0;i<26;i++)
{
p = tr[t][i];//字母p为字母t的下一个字母,t对应的是next[i-1]中的i-1 那么p就对应i
j = next[t];// j为前i轮循环后停在子串的下标 对比母串字母p[i] 和 子串j下标对应的字母的下一个字母p[j+1]
while(j && !tr[j][i]) j = next[j];// j的下一个字母(p[j+1])是不存在字母i(p[i])的 p[i]!=p[j+1]
if(tr[j][i]) j=tr[j][i]; //tr[j][i]代表idx为j的结点的儿子结点i的idx
next[p] = j; //next[]中存的即为字母p为后缀最后一个所对应的trie中最长相同前缀的
}
}
类比符号:
自动机 KMP
t = q[hh++]; -> i-1
p = tr[t][i] -> i for i in (0,25)
j = next[t] -> j=next[i-1] 这里的j是指刚经过前i-1个点循环后第i次循环while还没开始的j
在自动机里则表示刚经过前i-1层循环后第i层循环while还没开始的j
所以其值等同于刚赋值给的next[i-1],而自动机中t = i-1 所以j = next[i-1] = next[t]
tr[t][i]中代表字母的i-> p[i] 一个字母p[i]->trie可能有的26个字母
!tr[j][i] -> p[i] != p[j+1] tr[j][i] tr[j][i]表示j的下一个字母p[j+1]是不是这里的i(KMP中的p[i])
优化为trie图
while(j && !tr[j][i]) j = next[j];
优化思路 在没有匹配时 把while循环多次跳 优化为 直接跳到ne指针最终跳到的位置
数学归纳法
假定在循环第i层时,前i-1层都求对了
那么ne[t]就是ne指针最终跳到的位置
#include<iostream>
#include<cstring>
#include<cstdio>
#include<algorithm>
using namespace std;
const int N=10010,S=55,M=10000010;
int n;
int tr[N*S][26],cnt[N*S],idx;
char str[M];
int q[N*S],ne[N*S];
void insert()
{
int p = 0;
for(int i =0;str[i];i++)
{
int t = str[i]-'a';
if(!tr[p][t]) tr[p][t] = ++idx;//如果儿子不存在 创建一个新的节点
p = tr[p][t];// 沿字符串字母idx继续往下走
}
cnt[p] ++;
}
void build()
{
int hh=0,tt=-1;
for(int i=0;i<26;i++)//根节点以及第一层结点都是指向根节点,所以直接从第一层开始搜,也就是根的所有儿子开始搜
{
if(tr[0][i])
q[++tt] = tr[0][i];
}
while(hh<=tt)
{
int t = q[hh++];//队列popleft
for(int i=0;i<26;i++)
{
int p = tr[t][i];//p:自动机中某个第i层结点的idx -> KMP中的i
// if(p)
// {
// int j = ne[t];
// while(j && !tr[j][i]) j = ne[j];
// if(tr[j][i]) j = tr[j][i];
// ne[p] = j;
// q[++tt] = p;
// }
// 优化思路 在没有匹配时 把while循环多次跳 优化为 直接跳到ne指针最终跳到的位置
// 数学归纳法
// 假定在循环第i层时,前i-1层都求对了
// 在第i层没找到字母i,那么去第i-1层父结点t的next指针的位置就是它最终应该跳到的位置
if(!p) tr[t][i] = tr[ne[t]][i];//ne[t]:j 如果不存在儿子tr[t][i]的话
// 如果存在儿子节点 则对儿子节点的next指针赋值为tr[ne[t]][i]
else
{
ne[p] = tr[ne[t]][i];
q[++tt] = p;
}
}
}
}
int main()
{
int T;
scanf("%d", &T);
while (T -- )
{
memset(tr, 0, sizeof tr);
memset(cnt, 0, sizeof cnt);
memset(ne, 0, sizeof ne);
idx = 0;
scanf("%d", &n);
for (int i = 0; i < n; i ++ )
{
scanf("%s", str);
insert();
}
build();
scanf("%s", str);
int res = 0;
for (int i = 0, j = 0; str[i]; i ++ )
{
int t = str[i] - 'a';
/*
while(j && !tr[j][t]) j = ne[j];
if(tr[j][t]) j=tr[j][t];
int p = j;
// she 和 he 的 e结点都有cnt[e]=1
遍历到she的后缀he的时候 her的相同前缀he肯定是逐层遍历到了的 len(he)<len(she) 逐层遍历
把所有ne 指针全部加一遍 比如当前p到了she的e 把cnt[p]+进res后
把p通过ne[p]跳到he的e 再加上此时指向he中e的p的cnt[p]
while(p)
{
res += cnt[p];
cnt[p] = 0;
p = ne[p];
}
*/
j = tr[j][t];
int p = j;
while (p)
{
res += cnt[p];
cnt[p] = 0;//she he 把cnt[e]的用过了之后 res=2 此时再进来一个her 就不能再+he的cnt了,所以cnt[e]用过之后要置0
p = ne[p];
}
}
printf("%d\n", res);
}
return 0;
}