詳解AC自動機(附代碼講解)


淺談AC自動機

說在前面

當我在對着 \(OIwiki\) 學習AC自動機時,我是邊罵邊學的,畢竟一開始那個定義與后文的代碼不匹配時,我硬着頭皮肝了幾個小時,還以為我理解不夠深入......結果發現這是優化后的一個新方法,后面才交代......所以說,這篇博客也用於解釋 \(WiKi\) 上的一些解釋地不是特別好的問題。

什么是AC自動機

AC自動機不是讓你自動AC的......那種恐怕是自動AC機。AC自動機是一種處理字符串匹配問題的一種數據結構,采用字典樹的形式,運用 KMP 的思想來匹配字符串。

AC自動機與字典樹的區別

AC自動機也是基於字典樹之上的,不過AC自動機會多出一個 \(fail邊\) 的概念,其意義與 KMP 的 \(next\) 失配數組相似,都是用於失配時跳邊。該 \(fail邊\) 建立在字典樹節點與節點之間,由此會多出一些前向邊,橫叉邊的概念,在此不用理會。

詳談 \(fail邊\)

當你建立出一個字典樹時,就相當於建立出了一個大的各個模式串的前綴的集合題,\(fail邊\)的跳躍思想和KMP的 \(next\) 數組類似,但是KMP的 \(next\) 數組求的是當前前綴所匹配的最大后綴的位置,而 \(fail邊\)不同,它保存的是所有模式串前綴下的最大公共后綴的所在節點的位置。而AC自動機的過程其實就是構建 \(fail邊\) ,至於查詢?那是后面的事了。

構造AC自動機

一個小小的性質

首先,你應當明白一個性質,如果一個節點指向的 \(fail邊\) 存在,那么它的兒子節點的 fail 邊一定是這個節點通過 \(fail邊\) 所對的節點 x 的兒子 nex[x][c] (假設這個點存在),仔細思考便會發現這個性質十分正確。那么,我們考慮依據這個性質建立 \(fail邊\) 集合。

構造 \(fail邊\)

我們用一個隊列來存儲那些已經求好 \(fail邊\) 的節點,每次我們彈出一個節點,然后構造這個節點的所有兒子的 \(fail邊\)
具體代碼如下:

queue<int> q;
		int pos=0;
		for(int i=0;i<26;i++) 
			if(nex[pos][i])
				q.push(nex[pos][i]);//將根節點的幾個子節點接入隊列,他們的fail邊就是根節點 

一開始初始化。

while(!q.empty()) {
			pos=q.front();
			q.pop();
			for(int i=0;i<26;i++) {
				if(nex[pos][i]) //對於失配指針的建立,是當且僅當建立在有這么一個節點上的 
					fail[nex[pos][i]] = nex[fail[pos]][i],q.push(nex[pos][i]);
				//對於當前失配時的字典樹節點,取出一個區間的后綴,然后連接到那么一個節點使那個節點前面的節點的前綴和這個區間的后綴相同且最長,其fail指針已定,故加入隊列 
				else 
					nex[pos][i] = nex[fail[pos]][i];
				//反之則是重構字典樹,讓這個莫須有的點直接連接到另一個節點(或有或無)
				//屬於一種比較不錯的優化,查詢時節約了部分時間 
			}
		}

然后對於每個兒子節點,如果其存在,那么就是按照上面的思想建立 \(fail邊\) (不理會該 \(fail邊\) 所對的節點是否存在,看到后面就明白了。),如果其不存在,那么我們就直接將這個不存在節點指向另一個節點,這個節點就是其父親節點所對的點的兒子,不管其存在是否,重點在於這個方法能在查詢時優化一下下。
這相當於我們重構了一遍字典樹,把無用的節點直接指向有用的節點,同時也很好地對有用的節點構造了相應的 \(fail邊\)

查詢操作

提前聲明

接下來放出的代碼是用於查詢一個文本串內有多少個子串在模式串內出現過,這也是大部分題的問法(也有可能現在變式出得多一點)。

查詢

對於一個文本串,我們要求其有多少個子串在模式串內出現過,說白了對於當前位置的字符,我們遍歷其所有 \(fail邊\) 所指的節點,然后統計這個節點是否是一個單詞的結尾便可。其正確性是不言而喻的,我們遍歷到的所有節點的所在鏈的部分后綴肯定與文本串當前位置的前綴相同(不明白請回頭再來。)

所以,我們可以通過跳 \(fail邊\)​ 的方法來快速匹配每一個在字典樹上的鏈,我們能到達的鏈當且僅當是文本串上的字串。

code:

int search(char s[],int len) {//此函數為計算輸入的字符串里面有多少個字串在字典樹上出現 
		int pos=0,res=0;
		for(int i=0;i<len;i++) {
			int c=s[i]-'a';
			pos=nex[pos][c];//節點的轉移 
			for(int j=pos;j/*不能回到起始節點,反則會卡死*/&&excist[j]!=-1/*判斷遍歷情況*/;j=fail[j]) {
				res+=excist[j];
				excist[j]=-1;
			}
		}
		return res;
	}

[luoguP3808模板][https://www.luogu.com.cn/problem/P3808]

完整代碼

#include<iostream>
#include<cstdio>
#include<queue>
#include<cstring>
#define Maxn 5200000
using namespace std;
int n,ans;
char s[Maxn];
struct Trie{
	int nex[Maxn][26],cnt;
	int excist[Maxn];
	int fail[Maxn];
	void inserd(char *s,int len) {
		int pos=0;
		for(int i=0;i<len;i++) {
			int c=s[i]-'a';
			if(!nex[pos][c]) nex[pos][c]=++cnt;
			pos=nex[pos][c];
		}
		excist[pos]++;
	}
	void build() {
		queue<int> q;
		int pos=0;
		for(int i=0;i<26;i++) 
			if(nex[pos][i])
				q.push(nex[pos][i]);//將根節點的幾個子節點接入隊列,他們的fail邊就是根節點 
		while(!q.empty()) {
			pos=q.front();
			q.pop();
			for(int i=0;i<26;i++) {
				if(nex[pos][i]) //對於失配指針的建立,是當且僅當建立在有這么一個節點上的 
					fail[nex[pos][i]] = nex[fail[pos]][i],q.push(nex[pos][i]);
				//對於當前失配時的字典樹節點,取出一個區間的后綴,然后連接到那么一個節點使那個節點前面的節點的前綴和這個區間的后綴相同且最長,其fail指針已定,故加入隊列 
				else 
					nex[pos][i] = nex[fail[pos]][i];
				//反之則是重構字典樹,讓這個莫須有的點直接連接到另一個節點(或有或無)
				//屬於一種比較不錯的優化,查詢時節約了部分時間 
			}
		}
	}
	int search(char s[],int len) {//此函數為計算輸入的字符串里面有多少個字串在字典樹上出現 
		int pos=0,res=0;
		for(int i=0;i<len;i++) {
			int c=s[i]-'a';
			pos=nex[pos][c];//節點的轉移 
			for(int j=pos;j/*不能回到起始節點,反則會卡死*/&&excist[j]!=-1/*判斷遍歷情況*/;j=fail[j]) {
				res+=excist[j];
				excist[j]=-1;
			}
		}
		return res;
	}
}trie;
int main(){
	scanf("%d",&n);
	for(int i=1;i<=n;i++){
		scanf("%s",s);
		int len=strlen(s);
		trie.inserd(s,len); 
	} 
	trie.build();
	scanf("%s",s);
	int len=strlen(s);
	ans=trie.search(s,len);
	printf("%d",ans);
	return 0;
} 


免責聲明!

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



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