前置技能
簡介
看dalao們AC自動機的Blog,大多數奆奆都會感性地說:
AC_automation = KMP+TRIE
<!--more-->
然而在作者重蹈覆轍輾轉反側n次後才明白,這東西說了等於沒說。
- AC自動機是一種有限狀態自動機(說了等於沒說),它常被用於多模式串的字符串匹配。
- 在學完AC自動機,筆者也總結出一句說了等於沒說的話: AC自動機是以TRIE的結構爲基礎,結合KMP的思想建立的。
建立AC自動機
建立一個AC自動機通常需要兩個步驟:
- 基礎的TRIE結構:將所有的模式串構成一棵Trie。
- KMP的思想:對Trie樹上所有的結點構造失配指針。
然後就可以利用它進行多模式匹配了。
TRIE構建
- 和trie的insert操作一模一樣(強調!一模一樣!)
- 因爲我們只利用TRIE的結構,所以只需將模式串存入即可。
構造失配( failfailfail )指針
在講構造以前,先來對比一下這裏的 failfailfail 指針與KMP中的next指針:
- 共同點-兩者同樣是在失配的時候用於跳轉的指針。
- 不同點-KMP要求的是最長相同真前後綴,而AC自動機只需要相同後綴即可。
- 因爲KMP只對一個模式串做匹配,而AC自動機要對多個模式串做匹配。
- 有可能 failfailfail 指針指向的結點對應着另一個模式串,兩者前綴不同。
- 也就是說,AC自動機在對匹配串做逐位匹配時,同一位上可能匹配多個模式串。
- 因此 failfailfail 指針會在字典樹上的結點來回穿梭,而不像KMP在線性結構上跳轉。
下面介紹構建 failfailfail 指針的基礎思想:(強調!基礎思想!基礎!)
- 構建 failfailfail 指針,可以參考KMP中構造next數組的思想。
- 我們利用部分已經求出 failfailfail 指針的結點推導出當前結點的 failfailfail 指針。具體我們用BFS實現:
- 考慮字典樹中當前的節點u,u的父節點是p,p通過字符c的邊指向u。
- 假設深度小於u的所有節點的 failfailfail 指針都已求得。那麼p的 failfailfail 指針顯然也已求得。
- 我們跳轉到p的 failfailfail 指針指向的結點 fail[p]fail[p]fail[p] ;
- 如果結點 fail[p]fail[p]fail[p] 通過字母 ccc 連接到的子結點 www 存在:
- 則讓u的fail指針指向這個結點 www ( fail[u]=wfail[u]=wfail[u]=w )。
- 相當於在 ppp 和 fail[p]fail[p]fail[p] 後面加一個字符 ccc ,就構成了 fail[u]fail[u]fail[u] 。
- 如果 fail[p]fail[p]fail[p] 通過字母 ccc 連接到的子結點 www 不存在:
- 那麼我們繼續找到 fail[fail[p]]fail[fail[p]]fail[fail[p]] 指針指向的結點,重複上述判斷過程,一直跳 failfailfail 指針直到根節點。
- 如果真的沒有,就令 fail[u]=fail[u]=fail[u]= 根節點。
- 如果結點 fail[p]fail[p]fail[p] 通過字母 ccc 連接到的子結點 www 存在:
- 如此即完成了 failfailfail 指針的構建。
下面放一張GIF幫助大家理解: 對字典樹{i,he,his,she,hers}構建 failfailfail 指針:
-
黃色結點表示當前的結點u,綠色結點表示已經BFS遍歷完畢的結點,紅/橙色的邊表示 failfailfail 指針。
-
2號節點的 failfailfail 指針畫錯了, fail[2]=0fail[2]=0fail[2]=0 .
-
我們重點分析結點6的 failfailfail 指針構建:
-
找到6的父節點5,5的 failfailfail 指針指向10,然而10結點沒有字母's'連出的邊;
-
所以跳到10的 failfailfail 指針指向的結點0,發現0結點有字母's'連出的邊,指向7結點;
-
所以 fail[6]=7fail[6]=7fail[6]=7 .
另外,在構建 failfailfail 指針的同時,我們也對TRIE中模式串的結尾構建 failfailfail 指針。這樣在匹配到結尾後能自動跳轉到下一個匹配項。具體見代碼實現。
從代碼深入剖析
框架
const int N=1000; struct AC_automaton{ int tr[N][26],cnt;//TRIE int e[N];//標記這個結點是不是字符串結尾 int fail[N];//fail指針
void insert(char * s){}//插入模式串 void build(){}//構建fail指針 int query(char *t){}//匹配函數
};
AC_automation ac;
}
字典樹與字典圖
- 關於
insert()
筆者不做分析,先來看build()
:void build(){ queue<int>q; memset(fail,0,sizeof(fail)); for(int i=0;i<26;i++)if(tr[0][i])q.push(tr[0][i]);//Q1 while(!q.empty()){ int k=q.front();q.pop(); for(int i=0;i<26;i++){ if(tr[k][i]){ fail[tr[k][i]]=tr[fail[k]][i];//Q2 q.push(tr[k][i]); } else tr[k][i]=tr[fail[k]][i];//Q3 } } }
首先聲明瞭一個隊列用於BFS,並清空 failfailfail 數組。
這裏的字典樹根節點爲0,我們將根節點的子節點一一入隊。
- Q1-等等,爲什麼不將根節點入隊,非要將它的子節點入隊?
然後開始BFS:
- 每次取出隊首的結點k。注意,結點k本身的 failfailfail 指針已經求得,我們要求的是k的子節點們的 failfailfail 指針。
- 然後遍歷字符集(這裏是0-25,對應a-z):
- 如果字符i對應的子節點存在,我們就將這個子節點的 failfailfail 指針賦值爲 fail[k]fail[k]fail[k] 的字符i對應的結點。
- Q2-不是應該用while循環,不停的跳 failfailfail 指針,判斷是否存在字符i對應的結點,然後賦值嗎?怎麼一句話就完了?
- 否則,k結點沒有字符i對應的子節點,就將 fail[k]fail[k]fail[k] 的字符i對應的子節點編號賦值給k
- Q3-等等,說好的字典樹呢?怎麼將 fail[k]fail[k]fail[k] 的子節點直接賦成k的子節點去了?
這三個Questions構成了 failfailfail 指針構建的精髓。
Q1
- 若將根節點入隊,則在第一次BFS的時候,會將根節點的子節點的 failfailfail 指針標記爲本身。
- 而將根節點的子節點入隊,也不影響算法正確性(因爲 failfailfail 指針初始化爲0)
Q2&Q3
-
Q2與Q3的代碼是相輔相成的。
-
簡單地來講,我們將 failfailfail 指針跳轉的路徑做了壓縮(就像並查集的路徑壓縮),使得本來需要跳很多次 failfailfail 指針變成跳一次。
-
而這個路徑壓縮的就是Q3的代碼在做的事情之一。
-
我們將之前的GIF圖改一下:
-
藍色結點表示BFS遍歷到的結點k,深藍色、黑色的邊表示執行完Q3代碼連出的字典樹的邊。
-
可以發現,衆多交錯的黑色邊將字典樹變成了字典圖。
-
圖中省略了連向根節點的黑邊(否則會更亂)。
-
我們重點分析一下結點5遍歷時的情況:
-
顯然,本來應該跳2次才能找到7號結點,但是我們通過10號結點的黑色邊直接通過字母s找到了7號結點。
-
因此,Q2結合了Q3的代碼,就能在 O(1)O(1)O(1) 的時間內對單個結點構造 failfailfail 指針。
這就是build完成的兩件事:構建 failfailfail 指針和建立字典圖。這個字典圖也會在查詢的時候起到關鍵作用。
多模式匹配
- 接下來分析匹配函數
query()
:int query(char *t){ int p=0,res=0; for(int i=0;t[i];i++){ p=tr[p][t[i]-'a'];//Q for(int j=p;j&&~e[j];j=fail[j])res+=e[j],e[j]=-1; } return res; }
- 聲明p作爲字典樹上當前匹配到的結點,res即返回的答案
- 循環遍歷匹配串,p在字典樹上跟蹤當前字符。
- 利用 failfailfail 指針找出所有匹配的模式串,累加到答案中。然後清0。
- 對 e[j]e[j]e[j] 取反的操作用來判斷 e[j]e[j]e[j] 是否等於-1。
- Q-讀者可能納悶了:你這裏的p一直在往字典樹後面走,沒有跳 failfailfail 指針啊!這和KMP的思想不一樣啊,怎麼匹配得出來啊
Answer to Q
-
還記得剛纔的字典圖嗎?事實上你並不是一直在往後跳,而是在圖上穿梭跳動。比如,剛纔的字典圖:
-
我們從根節點開始嘗試匹配
ushersheishis
,那麼p的變化將是: -
紅色結點表示p結點,粉色箭頭表示p在字典圖上的跳轉,淺藍色的邊表示成功匹配的模式串,深藍色的結點表示跳 failfailfail 指針時的結點。
-
其中的部分跳轉,我們利用的就是新構建的字典圖上的邊,它也滿足後綴相同(sher和her),所以自動跳轉到下一個位置。
-
綜上, failfailfail 指針的意義是,在匹配串同一個位置失配時的跳轉指針,這樣就利用 failfailfail 指針在同一位置上進行多模式匹配,匹配完了,就在字典圖上自動跳轉到下一位置。
總結
到此,你已經理解了整個AC自動機的內容。我們一句話總結AC自動機的運行原理: 構建字典圖實現自動跳轉,構建失配指針實現多模式匹配。
代碼
const int N=1000; struct AC_automaton{ int tr[N][26],cnt;//TRIE int e[N];//標記字符串結尾 int fail[N];//fail指針
void insert(char * s){//插入模式串 int p=0; for(int i=0;s[i];i++){ int k=s[i]-'a'; if(!tr[p][k])tr[p][k]=++cnt; p=tr[p][k]; } e[p]++; } void build(){ queue<int>q; memset(fail,0,sizeof(fail)); for(int i=0;i<26;i++)if(tr[0][i])q.push(tr[0][i]); //首字符入隊 //不直接將0入隊是爲了避免指向自己 while(!q.empty()){ int k=q.front();q.pop();//當前結點 for(int i=0;i<26;i++){ if(tr[k][i]){ fail[tr[k][i]]=tr[fail[k]][i];//構建當前的fail指針 q.push(tr[k][i]);//入隊 } else tr[k][i]=tr[fail[k]][i]; //匹配到空字符,則索引到父節點fail指針對應的字符,以供後續指針的構建 //類似並差集的路徑壓縮,把不存在的tr[k][i]全部指向tr[fail[k]][i] //這句話在後面匹配主串的時候也能幫助跳轉 } } } int query(char *t){ int p=0,res=0; for(int i=0;t[i];i++){ p=tr[p][t[i]-'a']; for(int j=p;j&&~e[j];j=fail[j])res+=e[j],e[j]=-1; } return res; }