经典算法题每日演练——第八题 AC自动机
上一篇我们说了单模式匹配算法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 }