~Keywords Search~~~~AC自動機

題意(這裏可以點哦):有多個關鍵詞,在一個文中找到它們。
輸入:第一行是一個整數N,表示關鍵詞個數,下面有N個關鍵詞,N<=1000。每個關鍵詞只包含小寫字母,長度不超過50.最後一行是文本,長度不大於1000000。
輸出:在輸出文本中能找到多少關鍵詞。重複的關鍵詞只需要統計一次。

AC自動機可以說是KMP的升級版。KMP是單模匹配算法,處理在一個文本串中查找一個模式串的問題;AC自動機是多模匹配算法,能在一個文本串中同時查找多個不同的模式串。

  • KMP算法是一種在任何情況下都能達到O(n+m)複雜度的算法,它的核心——Next[ ]數組(當出現失配後,進行下一次匹配時,用Next[ ]指出 j(模式串指針)回溯的位置)。KMP算法中的getFail()函數代碼如下:
void getFail(string s){//預計算Next[],用於在失配的情況下得到j回溯的位置 
	for(int i=1;i<s.size();++i){
		int j=Next[i];
		while(j&&s[j]!=s[i])j=Next[j];
		Next[i+1]=(s[i]==s[j])?j+1:0;
	}
}

其實KMP也能解決多模匹配問題,缺點就是複雜度較高,假設有k個模式串的平均長度爲m,那麼對每一個模式串分別做一次KMP,總複雜度是O((n+m)k)。
AC自動機算法不需要對S(文本串)做多次KMP,而是隻搜索一遍S,在搜索時匹配所有模式串。
KMP是通過查找P對應的Next[ ]數組實現快速匹配的。如果把所有的P做成一個字典樹,然後在匹配的時候查找這個P對應的Next[ ]數組,不就實現了快速匹配了嗎?建立字典樹是O(km),建立fail指針O(km),模式匹配O(nm),乘m的原因是在統計的時候需要順着鏈回溯到root節點。所以總時間複雜度是O((n+k)m),當m<<k時,(n+k)m<<(n+m)k,AC自動機的優勢就非常大了。

  • 字典樹的代碼(用數組表示,需要一個Insert()函數)很簡單:
int trie[N][26],num[N],tot;//字典樹
void Insert(string s){ 
	int u=0;
	for(int i=0;i<s.size();++i){
		int n=s[i]-'a';
		if(trie[u][n]==0){
			trie[u][n]=++tot;
		}
		u=trie[u][n];
	} 
	num[u]++;//當前節點單詞數+1 
}

那接下來就是AC自動機的getFail()函數,我們首先定義一個數組fail[ ](失配回溯指針),然後用bfs遍歷字典樹的所有節點來構造每個fail指針。重點是怎麼構造的,先堆公式:某節點的fail指針=父節點的fail指針指向節點的子節點(該節點代表的字母與某節點代表的字母相同) ,如果某節點不存在那就用它的鍵值代替它的fail指針,聽起來有點繞,但還好吧。 而公式的效果是什麼呢?你想想某節點的fail指針指向的節點和它有着相同的鍵值,那它的父節點fail指針指向的節點不也是同樣的,而第一層的節點的fail指針都指向根節點0,那我們就可以把fail指針指向的字符串(從根節點到fail指針指向的節點)看做是原字符串的一個子串還是最長的子串(此處子串都有一個特點:包含尾字符),所以我們循着fail指針的路徑可以遍歷所有有效判斷並返回根節點,簡單說就是通過遍歷文本串(文本串指針不需要回溯)來一堆一堆的判斷模式串是否存在。

  • 如果還不明白,你就把模式串簡化成一個,你會發現AC自動機竟然模擬了KMP,是不是有所明悟。廢話就不多說了,直接上代碼:
//AC自動機
int fail[N];//失敗時的回溯指針 
void getFail(){
	queue<int>q;
	for(int i=0;i<26;++i){
		if(trie[0][i])
			q.push(trie[0][i]);
	} 
	while(!q.empty()){//bfs
		int r=q.front();
		q.pop();
		for(int i=0;i<26;++i){
			if(trie[r][i]){
				//子節點失配回溯到父節點fail指針指向節點的下一個節點 
				fail[trie[r][i]]=trie[fail[r]][i];
				q.push(trie[r][i]);
			}
			else {
				//否則子節點指向父節點fail指針指向節點的下一個節點 
				trie[r][i]=trie[fail[r]][i];
			}
		}
	}
}
  • 最後就把題解代碼寫出來了,多了一個沒見過的query()函數,如果上述講解聽懂了,那很自然就理解了,函數中的循環就是遍歷子串的意思,僅此而已。
#include<bits/stdc++.h> 
using namespace std;
const int N=1e6+1;
int trie[N][26],num[N],tot;//字典樹
void Insert(string s){ 
	int u=0;
	for(int i=0;i<s.size();++i){
		int n=s[i]-'a';
		if(trie[u][n]==0){
			trie[u][n]=++tot;
		}
		u=trie[u][n];
	} 
	num[u]++;//當前節點單詞數+1 
}
//AC自動機
int fail[N];//失敗時的回溯指針 
void getFail(){
	queue<int>q;
	for(int i=0;i<26;++i){
		if(trie[0][i])
			q.push(trie[0][i]);
	} 
	while(!q.empty()){//bfs
		int r=q.front();
		q.pop();
		for(int i=0;i<26;++i){
			if(trie[r][i]){
				//子節點失配回溯到父節點fail指針指向節點的下一個節點 
				fail[trie[r][i]]=trie[fail[r]][i];
				q.push(trie[r][i]);
			}
			else {
				//否則子節點指向父節點fail指針指向節點的下一個節點 
				trie[r][i]=trie[fail[r]][i];
			}
		}
	}
}
int query(string s){
	int sum=0,u=0;
	for(int i=0;i<s.size();++i){
		u=trie[u][s[i]-'a'];
		for(int j=u;j&&num[j]!=-1;j=fail[j]){
			sum+=num[j];
			num[j]=-1;	
		}
	}
	return sum;
} 
int main(){
	int n;
	string s;
	cin>>n;
	for(int i=1;i<=n;++i){
		cin>>s;
		Insert(s);
	}
	getFail();
	cin>>s;
	cout<<query(s)<<endl;
	return 0;
} 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章