begin:2019/5/2
update 2020/6/12 更新了LaTeX(咕了好久
感謝大家支持!
AC自動機詳細講解
AC自動機真是個好東西!之前學\(KMP\)被\(Next\)指針搞暈了,所以咕了許久都不敢開AC自動機,近期學完之后,發現AC自動機並不是很難,特別是對於\(KMP\),個人感覺AC自動機比\(KMP\)要好理解一些,可能是因為我對樹上的東西比較敏感(實際是因為我到現在都不會\(KMP\))。
很多人都說AC自動機是在\(Trie\)樹上作\(KMP\),我不否認這一種觀點,因為這確實是這樣,不過對於剛開始學AC自動機的同學們就一些誤導性的理解(至少對我是這樣的)。\(KMP\)是建立在一個字符串上的,現在把\(KMP\)搬到了樹上,不是很麻煩嗎?實際上AC自動機只是有\(KMP\)的一種思想,實際上跟一個字符串的\(KMP\)有着很大的不同。
所以看這篇blog,請放下\(KMP\),理解好\(Trie\),再來學習。
前置技能
1.\(Trie\)(很重要哦)
2.\(KMP\)的思想(懂思想就可以了,不需要很熟練)
問題描述
給定\(n\)個模式串和\(1\)個文本串,求有多少個模式串在文本串里出現過。
注意:是出現過,就是出現多次只算一次。
默認這里每一個人都已經會了\(Trie\)。
我們將\(n\)個模式串建成一顆\(Trie\)樹,建樹的方式和建\(Trie\)完全一樣。
假如我們現在有文本串\(ABCDBC\)。
我們用文本串在\(Trie\)上匹配,剛開始會經過\(2、3、4\)號點,發現到\(4\),成功地匹配了一個模式串,然后就不能再繼續匹配了,這時我們還要重新繼續從根開始匹配嗎?
不,這樣的效率太慢了。這時我們就要借用\(KMP\)的思想,從\(Trie\)上的某個點繼續開始匹配。
明顯在這顆\(Trie\)上,我們可以繼續從\(7\)號點開始匹配,然后匹配到\(8\)。
那么我們怎么確定從那個點開始匹配呢?我們稱\(i\)匹配失敗后繼續從\(j\)開始匹配,\(j\)是\(i\)的\(Fail\)(失配指針)。
構建Fail指針
\(Fail\)的含義
\(Fail\)指針的實質含義是什么呢?
如果一個點\(i\)的\(Fail\)指針指向\(j\)。那么\(root\)到\(j\)的字符串是\(root\)到\(i\)的字符串的一個后綴。
舉個例子:(例子來自上面的圖
i:4 j:7
root到i的字符串是“ABC”
root到j的字符串是“BC”
“BC”是“ABC”的一個后綴
所以i的Fail指針指向j
同時我們發現,“\(C\)”也是“\(ABC\)”的一個后綴。
所以\(Fail\)指針指的\(j\)的深度要盡量大。
重申一下\(Fail\)指針的含義:((最長的(當前字符串的后綴))在\(Trie\)上可以查找到)的末尾編號。
感覺讀起來挺繞口的蛤。感性理解一下就好了,沒什么卵用的。知道\(Fail\)有什么用就行了。
求\(Fail\)
首先我們可以確定,每一個點\(i\)的\(Fail\)指針指向的點的深度一定是比\(i\)小的。(Fail指的是后綴啊)
第一層的\(Fail\)一定指的是\(root\)。(比深度\(1\)還淺的只有\(root\)了)
設點\(i\)的父親\(fa\)的\(Fail\)指針指的是\(fafail\),那么如果\(fafail\)有和\(i\)值相同的兒子\(j\),那么\(i\)的\(Fail\)就指向\(j\)。這里可能比較難理解一點,建議畫圖理解,不過等會轉換成代碼就很好理解了。
由於我們在處理\(i\)的情況必須要先處理好\(fa\)的情況,所以求\(Fail\)我們使用\(BFS\)來實現。
實現的一些細節:
-
1、剛開始我們不是要初始化第一層的\(fail\)指針為\(root\),其實我們可以建一個虛節點\(0\)號節點,將\(0\)的所有兒子指向\(root\)(\(root\)編號為\(1\),記得初始化),然后\(root\)的\(fail\)指向\(0\)就OK了。效果是一樣的。
-
2、如果不存在一個節點\(i\),那么我們可以將那個節點設為\(fafail\)的((值和\(i\)相同)的兒子)。保證存在性,就算是\(0\)也可以成功返回到根,因為\(0\)的所有兒子都是根。
-
3、無論\(fafail\)存不存在和\(i\)值相同的兒子\(j\),我們都可以將\(i\)的\(fail\)指向\(j\)。因為在處理\(i\)的時候\(j\)已經處理好了,如果出現這種情況,\(j\)的值是第\(2\)種情況,也是有實際值的,所以沒有問題。
-
4、實現時不記父親,我們直接讓父親更新兒子
void getFail(){
for(int i=0;i<26;i++)trie[0].son[i]=1; //初始化0的所有兒子都是1
q.push(1);trie[1].fail=0; //將根壓入隊列
while(!q.empty()){
int u=q.front();q.pop();
for(int i=0;i<26;i++){ //遍歷所有兒子
int v=trie[u].son[i]; //處理u的i兒子的fail,這樣就可以不用記父親了
int Fail=trie[u].fail; //就是fafail,trie[Fail].son[i]就是和v值相同的點
if(!v){trie[u].son[i]=trie[Fail].son[i];continue;} //不存在該節點,第二種情況
trie[v].fail=trie[Fail].son[i]; //第三種情況,直接指就可以了
q.push(v); //存在實節點才壓入隊列
}
}
}
查詢
求出了\(Fail\)指針,查詢就變得十分簡單了。
為了避免重復計算,我們每經過一個點就打個標記為\(-1\),下一次經過就不重復計算了。
同時,如果一個字符串匹配成功,那么他的\(Fail\)也肯定可以匹配成功(后綴嘛),於是我們就把\(Fail\)再統計答案,同樣,\(Fail\)的\(Fail\)也可以匹配成功,以此類推……經過的點累加\(flag\),標記為\(-1\)。
最后主要還是和\(Trie\)的查詢是一樣的。
int query(char* s){
int u=1,ans=0,len=strlen(s);
for(int i=0;i<len;i++){
int v=s[i]-'a';
int k=trie[u].son[v]; //跳Fail
while(k>1&&trie[k].flag!=-1){ //經過就不統計了
ans+=trie[k].flag,trie[k].flag=-1; //累加上這個位置的模式串個數,標記 已 經過
k=trie[k].fail; //繼續跳Fail
}
u=trie[u].son[v]; //到兒子那,存在性看上面的第二種情況
}
return ans;
}
代碼
#include<bits/stdc++.h>
#define maxn 1000001
using namespace std;
struct kkk{
int son[26],flag,fail;
}trie[maxn];
int n,cnt;
char s[1000001];
queue<int >q;
void insert(char* s){
int u=1,len=strlen(s);
for(int i=0;i<len;i++){
int v=s[i]-'a';
if(!trie[u].son[v])trie[u].son[v]=++cnt;
u=trie[u].son[v];
}
trie[u].flag++;
}
void getFail(){
for(int i=0;i<26;i++)trie[0].son[i]=1; //初始化0的所有兒子都是1
q.push(1);trie[1].fail=0; //將根壓入隊列
while(!q.empty()){
int u=q.front();q.pop();
for(int i=0;i<26;i++){ //遍歷所有兒子
int v=trie[u].son[i]; //處理u的i兒子的fail,這樣就可以不用記父親了
int Fail=trie[u].fail; //就是fafail,trie[Fail].son[i]就是和v值相同的點
if(!v){trie[u].son[i]=trie[Fail].son[i];continue;} //不存在該節點,第二種情況
trie[v].fail=trie[Fail].son[i]; //第三種情況,直接指就可以了
q.push(v); //存在實節點才壓入隊列
}
}
}
int query(char* s){
int u=1,ans=0,len=strlen(s);
for(int i=0;i<len;i++){
int v=s[i]-'a';
int k=trie[u].son[v]; //跳Fail
while(k>1&&trie[k].flag!=-1){ //經過就不統計了
ans+=trie[k].flag,trie[k].flag=-1; //累加上這個位置的模式串個數,標記已經過
k=trie[k].fail; //繼續跳Fail
}
u=trie[u].son[v]; //到下一個兒子
}
return ans;
}
int main(){
cnt=1; //代碼實現細節,編號從1開始
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%s",s);
insert(s);
}
getFail();
scanf("%s",s);
printf("%d\n",query(s));
return 0;
}
updata:2019/5/7 AC自動機的應用
AC自動機的一些應用
先拿P3796 【模板】AC自動機(加強版)來說吧。
無疑,作為模板2,這道題的解法也是十分的經典。
我們先來分析一下題目:輸入和模板1一樣
1、求出現次數最多的次數
2、求出現次數最多的模式串
明顯,我們如果統計出每一個模式串在文本串出現的次數,那么這道題就變得十分簡單了,那么問題就變成了如何統計每個模式串出現的次數。
做法:AC自動機
首先題目統計的是出現次數最多的字符串,所以有重復的字符串是沒有關系的。(因為后面的會覆蓋前面的,統計的答案也是一樣的)
那么我們就將標記模式串的\(flag\)設為當前是第幾個模式串。就是下面插入時的變化:
trie[u].flag++;
變為
trie[u].flag=num; //num表示該字符串是第num個輸入的
求\(Fail\)指針沒有變化,原先怎么求就怎么求。
查詢:我們開一個數組\(vis\),表示第\(i\)個字符串出現的次數。
因為是重復計算,所以不能標記為\(-1\)了。
我們每經過一個點,如果有模式串標記,就將\(vis[模式串標記]++\)。然后繼續跳fail,原因上面說過了。
這樣我們就可以將每個模式串的出現次數統計出來。剩下的大家應該都會QwQ!
總代碼
//AC自動機加強版
#include<bits/stdc++.h>
#define maxn 1000001
using namespace std;
char s[151][maxn],T[maxn];
int n,cnt,vis[maxn],ans;
struct kkk{
int son[26],fail,flag;
void clear(){memset(son,0,sizeof(son));fail=flag=0;}
}trie[maxn];
queue<int>q;
void insert(char* s,int num){
int u=1,len=strlen(s);
for(int i=0;i<len;i++){
int v=s[i]-'a';
if(!trie[u].son[v])trie[u].son[v]=++cnt;
u=trie[u].son[v];
}
trie[u].flag=num; //變化1:標記為第num個出現的字符串
}
void getFail(){
for(int i=0;i<26;i++)trie[0].son[i]=1;
q.push(1);trie[1].fail=0;
while(!q.empty()){
int u=q.front();q.pop();
int Fail=trie[u].fail;
for(int i=0;i<26;i++){
int v=trie[u].son[i];
if(!v){trie[u].son[i]=trie[Fail].son[i];continue;}
trie[v].fail=trie[Fail].son[i];
q.push(v);
}
}
}
void query(char* s){
int u=1,len=strlen(s);
for(int i=0;i<len;i++){
int v=s[i]-'a';
int k=trie[u].son[v];
while(k>1){
if(trie[k].flag)vis[trie[k].flag]++; //如果有模式串標記,更新出現次數
k=trie[k].fail;
}
u=trie[u].son[v];
}
}
void clear(){
for(int i=0;i<=cnt;i++)trie[i].clear();
for(int i=1;i<=n;i++)vis[i]=0;
cnt=1;ans=0;
}
int main(){
while(1){
scanf("%d",&n);if(!n)break;
clear();
for(int i=1;i<=n;i++){
scanf("%s",s[i]);
insert(s[i],i);
}
scanf("%s",T);
getFail();
query(T);
for(int i=1;i<=n;i++)ans=max(vis[i],ans); //最后統計答案
printf("%d\n",ans);
for(int i=1;i<=n;i++)
if(vis[i]==ans)
printf("%s\n",s[i]);
}
}
update:2019/5/9
AC自動機的優化
topo建圖優化
讓我們了分析一下剛才那個模板2的時間復雜度,算了不分析了,直接告訴你吧,這樣暴力去跳\(fail\)的最壞時間復雜度是\(O(模式串長度 · 文本串長度)\)。
為什么?因為對於每一次跳\(fail\)我們都只使深度減\(1\),那樣深度是多少,每一次跳的時間復雜度就是多少。那么還要乘上文本串長度,就幾乎是 \(O(模式串長度 · 文本串長度)\)的了。
那么模板1的時間復雜度為什么就只有\(O(模式串總長)\)。因為每一個\(Trie\)上的點都只會經過一次(打了標記),但模板2每一個點就不止經過一次了(重復算,不打標記),所以時間復雜度就爆炸了。
那么我們可不可以讓模板2的\(Trie\)上每個點只經過一次呢?
嗯~,還真可以!
題目看這里:P5357 【模板】AC自動機(二次加強版)
做法:拓撲排序
讓我們把\(Trie\)上的\(fail\)都想象成一條條有向邊,那么我們如果在一個點對那個點進行一些操作,那么沿着這個點連出去的點也會進行操作(就是跳\(fail\)),所以我們才要暴力跳\(fail\)去更新之后的點。
我們還是用上面的圖,舉個例子解釋一下我剛才的意思。
我們先找到了編號\(4\)這個點,編號\(4\)的\(fail\)連向編號\(7\)這個點,編號\(7\)的\(fail\)連向編號\(9\)這個點。那么我們要更新編號\(4\)這個點的值,同時也要更新編號\(7\)和編號\(9\),這就是暴力跳\(fail\)的過程。
我們下一次找到編號\(7\)這個點,還要再次更新編號\(9\),所以時間復雜度就在這里被浪費了。
那么我們可不可以在找到的點打一個標記,最后再一次性將標記全部上傳 來 更新其他點的\(ans\)。例如我們找到編號\(4\),在編號\(4\)這個點打一個\(ans\)標記為\(1\),下一次找到了編號\(7\),又在編號\(7\)這個點打一個\(ans\)標記為\(1\),那么最后,我們直接從編號\(4\)開始跳\(fail\),然后將標記\(ans\)上傳,((點i的fail)的ans)加上(點i的ans),最后使編號\(4\)的\(ans\)為\(1\),編號\(7\)的\(ans\)為\(2\),編號\(9\)的\(ans\)為\(2\),這樣的答案和暴力跳\(fail\)是一樣的,並且每一個點只經過了一次。
最后我們將有\(flag\)標記的\(ans\)傳到\(vis\)數組里,就求出了答案。
em……,建議先消化一下。
那么現在問題來了,怎么確定更新順序呢?明顯我們打了標記后肯定是從深度大的點開始更新上去的。
怎么實現呢?拓撲排序!
我們使每一個點向它的\(fail\)指針連一條邊,明顯,每一個點的出度為\(1\)(\(fail\)只有一個),入度可能很多,所以我們就不需要像拓撲排序那樣先建個圖了,直接往\(fail\)指針跳就可以了。
最后我們根據\(fail\)指針建好圖后(想象一下,程序里不用實現),一定是一個\(DAG\),具體原因不解釋(很簡單的),那么我們就直接在上面跑拓撲排序,然后更新\(ans\)就可以了。
代碼實現:
首先是\(getfail\)這里,記得將\(fail\)的入度\(in\)更新。
trie[v].fail=trie[Fail].son[i]; in[trie[v].fail]++; //記得加上入度
然后是\(query\),不用暴力跳\(fail\)了,直接打上標記就行了,很簡單吧
void query(char* s){
int u=1,len=strlen(s);
for(int i=0;i<len;++i)
u=trie[u].son[s[i]-'a'],trie[u].ans++; //直接打上標記
}
最后是拓撲,解釋都在注釋里了OwO!
void topu(){
for(int i=1;i<=cnt;++i)
if(in[i]==0)q.push(i); //將入度為0的點全部壓入隊列里
while(!q.empty()){
int u=q.front();q.pop();vis[trie[u].flag]=trie[u].ans; //如果有flag標記就更新vis數組
int v=trie[u].fail;in[v]--; //將唯一連出去的出邊fail的入度減去(拓撲排序的操作)
trie[v].ans+=trie[u].ans; //更新fail的ans值
if(in[v]==0)q.push(v); //拓撲排序常規操作
}
}
應該還是很好理解的吧,實現起來也沒有多難嘛!
對了還有重復單詞的問題,和下面講的"P3966[TJOI2013]單詞"的解決方法一樣的,不講了吧。
習題講解
這道題和上面那道題沒有什么不同,文本串就是將模式串用神奇的字符(例如"♂")隔起來的串。
但這道題有相同字符串要統計,所以我們用一個\(Map\)數組存這個字符串指的是\(Trie\)中的那個位置,最后把\(vis[Map[i]]\)輸出就OK了。
下面是P5357【模板】AC自動機(二次加強版)的代碼(套娃?大霧),剩下的大家怎么改應該還是知道的吧。
#include<bits/stdc++.h>
#define maxn 2000001
using namespace std;
char s[maxn],T[maxn];
int n,cnt,vis[200051],ans,in[maxn],Map[maxn];
struct kkk{
int son[26],fail,flag,ans;
}trie[maxn];
queue<int>q;
void insert(char* s,int num){
int u=1,len=strlen(s);
for(int i=0;i<len;++i){
int v=s[i]-'a';
if(!trie[u].son[v])trie[u].son[v]=++cnt;
u=trie[u].son[v];
}
if(!trie[u].flag)trie[u].flag=num;
Map[num]=trie[u].flag;
}
void getFail(){
for(int i=0;i<26;i++)trie[0].son[i]=1;
q.push(1);
while(!q.empty()){
int u=q.front();q.pop();
int Fail=trie[u].fail;
for(int i=0;i<26;++i){
int v=trie[u].son[i];
if(!v){trie[u].son[i]=trie[Fail].son[i];continue;}
trie[v].fail=trie[Fail].son[i]; in[trie[v].fail]++;
q.push(v);
}
}
}
void topu(){
for(int i=1;i<=cnt;++i)
if(in[i]==0)q.push(i); //將入度為0的點全部壓入隊列里
while(!q.empty()){
int u=q.front();q.pop();vis[trie[u].flag]=trie[u].ans; //如果有flag標記就更新vis數組
int v=trie[u].fail;in[v]--; //將唯一連出去的出邊fail的入度減去(拓撲排序的操作)
trie[v].ans+=trie[u].ans; //更新fail的ans值
if(in[v]==0)q.push(v); //拓撲排序常規操作
}
}
void query(char* s){
int u=1,len=strlen(s);
for(int i=0;i<len;++i)
u=trie[u].son[s[i]-'a'],trie[u].ans++;
}
int main(){
scanf("%d",&n); cnt=1;
for(int i=1;i<=n;++i){
scanf("%s",s);
insert(s,i);
}getFail();scanf("%s",T);
query(T);topu();
for(int i=1;i<=n;++i)printf("%d\n",vis[Map[i]]);
}
To be continue……