基于C#实现AC自动机算法

我要检查一篇文章中是否有某些敏感词,这其实就是多模式匹配的问题。当然你也可以用 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 树还是很容易的。
image.png

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”节点,好,原理其实就是这样。(看看你的想法是不是跟图一样)
image.png
针对图中红线的”h,e“这两个节点,我们想起了什么呢?对”her“中的”e“来说,e 到 root 距离的 n 个字符恰好与”she“中的 e 向上的 n 个字符相等,我也非常类似于 kmp 中 next 函数,当字符失配时,next 数组中记录着下一次匹配时模式串的起始位置。

 #region Trie树节点
 /// <summary>
 /// Trie树节点
 /// </summary>
 public class TrieNode
 {
     /// <summary>
     /// 26个字符,也就是26叉树
     /// </summary>
     public TrieNode[] childNodes;

     /// <summary>
     /// 词频统计
     /// </summary>
     public int freq;

     /// <summary>
     /// 记录该节点的字符
     /// </summary>
     public char nodeChar;

     /// <summary>
     /// 失败指针
     /// </summary>
     public TrieNode faliNode;

     /// <summary>
     /// 插入记录时的编号id
     /// </summary>
     public HashSet<int> hashSet = new HashSet<int>();

     /// <summary>
     /// 初始化
     /// </summary>
     public TrieNode()
     {
         childNodes = new TrieNode[26];
         freq = 0;
     }
 }
 #endregion

刚才说到了 parent 和 current 两个节点,在给 trie 中的节点赋 failnode 的时候,如果采用深度优先的话还是很麻烦的,因为我要实时记录当前节点的父节点,相信写过树的朋友都清楚,除了深搜,我们还有广搜。

  /// <summary>
 /// 构建失败指针(这里我们采用BFS的做法)
 /// </summary>
 /// <param name="root"></param>
 public void BuildFailNodeBFS(ref TrieNode root)
 {
     //根节点入队
     queue.Enqueue(root);

     while (queue.Count != 0)
     {
         //出队
         var temp = queue.Dequeue();

         //失败节点
         TrieNode failNode = null;

         //26叉树
         for (int i = 0; i < 26; i++)
         {
             //代码技巧:用BFS方式,从当前节点找其孩子节点,此时孩子节点
             //         的父亲正是当前节点,(避免了parent节点的存在)
             if (temp.childNodes[i] == null)
                 continue;

             //如果当前是根节点,则根节点的失败指针指向root
             if (temp == root)
             {
                 temp.childNodes[i].faliNode = root;
             }
             else
             {
                 //获取出队节点的失败指针
                 failNode = temp.faliNode;

                 //沿着它父节点的失败指针走,一直要找到一个节点,直到它的儿子也包含该节点。
                 while (failNode != null)
                 {
                     //如果不为空,则在父亲失败节点中往子节点中深入。
                     if (failNode.childNodes[i] != null)
                     {
                         temp.childNodes[i].faliNode = failNode.childNodes[i];
                         break;
                     }
                     //如果无法深入子节点,则退回到父亲失败节点并向root节点往根部延伸,直到null
                     //(一个回溯再深入的过程,非常有意思)
                     failNode = failNode.faliNode;
                 }

                 //等于null的话,指向root节点
                 if (failNode == null)
                     temp.childNodes[i].faliNode = root;
             }
             queue.Enqueue(temp.childNodes[i]);
         }
     }
 }

3、模式匹配

所有字符在匹配完后都必须要走 failnode 节点来结束自己的旅途,相当于一个回旋,这样做的目的防止包含节点被忽略掉。比如:我匹配到了"she",必然会匹配到该字符串的后缀”he",要想在程序中匹配到,则必须节点要走失败指针来结束自己的旅途。
image.png
从上图中我们可以清楚的看到“she”的匹配到字符"e"后,从 failnode 指针撤退,在撤退途中将其后缀字符“e”收入囊肿,这也就是为什么像 kmp 中的 next 函数。

/// <summary>
/// 根据指定的主串,检索是否存在模式串
/// </summary>
/// <param name="root"></param>
/// <param name="s"></param>
/// <returns></returns>
public void SearchAC(ref TrieNode root, string s, ref HashSet<int> hashSet)
{
     int freq = 0;
    
     TrieNode head = root;
    
     foreach (var c in s)
     {
         //计算位置
         int index = c - 'a';
    
         //如果当前匹配的字符在trie树中无子节点并且不是root,则要走失败指针
         //回溯的去找它的当前节点的子节点
         while ((head.childNodes[index] == null) && (head != root))
             head = head.faliNode;
    
         //获取该叉树
         head = head.childNodes[index];
    
         //如果为空,直接给root,表示该字符已经走完毕了
         if (head == null)
             head = root;
    
         var temp = head;
    
         //在trie树中匹配到了字符,标记当前节点为已访问,并继续寻找该节点的失败节点。
         //直到root结束,相当于走了一个回旋。(注意:最后我们会出现一个freq=-1的失败指针链)
         while (temp != root && temp.freq != -1)
         {
             freq += temp.freq;
    
             //将找到的id追加到集合中
             foreach (var item in temp.hashSet)
                 hashSet.Add(item);
    
             temp.freq = -1;
    
             temp = temp.faliNode;
         }
     }
}

好了,到现在为止,我想大家也比较清楚了,最后上一个总的运行代码:

 using System;
   using System.Collections.Generic;
   using System.Linq;
   using System.Text;
   using System.Diagnostics;
   using System.Threading;
   using System.IO;
   
   namespace ConsoleApplication2
  {
      public class Program
      {
          public static void Main()
          {
              Trie trie = new Trie();
  
              trie.AddTrieNode("say", 1);
              trie.AddTrieNode("she", 2);
              trie.AddTrieNode("shr", 3);
              trie.AddTrieNode("her", 4);
              trie.AddTrieNode("he", 5);
  
              trie.BuildFailNodeBFS();
  
              string s = "yasherhs";
  
              var hashSet = trie.SearchAC(s);
  
              Console.WriteLine("在主串{0}中存在模式串的编号为:{1}", s, string.Join(",", hashSet));
  
              Console.Read();
          }
      }
  
      public class Trie
      {
          public TrieNode trieNode = new TrieNode();
  
          /// <summary>
          /// 用光搜的方法来构建失败指针
          /// </summary>
          public Queue<TrieNode> queue = new Queue<TrieNode>();
  
          #region Trie树节点
          /// <summary>
          /// Trie树节点
          /// </summary>
          public class TrieNode
          {
              /// <summary>
              /// 26个字符,也就是26叉树
              /// </summary>
              public TrieNode[] childNodes;
  
              /// <summary>
              /// 词频统计
              /// </summary>
              public int freq;
  
              /// <summary>
              /// 记录该节点的字符
              /// </summary>
              public char nodeChar;
  
              /// <summary>
              /// 失败指针
              /// </summary>
              public TrieNode faliNode;
  
              /// <summary>
              /// 插入记录时的编号id
              /// </summary>
              public HashSet<int> hashSet = new HashSet<int>();
  
              /// <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 }
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

神仙别闹

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值