从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

大概论述一下过程:

  1. 选定要加入到Trie树中的字符串
  2. 从根节点开始依次判断当前结点的子节点中是否包含下一个字符
  3. 如果包含,则直接访问,重复第2步
  4. 否则,则建立这个结点,继续重复第2步
  5. 若进行第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]]);
}

posted @ 2024-09-12 20:42  yzc_is_SadBee  阅读(5)  评论(0编辑  收藏  举报