从kmp到AC自动机
知道kmp的请跳过这一段
dalao们表示知道思想即可
找到最清晰的解析
kmp
我看了约114514个解析才搞懂
如何求next
首先,next[i]本应表示0~i的字符串的最长相同前缀后缀的长度。
不过为了方便匹配,实际可以存最长相同前缀后缀时前缀最后一个的地址
听起来好绕
那这么说吧:
例如串
abaabaabaab
next[0]=-1 肯定找不到
next[1]=-1 因为第一个前缀是a,它是b
next[2]=0 因为第一个前缀是a,它是a
next[3]=0 因为第一个前缀是a,它是a?
wait!不一样的点来啦!
先偷窥一下匹配的代码:
for (int i=1;i<m;i++){
int j=next[i-1];
while ((s[j+1]!=s[i])&&(j>=0))j=next[j];
if (s[j+1]==s[i]) next[i]=j+1;
else next[i]=-1;
}
也就是
a先问了它旁边那个,发现它匹配到了0
然后他也蠢蠢欲动
但下个是b
所以他的j被打到了next[2]=-1
然后循环没了
这时,他毫不惊奇地发现下一个是a
于是他就匹配上了(合理)
next[4]=1
因为它前面已经匹配到了,所以它可能是 前面匹配到的 前缀的地址+1的 那个字母
听起来还是好绕
说白了,它可以是
b先问了它旁边那个,发现它匹配到了0
然后他也蠢蠢欲动
发现下个是b
所以循环没了
这时,他毫不(是真的)惊奇地发现下一个是b
于是
abaab(内两b匹配上了)
理解了吧?
剩下的自己推,别问我
那么,现在烤馍片完成了,该做什么呢
next的作用
以下搬运自dalao题解,此处F[i]指next[i]+1
我们还是先给出一个例子:
A="abaabaabbabaaabaabbabaab"
B="abaabbabaab"
当然读者可以通过手动模拟得出只有一个地方匹配
abaabaabbabaaabaabbabaab
我们再用i表示当前A串要匹配的位置(即还未匹配),j表示当前B串匹配的位置(同样也是还未匹配),补充一下,若i>0则说明i-1是已经匹配的啦(j同理)。
首先我们还是从0开始匹配:
此时,我们发现,A的第5位和B的第5位不匹配(注意从0开始编号),此时i=5,j=5,那么我们看F[j-1]的值:
F[5-1]=2;
这说明我们接下来的匹配只要从B串第2位开始(也就是第3个字符)匹配,因为前两位已经是匹配的啦,具体请看图:
然后再接着匹配:
我们又发现,A串的第13位和B串的第10位不匹配,此时i=13,j=10,那么我们看F[j-1]的值:
F[10-1]=4
这说明B串的03位是与当前(i-4)(i-1)是匹配的,我们就不需要重新再匹配这部分了,把B串向后移,从B串的第4位开始匹配:
这时我们发现A串的第13位和B串的第4位依然不匹配
此时i=13,j=4,那么我们看F[j-1]的值:
F[4-1]=1
这说明B串的第0位是与当前i-1位匹配的,所以我们直接从B串的第1位继续匹配:
但此时B串的第1位与A串的第13位依然不匹配
此时,i=13,j=1,所以我们看一看F[j-1]的值:
F[1-1]=0
好吧,这说明已经没有相同的前后缀了,直接把B串向后移一位,直到发现B串的第0位与A串的第i位可以匹配(在这个例子中,i=13)
再重复上面的匹配过程,我们发现,匹配成功了!
这就是KMP算法的过程。
另外强调一点,当我们将B串向后移的过程其实就是i++,而当我们不动B,而是匹配的时候,就是i++,j++,这在后面的代码中会出现,这里先做一个说明。
最后来一个完整版的(dalao:话说做这些图做了好久啊!!!!):
kmp例题代码实现:
#include <bits/stdc++.h>
using namespace std;
string s1,s2;
int n,m,i,j,_next[1000010];
int main(){
cin>>s1>>s2;
n=s2.size();
m=s1.size();
_next[0]=-1;
for (i=1;i<n;i++){
j=_next[i-1];
while ((s2[j+1]!=s2[i])&&(j>=0))j=_next[j];
if (s2[j+1]==s2[i]) _next[i]=j+1;
else _next[i]=-1;
}
i=0;j=0;
while (i<m) {
if (s1[i]==s2[j]) {
i++;
j++;
if(j==n)cout<<i-n+1<<endl,j=_next[j-1]+1;
}else{
if (j==0)i++;
else j=_next[j-1]+1;
}
}
for (i=0;i<n;i++){
cout<<_next[i]+1<<" ";
}
return 0;
}
c,怎么这么难调
知道Trie的请跳过这一段
网上的解析
Trie
大概论述一下过程:
- 选定要加入到Trie树中的字符串
- 从根节点开始依次判断当前结点的子节点中是否包含下一个字符
- 如果包含,则直接访问,重复第2步
- 否则,则建立这个结点,继续重复第2步
- 若进行第2步时已经到达了最后一个字符,则直接结束
那么,我们来举个例子
假如,我要构建成字典树的单词是her hen hers say said
最终构建完的字典树就长这样:
root
/ \
h s
/ \
e a
/ \ / \
r n y i
| |
s d
然而我们并不知道这些东西分别代表那些字符串
于是,我们对每个字符串的结尾所在的那个节点加个标记。
于是乎,我们从根节点开始,一层层依次遍历,当读入到一个加了标记的结点时,一路读到的字符连成的字符串便是原来需要储存的字符串
那么,至于怎么加入一个串呀。。。
我们也来演示一下吧、。。。
比如当前这样子:
root
/ \
h s
/ \
e a
/ \ /
r n y
|
s
其他的串都已经加入,我们现在需要加入字符串said
那么,我们从根节点开始
ro_ot(用_表示当前节点)
/ \
h s
/ \
e a
/ \ /
r n y
|
s
发现根节点的子节点里面存在s这个字符结点
把一个指针移动过去,继续找接下来的字符
root
/ \
h _s
/ \
e a
/ \ /
r n y
|
s
接着,我们惊奇的发现a也存在了,于是继续遍历
root
/ \
h s
/ \
e _a
/ \ /
r n y
|
s
这个时候,却发现当前结点不存在一个i结点,那么,我们就手动的造一个i结点出来
root
/ \
h s
/ \
e a
/ \ / \
r n y i
|
s
同理:
root
/ \
h s
/ \
e a
/ \ / \
r n y i
| |
s d
OK,上代码!
#include <bits/stdc++.h>
using namespace std;
int n,end=0;
vector<int> fa(100005),son(100005),nth(100005),sa[26](100005);//窝喜欢用vector,别喷窝啊……
void build(int where,string s,int now){
if(now>=s.size())return;
if(son[where]&(1<<int(c[now]-'a')))build(sa[int(c[now]-'a')][where],s,now+1);
else{
end++;
fa[end]=where;
son[where]|=(1<<int(c[now]-'a'));
sa[int(c[now]-'a')][where]=end;
}
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
while(cin>>n&&n){
fa=nth;
son=nth;
fail=nth;
end=0;
for(int i=1;i<=n;i++){
string s;
cin>>s;
build(0,s,0);
}
}
return 0;
}
这个应该没人会跳过吧
AC自动机
依旧先上一个解析
这时你才发现你辛辛苦苦搞懂的烤馍片实际没啥鸟用啊啊啊啊啊啊
引入fail
首先,请构建一棵Trie
such as:
root
/ \
h s
/ \ |
e i a
| / \
s | |\
d i y
|
d
假设我们有一个文本串,名曰:hisadbeeyzc
我们从root开始,来:root——>h——>i——>s
发现到s,成功地匹配了一个模式串,然后就不能再继续匹配了,这时我们还要重新继续从根开始匹配吗?
NO!
这时我们就要借用KMP的思想,从Trie上的某个点继续开始匹配。
明显在这颗Trie上,我们可以继续从root下面内s——>a匹配,然后匹配到d
那么我们怎么确定从那个点开始匹配呢?我们称i匹配失败后继续从j开始匹配,j是i的Fail(失配指针)。
那么,Fail是什么?
看过前面的你一定想到了
没错,就是Next!
以下搬运自dalao解析(至结尾)
再说一下Fail指针的含义:((最长的(当前字符串的后缀))在Trie上可以查找到)的末尾编号。
感觉读起来挺绕口的蛤。感性理解一下就好了,没什么卵用的。知道Fail有什么用就行了。
Fail
首先我们可以确定,每一个点i的Fail指针指向的点的深度一定是比i小的。(Fail指的是后缀啊)
第一层的Fail一定指的是root。(比深度1还浅的只有root了)
设点i的父亲fa的Fail指针指的是fafail,那么如果fafail有和i值相同的儿子j,那么i的Fail就指向j。
这里可能比较难理解一点,建议画图理解,不过等会转换成代码就很好理解了。
由于我们在处理i的情况必须要先处理好fa的情况,所以求Fail我们使用BFS来实现。
实现的一些细节:
- 1、刚开始我们不是要初始化第一层的fail指针为root,其实我们可以建一个虚节点0号节点,将0的所有儿子指向root(root编号为1,记得初始化),然后root的fail指向0就OK了。效果是一样的。
- 2、如果不存在一个节点i,那么我们可以将那个节点设为fafail的((值和i相同)的儿子)。保证存在性,就算是0也可以成功返回到根,因为0的所有儿子都是根。
- 3、无论fafail存不存在和i值相同的儿子j,我们都可以将i的fail指向j。因为在处理i的时候j已经处理好了,如果出现这种情况,j的值是第2种情况,也是有实际值的,所以没有问题。
- 4、实现时不记父亲,我们直接让父亲更新儿子
你学废了吗?
求Fail代码
void getFail(){
for(int i=0;i<26;i++)trie[0].son[i]=1; //初始化0的所有儿子都是1
q.push(1);trie[1].fail=0; //将根压入队列
while(!q.empty()){
int u=q.front();q.pop();
for(int i=0;i<26;i++){ //遍历所有儿子
int v=trie[u].son[i]; //处理u的i儿子的fail,这样就可以不用记父亲了
int Fail=trie[u].fail; //就是fafail,trie[Fail].son[i]就是和v值相同的点
if(!v){trie[u].son[i]=trie[Fail].son[i];continue;} //不存在该节点,第二种情况
trie[v].fail=trie[Fail].son[i]; //第三种情况,直接指就可以了
q.push(v); //存在实节点才压入队列
}
}
}
查询代码
int query(char* s){
int u=1,ans=0,len=strlen(s);
for(int i=0;i<len;i++){
int v=s[i]-'a';
int k=trie[u].son[v]; //跳Fail
while(k>1&&trie[k].flag!=-1){ //经过就不统计了
ans+=trie[k].flag,trie[k].flag=-1; //累加上这个位置的模式串个数,标记 已 经过
k=trie[k].fail; //继续跳Fail
}
u=trie[u].son[v]; //到儿子那,存在性看上面的第二种情况
}
return ans;
}
完整代码
#include<bits/stdc++.h>
#define maxn 1000001
using namespace std;
struct kkk{
int son[26],flag,fail;
}trie[maxn];
int n,cnt;
char s[1000001];
queue<int >q;
void insert(char* s){
int u=1,len=strlen(s);
for(int i=0;i<len;i++){
int v=s[i]-'a';
if(!trie[u].son[v])trie[u].son[v]=++cnt;
u=trie[u].son[v];
}
trie[u].flag++;
}
void getFail(){
for(int i=0;i<26;i++)trie[0].son[i]=1; //初始化0的所有儿子都是1
q.push(1);trie[1].fail=0; //将根压入队列
while(!q.empty()){
int u=q.front();q.pop();
for(int i=0;i<26;i++){ //遍历所有儿子
int v=trie[u].son[i]; //处理u的i儿子的fail,这样就可以不用记父亲了
int Fail=trie[u].fail; //就是fafail,trie[Fail].son[i]就是和v值相同的点
if(!v){trie[u].son[i]=trie[Fail].son[i];continue;} //不存在该节点,第二种情况
trie[v].fail=trie[Fail].son[i]; //第三种情况,直接指就可以了
q.push(v); //存在实节点才压入队列
}
}
}
int query(char* s){
int u=1,ans=0,len=strlen(s);
for(int i=0;i<len;i++){
int v=s[i]-'a';
int k=trie[u].son[v]; //跳Fail
while(k>1&&trie[k].flag!=-1){ //经过就不统计了
ans+=trie[k].flag,trie[k].flag=-1; //累加上这个位置的模式串个数,标记已经过
k=trie[k].fail; //继续跳Fail
}
u=trie[u].son[v]; //到下一个儿子
}
return ans;
}
int main(){
cnt=1; //代码实现细节,编号从1开始
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%s",s);
insert(s);
}
getFail();
scanf("%s",s);
printf("%d\n",query(s));
return 0;
}
优化
拓扑建图
让我们了分析一下刚才那个模板2的时间复杂度……
算了不分析了,直接告诉你吧,这样暴力去跳fail的最坏时间复杂度是O(模式串长度⋅文本串长度)
为什么?
因为对于每一次跳fail我们都只使深度减1,那样深度是多少,每一次跳的时间复杂度就是多少。
那么还要乘上文本串长度,就几乎是O(模式串长度⋅文本串长度)的了。
那么模板1的时间复杂度为什么就只有O(模式串总长)?
因为每一个Trie上的点都只会经过一次(打了标记),但模板2每一个点就不止经过一次了(重复算,不打标记),所以时间复杂度就爆炸了。
那么我们可不可以让模板2的Trie上每个点只经过一次呢?
嗯~,还真可以!
题目看这里:P5357 【模板】AC自动机
做法:拓扑排序
让我们把Trie上的fail都想象成一条条有向边,那么我们如果在一个点对那个点进行一些操作,那么沿着这个点连出去的点也会进行操作(就是跳fail),所以我们才要暴力跳fail去更新之后的点。
我们用上面的图,举个例子解释一下我刚才的意思。
我们先找到了编号4这个点,编号4的fail连向编号7这个点,编号7的fail连向编号9这个点。
那么我们要更新编号4这个点的值,同时也要更新编号7和编号9,这就是暴力跳fail的过程。
我们下一次找到编号7这个点,还要再次更新编号9,所以时间复杂度就在这里被浪费了。
那么我们可不可以在找到的点打一个标记,最后再一次性将标记全部上传来更新其他点的ans。
例如我们找到编号4,在编号4这个点打一个ans标记为1,下一次找到了编号7,又在编号7这个点打一个ans标记为1,那么最后,我们直接从编号4开始跳fail,然后将标记ans上传,((点i的fail)的ans)加上(点i的ans),最后使编号4的ans为1,编号7的ans为2,编号9的ans为2,这样的答案和暴力跳fail是一样的,并且每一个点只经过了一次。
最后我们将有flag标记的ans传到vis数组里,就求出了答案。
em……,建议先消化一下。
那么现在问题来了,怎么确定更新顺序呢?
明显我们打了标记后肯定是从深度大的点开始更新上去的。怎么实现呢?
拓扑排序!
我们使每一个点向它的fail指针连一条边,明显,每一个点的出度为1(fail只有一个),入度可能很多,所以我们就不需要像拓扑排序那样先建个图了,直接往fail指针跳就可以了。
最后我们根据fail指针建好图后(想象一下,程序里不用实现),一定是一个DAG,具体原因不解释(很简单的),那么我们就直接在上面跑拓扑排序,然后更新ans就可以了。
代码实现:首先是getfail这里,记得将fail的入度in更新。
trie[v].fail=trie[Fail].son[i]; in[trie[v].fail]++; //记得加上入度
然后是query,不用暴力跳fail了,直接打上标记就行了,很简单吧
void query(char* s){
int u=1,len=strlen(s);
for(int i=0;i<len;++i)
u=trie[u].son[s[i]-'a'],trie[u].ans++; //直接打上标记
}
最后是拓扑,解释都在注释里了OwO!
void topu(){
for(int i=1;i<=cnt;++i)
if(in[i]==0)q.push(i); //将入度为0的点全部压入队列里
while(!q.empty()){
int u=q.front();q.pop();vis[trie[u].flag]=trie[u].ans; //如果有flag标记就更新vis数组
int v=trie[u].fail;in[v]--; //将唯一连出去的出边fail的入度减去(拓扑排序的操作)
trie[v].ans+=trie[u].ans; //更新fail的ans值
if(in[v]==0)q.push(v); //拓扑排序常规操作
}
}
应该还是很好理解的吧,实现起来也没有多难嘛!
对了还有重复单词的问题,和下面讲的"P3966[TJOI2013]单词"的解决方法一样的,不讲了吧。
基础题:P3966 [TJOI2013]单词
这道题和上面那道题没有什么不同,文本串就是将模式串用神奇的字符(例如"♂")隔起来的串。
但这道题有相同字符串要统计,所以我们用一个Map数组存这个字符串指的是Trie中的那个位置,最后把vis[Map[i]]输出就OK了。
下面是P5357【模板】AC自动机(二次加强版)的代码(套娃?大雾),剩下的大家怎么改应该还是知道的吧。
#include<bits/stdc++.h>
#define maxn 2000001
using namespace std;
char s[maxn],T[maxn];
int n,cnt,vis[200051],ans,in[maxn],Map[maxn];
struct kkk{
int son[26],fail,flag,ans;
}trie[maxn];
queue<int>q;
void insert(char* s,int num){
int u=1,len=strlen(s);
for(int i=0;i<len;++i){
int v=s[i]-'a';
if(!trie[u].son[v])trie[u].son[v]=++cnt;
u=trie[u].son[v];
}
if(!trie[u].flag)trie[u].flag=num;
Map[num]=trie[u].flag;
}
void getFail(){
for(int i=0;i<26;i++)trie[0].son[i]=1;
q.push(1);
while(!q.empty()){
int u=q.front();q.pop();
int Fail=trie[u].fail;
for(int i=0;i<26;++i){
int v=trie[u].son[i];
if(!v){trie[u].son[i]=trie[Fail].son[i];continue;}
trie[v].fail=trie[Fail].son[i]; in[trie[v].fail]++;
q.push(v);
}
}
}
void topu(){
for(int i=1;i<=cnt;++i)
if(in[i]==0)q.push(i); //将入度为0的点全部压入队列里
while(!q.empty()){
int u=q.front();q.pop();vis[trie[u].flag]=trie[u].ans; //如果有flag标记就更新vis数组
int v=trie[u].fail;in[v]--; //将唯一连出去的出边fail的入度减去(拓扑排序的操作)
trie[v].ans+=trie[u].ans; //更新fail的ans值
if(in[v]==0)q.push(v); //拓扑排序常规操作
}
}
void query(char* s){
int u=1,len=strlen(s);
for(int i=0;i<len;++i)
u=trie[u].son[s[i]-'a'],trie[u].ans++;
}
int main(){
scanf("%d",&n); cnt=1;
for(int i=1;i<=n;++i){
scanf("%s",s);
insert(s,i);
}getFail();scanf("%s",T);
query(T);topu();
for(int i=1;i<=n;++i)printf("%d\n",vis[Map[i]]);
}