AC自動機講解超詳細

begin:2019/5/2html

update 2020/6/12 更新了LaTeX(咕了很久c++

感謝你們支持!數組

AC自動機詳細講解

AC自動機真是個好東西!以前學\(KMP\)\(Next\)指針搞暈了,因此咕了許久都不敢開AC自動機,近期學完以後,發現AC自動機並非很難,特別是對於\(KMP\)​,我的感受AC自動機\(KMP\)要好理解一些,多是由於我對樹上的東西比較敏感(實際是由於我到如今都不會\(KMP\))。學習

不少人都說AC自動機是在\(Trie\)樹上做\(KMP\),我不否定這一種觀點,由於這確實是這樣,不過對於剛開始學AC自動機的同窗們就一些誤導性的理解(至少對我是這樣的)。\(KMP\)是創建在一個字符串上的,如今把\(KMP\)搬到了樹上,不是很麻煩嗎?實際上AC自動機只是有\(KMP\)的一種思想,實際上跟一個字符串的\(KMP\)有着很大的不一樣。優化

因此看這篇blog,請放下\(KMP\),理解好\(Trie\),再來學習。spa

前置技能

1.\(Trie\)(很重要哦)指針

2.\(KMP\)的思想(懂思想就能夠了,不須要很熟練)code

問題描述

給定\(n\)個模式串和\(1\)個文本串,求有多少個模式串在文本串裏出現過htm

注意:是出現過,就是出現屢次只算一次。blog

默認這裏每個人都已經會了\(Trie\)

咱們將\(n\)個模式串建成一顆\(Trie\)樹,建樹的方式和建\(Trie\)徹底同樣。

假如咱們如今有文本串\(ABCDBC\)

咱們用文本串在\(Trie\)上匹配,剛開始會通過\(二、三、4\)號點,發現到\(4\),成功地匹配了一個模式串,而後就不能再繼續匹配了,這時咱們還要從新繼續從根開始匹配嗎?

不,這樣的效率太慢了。這時咱們就要借用\(KMP\)的思想,從\(Trie\)上的某個點繼續開始匹配。

明顯在這顆\(Trie\)上,咱們能夠繼續從\(7\)號點開始匹配,而後匹配到\(8\)

那麼咱們怎麼肯定從那個點開始匹配呢?咱們稱\(i\)匹配失敗後繼續從\(j\)開始匹配,\(j\)\(i\)\(Fail\)(失配指針)。

構建Fail指針

\(Fail\)的含義

\(Fail\)指針的實質含義是什麼呢?

若是一個點\(i\)\(Fail\)指針指向\(j\)。那麼\(root\)\(j\)的字符串是\(root\)\(i\)的字符串的一個後綴。

舉個例子:(例子來自上面的圖

i:4     j:7
root到i的字符串是「ABC」
root到j的字符串是「BC」
「BC」是「ABC」的一個後綴
因此i的Fail指針指向j

同時咱們發現,「\(C\)」也是「\(ABC\)」的一個後綴。

因此\(Fail\)指針指的\(j\)的深度要儘可能大。

重申一下\(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\)來實現。

實現的一些細節:

  • 一、剛開始咱們不是要初始化第一層的\(fail\)指針爲\(root\),其實咱們能夠建一個虛節點\(0\)號節點,將\(0\)全部兒子指向\(root\)\(root\)編號爲\(1\),記得初始化),而後\(root\)\(fail\)指向\(0\)就OK了。效果是同樣的。

  • 二、若是不存在一個節點\(i\),那麼咱們能夠將那個節點設爲\(fafail\)((值和\(i\)相同)的兒子)。保證存在性,就算是\(0\)也能夠成功返回到根,由於\(0\)的全部兒子都是根。

  • 三、不管\(fafail\)存不存在和\(i\)值相同的兒子\(j\),咱們均可以將\(i\)\(fail\)指向\(j\)。由於在處理\(i\)的時候\(j\)已經處理好了,若是出現這種狀況,\(j\)的值是第\(2\)種狀況,也是有實際值的,因此沒有問題。

  • 四、實現時不記父親,咱們直接讓父親更新兒子

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);						//存在實節點才壓入隊列
		}
	}
}

查詢

求出了\(Fail\)指針,查詢就變得十分簡單了。

爲了不重複計算,咱們每通過一個點就打個標記爲\(-1\),下一次通過就不重複計算了。

同時,若是一個字符串匹配成功,那麼他的\(Fail\)也確定能夠匹配成功(後綴嘛),因而咱們就把\(Fail\)再統計答案,一樣,\(Fail\)\(Fail\)也能夠匹配成功,以此類推……通過的點累加\(flag\),標記爲\(-1\)

最後主要仍是和\(Trie\)的查詢是同樣的。

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;
}

updata:2019/5/7 AC自動機的應用

AC自動機的一些應用

先拿P3796 【模板】AC自動機(增強版)來講吧。

無疑,做爲模板2,這道題的解法也是十分的經典。

咱們先來分析一下題目:輸入和模板1同樣

一、求出現次數最多的次數

二、求出現次數最多的模式串

明顯,咱們若是統計出每個模式串在文本串出現的次數,那麼這道題就變得十分簡單了,那麼問題就變成了如何統計每一個模式串出現的次數。

作法:AC自動機

首先題目統計的是出現次數最多的字符串,因此有重複的字符串是沒有關係的。(由於後面的會覆蓋前面的,統計的答案也是同樣的)

那麼咱們就將標記模式串的\(flag\)設爲當前是第幾個模式串。就是下面插入時的變化:

trie[u].flag++;
變爲
trie[u].flag=num; //num表示該字符串是第num個輸入的

\(Fail\)指針沒有變化,原先怎麼求就怎麼求。

查詢:咱們開一個數組\(vis\),表示第\(i\)個字符串出現的次數。

由於是重複計算,因此不能標記爲\(-1\)了。

咱們每通過一個點,若是有模式串標記,就將\(vis[模式串標記]++\)。而後繼續跳fail,緣由上面說過了。

這樣咱們就能夠將每一個模式串的出現次數統計出來。剩下的你們應該都會QwQ!

總代碼

//AC自動機增強版
#include<bits/stdc++.h>
#define maxn 1000001
using namespace std;
char s[151][maxn],T[maxn];
int n,cnt,vis[maxn],ans;
struct kkk{
	int son[26],fail,flag;
	void clear(){memset(son,0,sizeof(son));fail=flag=0;}
}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];
	}
	trie[u].flag=num;			//變化1:標記爲第num個出現的字符串
}
void getFail(){
	for(int i=0;i<26;i++)trie[0].son[i]=1;
	q.push(1);trie[1].fail=0;
	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];
			q.push(v);
		}
	}
}
void query(char* s){
	int u=1,len=strlen(s);
	for(int i=0;i<len;i++){
		int v=s[i]-'a';
		int k=trie[u].son[v];
		while(k>1){
			if(trie[k].flag)vis[trie[k].flag]++;	//若是有模式串標記,更新出現次數
			k=trie[k].fail;
		}
		u=trie[u].son[v];
	}
}
void clear(){
	for(int i=0;i<=cnt;i++)trie[i].clear();
	for(int i=1;i<=n;i++)vis[i]=0;
	cnt=1;ans=0;
}
int main(){
	while(1){
		scanf("%d",&n);if(!n)break;
		clear();
		for(int i=1;i<=n;i++){
			scanf("%s",s[i]);
			insert(s[i],i);
		}
		scanf("%s",T);
		getFail();
		query(T);
		for(int i=1;i<=n;i++)ans=max(vis[i],ans);	//最後統計答案
		printf("%d\n",ans);
		for(int i=1;i<=n;i++)
		if(vis[i]==ans)
		printf("%s\n",s[i]);
	}
}

update:2019/5/9

AC自動機的優化

topo建圖優化

讓咱們了分析一下剛纔那個模板2的時間複雜度,算了不分析了,直接告訴你吧,這樣暴力去跳\(fail\)的最壞時間複雜度是\(O(模式串長度 · 文本串長度)\)

爲何?由於對於每一次跳\(fail\)咱們都只使深度減\(1\),那樣深度是多少,每一次跳的時間複雜度就是多少。那麼還要乘上文本串長度,就幾乎是 \(O(模式串長度 · 文本串長度)\)的了。

那麼模板1的時間複雜度爲何就只有\(O(模式串總長)\)。由於每個\(Trie\)上的點都只會通過一次(打了標記),但模板2每個點就不止通過一次了(重複算,不打標記),因此時間複雜度就爆炸了。

那麼咱們可不可讓模板2\(Trie\)上每一個點只通過一次呢?

嗯~,還真能夠!

題目看這裏:P5357 【模板】AC自動機(二次增強版)

作法:拓撲排序

讓咱們把\(Trie\)上的\(fail\)想象成一條條有向邊,那麼咱們若是在一個點對那個點進行一些操做,那麼沿着這個點連出去的點也會進行操做(就是跳\(fail\)),因此咱們纔要暴力跳\(fail\)去更新以後的點。

AC自動機

咱們仍是用上面的圖,舉個例子解釋一下我剛纔的意思。

咱們先找到了編號\(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]]);
}

廣告:學完來學PAM吧 PAM學習小結

To be continue……

相關文章
相關標籤/搜索