AC自動機筆記
0.0 前言
哇,好久之前就看了 KMP 和 Trie 樹,但是似乎一直沒看懂 AC自動機??
今天靈光一閃,加上之前看到一些博客和視頻,瞬間秒懂啊... 其實這個玩意還是蠻好理解的...
在這里先給一個樣例,之后也都好舉例子.
模式串:
5
FG
HE
HERS
HIS
SHE
匹配串:HISHERS
1.1 深度理解 KMP
KMP 算法的精華部分即其處理的 Next 數組.
Next 數組所存的數值即代表j之前的字符串中有最大長度為k 的相同前后綴.
記錄這個有什么用呢?
對於ABCDABC這個串,如果我們匹配ABCDABTBCDABC這個長串,當匹配到第7個字符T的時候就不匹配了,我們就不用直接移到B開始再比一次,而是直接移到第5位來比較,豈不美哉?
Next 數組存的是最長的相同的串長度,再從第一個開始匹配明顯是不需要的,我的最長公共前后綴的位置即為第一個可能匹配成功的位置.
所以其正確性很明顯,而 KMP 其中的失配匹配思想 (姑且讓我這么說說罷) 是 AC自動機中很重要的一環.
1.2 利用 Trie 樹
關於AC自動機中的 Trie 樹,其實其最大做用就是用來存儲給出的字典.
Trie 樹在處理很多查找問題時都會有不錯的效用.01 Trie 樹也是處理異或問題的一大利器.
利用 Trie 樹,我們對上文中的例子就可以構建出一張圖:
(先不要管點上的數字,度娘的圖...虛線后面會用到)
2.0 Trie 樹上的 KMP
以上二者即為 AC自動機 的前置技能.
我們需要建立一棵 Trie 樹,用於存儲其給出的字典.
同時我們需要利用 KMP 其中的失配匹配思想,用以優化時間復雜度.
2.1 Fail 指針
概念
AC 自動機中的 Fail 指針,其實就相當於 KMP 中的 Next 數組.
我們先看看 Fail 是怎么跳的.
然后再一次搬出上文那張圖:
此時我們便可以對圖中的虛線作出解釋了.
每個點所對應虛線指向的地方,即為這個點的 Fail 指針.
Fail 指針指向的地方是與 從Trie 樹起點到這個點 公共前后綴最長的地方.
同時要求這個前后綴不能與其等長(否則不就是自己么?).
比如說 90 這個節點, 從起點到這個點所形成的串即為 S H.
然后我們找到另外一個與其公共前后綴最長的點,就是 74 了.
此時它們的公共前后綴即為 H.
又如 S H E 和 H E 這兩個位置.
匹配過程
然后此時我們看一下怎么匹配所給出的匹配串.
H I S H E R S
先匹配 H 然后走進 74 這個節點.
繼續匹配 I 然后走進 80 這個節點.
繼續匹配 S 然后發現這里有一個單詞,於是統計答案.
統計到 S 后,我們下一個看往的方向即為它的 Fail -- 85.
因為它們有公共前后綴 S.
然后發現 85 的后一個正好即為 H,然后我們與之匹配.
再匹配 90和91. 然后我在 91 統計答案.
因為后面已經無路可走,於是我們跳到 91 的 Fail,即為 76.
它們有公共前后綴 H E.
然后我們繼續跑到 86 ,此時需要被匹配的串已經走完了,我們整個過程結束.
2.2 Fail 的尋找
可以看出,當我們 Fail 找完之后,整個 AC 自動機的工作其實已經完成了一大半.那么我們如何處理出 Fail 指針呢?
我們用廣搜實現.
首先對於根節點的子節點,因為只有一個字母,所以不可能有其余的子串與其有公共前后綴,所以直接會根節點.
然后對於接下來的 Fail ,我們把它直接賦為它爸爸的Fail 子節點中與其字母相等的子節點. 上一小段代碼:
for(int i=0;i<26;i++)
if(ch[u][i])
{
f[ch[u][i]]=ch[f[u]][i];
q.push(ch[u][i]);
}
2.3 Code
/*
Problem : AC自動機
Time : Day -94
*/
#include<bits/stdc++.h>
#define maxn 1000008
using namespace std;
struct AC_machine
{
int ch[maxn][26];
int num[maxn],f[maxn];
// f即為fail指針.
int tot;
void insert(string s)
{
int u=0,len=s.length();
for(int i=0;i<len;i++)
{
if(!ch[u][s[i]-'a'])
ch[u][s[i]-'a']=++tot;
u=ch[u][s[i]-'a'];
}
num[u]++;
} //往Trie樹里插入元素.
void build()
{
queue<int> q;
for(int i=0;i<26;i++)
{
if(ch[0][i])
f[ch[0][i]]=0,
//第一層與其他單詞不可能有公共前后綴,fail直接為根.
q.push(ch[0][i]);
}
while(q.empty()!=1)
{
int u=q.front(); q.pop();
for(int i=0;i<26;i++)
if(ch[u][i])
{
f[ch[u][i]]=ch[f[u]][i];
q.push(ch[u][i]);
//畫圖理解賊容易.
}
else ch[u][i]=ch[f[u]][i];
//這一步直接省略了查詢時的比較.
}
} //構建Fail指針.
int query(string s)
{
int u=0,len=s.length(),ans = 0;
for(int i=0;i<len;i++)
{
u=ch[u][s[i]-'a'];
for(int j=u;j&&num[j]!=-1;j=f[j])
//就用這個循環實現跳的過程.
ans+=num[j],num[j]=-1;
//因為直接已經在每個單詞的最后面打了標記,所以直接加上即可.
}
return ans;
}
}AC;
int n;string s;
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
{
cin>>s;
AC.insert(s);
}
AC.build();
cin>>s;
cout<<AC.query(s)<<endl;
}
幾個常用的優化
1.類路徑壓縮(構建 Fail 時優化)
詳見以下代碼:
if(ch[u][i])
{
f[ch[u][i]]=ch[f[u]][i];
q.push(ch[u][i]);
}
else ch[u][i]=ch[f[u]][i];
此步操作使得在比較時省去了 While 循環.
如果沒有匹配,直接進入 Fail 的匹配之中.
(參考劉汝佳《算法競賽入門經典訓練指南》P216).
2.后綴鏈接
這里我們需要多加一個 last 數組.
同樣見代碼:
- 構建 Fail 部分
if(ch[u][i])
{
f[ch[u][i]]=ch[f[u]][i];
q.push(ch[u][i]);
last[u]=num[f[u]]?f[u]:last[f[p]]
}
else ch[u][i]=ch[f[u]][i];
- 匹配查詢過程
for(int i=0;i<len;i++)
{
u=ch[u][s[i]-'a'];
for(int j=u;j&&num[j]!=-1;j=last[j])
ans+=num[j],num[j]=-1;
}
此處我們的 last 大概可以理解為一個 超級 Fail.
因為我們只有到根節點時才會重新匹配一個字母
所以我們此時直接記錄一個last ,直接結束當前匹配過程.
直接省去原 Fail 指針到可以匹配的節點之間的距離.
同時結合上文類路徑壓縮,在匹配時可以完全不使用原 Fail.
3.樹形DP優化
此處樹形DP優化的是查詢部分.
首先我們可以發現, Fail 指針是絕對滿足樹形結構的.
顯而易見,每個點的 Fail 都僅指向一個一個節點.
然后具體做的過程我似乎還沒學懂...
不過也可以理解為構建一個 超級 Fail,優化的部分與 2 差不多.
小結
AC自動機算法的精華在於Fail 所體現的失配匹配思想.
在 KMP 中也都有體現,在不同的題目中,也應巧妙運用這一性質.
參考(講的都比我好QwQ):