1. 程式人生 > 其它 >[模板] AC自動機

[模板] AC自動機

[模板] AC自動機

AC 自動機是以 Trie 樹的結構 為基礎,結合 KMP 的思想進行的一種多模式匹配演算法。

典型應用是:用一個文字串來匹配多個模式串。

Trie 樹構建

和 Trie 樹模板沒有區別,還是要記錄模式串的結束位置。

放在 AC 自動機的演算法裡,一個結點表示一個字串 \(S\) 的字首,這也是這個模式串的一種狀態。

嚴格意義來說,應該把 Trie 樹的字元指標看成 比較形象。

失配指標 fail

類似與 KMP 中的 \(next\) 陣列,我們把它們作下對比:

  1. \(next\) 陣列記錄的是字串 \(S\) 的一個字首的 字首等於字尾的最大長度

  2. \(fail\)

    指標指向 所有模式串的字首中匹配當前狀態的最長字尾。更加形象的說,\(fail\) 就是當前狀態的一個字尾集合。

  3. 兩者都是為了在失配的時候進行跳轉用的。

構造思想 ,基本思想 。

設當前結點為 \(u\)\(p\) 為通過字元 \(c\) 指標的父親,考慮如何構造 \(u\) 結點的失配指標。

  1. 如果 \(t[fail[p]][c]\) 存在,那麼 \(fail[u]=t[fail[p][c]]\) ,類似於 KMP 中的繼承 \(nxt\) 陣列操作,這裡是通過繼承上一個狀態而得到了延續狀態的最長字尾。

  2. 如果上述結點不存在,向跳 \(next\) 陣列一樣跳 \(fail\)

    ,比如第二步就要找 \(t[fail[fail[p]]][c]\),直到找到存在結點或者到達根節點 \(rt=0\) 為止。

具體實現就是一直跳 \(fail\),直到存在該結點為止。

放一張 \(oi-wiki\) 的圖理解一下:

其中橙色和紅色的邊代表失配指標。

至於我們為什麼要在 \(bfs\) 的時候處理 \(fail\) 指標,那就顯然了,那麼可以輕易得出結論:

**任意一個結點指向的 fail 指標的深度至少為它的深度 -1 **。

Trie 圖的構建

Trie 圖(字典圖),是在對原有的 Trie 結構的基礎上進行更改形成的 AC 自動機最後的圖。

又好寫又快

用來解決兩個事情:fail 指標的處理

和 **AC 自動機的構建 **。

同樣還是 bfs 更新子結點,在這裡我們不妨把 \(t[p][c]\) 看成從 \(S\) 後加一個字元 \(c\) 形成的 新狀態 。(即一個狀態轉移函式 \(trans(u,c)\)

類似於上面構造 \(fail\) 指標的過程,還是分兩種情況討論:

  1. \(t[p][c]\) 存在,則讓 \(t[p][c]\)\(fail\) 指標指向 \(t[fail[p]][c]\)

  2. 否則如果不存在這個轉移函式,我們就讓這個轉移函式 \(t[p][c]\) 指向 \(t[fail[p]][c]\)

這似乎有一個問題:我們之前在構建 \(fail\) 指標的時候得滿足合法才能停止跳 \(fail\) 更新子節點的 \(fail\) 指標。

可以說我們通過靈魂操作 2 保證了 比當前結點 \(t[p][c]\) 深度低的點的兒子都填滿了

操作二的意義就是:我們之前可能會失配後跳 \(fail\) 指標多次才能來到下一個能夠匹配到的位置,但是通過操作二,可以讓失配的位置直接指向它下一個要匹配的位置。

這樣修改字典樹的結構,使得 匹配轉移更加完善 。同時它將 fail 指標跳轉的路徑做了壓縮(就像並查集的路徑壓縮),使得本來需要跳很多次 fail 指標變成跳一次。

這也是 Trie 圖常數小的原因。

再來看一張動圖:

黑色的邊代表在 Trie 樹上修改轉移函式得到的邊,黃色的邊代表 \(fail\) 指標。

如何理解這個東西?比如我們看四號結點的 \(trans(4,h)\) 更新情況。

先找到 \(fail[4]=7\) ,然後發現有 \(trans(7,h)\) 轉移函式指向 \(8\) 號結點,那麼 \(trans(4,h)\) 就是 \(8\) 號結點。

這個明顯是有意義的,比如當前文字串 \(T\) 匹配了 \(h-e-r-s\) ,接下來一個字元恰好是 \(h\) ,我們通過它的 \(trans(4,h)\) 轉移函式保證了一個儘量長的字尾。

\(tip\):不難發現自環只能發生在 \(bfs\) 的第一層。

void build(){
	for(int i=0;i<26;i++){
		if(t[0][i])q.push(t[0][i]);
	}
	while(q.size()){
		int u=q.front();q.pop();
		for(int i=0;i<26;i++){
			if(t[u][i])fail[t[u][i]]=t[fail[u]][i],q.push(t[u][i]);
			else t[u][i]=t[fail[u]][i];
		}
	}
}

多模式匹配操作

查詢操作在建完 Trie 圖後就簡單了,分為兩步:

  1. 在 Trie 圖(樹)上進行自我和樹的匹配。(因為兒子都被填滿了)

  2. 在匹配到的當前結點跳失配指標,並進行答案求解後清空標記陣列。

int query(char *s){
	int u=0,res=0;
	for(int i=1;s[i];i++){
		u=t[u][s[i]-'a'];
		for(int j=u;j && end[j]!=-1;j=fail[j]){
			res+=end[j];end[j]=-1;
		}
	}
	return res;
}

由於跳 \(fail\) 指標是唯一的,所以一個點 \(end[j]=-1\) 當且僅當 \(j\)\(fail\) 指標的路徑上到根已經被打通了,這樣做可以保證複雜度。

還是來一張多模式匹配動圖:

可以發現,在匹配完 \(s-h-e\) 後失配 \(r\) 的時候,直接跳到了 \(3\) 的位置,這也是最佳位置。

板子們

P3808 【模板】AC自動機(簡單版)

#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
template <typename T>
inline T read(){
	T x=0;char ch=getchar();bool fl=false;
	while(!isdigit(ch)){if(ch=='-')fl=true;ch=getchar();}
	while(isdigit(ch)){
		x=(x<<3)+(x<<1)+(ch^48);ch=getchar();
	}
	return fl?-x:x;
}
#define read() read<int>()
const int maxn = 1e6 + 6;
int n;
#include <queue>
namespace AC{
int t[maxn][26],cnt,end[maxn],fail[maxn];
void insert(char *s){
	int u=0;
	for(int i=1;s[i];i++){
		if(!t[u][s[i]-'a'])t[u][s[i]-'a']=++cnt;
		u=t[u][s[i]-'a'];
	}
	end[u]++;
}
queue<int> q;
void build(){
	for(int i=0;i<26;i++){
		if(t[0][i])q.push(t[0][i]);
	}
	while(q.size()){
		int u=q.front();q.pop();
		for(int i=0;i<26;i++){
			if(t[u][i])fail[t[u][i]]=t[fail[u]][i],q.push(t[u][i]);
			else t[u][i]=t[fail[u]][i];
		}
	}
}
int query(char *s){
	int u=0,res=0;
	for(int i=1;s[i];i++){
		u=t[u][s[i]-'a'];
		for(int j=u;j && end[j]!=-1;j=fail[j]){
			res+=end[j];end[j]=-1;
		}
	}
	return res;
}
}
using namespace AC;
char s[maxn];
#define read() read<int>()
int main(){
	n=read();
	for(int i=1;i<=n;i++)scanf("%s",s+1),insert(s);
	scanf("%s",s+1);
	build();
	printf("%d\n",query(s));
	return 0;
}

P3796 【模板】AC自動機(加強版)

#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
template <typename T>
inline T read(){
	T x=0;char ch=getchar();bool fl=false;
	while(!isdigit(ch)){if(ch=='-')fl=true;ch=getchar();}
	while(isdigit(ch)){
		x=(x<<3)+(x<<1)+(ch^48);ch=getchar();
	}
	return fl?-x:x;
}
#define read() read<int>()
const int maxn = 1e6 + 6;
int n;
#include <queue>
namespace AC{
int t[maxn][26],cnt,fail[maxn],val[maxn],idx[maxn],tot[200];
void insert(char *s,int id){
	int u=0;
	for(int i=1;s[i];i++){
		if(!t[u][s[i]-'a'])t[u][s[i]-'a']=++cnt;
		u=t[u][s[i]-'a'];
	}
	idx[u]=id;
}
queue<int> q;
void build(){
	for(int i=0;i<26;i++){
		if(t[0][i])q.push(t[0][i]);
	}
	while(q.size()){
		int u=q.front();q.pop();
		for(int i=0;i<26;i++){
			if(t[u][i])fail[t[u][i]]=t[fail[u]][i],q.push(t[u][i]);
			else t[u][i]=t[fail[u]][i];
		}
	}
}
inline void init(){
	cnt=0;
	memset(fail,0,sizeof fail);
	memset(t,0,sizeof t);
	memset(val,0,sizeof val);
	memset(idx,0,sizeof idx);
	memset(tot,0,sizeof tot);
}
int query(char *s){
	int u=0,res=0;
	for(int i=1;s[i];i++){
		u=t[u][s[i]-'a'];
		for(int j=u;j;j=fail[j]){
			val[j]++;
		}
	}
	for(int i=0;i<=cnt;i++)if(idx[i])res=max(res,val[i]),tot[idx[i]]=val[i];
	return res;
}
}
using namespace AC;
char s[200][107],T[maxn];
#define read() read<int>()
int main(){
	while(scanf("%d",&n)==1){
		if(!n)break;
		init();
		for(int i=1;i<=n;i++)cin>>s[i]+1,insert(s[i],i);
		build();
		cin>>T+1;
		int x=query(T);
		printf("%d\n",x);
		for(int i=1;i<=n;i++)if(tot[i]==x)printf("%s\n",s[i]+1);
	}
	return 0;
}

加強版唯一的區別就是需要記錄最多位置,因此不能用上一題的套路清空\(end\) 陣列了。(其實沒有 \(end\) 陣列)