简介:html
本文是博主自身对AC自动机的原理的一些理解和见解,主要以举例的方式讲解,同时又配以相应的图片。代码实现部分也予以明确的注释,但愿给你们不同的感觉。AC自动机主要用于多模式字符串的匹配,本质上是KMP算法的树形扩展。这篇文章主要介绍AC自动机的工做原理,并在此基础上用Java代码实现一个简易的AC自动机。 java
欢迎探讨,若有错误敬请指正 算法
如需转载,请注明出处 http://www.cnblogs.com/nullzx/数组
咱们如今考虑这样一个问题,在一个文本串text中,咱们想找出多个目标字符串target1,target2,……出现的次数和位置。例如:求出目标字符串集合{"nihao","hao","hs","hsr"}在给定文本"sdmfhsgnshejfgnihaofhsrnihao"中全部可能出现的位置。解决这个问题,咱们通常的办法就是在文本串中对每一个目标字符串单独查找,并记录下每次出现的位置。显然这样的方式可以解决问题,可是在文本串较大、目标字符串众多的时候效率比较低。为了提升效率,贝尔实验室于1975年发明著名的多模字符串匹配算法——AC自动机。AC自动机在实现上要依托于Trie树(也称字典树)并借鉴了KMP模式匹配算法的核心思想。实际上你能够把KMP算法当作每一个节点都仅有一个孩子节点的AC自动机。ui
2.1 初识AC自动机this
AC自动机的基础是Trie树。和Trie树不一样的是,树中的每一个结点除了有指向孩子的指针(或者说引用),还有一个fail指针,它表示输入的字符与当前结点的全部孩子结点都不匹配时(注意,不是和该结点自己不匹配),自动机的状态应转移到的状态(或者说应该转移到的结点)。fail指针的功能能够类比于KMP算法中next数组的功能。spa
咱们如今来看一个用目标字符串集合{abd,abdk, abchijn, chnit, ijabdf, ijaij}构造出来的AC自动机.net
上图是一个构建好的AC自动机,其中根结点不存储任何字符,根结点的fail指针为null。虚线表示该结点的fail指针的指向,全部表示字符串的最后一个字符的结点外部都用红圈表示,咱们称该结点为这个字符串的终结结点。每一个结点实际上都有fail指针,但为了表示方便,本文约定一个原则,即全部指向根结点的 fail虚线都未画出。3d
从上图中的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.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自动机构造过程的理解。
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]
[1] AC自动机算法