AC自動機講解超詳細


begin:2019/5/2

update 2020/6/12 更新了LaTeX(咕了好久

感謝大家支持!

AC自動機詳細講解

AC自動機真是個好東西!之前學\(KMP\)\(Next\)指針搞暈了,所以咕了許久都不敢開AC自動機,近期學完之后,發現AC自動機並不是很難,特別是對於\(KMP\)​,個人感覺AC自動機\(KMP\)要好理解一些,可能是因為我對樹上的東西比較敏感(實際是因為我到現在都不會\(KMP\))。

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

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

前置技能

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

2.\(KMP\)的思想(懂思想就可以了,不需要很熟練)

問題描述

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

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

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

我們將\(n\)個模式串建成一顆\(Trie\)樹,建樹的方式和建\(Trie\)完全一樣。

假如我們現在有文本串\(ABCDBC\)

我們用文本串在\(Trie\)上匹配,剛開始會經過\(2、3、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\)來實現。

實現的一些細節:

  • 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、實現時不記父親,我們直接讓父親更新兒子

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一樣

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

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

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

做法: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]]);
}

To be continue……


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM