摘要:
本文主要講述了AC自動機的基本思想和實現原理,如何構造AC自動機,着重講解AC自動機在算法競賽中的一些典型應用。
- 什么是AC自動機?
- 如何構造一個AC自動機?
- AC自動機在算法競賽中的典型應用有哪些?
- 例題解析
什么是AC自動機?
什么是AC自動機,不是自動AC的機器(想的美),而是一種多模匹配算法,英文名稱Aho-Corasick automaton(前面的一串據說是一位科學家的名字),於1975年誕生於貝爾實驗室。
回憶之前的KMP算法解決的一類問題是給出一個模板和一個文本串,問這一個模板在該文本串中的存在情況(包括是否存在、存在幾次、哪些位置等等)。現在如果是多個模板呢?可能你會想到一個一個拿出來用KMP算法進行匹配,但是如果文本串很長,模板又很多的話,KMP算法就不適合了(不滿足於能解決問題,而追求又快又好的解決問題是算法研究的源動力)。而AC自動機正是為了解決這類問題而生的。
基本思想
不得不重提的是KMP算法之所以能夠在高效的處理單模匹配問題,主要得益於next數組的建立,能夠使匹配的狀態在線性的字符串上進行轉移,使得失配后副串能夠盡可能的“滑的遠一些“。而AC自動機也有類似功能的工具那就是fail指針。
應該能想到的是單模匹配的KMP算法的狀態轉移圖是線性的字符串加上失配邊組成的,那么多模匹配的AC自動機算法的狀態轉移圖是字典樹加上失配邊組成的。
為了說明實際問題,直接看一個例子如下:
問題很明確,我們需要只遍歷一遍文本串就找出所有單詞表中存在的單詞(只遍歷一遍的想法和KMP算法有異曲同工之妙)。
我們先根據字符集合{she,he,say,shr,her}建立字典樹如上圖所示,然后我們拿着yasherhs去匹配,發現前兩個字符無法匹配,跳過,第三個字符開始,she可以匹配,記錄下來,繼續往后走發現沒有匹配了,結果就是該文本串只存在一個單詞,很明顯,答案是錯的,因為存在she、he、her三個單詞。
可以發現的是使用文本串在字典樹上進行匹配的時候,找到了一個單詞結點后還應該看看有沒有以該單詞結點的后綴為前綴的其他單詞,比如she的后綴he是單詞he和her的前綴。因此就需要一個fail指針在發現失配的時候指向其他存在e的結點,來“安排”之后應該怎么辦。
總的來說,AC自動機中fail指針和KMP中next數組的作用是一致的,就是要想在只遍歷一遍文本串的前提下,找到全部匹配模板,就必須安排好匹配過程中失配后怎么辦。具體如何安排就是怎么在字典樹上加失配邊的問題了(也即如何構造一個AC自動機)。
如何構造一個AC自動機?
字典樹之前已經學過了(需要回顧的請點這里),關鍵是怎么加失配邊。規則如下:
- 根結點的fail指針為空(或者它自己);
- 直接和根結點相連的結點,如果這些結點失配,就只能重新開始匹配,故它們的fail指針指向根結點;
- 其他結點,設當前結點為father,其孩子結點為child。要尋找child的fail指針,需要看father的fail指針指向的結點,假設是tmp,要看tmp的孩子中有沒有和child所代表的字符相同的,有則child的fail指針指向tmp的這個孩子結點,沒有則繼續沿着tmp的fail指針往上走,如果找到相同,就指向,如果一直找到了根結點的fail也就是空的時候,child的fail指針就指向root,表示重新從根結點開始匹配。
其中考察father的fail指針指向的結點 有沒有和child相同的結點,包括繼續往上,就保證了前綴是相同的,比如剛才尋找右側h的孩子結點e的fail指針時,找到右側h的fail指針指向左側的h結點,他的孩子中有e,就將右側h的孩子e的fail指針指向它就保證了前綴h是相同的。
這樣,就用fail指針來安排好每次失配后應該跳到哪里,而fail指針跳到哪里,說明從根結點到這個結點之前的字符串已經匹配過了,從而避免了重復匹配,也就完美的解決了只遍歷一次文本串就找出所有單詞的問題。
具體編程實現在字典樹上添加失配邊有兩種方法,一種是鏈表法,一種是轉移矩陣法。
鏈表法
有了上面fail指針的計算規則,利用隊列BFS順序遞推可以寫出如下代碼:
1 const int maxw = 10010; //最大單詞數 2 const int maxwl = 61; //最大單詞長度 3 const int maxl = 1001000; //最大文本長度 4 const int sigm_size = 26; //字符集大小 5 6 struct Node { 7 int sum;//>0表示以該結點為前綴的單詞個數,=0表示不是單詞結點,=-1表示已經經過計數 8 Node* chld[sigm_size]; 9 Node* fail; 10 Node() { 11 sum = 0; 12 memset(chld, 0, sizeof(chld)); 13 fail = 0; 14 } 15 }; 16 struct ac_automaton { 17 Node* root; 18 void init() { 19 root = new Node; 20 } 21 int idx(char c) { 22 return c - 'a'; 23 } 24 void insert(char *s) { 25 Node* u = root; 26 for(int i = 0; i < s[i]; i++) { 27 int c = idx(s[i]); 28 if(u->chld[c] == NULL) 29 u->chld[c] = new Node; 30 31 u = u->chld[c]; 32 } 33 u->sum++;//以該串為前綴的單詞個數++ 34 } 35 void getfail() { 36 queue<Node*> q; 37 q.push(root);//根結點的fail指針為空 38 while(!q.empty()) { 39 Node* u = q.front(); 40 q.pop(); 41 for(int i = 0; i < sigm_size; i++) { //尋找當前結點的所有非空子結點的fail指針 42 if(u->chld[i] != NULL) { 43 if(u == root)//根結點 44 u->chld[i]->fail = root; 45 else { //非根節點 46 Node* tmp = u->fail; //找到它父親的fail指針指向的結點 47 while(tmp != NULL) { //向上只有根結點的fail指針是空,所以只要不是根結點就循環 48 if(tmp->chld[i] != NULL) { //直到發現存在一個結點的子結點與其相同 49 u->chld[i]->fail = tmp->chld[i];//就將它的fail指針指向該子結點然后結束循環 50 break; 51 } 52 tmp = tmp->fail;//否則一直往上找 53 } 54 if(tmp == NULL) //如果尋找到根結點還沒有找到,就指向根結點,讓主串從根結點重新開始匹配 55 u->chld[i]->fail = root; 56 } 57 q.push(u->chld[i]); //子結點入隊 58 } 59 } 60 } 61 } 62 int query(char *t) { 63 64 int cnt = 0;//文本中存在單詞的個數 65 Node* u = root; 66 for(int i = 0; t[i]; i++) {//yasherhs 67 int c = idx(t[i]); 68 while(u != root && u->chld[c] == NULL)//不是根結點而且不匹配,順着fail指針走,直到可以匹配或者走到根結點 69 u = u->fail; 70 71 u = u->chld[c]; //經過上面的循環,u要么是匹配結點要么是根結點,繼續往下走 72 if(u == NULL) //如果結點為空,下一個字符重新從根結點開始 73 u = root; 74 75 Node* tmp = u; 76 while(tmp != root) { //只要沒有返回到根結點,就證明在字典樹上還存在找到單詞的可能 77 if(tmp->sum > 0) { 78 cnt += tmp->sum;//單詞計數器加上以當前結點為前綴的單詞數 79 tmp->sum = -1; //表示該單詞結點已經計過數,防止重復計數 80 } 81 else //該單詞結點已經匹配過了直接退出,因為后面的狀態轉移是確定的並且是走過的 82 break; 83 tmp = tmp->fail; //往其他子樹上找 84 } 85 } 86 return cnt; 87 } 88 };
上面的代碼中在調用getfail方法之后就構造好了一個AC自動機,具體查詢的時候就需要在字典樹的狀態轉移圖上進行匹配了。
具體的匹配過程可分為兩種情況:
1.當前字符匹配,就沿着它的狀態轉移圖往上找,找到單詞結點就統計,直到返回到根結點,說明不存在其他單詞。
2.當前字符不匹配,就沿着它的fail指針往上走,直到找到匹配再進入while循環統計單詞,或者一直到不到匹配直接跳過。
如此兩種情況交替,直到將文本串遍歷完,也就完成了統計。
用上圖中的例子來說,統計yasherhs中幾個單詞表中的單詞。
當i=0,1時,由於Trie中沒有對應的路徑,故直接跳過;i=2,3,4時,指針u指向右下節點e。因為節點e的sum為1,所以cnt += sum,並將節點e的sum值置為-1,表示該單詞已經出現過,避免重復計數,然后tmp指向e節點的失敗指針所指向的節點左下e,發現是單詞結點cnt += sum,最后tmp指向root,退出while循環,這個過程中cnt增加了2,表示找到了2個單詞she和he。
當i=5時,u上次指向的是右下e,r不匹配,u指向u的fail指針指向的結點左下e,發現匹配r,u指向左下r,進入下面的while循環,因為左下r的sum為1,所以cnt += sum,表示發現了單詞her;
最后當i=6,7時,找不到任何匹配,查詢過程結束(強烈建議手動模擬一下)。
鏈表法可以將原理實現直觀的轉化成代碼,不過更常見的是實現起來較為簡潔也更為巧妙的轉移矩陣法。
轉移矩陣法
有了轉移矩陣建立字典樹的基礎,然后在字典樹上加失配邊,代碼如下:
1 struct ac_automaton { 2 int ch[maxnode][sigm_size];//一個結點對應一個字符集 3 int fail[maxnode]; //每個結點的fail指針 4 int val[maxnode]; //每個結點的權值 5 int sz; 6 7 void init() { 8 sz = 0; 9 newnode(); //創建一個根結點 10 } 11 int newnode() { 12 memset(ch[sz], -1, sizeof(ch[sz])); 13 val[sz] = 0; 14 return sz++; 15 } 16 int idx(char c) { 17 return c - 'a'; 18 } 19 void insert(char *s) { 20 int u = 0; 21 for(int i = 0; s[i]; i++) { 22 int c = idx(s[i]); 23 if(ch[u][c] == -1) 24 ch[u][c] = newnode(); 25 26 u = ch[u][c]; 27 } 28 val[u]++; 29 } 30 void getfail() { 31 queue<int> q; 32 fail[0] = 0; //根結點的fail指針指向它自己也就是空 33 for(int i = 0; i < sigm_size; i++) { 34 int u = ch[0][i]; 35 if(u == -1){ //根結點編號為i的結點不存在時 36 ch[0][i] = 0; //把不存在的邊補上,將其標記為0 37 } 38 else { //存在時 39 fail[u] = 0; //失配指針指向根結點並入隊 40 q.push(u); 41 } 42 } 43 while(!q.empty()) { 44 int u =q.front(); 45 q.pop(); 46 for(int i = 0; i < sigm_size; i++) { //尋找當前結點u的孩子結點的fail指針 47 int tmp = ch[u][i]; 48 if(tmp == -1) 49 ch[u][i] = ch[fail[u]][i]; //把不存在的邊補上,當前結點u不存在編號為i的孩子時, 50 //讓它指向當前結點u的fail指針指向的結點對應編號為i的孩子中存的結點編號 51 else { 52 //當前孩子結點的fail指針指向 當前結點u的fail指針指向的結點對應的孩子的編號 53 fail[tmp] = ch[fail[u]][i]; 54 q.push(tmp); 55 } 56 } 57 } 58 } 59 int query(char *t) { 60 int u = 0, cnt = 0; 61 for(int i = 0; t[i]; i++) { 62 int c = idx(t[i]); 63 u = ch[u][c]; //由於之前把邊補齊了,所以可以直接往下走,有匹配直接就是結點,沒有匹配直接是根結點 64 65 int tmp = u; 66 while(tmp != 0) { //只要不是根結點,就證明有存在繼續找到單詞的可能 67 cnt += val[tmp]; 68 val[tmp] = 0; 69 70 tmp = fail[tmp]; 71 } 72 } 73 return cnt; 74 } 75 };
之所以說實現起來較為簡單,是因為使用了二維數組,不用指針指來指去;而說更為巧妙是因為當一個結點u不存在哪個編號為i的結點時 就填充為u的fail指針指向的結點對應編號為i的結點編號,如此一來查詢的時候就可以直接往下走,而不是需要進入一個循環找到匹配或者根結點再繼續。
這個是根據ACM大佬bin神的AC自動機小結中學來的,仔細體會有種DP的思想在里面。
AC自動機在算法競賽中的典型應用有哪些?
基本的問題是給出單詞表,給一段文本串,問單詞表中的單詞存在於文本串中的情況。
1、存在的單詞個數 HDU 2222 Keywords Search
2、輸出存在的單詞的編號 HDU 2896 病毒入侵
3、輸出存在單詞及其個數 HDU 3065 病毒持續入侵中
4、單詞重疊和不重疊的個數 ZOJ 3288 Searching the String
5、在二維矩陣中查找小的二維矩陣 UVa 11019 矩陣適配器
復雜的問題有和DP結合起來的,有和大數結合起來的,有和最短路結合起來的
1、修改最少次數使得文本串中不包含任何一個模板 HDU 2457 DNA repair
2、給定n個文本串,m個病毒串,文本串重疊部分可以合並,但合並后不能含有病毒串,問所有文本串合並后最短多長 HDU 3247 Resource Archiver
3、AC自動機+DP+高精度 POJ 1625 Censored!
例題解析
HDU 2222 Keywords Search AC自動機入門題,給出單詞表和一個文本串,問文本串中有多少個單詞表中的單詞。首先根據單詞表構建字典樹,每個單詞結點的末尾++,構造AC自動機,匹配文本串統計即可,注意不要忘了將統計過的單詞標記一下。
為了體會AC自動機的基本思想最好兩種構建方法都試一下。參考代碼如下:
鏈表法:

1 #include <cstdio> 2 #include <queue> 3 #include <cstring> 4 using namespace std; 5 6 const int maxw = 10010; //最大單詞數 7 const int maxwl = 61; //最大單詞長度 8 const int maxl = 1001000; //最大文本長度 9 const int sigm_size = 26; //字符集大小 10 11 struct Node { 12 int sum;//>0表示以該結點為前綴的單詞個數,=0表示不是單詞結點,=-1表示已經經過計數 13 Node* chld[sigm_size]; 14 Node* fail; 15 Node() { 16 sum = 0; 17 memset(chld, 0, sizeof(chld)); 18 fail = 0; 19 } 20 }; 21 struct ac_automaton { 22 Node* root; 23 void init() { 24 root = new Node; 25 } 26 int idx(char c) { 27 return c - 'a'; 28 } 29 void insert(char *s) { 30 Node* u = root; 31 for(int i = 0; i < s[i]; i++) { 32 int c = idx(s[i]); 33 if(u->chld[c] == NULL) 34 u->chld[c] = new Node; 35 36 u = u->chld[c]; 37 } 38 u->sum++;//以該串為前綴的單詞個數++ 39 } 40 void getfail() { 41 queue<Node*> q; 42 q.push(root);//根結點的fail指針為空 43 while(!q.empty()) { 44 Node* u = q.front(); 45 q.pop(); 46 for(int i = 0; i < sigm_size; i++) { //尋找當前結點的所有非空子結點的fail指針 47 if(u->chld[i] != NULL) { 48 if(u == root)//根結點 49 u->chld[i]->fail = root; 50 else { //非根節點 51 Node* tmp = u->fail; //找到它父親的fail指針指向的結點 52 while(tmp != NULL) { //向上只有根結點的fail指針是空,所以只要不是根結點就循環 53 if(tmp->chld[i] != NULL) { //直到發現存在一個結點的子結點與其相同 54 u->chld[i]->fail = tmp->chld[i];//就將它的fail指針指向該子結點然后結束循環 55 break; 56 } 57 tmp = tmp->fail;//否則一直往上找 58 } 59 if(tmp == NULL) //如果尋找到根結點還沒有找到,就指向根結點,讓主串從根結點重新開始匹配 60 u->chld[i]->fail = root; 61 } 62 q.push(u->chld[i]); //子結點入隊 63 } 64 } 65 } 66 } 67 int query(char *t) { 68 int cnt = 0;//文本中存在單詞的個數 69 Node* u = root; 70 for(int i = 0; t[i]; i++) {//yasherhs 71 int c = idx(t[i]); 72 while(u != root && u->chld[c] == NULL)//不是根結點而且不匹配,順着fail指針走,直到可以匹配或者走到根結點 73 u = u->fail; 74 75 u = u->chld[c]; //經過上面的循環,u要么是匹配結點要么是根結點,繼續往下走 76 if(u == NULL) //如果結點為空,下一個字符重新從根結點開始 77 u = root; 78 79 Node* tmp = u; 80 while(tmp != root) { //只要沒有返回到根結點,就證明在字典樹上還存在找到單詞的可能 81 if(tmp->sum > 0) { 82 cnt += tmp->sum;//單詞計數器加上以當前結點為前綴的單詞數 83 tmp->sum = -1; //表示該單詞結點已經計過數,防止重復計數 84 } 85 else //該單詞結點已經匹配過了直接退出,因為后面的狀態轉移是確定的並且是走過的 86 break; 87 tmp = tmp->fail; //往其他子樹上找 88 } 89 } 90 return cnt; 91 } 92 }; 93 94 ac_automaton ac; 95 char txt[maxl]; 96 int main() 97 { 98 int T,n; 99 char word[maxwl]; 100 scanf("%d", &T); 101 while(T--) { 102 ac.init(); 103 scanf("%d", &n); 104 for(int i = 0; i < n; i++) { 105 scanf("%s", word); 106 ac.insert(word); 107 } 108 ac.getfail(); 109 110 scanf("%s", txt); 111 printf("%d\n", ac.query(txt)); 112 } 113 return 0; 114 }
轉移矩陣法:

1 #include <cstdio> 2 #include <cstring> 3 #include <queue> 4 using namespace std; 5 6 const int maxwl = 61; 7 const int maxw = 10010; 8 const int maxl = 1001000; 9 const int sigm_size = 26; 10 const int maxnode = maxw * maxwl; 11 12 struct ac_automaton { 13 int ch[maxnode][sigm_size];//一個結點對應一個字符集 14 int fail[maxnode]; //每個結點的fail指針 15 int val[maxnode]; //每個結點的權值 16 int root, sz; 17 18 void init() { 19 sz = 0; 20 root = newnode(); //創建一個根結點 21 } 22 int newnode() { 23 memset(ch[sz], -1, sizeof(ch[sz])); 24 val[sz] = 0; 25 return sz++; 26 } 27 int idx(char c) { 28 return c - 'a'; 29 } 30 void insert(char *s) { 31 int u = root; 32 for(int i = 0; s[i]; i++) { 33 int c = idx(s[i]); 34 if(ch[u][c] == -1) 35 ch[u][c] = newnode(); 36 37 u = ch[u][c]; 38 } 39 val[u]++; 40 } 41 void getfail() { 42 queue<int> q; 43 fail[root] = root; //根結點的fail指針指向它自己也就是空 44 for(int i = 0; i < sigm_size; i++) { 45 int u = ch[root][i]; 46 if(u == -1){ //根結點編號為i的結點不存在時 47 ch[root][i] = root; //把不存在的邊補上,將其標記為0 48 } 49 else { //存在時 50 fail[u] = root; //失配指針指向根結點並入隊 51 q.push(u); 52 } 53 } 54 while(!q.empty()) { 55 int u =q.front(); 56 q.pop(); 57 for(int i = 0; i < sigm_size; i++) { //尋找當前結點u的孩子結點的fail指針 58 int tmp = ch[u][i]; 59 if(tmp == -1) 60 ch[u][i] = ch[fail[u]][i]; //把不存在的邊補上,當前結點u不存在編號為i的孩子時, 61 //讓它指向當前結點u的fail指針指向的結點對應編號為i的孩子中存的結點編號 62 else { 63 //當前孩子結點的fail指針指向 當前結點u的fail指針指向的結點對應的孩子的編號 64 fail[tmp] = ch[fail[u]][i]; 65 q.push(tmp); 66 } 67 } 68 } 69 } 70 int query(char *t) { 71 int u = root, cnt = 0; 72 for(int i = 0; t[i]; i++) { 73 int c = idx(t[i]); 74 u = ch[u][c]; //由於之前把邊補齊了,所以可以直接往下走,有匹配直接就是結點,沒有匹配直接是根結點 75 76 int tmp = u; 77 while(tmp != 0) { //只要不是根結點,就證明有存在繼續找到單詞的可能 78 cnt += val[tmp]; 79 val[tmp] = 0; 80 81 tmp = fail[tmp]; 82 } 83 } 84 return cnt; 85 } 86 }; 87 88 ac_automaton ac; 89 char txt[maxl]; 90 int main() 91 { 92 int n, m; 93 char word[maxwl]; 94 scanf("%d", &n); 95 while(n--) { 96 scanf("%d", &m); 97 ac.init(); 98 for(int i = 0; i < m; i++) { 99 scanf("%s", word); 100 ac.insert(word); 101 } 102 ac.getfail(); 103 104 scanf("%s", txt); 105 printf("%d\n", ac.query(txt)); 106 } 107 return 0; 108 }
HDU 2896 病毒侵襲 給出病毒和多個文本串,輸出每個文本串中存在病毒的編號。
想到怎么記錄編號和注意輸出格式就沒什么大問題。需要知道的是ASCII可見字符是32到126,共95個可見字符。參考代碼如下(鏈表法,請使用C++提交,G++結果MLE,可能G++和C++的內存分配機制不同):

1 #include <cstdio> 2 #include <cstring> 3 #include <queue> 4 #include <vector> 5 #include <algorithm> 6 using namespace std; 7 8 const int maxwl = 210; 9 const int maxl = 10010; 10 const int sigm_size = 128 - 33; 11 12 struct Node { 13 int num; 14 Node* fail; 15 Node* chld[sigm_size]; 16 Node() { 17 num = 0; 18 fail = 0; 19 memset(chld, 0, sizeof(chld)); 20 } 21 }; 22 struct ac_automaton { 23 Node* root; 24 void init() { 25 root = new Node; 26 } 27 int idx(char c) { 28 return c - 32; 29 } 30 void insert(char *s, int v) { 31 Node* u = root; 32 for(int i = 0; s[i]; i++) { 33 int c = idx(s[i]); 34 if(u->chld[c] == NULL) 35 u->chld[c] = new Node; 36 37 u = u->chld[c]; 38 } 39 u->num = v; 40 } 41 void getfail() { 42 queue<Node*> q; 43 q.push(root); 44 while(!q.empty()) { 45 Node* u = q.front(); 46 q.pop(); 47 for(int i = 0; i < sigm_size; i++) { 48 if(u->chld[i] != NULL) { 49 if(u == root) 50 u->chld[i]->fail = root; 51 else { 52 Node* tmp = u->fail; 53 while(tmp != NULL) { 54 if(tmp->chld[i] != NULL) { 55 u->chld[i]->fail = tmp->chld[i]; 56 break; 57 } 58 tmp = tmp->fail; 59 } 60 if(tmp == NULL) 61 u->chld[i]->fail = root; 62 } 63 q.push(u->chld[i]); 64 } 65 } 66 } 67 } 68 void query(char *t, vector<int> &p) { 69 Node* u = root; 70 for(int i = 0; t[i]; i++) { 71 int c = idx(t[i]); 72 while(u != root && u->chld[c] == NULL) 73 u = u->fail; 74 75 u = u->chld[c]; 76 if(u == NULL) 77 u = root; 78 79 Node* tmp = u; 80 while(tmp != root) { 81 if(tmp->num > 0) 82 p.push_back(tmp->num);//記錄存在的病毒編號 83 84 tmp = tmp->fail; 85 } 86 } 87 } 88 }ac; 89 90 int main() 91 { 92 int n, m; 93 char word[maxwl], txt[maxl]; 94 while(scanf("%d", &n) != EOF) { 95 ac.init(); 96 for(int i = 0; i < n; i++) { 97 scanf("%s", word); 98 ac.insert(word, i+1); 99 } 100 ac.getfail(); 101 102 scanf("%d", &m); 103 int tot = 0; 104 for(int i = 0; i < m; i++) { 105 scanf("%s", txt); 106 vector<int> p; 107 ac.query(txt, p); 108 if(!p.empty()) { 109 sort(p.begin(), p.end()); 110 printf("web %d:",i+1); 111 for(int i = 0; i < p.size(); i++) 112 printf(" %d", p[i]); 113 puts(""); 114 tot++; 115 } 116 } 117 printf("total: %d\n", tot); 118 } 119 return 0; 120 }
HDU 3065 病毒侵襲持續中 給出病毒和文本串,輸出每個病毒及其存在的次數。
和上一題很像,注意使用鏈表法寫的時候多樣例要釋放內存,否則可能會超內存,但是轉移矩陣就不會,因此優先選擇轉移矩陣實現。鏈表法參考如下(如何遞歸釋放內存):

1 #include <cstdio> 2 #include <cstring> 3 #include <queue> 4 using namespace std; 5 6 const int maxw = 1010; 7 const int maxwl = 61; 8 const int maxl = 2000010; 9 const int sigm_size = 128 -33; 10 11 char words[maxw][maxwl]; 12 char txt[maxl]; 13 14 struct Node { 15 int flag; 16 Node* fail; 17 Node* chld[sigm_size]; 18 Node() { 19 flag = 0; 20 fail = 0; 21 memset(chld, 0, sizeof(chld)); 22 } 23 }; 24 struct ac_automaton { 25 Node* root; 26 int num[maxw]; 27 void init() { 28 root = new Node; 29 } 30 int idx(char c) { 31 return c - 32; 32 } 33 void insert(char *s, int v) { 34 Node* u = root; 35 for(int i = 0; s[i]; i++) { 36 int c = idx(s[i]); 37 if(u->chld[c] == NULL) 38 u->chld[c] = new Node; 39 u = u->chld[c]; 40 } 41 u->flag = v; 42 } 43 void getfail() { 44 queue<Node*> q; 45 q.push(root); 46 while(!q.empty()) { 47 Node* u = q.front(); 48 q.pop(); 49 for(int i = 0; i < sigm_size; i++) { 50 if(u->chld[i] != NULL) { 51 if(u == root) 52 u->chld[i]->fail = root; 53 else { 54 Node* tmp = u->fail; 55 while(tmp != NULL) { 56 if(tmp->chld[i] != NULL) { 57 u->chld[i]->fail = tmp->chld[i]; 58 break; 59 } 60 tmp = tmp->fail; 61 } 62 if(tmp == NULL) 63 u->chld[i]->fail = root; 64 } 65 q.push(u->chld[i]); 66 } 67 } 68 } 69 } 70 void query(char *t, int n) { 71 Node* u = root; 72 memset(num, 0, sizeof(num)); 73 for(int i = 0; t[i]; i++) { 74 int c = idx(t[i]); 75 while(u != root && u->chld[c] == NULL) 76 u = u->fail; 77 78 u = u->chld[c]; 79 if(u == NULL) 80 u = root; 81 82 Node* tmp = u; 83 while(tmp != root) { 84 if(tmp->flag > 0) 85 num[tmp->flag]++; 86 87 tmp = tmp->fail; 88 } 89 } 90 for(int i = 1; i <= n; i++) { 91 if(num[i] > 0) 92 printf("%s: %d\n", words[i], num[i]); 93 } 94 } 95 void freenode(Node* u) { 96 if(u == NULL) 97 return; 98 for(int i = 0; i < sigm_size; i++) 99 freenode(u->chld[i]); 100 delete u; 101 } 102 }ac; 103 104 int main() 105 { 106 int n; 107 char word[maxwl]; 108 while(scanf("%d", &n) != EOF) { 109 ac.init(); 110 for(int i = 1; i <= n; i++) { 111 scanf("%s", words[i]); 112 ac.insert(words[i], i); 113 } 114 ac.getfail(); 115 116 scanf("%s", txt); 117 ac.query(txt, n); 118 ac.freenode(ac.root);//多樣例測試時別忘記釋放內存 119 } 120 return 0; 121 }
ZOJ 3228 Searching the String 先給出文本串,再給出多個單詞,但詢問方式不同,0表示可以重疊存在的次數,1表示不可重疊存在的次數。
重疊的詢問好求,一遍AC自動機解決,關鍵是不可重疊次數。設想,如果我們能夠記錄一個單詞上一次在文本串中的匹配位置,那么當前單詞結點的末尾在文本串中的位置 - 當前單詞結點在文本串中上一次匹配的位置 大於等於以當前字符結尾的單詞結點的長度時,表示不重疊。可以使用一個二維數組記錄每個單詞兩種詢問的答案,最后查詢輸出。參考代碼如下:

1 #include <cstdio> 2 #include <queue> 3 #include <cstring> 4 using namespace std; 5 6 const int sigm_size = 26; //字符集的大小 7 const int maxl = 100010; //文本串的長度 8 const int maxw = 10010; //單詞個數 9 const int maxwl = 7; //單詞長度 10 const int maxnode = maxw * maxwl * 10; //字典樹結點數 = 單詞數乘以單詞長度乘以10 11 12 char txt[maxl]; 13 int node[maxl]; //記錄每個單詞在字典樹中單詞結點的編號 14 int op[maxl]; //每個單詞的查詢方式 15 16 struct ac_automaton { 17 int ch[maxnode][sigm_size], fail[maxnode]; 18 int pos[maxnode]; //記錄以當前字符結尾的單詞的長度 19 int L, root; 20 void init() { 21 L = 0; 22 root = newnode(); 23 } 24 int newnode() { 25 memset(ch[L], -1, sizeof(ch[L])); 26 pos[L++] = 0; //以當前字符結尾的單詞長度為0 27 return L-1; 28 } 29 int idx(char c) { 30 return c - 'a'; 31 } 32 void insert(char *s, int v) { 33 int now = root; 34 for(int i = 0; s[i]; i++) { 35 int c = idx(s[i]); 36 if(ch[now][c] == -1) 37 ch[now][c] = newnode(); 38 now = ch[now][c]; 39 pos[now] = i+1;//以當前字符結尾的單詞的長度 40 } 41 node[v] = now;//編號為v的模式串在字典樹中的序號 42 } 43 void getfail() { 44 queue<int> q; 45 fail[root] = root; 46 for(int i = 0; i < sigm_size; i++) { 47 if(ch[root][i] == -1) 48 ch[root][i] = root; 49 else { 50 fail[ch[root][i]] = root; 51 q.push(ch[root][i]); 52 } 53 } 54 while(!q.empty()) { 55 int now = q.front(); 56 q.pop(); 57 for(int i = 0; i < sigm_size; i++) { 58 if(ch[now][i] == -1) 59 ch[now][i] = ch[fail[now]][i]; 60 else { 61 fail[ch[now][i]] = ch[fail[now]][i]; 62 q.push(ch[now][i]); 63 } 64 } 65 } 66 } 67 int ans[maxnode][2]; //標號為i的單詞的重疊和不重疊的個數 68 int last[maxnode]; //記錄當前單詞結點在文本串中的上一個匹配位置 69 void query(char *t) { 70 memset(last, -1, sizeof(last)); 71 memset(ans, 0, sizeof(ans)); 72 int now = root; 73 for(int i = 0; t[i]; i++) { 74 int c = idx(t[i]); 75 now = ch[now][c]; 76 int tmp = now; 77 while(tmp != root) { 78 ans[tmp][0] ++; 79 /* 80 當前字符的位置 - 當前單詞結點在文本串中上一次匹配的位置 81 大於等於以當前字符結尾的單詞結點的長度時,表示不重疊 82 */ 83 if(i - last[tmp] >= pos[tmp]) { 84 ans[tmp][1] ++; 85 last[tmp] = i;//記錄當前單詞結點在文本串中的位置 86 } 87 tmp = fail[tmp]; 88 } 89 } 90 } 91 }ac; 92 93 int main() 94 { 95 int n, kase = 1; 96 char word[maxwl]; 97 while(scanf("%s", txt) != EOF) { 98 scanf("%d", &n); 99 ac.init(); 100 for(int i = 0; i < n; i++) { 101 scanf("%d%s", &op[i], word); 102 ac.insert(word, i); 103 } 104 ac.getfail(); 105 ac.query(txt); 106 107 printf("Case %d\n", kase++); 108 for(int i = 0; i < n; i++) { 109 printf("%d\n", ac.ans[node[i]][op[i]]); 110 } 111 puts(""); 112 } 113 return 0; 114 }
UVA 11019 Matrix Matcher AC自動機應用的二維推廣。給出一個大的二維字符矩陣T,一個小的二維矩陣P,問P在T中存在的次數。
思路很簡單,使用一個二維矩陣cnt,如果cnt[r][c]表示T中以(r,c)為左上角、與P等大的矩形有多少個完整的行和P對應位置的行完全相同。當P的第j行出現在T的第r行、起始列號為i時,意味着cnt[r-j+1][i-y+2]++,其中具體加幾和存儲的起始位置有關,按照自己的規則即可。所有匹配結束后,那些cnt[r][c] == x(P的行數)的點就是一個二維匹配點。
另外需要注意的是P中可能存在重復,存在重復的模板會導致字典樹中結點編號覆蓋,所以使用一個vector數組保存所有的編號。參考代碼如下:

1 #include <vector> 2 #include <cstdio> 3 #include <queue> 4 #include <cstring> 5 using namespace std; 6 7 const int maxn = 1100; 8 const int maxw = 110; 9 const int maxwl = 110; 10 const int maxnode = maxw * maxwl; 11 const int sigm_size = 26; 12 13 struct ac_automaton { 14 int cnt[maxn][maxn]; 15 int ch[maxnode][sigm_size]; 16 int fail[maxnode]; 17 vector<int> val[maxnode]; 18 int sz, root; 19 20 void init() { 21 sz = 0; 22 root = newnode(); 23 memset(cnt, 0, sizeof(cnt)); 24 } 25 int newnode() { 26 memset(ch[sz], -1, sizeof(ch[sz])); 27 val[sz].clear(); 28 return sz++; 29 } 30 int idx(char c) { 31 return c - 'a'; 32 } 33 void insert(char *s, int v) { 34 int u = root; 35 for(int i = 0; s[i]; i++) { 36 int c = idx(s[i]); 37 if(ch[u][c] == -1) 38 ch[u][c] = newnode(); 39 40 u = ch[u][c]; 41 } 42 val[u].push_back(v);//以該結點為末尾的p的行編號 43 } 44 void getfail() { 45 queue<int> q; 46 fail[root] = root; 47 for(int i = 0; i < sigm_size; i++) { 48 if(ch[root][i] == -1) 49 ch[root][i] = root; 50 else { 51 fail[ch[root][i]] = root; 52 q.push(ch[root][i]); 53 } 54 } 55 while(!q.empty()) { 56 int u = q.front(); 57 q.pop(); 58 for(int i = 0; i < sigm_size; i++) { 59 if(ch[u][i] == -1) 60 ch[u][i] = ch[fail[u]][i]; 61 else { 62 fail[ch[u][i]] = ch[fail[u]][i]; 63 q.push(ch[u][i]); 64 } 65 } 66 } 67 } 68 void query(char *t, int r, int y) { 69 int u = root; 70 for(int i = 0; t[i]; i++) { 71 int c = idx(t[i]); 72 u = ch[u][c];//走到u結點 73 74 for(int k = 0; k < val[u].size(); k ++){//遍歷以該結點為結尾的p的每一個行編號 75 int j = val[u][k]; 76 //如果當前行T的第r行 - P的第j行 + 1 > 0,也就是在左上(1,1)到右下(n,m)這個區域內 77 if(r-j+1>0) cnt[r-j+1][i-y+2]++; 78 //其中+1或者+2是數據存儲問題引起,二維數組從第1行第0列開始 79 } 80 } 81 } 82 int count(int n, int m, int x) { 83 int ans = 0; 84 for(int i = 1; i <= n ; i++) { 85 for(int j = 1; j <= m; j++) { 86 if(cnt[i][j] == x) 87 ans ++; 88 } 89 } 90 return ans; 91 } 92 }ac; 93 94 char t[maxn][maxn], p[maxn/10][maxn/10]; 95 int n, m, x, y; 96 97 int main() 98 { 99 int T; 100 scanf("%d", &T); 101 while(T--) { 102 scanf("%d%d", &n, &m); 103 for(int i = 1; i <= n; i++) { 104 scanf("%s", t[i]); 105 } 106 ac.init(); 107 scanf("%d%d", &x, &y); 108 for(int i = 1; i <= x; i++) { 109 scanf("%s", p[i]); 110 ac.insert(p[i], i); 111 } 112 ac.getfail(); 113 for(int i = 1; i <= n; i++) 114 ac.query(t[i], i, y); 115 printf("%d\n", ac.count(n,m,x)); 116 } 117 return 0; 118 }
POJ 3691 DNA repair 給出單詞表和一個文本串,問最少修改幾個字符使得該文本串不包含所有的單詞。
先根據單詞表構建一個AC自動機,具體匹配的時候我們可以定義一個狀態dp[i][j]表示長度為i、以字典樹中j號結點結尾的字符串不包含所有單詞所需的最少修改次數。很容易遞推發現,dp[i+1][u]也就是長度為1+1、以當前結點結尾的字符串的最小修改次數等於 u的所有孩子結點ch[j][k]是否和當前s[i]相等的最小值。參考代碼如下:

1 #include <cstdio> 2 #include <queue> 3 #include <algorithm> 4 #include <cstring> 5 using namespace std; 6 7 const int inf = 0x3f3f3f3f; 8 const int maxw = 60; 9 const int maxwl = 30; 10 const int maxl = 1100; 11 const int maxnode = maxw * maxwl; 12 const int sigm_size = 4; 13 14 struct ac_automaton { 15 int ch[maxnode][sigm_size]; 16 int fail[maxnode]; 17 bool val[maxnode]; 18 int root, sz; 19 20 void init() { 21 sz = 0; 22 root = newnode(); 23 } 24 int newnode() { 25 memset(ch[sz], -1, sizeof(ch[sz])); 26 val[sz] = false; 27 return sz++; 28 } 29 int idx(char c) { 30 if(c == 'A') 31 return 0; 32 if(c == 'C') 33 return 1; 34 if(c == 'G') 35 return 2; 36 if(c == 'T') 37 return 3; 38 } 39 void insert(char *s) { 40 int u = root; 41 for(int i = 0; s[i]; i++) { 42 int c = idx(s[i]); 43 if(ch[u][c] == -1) 44 ch[u][c] = newnode(); 45 u = ch[u][c]; 46 } 47 val[u] = true; 48 } 49 void getfail() { 50 queue<int> q; 51 fail[root] = root; 52 for(int i = 0; i < sigm_size; i++) { 53 if(ch[root][i] == -1) 54 ch[root][i] = root; 55 else { 56 fail[ch[root][i]] = root; 57 q.push(ch[root][i]); 58 } 59 } 60 while(!q.empty()) { 61 int u = q.front(); 62 q.pop(); 63 if(val[fail[u]]) val[u] = true; 64 65 for(int i = 0; i < sigm_size; i++) { 66 if(ch[u][i] == -1) 67 ch[u][i] = ch[fail[u]][i]; 68 else { 69 fail[ch[u][i]] = ch[fail[u]][i]; 70 q.push(ch[u][i]); 71 } 72 } 73 } 74 } 75 76 int dp[maxnode][maxnode]; 77 //定義狀態dp[i][j]表示長度為i、以字典樹上結點編號為j的字符結尾的字符串 所需的最小修改次數 78 int solve(char *s) { 79 int len = strlen(s); 80 for(int i = 0; i <= len; i ++) {//初始化大小為len * sz大小的空間 81 for(int j = 0; j < sz; j++) { 82 dp[i][j] = inf; 83 } 84 } 85 dp[0][root] = 0;//初始化長度為0,以根結點結尾的字符串 所需最小修改次數為 0 86 for(int i = 0; i <= len; i++) { 87 for(int j = 0; j < sz; j++) { 88 //之前一次拓展沒有更新表示該長度以j結尾的字符串存在病毒結點,故直接跳過 89 if(dp[i][j] >= inf) continue; 90 91 for(int k = 0; k < 4; k++) { 92 int u = ch[j][k]; 93 if(val[u]) continue;//當前結點j的孩子中有的是病毒結點直接跳過 94 int tmp; 95 if(k == idx(s[i])) 96 tmp = dp[i][j]; 97 else 98 tmp = dp[i][j] + 1; 99 //更新長度加一、以孩子結點u結尾的的狀態 100 dp[i+1][u] = min(dp[i+1][u], tmp); 101 } 102 103 } 104 } 105 int ans = inf; 106 for(int i = 0; i < sz; i++) 107 ans = min(dp[len][i], ans); 108 if(ans == inf) 109 return -1; 110 return ans; 111 } 112 }ac; 113 114 int main() 115 { 116 int n, kase = 1; 117 char word[maxwl], txt[maxl]; 118 while(scanf("%d", &n) == 1 && n) { 119 ac.init(); 120 for(int i = 0; i < n; i++) { 121 scanf("%s", word); 122 ac.insert(word); 123 } 124 ac.getfail(); 125 126 scanf("%s", txt); 127 printf("Case %d: %d\n",kase++, ac.solve(txt)); 128 } 129 return 0; 130 }
還有其他綜合型的題目,有興趣的同學自行嘗試(很刺激,一題坑一天的都是少的那種)。
至此,AC自動機解析及其在競賽中的典型應用就總結完了,算法很精妙,關鍵是體會算法的基本思想,加上一些具體的應用實踐,才能掌握牢固。AC自動機有很多變形,要想學好,用好,還需掌握其他知識,比如矩陣加速,高精度,狀壓DP(省略很多我還不知道的算法)。算法學習並非易事,要堅持思考,實踐,總結才行。(原創不易,轉載請注明出處哦)