java编程之AC自动机工作原理与实现代码

在阅读本文之前,大家可以先参考下《多模字符串匹配算法原理及Java实现代码》

简介:

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

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

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

AC自动机用于多模匹配,所谓多模匹配,就是给定一个带匹配的字符串string,给定一个字典dictionary,dictionary中有多个字符串{ str1,str2, str3 … } 多模匹配就是要得到string字符串中出现了dictionary的哪些字符,且这些字符出现在了string中的哪个位置。

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.2AC自动机的运行过程:

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]

总结

以上就是本文关于java编程之AC自动机工作原理与实现代码的全部内容,希望对大家有所帮助。感兴趣的朋友可以继续参阅本站:

java算法实现红黑树完整代码示例

java编程之递归算法总结

java实现的各种排序算法代码示例

如有不足之处,欢迎留言指出。

(0)

相关推荐

  • Java实现AC自动机全文检索示例

    第一步,构建Trie树,定义Node类型: /** * Created by zhaoyy on 2017/2/7. */ interface Node { char value(); boolean exists(); boolean isRoot(); Node parent(); Node childOf(char c); Node fail(); void setFail(Node node); void setExists(boolean exists); void add(Node

  • java编程之AC自动机工作原理与实现代码

    在阅读本文之前,大家可以先参考下<多模字符串匹配算法原理及Java实现代码> 简介: 本文是博主自身对AC自动机的原理的一些理解和看法,主要以举例的方式讲解,同时又配以相应的图片.代码实现部分也予以明确的注释,希望给大家不一样的感受.AC自动机主要用于多模式字符串的匹配,本质上是KMP算法的树形扩展.这篇文章主要介绍AC自动机的工作原理,并在此基础上用Java代码实现一个简易的AC自动机. 1.应用场景-多模字符串匹配 我们现在考虑这样一个问题,在一个文本串text中,我们想找出多个目标字符串

  • 详解Java中AC自动机的原理与实现

    目录 简介 工作过程 数据结构 初始化 构建字典树 构建失败指针 匹配 执行结果 简介 AC自动机是一个多模式匹配算法,在模式匹配领域被广泛应用,举一个经典的例子,违禁词查找并替换为***.AC自动机其实是Trie树和KMP 算法的结合,首先将多模式串建立一个Tire树,然后结合KMP算法前缀与后缀匹配可以减少不必要比较的思想达到高效找到字符串中出现的匹配串. 如果不知道什么是Tire树,可以先查看:详解Java中字典树(Trie树)的图解与实现 如果不知道KMP算法,可以先查看:详解Java中

  • Java编程之jdk1.4,jdk1.5和jdk1.6的区别分析(经典)

    本文结合实例详细分析了Java编程之jdk1.4,jdk1.5和jdk1.6的区别.分享给大家供大家参考,具体如下: 简单说:1.4和1.5最大的区别有两个,一个是1.5有泛型,另一个1.5可以自动封装八大基本数据类型的封装数据类型,即,Integer a = 4这个1.4是不可以的.1.5和1.6的区别不大.1.6我觉得最多的变化,我觉得最大的部分是在GUI上面,提供了很多方便的布局管理和扩展. 这段时间进了一家电子政务公司,都用weblogic8,那咱就用jdk1.4吧,eclipse一改j

  • Java数据结构之AC自动机算法的实现

    目录 1 概念和原理 2 节点定义 3 构建Trie前缀树 4 构建fail失配指针 5 匹配文本 6 案例演示 7 总结 1 概念和原理 一般的字符串匹配算法都是匹配一个子串,例如KMP.Trie,那么如果同时匹配多个子串呢?此时就需要用到AC自动机了. AC自动机算法是一个多模式字符串匹配算法,在模式匹配领域被广泛应用,例如违禁词查找替换.搜索关键词查找等等. 关于Trie树和KMP算法,我们此前已经讲解过了: 前缀树Trie的实现原理以及Java代码的实现 KMP算法详解以及Java代码实

  • 解析Java编程之Synchronized锁住的对象

    图片上传 密码修改为  synchronized是java中用于同步的关键字,一般我们通过Synchronized锁住一个对象,来进行线程同步.我们需要了解在程序执行过程中,synchronized锁住的到底是哪个对象,否则我们在多线程的程序就有可能出现问题. 看下面的代码,我们定义了一个静态变量n,在run方法中,我们使n增加10,然后在main方法中,我们开辟了100个线程,来执行n增加的操作,如果线程没有并发执行,那么n最后的值应该为1000,显然下面的程序执行完结果不是1000,因为我们

  • java编程之xpath介绍

    一.使用dom4j支持XPATH的操作 -可以直接获取到某个元素,而不用一层一层的解析获取 XPATH如何使用: 第一种形式:/AAA/BBB/CCC,一个/代表一层,表示获取到AAA下面的BBB下面的CCC 第二种形式://BBB,表示和这个名称相同的都可以得到,只要名称是BBB都可以得到.//DDD/BBB:得到所有DDD下面的所有的BBB 第三种形式:/AAA/BBB/CCC/*,得到所有AAA下面BBB下面CCC下面的所有的元素./*/*/*/BBB,表示限制前三层,前三层无论是什么名称

  • java中Servlet监听器的工作原理及示例详解

    监听器就是一个实现特定接口的普通java程序,这个程序专门用于监听另一个java对象的方法调用或属性改变,当被监听对象发生上述事件后,监听器某个方法将立即被执行. 监听器原理 监听原理 1.存在事件源 2.提供监听器 3.为事件源注册监听器 4.操作事件源,产生事件对象,将事件对象传递给监听器,并且执行监听器相应监听方法 监听器典型案例:监听window窗口的事件监听器 例如:swing开发首先制造Frame**窗体**,窗体本身也是一个显示空间,对窗体提供监听器,监听窗体方法调用或者属性改变:

  • Java多线程揭秘之synchronized工作原理

    目录 一. 特性 二. 加锁过程(锁升级/锁膨胀) 1. 无锁状态 2. 偏向锁 3. 轻量级锁 4. 重量级锁 5. 总结 三. 锁优化 1. 锁消除 2. 锁粗化 在学习本篇文章时,如果有不太懂的地方,大家也可以先看看博主上一篇文章,锁的这部分内容是面试中很常见的问题,多学学对自己是非常有帮助的.同时,朋友们如果有什么问题都可以随时和我探讨,大家一起进步! 一. 特性 这部分内容在上篇文章中的 synchronized充当了哪些锁部分已经介绍过了哦,没有看的小伙伴可以去看看synchroni

  • Python编程之gui程序实现简单文件浏览器代码

    本文主要分享了关于在python中实现一个简单的文件浏览器的代码示例,代码及展示如下. #!/usr/bin/env python # -*- coding: UTF-8 -*- import os from time import sleep from Tkinter import * class DirList(object): def __init__(self, initdir=None): '''构造函数,说明版本信息''' self.top = Tk() self.label = L

  • 浅谈Java编程之if-else的优化技巧总结

    一.使用策略枚举来优化if-else 看到网上蛮多人推荐使用策略模式来优化if-else,但我总觉得,搞一堆策略类来优化大批量if-else,虽然想法很好,但无意之中很可能又会创造出很多类对象,就显得过于繁重了.若想使用策略模式来优化大批量if-else,其实有一种更好的方式,这是策略模式+枚举方式的改良 二.使用三目运算符来优化if-else 1.根据if-else条件来判断赋值的,如: String id=""; if(flag){ id="a"; }else{

随机推荐