演算法總結篇---AC自動機
寫在前面
鳴謝:
OiWiki
「筆記」AC 自動機---LuckyBlock
字串四姐妹---老色批
AC自動機講解超詳細---某不知名大佬
Q:AC自動機?是能自己AC題目的演算法嗎?(興奮)
A:不不不,那叫自動AC機,通過開啟答案檔案輸出答案的一種小手段,而AC自動機是一個字串匹配演算法
AC自動機,全稱\(Aho-Corasick\ automaton\),是一種用來處理字串多模式匹配的演算法
本人將盡可能詳細的解釋AC自動機的演算法流程(其實大部分抄的Oiwiki,這是一個幫助我們共同理解的過程,畢竟作者也是個萌新。開始接受的過程可能比較困難,但多回顧幾遍還是有助於理解的
演算法流程
什麼是自動機?(粘個連結,感性理解就好,不要過於執著)
引例:
給定 \(n\) 個模式串 \(s_i\) 和一個文字串 \(t\),求有多少個不同的模式串在文字串裡出現過。
兩個模式串不同當且僅當他們編號不同。
概述:
結合Trie的結構和KMP的思想建立,建立一個AC自動機主要通過兩個步驟:
-
1、建立Trie樹;
-
2、對Trie樹上的所有結點構造失配指標
Trie樹的構建(第一步)
這個Trie樹就是普通的Trie樹,該怎麼建怎麼建
解釋一下Trie樹結點的含義:表示某個模式串的字首
後文也將稱作狀態。一個結點表示一個狀態,Trie樹的邊就是狀態的轉移
形式化的說,對於若干個模式串 \(s_1,s_2,s_3···s_n\),將它們構建一個Trie樹後的所有狀態的集合記為 \(Q\)
失配指標(第二步)
AC 自動機利用一個 fail 指標來輔助多模式串的匹配。
狀態 \(u\) 的 fail 指標指向另一個狀態 \(v\) ,其中 \(v \in Q\) ,且 \(v\) 是 \(u\) 的最長字尾(即在若干個字尾狀態中取最長的一個作為 fail 指標)。
注意和KMP的next指標的區別:
兩者都是在失配的時候用於跳轉的指標;
next指標求的是最長的border(最長的 相同的 前後綴),而fail指標指向所有模式串的字首中匹配當前狀態的最長字尾
因為 KMP 只對一個模式串做匹配,而 AC 自動機要對多個模式串做匹配。有可能 fail 指標指向的結點對應著另一個模式串,兩者字首不同。
AC 自動機在做匹配時,同一位上可匹配多個模式串。
構建失配指標
(可以參考KMP中構建next指標的思想(
考慮更新 \(fail_u\),\(u\) 的父節點是 \(p\) , \(p\) 通過字元 \(c\) 的邊指向 \(u\) ,即 \(tr[p,c] = u\) 。假設深度小於 \(u\) 的所有結點的 \(fail\) 指標均已求得。
如果 \(tr[fail_p,c]\) 存在:則讓 \(fail_u\) 指向 \(tr[fail[p],c]\) 。相當於在 \(p\) 和 \(fail\) 後面加一個字元
c
,分別對應 \(u\) 和 \(fail_u\) 。
如果 \(tr[fail_p,c]\) 不存在:那麼我們繼續找到 \(tr[fail_{fail_p},c],c]\) 。重複 \(1\) 的判斷過程,一直跳 \(fail_u\) 指標指到根結點。
如果真的沒有,就讓 \(fail_u\) 指標指向根結點。
這樣就完成了 \(fail\) 的構建,並得到一份比較暴力的構建方式,我們來看優化
字典樹和字典圖
先來看構建函式 build()
,該函式的目標有兩個,一個是構建 fail 指標,一個是構建自動機。
void build(){
for(int i = 0; i < 26; ++i) if(tr[0][i]) q.push(tr[0][i]);
//如果存在這個邊就入隊
while(!q.empty()){
int u = q.front(); q.pop();
for(int i = 0; i < 26; ++i){
if(tr[u][i]) fail[tr[u][i]] = tr[fail[u]][i], q.push(tr[u][i]);
//按照上面所說的方式更新fail指標
else tr[u][i] = tr[fail[u]][i];//這是那個優化,後面會講
}
}
}
原來的構建方法可以通過 \(while\) 迴圈尋找 \(fail\) 結點實現,迴圈太多次導致複雜度太高
上面提到的優化就是通過else
語句的程式碼修改了字典樹的結構。
而它將不存在的字典樹狀態鏈連線到失配指標的對應狀態。使得再次遍歷這裡的時候會繼續向下跳轉,起到一個通過繼續開鏈來壓縮路徑的效果,這樣就能節省很多時間。
這樣AC 自動機修改字典樹結構連出的邊就會使字典樹變為字典圖
會不會影響原樹?在原字典樹中,每一個結點代表一個字串 ,是某個模式串的字首。而在修改字典樹結構後,儘管增加了許多轉移關係,但結點(狀態)所代表的字串是不變的。
多模式匹配
(這只是對於引例的query
函式,具體題目的函式寫法可能不太相同)
int query(char *t){
int u = 0, res = 0;
for(int i = 1; t[i]; ++i){
u = tr[u][t[i] - 'a'];
for(int j = u; j && e[j] != -1; j = fail[j]){
res += e[j], e[j] = -1;
}
}
return res;
}
這裡 \(u\) 作為字典樹上當前匹配到的結點, \(res\) 即返回的答案。迴圈遍歷匹配串, \(u\) 在字典樹上跟蹤當前字元。利用 \(fail\) 指標找出所有匹配的模式串,累加到答案中。然後清零。對 \(cnt[j]\) 取反的操作用來判斷 \(cnt[j]\) 是否等於 \(-1\)。在上文中我們分析過,字典樹的結構其實就是一個 \(trans\) 函式,而構建好這個函式後,在匹配字串的過程中,我們會捨棄部分字首達到最低限度的匹配。\(fail\) 指標則指向了更多的匹配狀態。
例題
P3808 【模板】AC自動機(簡單版)
P3796 【模板】AC自動機(加強版)
P5357 【模板】AC自動機(二次加強版)