经典算法题每日演练——第八题 AC自动机

原文:经典算法题每日演练——第八题 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 }
时间: 2024-08-08 04:04:05

经典算法题每日演练——第八题 AC自动机的相关文章

经典算法题每日演练——第三题 猴子吃桃

原文:经典算法题每日演练--第三题 猴子吃桃 猴子第一天摘下若干个桃子,当即吃了一半,还不过瘾就多吃了一个.第二天早上又将剩下的桃子吃了一半,还是不过瘾又多 吃了一个.以后每天都吃前一天剩下的一半再加一个.到第10天刚好剩一个.问猴子第一天摘了多少个桃子? 分析: 这是一套非常经典的算法题,这个题目体现了算法思想中的递推思想,递归有两种形式,顺推和逆推,针对递推,只要 我们找到递推公式,问题就迎刃而解了. 令S10=1,容易看出 S9=2(S10+1), 简化一下 S9=2S10+2 S8=2S

经典算法题每日演练——第二十题 三元组

原文:经典算法题每日演练--第二十题 三元组 我们知道矩阵是一个非常强大的数据结构,在动态规划以及各种图论算法上都有广泛的应用,当然矩阵有着不足的地方就是空间和时间 复杂度都维持在N2上,比如1w个数字建立一个矩阵,在内存中会占用1w*1w=1亿的类型空间,这时就会遇到outofmemory...那么面 临的一个问题就是如何来压缩矩阵,当然压缩的方式有很多种,这里就介绍一个顺序表的压缩方式:三元组. 一:三元组 有时候我们的矩阵中只有零星的一些非零元素,其余的都是零元素,那么我们称之为稀疏矩阵,

经典算法题每日演练——第十七题 Dijkstra算法

原文:经典算法题每日演练--第十七题 Dijkstra算法 或许在生活中,经常会碰到针对某一个问题,在众多的限制条件下,如何去寻找一个最优解?可能大家想到了很多诸如“线性规划”,“动态规划” 这些经典策略,当然有的问题我们可以用贪心来寻求整体最优解,在图论中一个典型的贪心法求最优解的例子就莫过于“最短路径”的问题. 一:概序 从下图中我要寻找V0到V3的最短路径,你会发现通往他们的两点路径有很多:V0->V4->V3,V0->V1->V3,当然你会认为前者是你要找的最短 路径,那如

经典算法题每日演练——第七题 KMP算法

原文:经典算法题每日演练--第七题 KMP算法 在大学的时候,应该在数据结构里面都看过kmp算法吧,不知道有多少老师对该算法是一笔带过的,至少我们以前是的, 确实kmp算法还是有点饶人的,如果说红黑树是变态级的,那么kmp算法比红黑树还要变态,很抱歉,每次打kmp的时候,输 入法总是提示“看毛片”三个字,嘿嘿,就叫“看毛片算法”吧. 一:BF算法 如果让你写字符串的模式匹配,你可能会很快的写出朴素的bf算法,至少问题是解决了,我想大家很清楚的知道它的时间复 杂度为O(MN),原因很简单,主串和模

经典算法题每日演练——第二十一题 十字链表

原文:经典算法题每日演练--第二十一题 十字链表 上一篇我们看了矩阵的顺序存储,这篇我们再看看一种链式存储方法“十字链表”,当然目的都是一样,压缩空间. 一:概念 既然要用链表节点来模拟矩阵中的非零元素,肯定需要如下5个元素(row,col,val,down,right),其中: row:矩阵中的行. col:矩阵中的列. val:矩阵中的值. right:指向右侧的一个非零元素. down:指向下侧的一个非零元素. 现在我们知道单个节点该如何表示了,那么矩阵中同行的非零元素的表示不就是一个单链

经典算法题每日演练——第六题 协同推荐SlopeOne 算法

原文:经典算法题每日演练--第六题 协同推荐SlopeOne 算法 相信大家对如下的Category都很熟悉,很多网站都有类似如下的功能,“商品推荐”,"猜你喜欢“,在实体店中我们有导购来为我们服务,在网络上 我们需要同样的一种替代物,如果简简单单的在数据库里面去捞,去比较,几乎是完成不了的,这时我们就需要一种协同推荐算法,来高效的推荐浏览者喜 欢的商品. 一:概念 SlopeOne的思想很简单,就是用均值化的思想来掩盖个体的打分差异,举个例子说明一下: 在这个图中,系统该如何计算“王五“对”电

经典算法题每日演练——第十三题 赫夫曼树

原文:经典算法题每日演练--第十三题 赫夫曼树 赫夫曼树又称最优二叉树,也就是带权路径最短的树,对于赫夫曼树,我想大家对它是非常的熟悉,也知道它的应用场景, 但是有没有自己亲手写过,这个我就不清楚了,不管以前写没写,这一篇我们来玩一把. 一:概念 赫夫曼树里面有几个概念,也是非常简单的,先来看下面的图: 1. 基础概念 <1>  节点的权: 节点中红色部分就是权,在实际应用中,我们用“字符”出现的次数作为权. <2>  路径长度:可以理解成该节点到根节点的层数,比如:“A”到根节点

经典算法题每日演练——第五题 字符串相似度

原文:经典算法题每日演练--第五题 字符串相似度 这篇我们看看最长公共子序列的另一个版本,求字符串相似度(编辑距离),我也说过了,这是一个非常实用的算法,在DNA对比,网 页聚类等方面都有用武之地. 一:概念 对于两个字符串A和B,通过基本的增删改将字符串A改成B,或者将B改成A,在改变的过程中我们使用的最少步骤称之为“编辑距离”. 比如如下的字符串:我们通过种种操作,痉挛之后编辑距离为3,不知道你看出来了没有? 二:解析 可能大家觉得有点复杂,不好理解,我们试着把这个大问题拆分掉,将"字符串

经典算法题每日演练——第二十三题 鸡尾酒排序

原文:经典算法题每日演练--第二十三题 鸡尾酒排序 这篇我们继续扯淡一下鸡尾酒排序,为了知道为啥取名为鸡尾酒,特意看了下百科,见框框的话,也只能勉强这么说了. 要是文艺点的话,可以说是搅拌排序,通俗易懂点的话,就叫“双向冒泡排序”,我想作为码农的话,不可能不知道冒泡排序, 冒泡是一个单向的从小到大或者从大到小的交换排序,而鸡尾酒排序是双向的,从一端进行从小到大排序,从另一端进行从大 到小排序. 从图中可以看到,第一次正向比较,我们找到了最大值9. 第一次反向比较,我们找到了最小值1. 第二次正向