简介:

本文是博主自身对AC自动机的原理的一些理解和看法,主要以举例的方式讲解,同时又配以相应的图片。代码实现部分也予以明确的注释,希望给大家不一样的感受。AC自动机主要用于多模式字符串的匹配,本质上是KMP算法的树形扩展。这篇文章主要介绍AC自动机的工作原理,并在此基础上用Java代码实现一个简易的AC自动机。

欢迎探讨,如有错误敬请指正

1. 应用场景—多模字符串匹配

我们现在考虑这样一个问题,在一个文本串text中,我们想找出多个目标字符串target1,target2,……出现的次数和位置。例如:求出目标字符串集合{"nihao","hao","hs","hsr"}在给定文本sdmfhsgnshejfgnihaofhsrnihao中所有可能出现的位置。解决这个问题,我们一般的办法就是在文本串中对每个目标字符串单独查找,并记录下每次出现的位置。显然这样的方式能够解决问题,但是在文本串较大、目标字符串众多的时候效率比较低。为了提高效率,贝尔实验室于1975年发明著名的多模字符串匹配算法——AC自动机。AC自动机在实现上要依托于Trie树(也称字典树)并借鉴了KMP模式匹配算法的核心思想。实际上你可以把KMP算法看成每个节点都仅有一个孩子节点的AC自动机。

2. AC自动机及其运行原理

2.1 初识AC自动机

AC自动机的基础是Trie树。和Trie树不同的是,树中的每个结点除了有指向孩子的指针(或者说引用),还有一个fail指针,它表示输入的字符与当前结点的所有孩子结点都不匹配时(注意,不是和该结点本身不匹配),自动机的状态应转移到的状态(或者说应该转移到的结点)。fail指针的功能可以类比于KMP算法中next数组的功能。

我们现在来看一个用目标字符串集合{abd,abdk, abchijn, chnit, ijabdf, ijaij}构造出来的AC自动机

      上图是一个构建好的AC自动机,其中根结点不存储任何字符,根结点的fail指针为null。虚线表示该结点的fail指针的指向,所有表示字符串的最后一个字符的结点外部都用红圈表示,我们称该结点为这个字符串的终结结点。每个结点实际上都有fail指针,但为了表示方便,本文约定一个原则,即所有指向根结点的 fail虚线都未画出

从上图中的AC自动机,我们可以看出一个重要的性质:每个结点的fail指针表示由根结点到该结点所组成的字符序列的所有后缀 和 整个目标字符串集合(也就是整个Trie树)中的所有前缀 两者中最长公共的部分。

比如图中,由根结点到目标字符串ijabdf中的 d组成的字符序列ijabd的所有后缀在整个目标字符串集{abd,abdk, abchijn, chnit, ijabdf, ijaij}的所有前缀中最长公共的部分就是abd,而图中d结点(字符串ijabdf中的这个d)的fail正是指向了字符序列abd的最后一个字符。

2.2 AC自动机的运行过程:

  1. 表示当前结点的指针指向AC自动机的根结点,即curr = root
  2. 从文本串中读取(下)一个字符
  3. 从当前结点的所有孩子结点中寻找与该字符匹配的结点,若成功:判断当前结点以及当前结点fail指向的结点是否表示一个字符串的结束,若是,则将文本串中索引起点记录在对应字符串保存结果集合中(索引起点= 当前索引-字符串长度+1)。curr指向该孩子结点,继续执行第2步;若失败:执行第4步。
  4. 若fail == null(说明目标字符串中没有任何字符串是输入字符串的前缀,相当于重启状态机)curr = root, 执行步骤2,否则,将当前结点的指针指向fail结点,执行步骤3)

现在,我们来一个具体的例子加深理解,初始时当前结点为root结点,我们现在假设文本串text = “abchnijabdfk”

       图中的实曲线表示了整个搜索过程中的当前结点指针的转移过程,结点旁的文字表示了当前结点下读取的文本串字符。比如初始时,当前指针指向根结点时,输入字符a,则当前指针指向结点a,此时再输入字符b,自动机状态转移到结点b,……,以此类推。图中AC自动机的最后状态只是恰好回到根结点。

需要说明的是,当指针位于结点b(图中曲线经过了两次b,这里指第二次的b,即目标字符串ijabdf中的b),这时读取文本串字符下标为9的字符(即d)时,由于b的所有孩子结点(这里恰好只有一个孩子结点)中存在能够匹配输入字符d的结点,那么当前结点指针就指向了结点d,而此时该结点d的fail指针指向的结点又恰好表示了字符串abc的终结结点(用红圈表示),所以我们找到了目标字符串abc一次。这个过程我们在图中用虚线表示,但状态没有转移到abd中的d结点。

在输入完所有文本串字符后,我们在文本串中找到了目标字符串集合中的abd一次,位于文本串中下标为7的位置;目标字符串ijabdf一次,位于文本串中下标为5的位置。

3. 构造AC自动机的方法与原理

3.1 构造的基本方法

首先我们将所有的目标字符串插入到Trie树中,然后通过广度优先遍历为每个结点的所有孩子节点的fail指针找到正确的指向。
确定fail指针指向的问题和KMP算法中构造next数组的方式如出一辙。具体方法如下

1)将根结点的所有孩子结点的fail指向根结点,然后将根结点的所有孩子结点依次入列。

2)若队列不为空:

2.1)出列,我们将出列的结点记为curr, failTo表示curr的fail指向的结点,即failTo = curr.fail

2.2) a.判断curr.child[i] == failTo.child[i]是否成立,
                        成立:curr.child[i].fail = failTo.child[i],
                        不成立:判断 failTo == null是否成立
                         成立: curr.child[i].fail == root
                        不成立:执行failTo = failTo.fail,继续执行2.2)
                        b.curr.child[i]入列,再次执行再次执行步骤2)

若队列为空:结束

3.2 通过一个例子来理解构造AC自动机的原理

每个结点fail指向的解决顺序是按照广度优先遍历的顺序完成的,或者说层序遍历的顺序进行的,也就是说我们是在解决当前结点的孩子结点fail的指向时,当前结点的fail指针一定已指向了正确的位置。

      为了说明问题,我们再次强调“每个结点的fail指针表示:由根结点到该结点所组成的字符序列的所有后缀 和 整个目标字符串集合(也就是整个Trie树)中的所有前缀 两者中最长公共的部分”。

以上图所示为例,我们要解决结点x1的某个孩子结点y的fail的指向问题。已知x1.fail指向x2,依据x1结点的fail指针的含义,我们可知红色实线椭圆框内的字符序列必然相等,且表示了最长公共部分。依据y.fail的含义,如果x2的某个孩子结点和结点y表示的字符相等,那么y.fail就该指向它。

如果x2的孩子结点中不存在结点y表示的字符,这个时候该怎么办?由于x2.fail指向x3,根据x2.fail的含义,我们可知绿色方框内的字符序列必然相等。显然,如果x3的某个孩子结点和结点y表示的字符相等,那么y.fail就该指向它。

如果x3的孩子结点中不存在结点y表示的字符,我们可以依次重复这个步骤,直到xi结点的fail指向null,这时说明我们已经到了最顶层的根结点,这时,我们只需要让y.fail = root即可。

构造的过程的核心本质就是,已知当前结点的最长公共前缀的前提下,去确定孩子结点的最长公共前缀。这完全可以类比于KMP算法的next数组的求解过程。

3.2.1 确定图中h结点fail指向的过程

现在我们假设我们要确定图中结点c的孩子结点h的fail指向。图中每个结点都应该有表示fail的虚线,但为了表示方便,按照本文约定的原则,所有指向根结点的 fail虚线均未画出。

      左图表示h.fail确定之前, 右图表示h.fail确定之后
      左图中,蓝色实线框住的结点的fail都已确定。现在我们应该怎样找到h.fail的正确指向?由于且结点c的fail已知(c结点为h结点的父结点),且指向了Trie树中所有前缀与字符序列‘a’‘b’‘c’的所有后缀(“bc”和“c”)的最长公共部分。现在我们要解决的问题是目标字符串集合的所有前缀中与字符序列‘a’‘b’‘c’ ‘h’的所有后缀的最长公共部分。显然c.fail指向的结点的孩子结点中存在结点h,那么h.fail就应该指向c.fail的孩子结点h,所以右图表示了h.fail确定后的情况。

3.2.2 确定图中i.fail指向的过程


      左图表示i.fail确定之前, 右图表示i.fail确定之后
      确定i.fail的指向时,显然h.fail(h指图中i的父结点的那个h)已指向了正确的位置。也就是说我们现在知道了目标字符串集合所有前缀中与字符序列‘a’‘b’‘c’ ‘h’的所有后缀在Trie树中的最长前缀是‘c’‘h’。显然从图中可知h.fail的孩子结点是没有i结点(这里h.fail只有一个孩子结点n)。字符序列‘c’‘h’的所有后缀在Trie树中的最长前缀可由h.fail的fail得到,而h.fail的fail指向root(依据本博客中画图的原则,这条fail虚线并未画出),root的孩子结点中存在表示字符i的结点,所以结果如右图所示。

在知道i.fail的情况下,大家可以尝试在纸上画出j.fail的指向,以加深AC自动机构造过程的理解。

4. AC自动机的java代码实现

package datastruct;import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map.Entry;public class AhoCorasickAutomation {/*本示例中的AC自动机只处理英文类型的字符串,所以数组的长度是128*/private static final int ASCII = 128;/*AC自动机的根结点,根结点不存储任何字符信息*/private Node root;/*待查找的目标字符串集合*/private List<String> target;/*表示在文本字符串中查找的结果,key表示目标字符串, value表示目标字符串在文本串出现的位置*/private HashMap<String, List<Integer>> result;/*内部静态类,用于表示AC自动机的每个结点,在每个结点中我们并没有存储该结点对应的字符*/private static class Node{/*如果该结点是一个终点,即,从根结点到此结点表示了一个目标字符串,则str != null, 且str就表示该字符串*/String str;/*ASCII == 128, 所以这里相当于128叉树*/Node[] table = new Node[ASCII];/*当前结点的孩子结点不能匹配文本串中的某个字符时,下一个应该查找的结点*/Node fail;public boolean isWord(){return str != null;}}/*target表示待查找的目标字符串集合*/public AhoCorasickAutomation(List<String> target){root = new Node();this.target = target;buildTrieTree();build_AC_FromTrie();}/*由目标字符串构建Trie树*/private void buildTrieTree(){for(String targetStr : target){Node curr = root;for(int i = 0; i < targetStr.length(); i++){char ch = targetStr.charAt(i);if(curr.table[ch] == null){curr.table[ch] = new Node();}curr = curr.table[ch];}/*将每个目标字符串的最后一个字符对应的结点变成终点*/curr.str = targetStr;}}/*由Trie树构建AC自动机,本质是一个自动机,相当于构建KMP算法的next数组*/private void build_AC_FromTrie(){/*广度优先遍历所使用的队列*/LinkedList<Node> queue = new LinkedList<Node>();/*单独处理根结点的所有孩子结点*/for(Node x : root.table){if(x != null){/*根结点的所有孩子结点的fail都指向根结点*/x.fail = root;queue.addLast(x);/*所有根结点的孩子结点入列*/}}while(!queue.isEmpty()){/*确定出列结点的所有孩子结点的fail的指向*/Node p = queue.removeFirst();for(int i = 0; i < p.table.length; i++){if(p.table[i] != null){/*孩子结点入列*/queue.addLast(p.table[i]);/*从p.fail开始找起*/Node failTo = p.fail;while(true){/*说明找到了根结点还没有找到*/if(failTo == null){p.table[i].fail = root;break;}/*说明有公共前缀*/if(failTo.table[i] != null){p.table[i].fail = failTo.table[i];break;}else{/*继续向上寻找*/failTo = failTo.fail;}}}}}}/*在文本串中查找所有的目标字符串*/public HashMap<String, List<Integer>> find(String text){/*创建一个表示存储结果的对象*/result = new HashMap<String, List<Integer>>();for(String s : target){result.put(s, new LinkedList<Integer>());}Node curr = root;int i = 0;while(i < text.length()){/*文本串中的字符*/char ch = text.charAt(i);/*文本串中的字符和AC自动机中的字符进行比较*/if(curr.table[ch] != null){/*若相等,自动机进入下一状态*/curr = curr.table[ch];if(curr.isWord()){result.get(curr.str).add(i - curr.str.length()+1);}/*这里很容易被忽视,因为一个目标串的中间某部分字符串可能正好包含另一个目标字符串,* 即使当前结点不表示一个目标字符串的终点,但到当前结点为止可能恰好包含了一个字符串*/if(curr.fail != null && curr.fail.isWord()){result.get(curr.fail.str).add(i - curr.fail.str.length()+1);}/*索引自增,指向下一个文本串中的字符*/i++;}else{/*若不等,找到下一个应该比较的状态*/curr = curr.fail;/*到根结点还未找到,说明文本串中以ch作为结束的字符片段不是任何目标字符串的前缀,* 状态机重置,比较下一个字符*/if(curr == null){curr = root;i++;}}}return result;}public static void main(String[] args){List<String> target = new ArrayList<String>();target.add("abcdef");target.add("abhab");target.add("bcd");target.add("cde");target.add("cdfkcdf");String text = "bcabcdebcedfabcdefababkabhabk";AhoCorasickAutomation aca = new AhoCorasickAutomation(target);HashMap<String, List<Integer>> result = aca.find(text);System.out.println(text);for(Entry<String, List<Integer>> entry : result.entrySet()){System.out.println(entry.getKey()+" : " + entry.getValue());}}
}

运行结果如下,从结果中我们可以看出文本串中bcd出现了二次,分别是文本串下标为3和下标为13的位置,……。

bcabcdebcedfabcdefababkabhabk
bcd : [3, 13]
cdfkcdf : []
cde : [4, 14]
abcdef : [12]
abhab : [23]

5. 参考内容

[1] AC自动机算法

AC自动机维基百科

原文地址: https://www.cnblogs.com/nullzx/p/7499397.html

【算法无用系列】AC自动机敏感词过滤相关推荐

  1. 字符串匹配算法 -- AC自动机 基于Trie树的高效的敏感词过滤算法

    文章目录 1. 算法背景 2. AC自动机实现原理 2.1 构建失败指针 2.2 依赖失败指针过滤敏感词 3. 复杂度及完整代码 1. 算法背景 之前介绍过单模式串匹配的高效算法:BM和KMP 以及 ...

  2. AC自动机:多模式串匹配实现敏感词过滤

    文章出处:极客时间<数据结构和算法之美>-作者:王争.该系列文章是本人的学习笔记. 1 敏感词过滤场景 在很多支持用户发表内容的网站,都有敏感词过滤替换的功能.例如将一些淫秽.反动内容过滤 ...

  3. Trie树实现前缀自动补全 + AC自动机实现敏感词过滤

    文章目录 背景 扩展 AC自动机 背景 最近参与了某业务系统的开发, 需要根据城市的名字简称,找到其官方的完整名称.比如云南的大理,其实其完整的名称是大理白族自治州.可以参考官方的行政区划,点这里. ...

  4. TypeScript:Aho–Corasick算法实现敏感词过滤

    敏感词过滤应该是许多后端同事经常会遇到的需求,无论是评论.弹幕.文章,都需要做敏感词过滤处理来规避风险.在前端开发中,使用replace函数来替换字符串是我们的常规操作,在这之前我思考过如果用Java ...

  5. 对敏感词过滤(DFA算法)的思考与理解

    对敏感词过滤的思考与理解 一.技术概述 1.这个技术是干什么用的? 2.学习这个技术的原因 3.技术的难点在哪 二.技术详述 1.流程图 2.代码 三.技术过程中遇见的问题和解决过程 四.总结 五.参 ...

  6. dfa算法c语言,DFA跟trie字典树实现敏感词过滤(python和c语言)

    DFA和trie字典树实现敏感词过滤(python和c语言) 现在做的项目都是用python开发,需要用做关键词检查,过滤关键词,之前用c语言做过这样的事情,用字典树,蛮高效的,内存小,检查快. 到了 ...

  7. 【图解算法面试】记一次面试:说说游戏中的敏感词过滤是如何实现的?

    版权声明:本文为苦逼的码农原创.未经同意禁止任何形式转载,特别是那些复制粘贴到别的平台的,否则,必定追究.欢迎大家多多转发,谢谢. 小秋今天去面试了,面试官问了一个与敏感词过滤算法相关的问题,然而小秋 ...

  8. 敏感词过滤 - DFA算法[确定有穷自动机]的Java 实现

    文章目录 敏感词过滤 - DFA算法[确定有穷自动机]的Java 实现 敏感词过滤 - DFA算法[确定有穷自动机]的Java 实现 代码如下 package utils;import com.goo ...

  9. 敏感词过滤算法 为内容保驾护航 Java/.Net/C++/c/Python等语言是如何进行敏感词打码限制的 高效防范违规内容

    有人的地方,就有江湖,有输入框的地方,就有注入风险!有输入框的地方,就有敏感词!敏感词就像一个平台杀手,可能直接导致平台被封锁! 敏感词是一个APP.一个网站.一个内容平台的"杀手" ...

最新文章

  1. 吃亏是福--创业[3]
  2. 【Spark】Spark SQL, DataFrames and Datasets Guide(翻译文,持续更新)
  3. lombok链式调用_记一次使用 Lombok 翻车造成的事故!
  4. 【老王来了】之隔壁路由器坏了,他来了...
  5. 神经网络 梯度下降_梯度下降优化器对神经网络训练的影响
  6. [css] padding会影响到元素的大小,那不想让它影响到元素的宽度应该怎么办?
  7. 算法(七):图解动态规划
  8. Python开发规范
  9. 洞仙歌·冰肌玉骨 [宋] 苏轼
  10. git tag 的基本用法
  11. 架构师考试的一些想法
  12. VS2015图形界面YOLO3应用程序
  13. IDEA2019安装教程
  14. 【第五组】交互设计文档Hunger Killer
  15. 如何用HBuilderX把uni-app项目运行到微信开发者工具上
  16. 烧脑难题:诡异的世界9大悖论
  17. Codeforces 1293 E. Xenon‘s Attack on the Gangs —— 树上记忆化搜索,单点加改成区间加,有丶东西
  18. 怎么将计算机的触摸鼠标锁定,戴尔电脑怎么将触控板锁定?
  19. 【数据挖掘】任务4:20Newsgroups聚类
  20. 2022年终总结--你好2023

热门文章

  1. 【electron】打开离线包-读本地文件
  2. 乘风破浪,一往无前 – Smartbi和你一起走过的2020年
  3. 印度狂妄,华为和中兴在印度5G设备市场面临不确定性,三星却已占领市场
  4. 重拾Python学习(六)----------面向对象高级编程
  5. Android Context完全解析,你所不知道的Context的各种细节
  6. STP生成树算法广播风暴的产生
  7. vue 中luckysheet实现导出
  8. AxureRP Chrome谷歌浏览器插件安装流程(图文详解)
  9. sshfs rm: cannot rm ‘mybucket’: Transport endpoint is not connected
  10. MacOS对文件夹加密的方法