上一篇我們說了單模式匹配算法KMP,現在我們有需求了,我要檢查一篇文章中是否有某些敏感詞,這其實就是多模式匹配的問題。
當然你也可以用KMP算法求出,那么它的時間復雜度為O(c*(m+n)),c:為模式串的個數。m:為模式串的長度,n:為正文的長度,那
么這個復雜度就不再是線性了,我們學算法就是希望能把要解決的問題優化到極致,這不,AC自動機就派上用場了。
其實AC自動機就是Trie樹的一個活用,活用點就是灌輸了kmp的思想,從而再次把時間復雜度優化到線性的O(N),剛好我前面的文
章已經說過了Trie樹和KMP,這里還是默認大家都懂。
一:構建AC自動機
同樣我也用網上的經典例子,現有say she shr he her 這樣5個模式串,主串為yasherhs,我要做的就是哪些模式串在主串中出現過?
1: 構建trie樹
如果看過我前面的文章,構建trie樹還是很容易的。
2:失敗指針
構建失敗指針是AC自動機的核心所在,玩轉了它也就玩轉了AC自動機,失敗指針非常類似於KMP中的next數組,也就是說,
當我的主串在trie樹中進行匹配的時候,如果當前節點不能再繼續進行匹配,那么我們就會走到當前節點的failNode節點繼續進行
匹配,構建failnode節點也是很流程化的。
①:root節點的子節點的failnode都是指向root。
②:當走到在“she”中的”h“節點時,我們給它的failnode設置什么呢?此時就要走該節點(h)的父節點(s)的失敗指針,一直回溯直
到找到某個節點的孩子節點也是當初節點同樣的字符(h),沒有找到的話,其失敗指針就指向root。
比如:h節點的父節點為s,s的failnode節點為root,走到root后繼續尋找子節點為h的節點,恰好我們找到了,(假如還是沒
有找到,則繼續走該節點的failnode,嘿嘿,是不是很像一種回溯查找),此時就將 ”she"中的“h”節點的fainode"指向
"her"中的“h”節點,好,原理其實就是這樣。(看看你的想法是不是跟圖一樣)
針對圖中紅線的”h,e“這兩個節點,我們想起了什么呢?對”her“中的”e“來說,e到root距離的n個字符恰好與”she“中的e向上的n
個字符相等,我也非常類似於kmp中next函數,當字符失配時,next數組中記錄着下一次匹配時模式串的起始位置。
1 #region Trie樹節點 2 /// <summary> 3 /// Trie樹節點 4 /// </summary> 5 public class TrieNode 6 { 7 /// <summary> 8 /// 26個字符,也就是26叉樹 9 /// </summary> 10 public TrieNode[] childNodes; 11 12 /// <summary> 13 /// 詞頻統計 14 /// </summary> 15 public int freq; 16 17 /// <summary> 18 /// 記錄該節點的字符 19 /// </summary> 20 public char nodeChar; 21 22 /// <summary> 23 /// 失敗指針 24 /// </summary> 25 public TrieNode faliNode; 26 27 /// <summary> 28 /// 插入記錄時的編號id 29 /// </summary> 30 public HashSet<int> hashSet = new HashSet<int>(); 31 32 /// <summary> 33 /// 初始化 34 /// </summary> 35 public TrieNode() 36 { 37 childNodes = new TrieNode[26]; 38 freq = 0; 39 } 40 } 41 #endregion
剛才我也說到了parent和current兩個節點,在給trie中的節點賦failnode的時候,如果采用深度優先的話還是很麻煩的,因為我要實時
記錄當前節點的父節點,相信寫過樹的朋友都清楚,除了深搜,我們還有廣搜。
1 /// <summary> 2 /// 構建失敗指針(這里我們采用BFS的做法) 3 /// </summary> 4 /// <param name="root"></param> 5 public void BuildFailNodeBFS(ref TrieNode root) 6 { 7 //根節點入隊 8 queue.Enqueue(root); 9 10 while (queue.Count != 0) 11 { 12 //出隊 13 var temp = queue.Dequeue(); 14 15 //失敗節點 16 TrieNode failNode = null; 17 18 //26叉樹 19 for (int i = 0; i < 26; i++) 20 { 21 //代碼技巧:用BFS方式,從當前節點找其孩子節點,此時孩子節點 22 // 的父親正是當前節點,(避免了parent節點的存在) 23 if (temp.childNodes[i] == null) 24 continue; 25 26 //如果當前是根節點,則根節點的失敗指針指向root 27 if (temp == root) 28 { 29 temp.childNodes[i].faliNode = root; 30 } 31 else 32 { 33 //獲取出隊節點的失敗指針 34 failNode = temp.faliNode; 35 36 //沿着它父節點的失敗指針走,一直要找到一個節點,直到它的兒子也包含該節點。 37 while (failNode != null) 38 { 39 //如果不為空,則在父親失敗節點中往子節點中深入。 40 if (failNode.childNodes[i] != null) 41 { 42 temp.childNodes[i].faliNode = failNode.childNodes[i]; 43 break; 44 } 45 //如果無法深入子節點,則退回到父親失敗節點並向root節點往根部延伸,直到null 46 //(一個回溯再深入的過程,非常有意思) 47 failNode = failNode.faliNode; 48 } 49 50 //等於null的話,指向root節點 51 if (failNode == null) 52 temp.childNodes[i].faliNode = root; 53 } 54 queue.Enqueue(temp.childNodes[i]); 55 } 56 } 57 }
3:模式匹配
所有字符在匹配完后都必須要走failnode節點來結束自己的旅途,相當於一個回旋,這樣做的目的防止包含節點被忽略掉。
比如:我匹配到了"she",必然會匹配到該字符串的后綴”he",要想在程序中匹配到,則必須節點要走失敗指針來結束自己的旅途。
從上圖中我們可以清楚的看到“she”的匹配到字符"e"后,從failnode指針撤退,在撤退途中將其后綴字符“e”收入囊腫,這也就是
為什么像kmp中的next函數。
1 /// <summary> 2 /// 根據指定的主串,檢索是否存在模式串 3 /// </summary> 4 /// <param name="root"></param> 5 /// <param name="s"></param> 6 /// <returns></returns> 7 public void SearchAC(ref TrieNode root, string s, ref HashSet<int> hashSet) 8 { 9 int freq = 0; 10 11 TrieNode head = root; 12 13 foreach (var c in s) 14 { 15 //計算位置 16 int index = c - 'a'; 17 18 //如果當前匹配的字符在trie樹中無子節點並且不是root,則要走失敗指針 19 //回溯的去找它的當前節點的子節點 20 while ((head.childNodes[index] == null) && (head != root)) 21 head = head.faliNode; 22 23 //獲取該叉樹 24 head = head.childNodes[index]; 25 26 //如果為空,直接給root,表示該字符已經走完畢了 27 if (head == null) 28 head = root; 29 30 var temp = head; 31 32 //在trie樹中匹配到了字符,標記當前節點為已訪問,並繼續尋找該節點的失敗節點。 33 //直到root結束,相當於走了一個回旋。(注意:最后我們會出現一個freq=-1的失敗指針鏈) 34 while (temp != root && temp.freq != -1) 35 { 36 freq += temp.freq; 37 38 //將找到的id追加到集合中 39 foreach (var item in temp.hashSet) 40 hashSet.Add(item); 41 42 temp.freq = -1; 43 44 temp = temp.faliNode; 45 } 46 } 47 }
好了,到現在為止,我想大家也比較清楚了,最后上一個總的運行代碼:

1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 using System.Diagnostics; 6 using System.Threading; 7 using System.IO; 8 9 namespace ConsoleApplication2 10 { 11 public class Program 12 { 13 public static void Main() 14 { 15 Trie trie = new Trie(); 16 17 trie.AddTrieNode("say", 1); 18 trie.AddTrieNode("she", 2); 19 trie.AddTrieNode("shr", 3); 20 trie.AddTrieNode("her", 4); 21 trie.AddTrieNode("he", 5); 22 23 trie.BuildFailNodeBFS(); 24 25 string s = "yasherhs"; 26 27 var hashSet = trie.SearchAC(s); 28 29 Console.WriteLine("在主串{0}中存在模式串的編號為:{1}", s, string.Join(",", hashSet)); 30 31 Console.Read(); 32 } 33 } 34 35 public class Trie 36 { 37 public TrieNode trieNode = new TrieNode(); 38 39 /// <summary> 40 /// 用光搜的方法來構建失敗指針 41 /// </summary> 42 public Queue<TrieNode> queue = new Queue<TrieNode>(); 43 44 #region Trie樹節點 45 /// <summary> 46 /// Trie樹節點 47 /// </summary> 48 public class TrieNode 49 { 50 /// <summary> 51 /// 26個字符,也就是26叉樹 52 /// </summary> 53 public TrieNode[] childNodes; 54 55 /// <summary> 56 /// 詞頻統計 57 /// </summary> 58 public int freq; 59 60 /// <summary> 61 /// 記錄該節點的字符 62 /// </summary> 63 public char nodeChar; 64 65 /// <summary> 66 /// 失敗指針 67 /// </summary> 68 public TrieNode faliNode; 69 70 /// <summary> 71 /// 插入記錄時的編號id 72 /// </summary> 73 public HashSet<int> hashSet = new HashSet<int>(); 74 75 /// <summary> 76 /// 初始化 77 /// </summary> 78 public TrieNode() 79 { 80 childNodes = new TrieNode[26]; 81 freq = 0; 82 } 83 } 84 #endregion 85 86 #region 插入操作 87 /// <summary> 88 /// 插入操作 89 /// </summary> 90 /// <param name="word"></param> 91 /// <param name="id"></param> 92 public void AddTrieNode(string word, int id) 93 { 94 AddTrieNode(ref trieNode, word, id); 95 } 96 97 /// <summary> 98 /// 插入操作 99 /// </summary> 100 /// <param name="root"></param> 101 /// <param name="s"></param> 102 public void AddTrieNode(ref TrieNode root, string word, int id) 103 { 104 if (word.Length == 0) 105 return; 106 107 //求字符地址,方便將該字符放入到26叉樹中的哪一叉中 108 int k = word[0] - 'a'; 109 110 //如果該叉樹為空,則初始化 111 if (root.childNodes[k] == null) 112 { 113 root.childNodes[k] = new TrieNode(); 114 115 //記錄下字符 116 root.childNodes[k].nodeChar = word[0]; 117 } 118 119 var nextWord = word.Substring(1); 120 121 //說明是最后一個字符,統計該詞出現的次數 122 if (nextWord.Length == 0) 123 { 124 root.childNodes[k].freq++; 125 root.childNodes[k].hashSet.Add(id); 126 } 127 128 AddTrieNode(ref root.childNodes[k], nextWord, id); 129 } 130 #endregion 131 132 #region 構建失敗指針 133 /// <summary> 134 /// 構建失敗指針(這里我們采用BFS的做法) 135 /// </summary> 136 public void BuildFailNodeBFS() 137 { 138 BuildFailNodeBFS(ref trieNode); 139 } 140 141 /// <summary> 142 /// 構建失敗指針(這里我們采用BFS的做法) 143 /// </summary> 144 /// <param name="root"></param> 145 public void BuildFailNodeBFS(ref TrieNode root) 146 { 147 //根節點入隊 148 queue.Enqueue(root); 149 150 while (queue.Count != 0) 151 { 152 //出隊 153 var temp = queue.Dequeue(); 154 155 //失敗節點 156 TrieNode failNode = null; 157 158 //26叉樹 159 for (int i = 0; i < 26; i++) 160 { 161 //代碼技巧:用BFS方式,從當前節點找其孩子節點,此時孩子節點 162 // 的父親正是當前節點,(避免了parent節點的存在) 163 if (temp.childNodes[i] == null) 164 continue; 165 166 //如果當前是根節點,則根節點的失敗指針指向root 167 if (temp == root) 168 { 169 temp.childNodes[i].faliNode = root; 170 } 171 else 172 { 173 //獲取出隊節點的失敗指針 174 failNode = temp.faliNode; 175 176 //沿着它父節點的失敗指針走,一直要找到一個節點,直到它的兒子也包含該節點。 177 while (failNode != null) 178 { 179 //如果不為空,則在父親失敗節點中往子節點中深入。 180 if (failNode.childNodes[i] != null) 181 { 182 temp.childNodes[i].faliNode = failNode.childNodes[i]; 183 break; 184 } 185 //如果無法深入子節點,則退回到父親失敗節點並向root節點往根部延伸,直到null 186 //(一個回溯再深入的過程,非常有意思) 187 failNode = failNode.faliNode; 188 } 189 190 //等於null的話,指向root節點 191 if (failNode == null) 192 temp.childNodes[i].faliNode = root; 193 } 194 queue.Enqueue(temp.childNodes[i]); 195 } 196 } 197 } 198 #endregion 199 200 #region 檢索操作 201 /// <summary> 202 /// 根據指定的主串,檢索是否存在模式串 203 /// </summary> 204 /// <param name="s"></param> 205 /// <returns></returns> 206 public HashSet<int> SearchAC(string s) 207 { 208 HashSet<int> hash = new HashSet<int>(); 209 210 SearchAC(ref trieNode, s, ref hash); 211 212 return hash; 213 } 214 215 /// <summary> 216 /// 根據指定的主串,檢索是否存在模式串 217 /// </summary> 218 /// <param name="root"></param> 219 /// <param name="s"></param> 220 /// <returns></returns> 221 public void SearchAC(ref TrieNode root, string s, ref HashSet<int> hashSet) 222 { 223 int freq = 0; 224 225 TrieNode head = root; 226 227 foreach (var c in s) 228 { 229 //計算位置 230 int index = c - 'a'; 231 232 //如果當前匹配的字符在trie樹中無子節點並且不是root,則要走失敗指針 233 //回溯的去找它的當前節點的子節點 234 while ((head.childNodes[index] == null) && (head != root)) 235 head = head.faliNode; 236 237 //獲取該叉樹 238 head = head.childNodes[index]; 239 240 //如果為空,直接給root,表示該字符已經走完畢了 241 if (head == null) 242 head = root; 243 244 var temp = head; 245 246 //在trie樹中匹配到了字符,標記當前節點為已訪問,並繼續尋找該節點的失敗節點。 247 //直到root結束,相當於走了一個回旋。(注意:最后我們會出現一個freq=-1的失敗指針鏈) 248 while (temp != root && temp.freq != -1) 249 { 250 freq += temp.freq; 251 252 //將找到的id追加到集合中 253 foreach (var item in temp.hashSet) 254 hashSet.Add(item); 255 256 temp.freq = -1; 257 258 temp = temp.faliNode; 259 } 260 } 261 } 262 #endregion 263 } 264 }