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

   2023-02-09 学习力0
核心提示:目录1 概念和原理2 节点定义3 构建Trie前缀树4 构建fail失配指针5 匹配文本6 案例演示7 总结1 概念和原理一般的字符串匹配算法都是匹配一个子串,例如KMP、Trie,那么如果同时匹配多个子串呢?此时就需要用到AC自动机了。AC自动机算法是一个多模式字符串匹配

1 概念和原理

一般的字符串匹配算法都是匹配一个子串,例如KMP、Trie,那么如果同时匹配多个子串呢?此时就需要用到AC自动机了。

AC自动机算法是一个多模式字符串匹配算法,在模式匹配领域被广泛应用,例如违禁词查找替换、搜索关键词查找等等。

关于Trie树和KMP算法,我们此前已经讲解过了:

AC自动机算法常被认为是Trie树+KMP算法的结合体,为什么呢?我们先看看它的构建步骤:

  • 对所有的关键词构建Trie前缀树。
  • 为Trie树上的所有节点构建fail失配指针。

第一步,对所有的关键词构建Trie前缀树。这一步利用Trie的特点构建快速前缀查找结构,trie树的特点是可以从字符串头部开始匹配,并且相同前缀的词共用前面的节点,因此它可以避免相同前缀pattern的重复匹配,但是对于相同的后缀无能为力。

第二步,为Trie树上的所有节点构建fail失配指针,即匹配失败后应该跳到哪个节点。所谓节点的失配指针,就是指向当前字符串最长真后缀位置的指针节点。这里需要理解KMP的next数组的概念,这一步就是利用KMP前后缀匹配的思想实现关键词前缀失配时利用相同的后缀信息快速跳转到另一个关键词继续前缀匹配。他们的区别是:

  • 在KMP算法中,是针对单个关键词匹配,求出的最长匹配长度的前缀和后缀都位于同一个关键词内。例如关键词abcdabc,最长匹配前后缀,为abc,他们属于该关键词。
  • 在AC自动机算法中,是针对多个关键词匹配,求出的最长匹配长度的前缀和后缀分别属于不同的关键词的前缀和后缀。

例如两个关键词dabab,ababd,那么关键词dabab中第二个b位置的失败指针应该指向关键词ababd中的第二个b。即dabab的后缀与ababd的前缀能够匹配的最长子串为abab。

2 节点定义

在这里,我们给出一个比较简单的节点的定义。

  • next,表示经过该节点的模式串的下层节点,这是Trie树结构的保证,存储着子节点的值到对应的节点的映射关系。
  • depth,表示以当前即诶单结尾的模式串的长度,也是节点的深度,默认为0。
  • failure,失配指针,其指向表示另一个关键词前缀的最长后缀节点,如果当前节点没有匹配到,则跳转到此节点继续匹配。如果当前节点匹配到了,那么可以通过此指针找到该节点的模式串包含的最长后缀模式串。
class AcNode {
    /**
     * 经过该节点的模式串的下层节点
     */
    Map<Character, AcNode> next = new HashMap<>();

    /**
     * 模式串的长度,也是节点的深度
     */
    int depth;

    /**
     * 失配指针,如果没有匹配到,则跳转到此状态。
     */
    AcNode failure;

    public boolean hashNext(char nextKey) {
        return next.containsKey(nextKey);
    }

    public AcNode getNext(char nextKey) {
        return next.get(nextKey);
    }
}

3 构建Trie前缀树

构建Ac自动机的Trie的方法和构建普通Trie的方法几乎一致。在添加每个模式串成功后,会为最后一个节点的depth赋值为当前模式串的长度。

/**
 * trie根节点
 */
private AcNode root;
/**
 * 加入模式串,构建Trie
 *
 * @param word 模式串,非空
 */
public void insert(String word) {
    AcNode cur = root;
    for (char c : word.toCharArray()) {
        if (!cur.next.containsKey(c)) {
            cur.next.put(c, new AcNode());
        }
        cur = cur.next.get(c);
    }
    cur.depth = word.length();
}

4 构建fail失配指针

构建fail失配指针的一种常见的方法如下,实际上是一个BFS层序遍历的算法:

1.Trie的root节点没有失配指针,或者说失配指针为null,其他节点都有失配指针,或者说不为null。

2.遍历root节点的所有下一层直接子节点,将它们的失配指针设置为root。因为这些节点代表着所有模式串的第一个字符,基于KMP的next数组定义,单个字符没有最长真后缀,此时直接指向root。

3.继续循环向下遍历每一层的子节点,由于bfs的遍历,那么上一层父节点的失配指针肯定都已经确定了。基于next数组的构建思想,子节点的失配指针可以通过父节点的是失配指针快速推导出来。设当前遍历的节点为c,它的父节点为p,父节点的失配指针为pf。

  • 如果pf节点的子节点对应的字符中,包含了当前节点的所表示的字符。那么基于求最长后缀的原理,此时c节点的失配指针可以直接指向pf节点下的相同字符对应的子节点。
  • 如果pf节点的子节点对应的字符中,没有包含了当前节点的所表示的字符。那么继续获取pf节点的失配指针节点,继续重复判断。直到满足第一种情况,或者pf指向了根节点,并且根节点的子节点也没有匹配,那么此时直接将c节点的失配指针指向根节点。
/**
 * 为所有节点构建失配指针,一个bfs层序遍历
 */
public void buildFailurePointer() {
    ArrayDeque<AcNode> queue = new ArrayDeque<AcNode>();
    //将所有root的直接子节点的failure设置为root,并且加入queue
    for (AcNode acNode : root.next.values()) {
        acNode.failure = root;
        queue.addLast(acNode);
    }
    //bfs构建失配指针
    while (!queue.isEmpty()) {
        //父节点出队列
        AcNode parent = queue.pollFirst();
        //遍历父节点的下层子节点,基于父节点求子节点的失配指针
        for (Map.Entry<Character, AcNode> characterAcNodeEntry : parent.next.entrySet()) {
            //获取父节点的失配指针
            AcNode pf = parent.failure;
            //获取子节点
            AcNode child = characterAcNodeEntry.getValue();
            //获取子节点对应的字符
            Character nextKey = characterAcNodeEntry.getKey();
            //如果pf节点不为null,并且pf节点的子节点对应的字符中,没有包含了当前节点的所表示的字符
            while (pf != null && !pf.hashNext(nextKey)) {
                //继续获取pf节点的失配指针节点,继续重复判断
                pf = pf.failure;
            }
            //pf为null,表示找到了根节点,并且根节点的子节点也没有匹配
            if (pf == null) {
                //此时直接将节点的失配指针指向根节点
                child.failure = root;
            }
            //pf节点的子节点对应的字符中,包含了当前节点的所表示的字符
            else {
                //节点的失配指针可以直接指向pf节点下的相同字符对应的子节点
                child.failure = pf.getNext(nextKey);
            }
            //最后不要忘了,将当前节点加入队列
            queue.addLast(child);
        }
    }
}

5 匹配文本

构建完AC自动机之后,下面我们需要进行文本的匹配,匹配的方式实际上比较简单。

1.遍历文本的每个字符,依次匹配,从Trie的根节点作为cur节点开始匹配:

2.将当前字符作为nextKey,如果cur节点不为null且节点的next映射中不包含nextKey,那么当前cur节点指向自己的failure失配指针。

3.如果cur节点为null,说明当前字符匹配到了root根节点且失败,那么cur设置为root继续从根节点开始进行下一轮匹配。

4.否则表示匹配成功的节点,cur指向匹配节点,获取该节点继续判断:

  • 如果该节点是某个关键词的结尾,那么取出来,也就是depth不为0,那么表示匹配到了一个关键词。
  • 继续判断该节点的失配指针节点表示的模式串。因为失配指针节点表示的模式串是当前匹配的模式串的在这些关键词中的最长后缀,且由于当前节点的路径包括了失配指针的全部路径,并且失配指针路径也是一个完整的关键词,需要找出来。
/**
 * 匹配文本
 *
 * @param text 文本字符串
 */
public List<ParseResult> parseText(String text) {
    List<ParseResult> parseResults = new ArrayList<>();
    char[] chars = text.toCharArray();
    //从根节点开始匹配
    AcNode cur = root;
    //遍历字符串的每个字符
    for (int i = 0; i < chars.length; i++) {
        //当前字符
        char nextKey = chars[i];
        //如果cur不为null,并且当前节点的的子节点不包括当前字符,即不匹配
        while (cur != null && !cur.hashNext(nextKey)) {
            //那么通过失配指针转移到下一个节点继续匹配
            cur = cur.failure;
        }
        //如果节点为null,说明当前字符匹配到了根节点且失败
        //那么继续从根节点开始进行下一轮匹配
        if (cur == null) {
            cur = root;
        } else {
            //匹配成功的节点
            cur = cur.getNext(nextKey);
            //继续判断
            AcNode temp = cur;
            while (temp != null) {
                //如果当前节点是某个关键词的结尾,那么取出来
                if (temp.depth != 0) {
                    int start = i - temp.depth + 1, end = i;
                    parseResults.add(new ParseResult(start, end, new String(chars, start, temp.depth)));
                    //System.out.println(start + " " + end + " " + new String(chars, start, temp.depth));
                }
                //继续判断该节点的失配指针节点
                //因为失配指针节点表示的模式串是当前匹配的模式串的在这些关键词中的最长后缀,且由于当前节点的路径包括了失配指针的全部路径
                //并且失配指针路径也是一个完整的关键词,需要找出来。
                temp = temp.failure;
            }
        }
    }
    return parseResults;
}

class ParseResult {
    int startIndex;
    int endIndex;
    String key;

    public ParseResult(int startIndex, int endIndex, String key) {
        this.startIndex = startIndex;
        this.endIndex = endIndex;
        this.key = key;
    }

    @Override
    public String toString() {
        return "{" +
                "startIndex=" + startIndex +
                ", endIndex=" + endIndex +
                ", key='" + key + '\'' +
                '}';
    }
}

6 案例演示

基于上面的方法,假如关键词为:he、shes、shers、hes、h、e,那么最终构建的AC自动机的结构如下(红色圈表示某个关键词的结束位置)。

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

假如我们的文本为sheshe,那么我们来看看AC自动机匹配的过程:

遍历文本,cur首先指向root。

nextKey=s,cur.next包含s,表示这是一个匹配成功的节点,那么获取到该节点s,cur=s,s不是某个关键词的结尾,failure节点也不包含模式串,那么查找完毕进行下一轮。

nextKey=h,cur=s,cur.next包含h,表示这是一个匹配成功的节点,那么获取到该节点h,cur=h,h节点不是某个关键词的结尾,但是它的failure节点包含模式串h,因此找到了第1个匹配的关键词h,查找完毕后进行下一轮。

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

nextKey=e,cur=h,cur.next包含e,表示这是一个匹配成功的节点,那么获取到该节点e,cur=e,e节点不是某个关键词的结尾,但是它的failure节点包含模式串he,因此找到了第2个匹配的关键词he。

而fuilure节点e又包含另一个模式串e,因此找到了第3个匹配的关键词e,查找完毕后进行下一轮。

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

nextKey=s,cur=e,cur.next包含s,表示这是一个匹配成功的节点,那么获取到该节点e,cur=e,s节点是关键词shes的结尾,因此找到了第4个匹配的关键词shes。

继续判断s的failure,它的failure节点包含模式串hes,因此找到了第5个匹配的关键词hes,查找完毕后进行下一轮。

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

nextKey=h,cur=s,cur.next不包含h,表示这是一个匹配失败的节点,那么继续匹配它的failure节点,经过s-s-s的匹配,最终匹配到子节点h。

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

该节点h并不是关键词的结尾,但是h的failure,它的failure节点包含模式串h,因此找到了第6个匹配的关键词h,查找完毕后进行下一轮。

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

nextKey=e,cur=h,cur.next包含e,表示这是一个匹配成功的节点,那么获取到该节点e,cur=e,e节点不是某个关键词的结尾,但是它的failure节点包含模式串he,因此找到了第7个匹配的关键词he。

而fuilure节点e又包含另一个模式串e,因此找到了第8个匹配的关键词e。

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

到此字符串遍历完毕,查找完毕!

最终文本sheshe,匹配到关键词如下:

[{startIndex=1, endIndex=1, key='h'}, {startIndex=1, endIndex=2, key='he'}, 
{startIndex=2, endIndex=2, key='e'}, {startIndex=0, endIndex=3, key='shes'}, 
{startIndex=1, endIndex=3, key='hes'}, {startIndex=4, endIndex=4, key='h'}, 
{startIndex=4, endIndex=5, key='he'}, {startIndex=5, endIndex=5, key='e'}]

7 总结

AC自动机匹配某个文本text,需要遍历文本的每个字符,每次遍历过程中,都可能涉及到循环向上查找失配指针的情况,但是这里的循环次数不会超过Trie树的深度,在最后匹配成功时,同样涉及到向上查找失配指针的情况,这里的循环次数不会超过Trie树的深度。

设匹配的文本长度m,模式串平均长度n,那么AC自动机算法的匹配的时间复杂度为O(m*n)。可以发现,匹配的时间复杂度和关键词的数量无关,这就是AC自动机的强大之处。如果考虑模式串平均长度不会很长,那么时间复杂度近似O(m)。

原文地址:https://blog.csdn.net/weixin_43767015/article/details/128160307
 
标签: Java AC 自动机 算法
反对 0举报 0 评论 0
 

免责声明:本文仅代表作者个人观点,与乐学笔记(本网)无关。其原创性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容、文字的真实性、完整性、及时性本站不作任何保证或承诺,请读者仅作参考,并请自行核实相关内容。
    本网站有部分内容均转载自其它媒体,转载目的在于传递更多信息,并不代表本网赞同其观点和对其真实性负责,若因作品内容、知识产权、版权和其他问题,请及时提供相关证明等材料并与我们留言联系,本网站将在规定时间内给予删除等相关处理.

  • #新闻拍一拍# Oracle 调研如何避免让 Java 开发者投奔 Rust 和 Kotlin | Linux 中国
    #新闻拍一拍# Oracle 调研如何避免让 Java 开发
     导读:• 英特尔对迟迟不被 Linux 主线接受的 SGX Enclave 进行了第 38 次修订 • ARM 支持开源的 Panfrost Gallium3D 驱动本文字数:977,阅读时长大约:1分钟作者:硬核老王Oracle 调研如何避免让 Java 开发者投奔 Rust 和 KotlinOracle 委托分析公司 Omd
    03-08
  • oogle的“ JavaScript杀手” Dart 与JavaScript的比较
    oogle的“ JavaScript杀手” Dart 与JavaScript
    JavaScript通常被称为浏览器脚本语言,但它也已扩展到许多服务器端和移动应用程序开发环境。JS已经存在了将近20年,可以肯定地说它确实是一种成熟且稳定的编程语言。在Facebook发布React和React Native框架之后,JS变得越来越流行。JavaScript具有自己的软件
    03-08
  • sf02_选择排序算法Java Python rust 实现
    Java 实现package common;public class SimpleArithmetic {/** * 选择排序 * 输入整形数组:a[n] 【4、5、3、7】 * 1. 取数组编号为i(i属于[0 , n-2])的数组值 a[i],即第一重循环 * 2. 假定a[i]为数组a[k](k属于[i,n-1])中的最小值a[min],即执行初始化 min =i
    02-09
  • Delphi XE6 通过JavaScript API调用百度地图
    Delphi XE6 通过JavaScript API调用百度地图
    参考昨天的内容,有朋友还是问如何调用百度地图,也是,谁让咱都在国内呢,没办法,你懂的。 首先去申请个Key,然后看一下百度JavaScript的第一个例子:http://developer.baidu.com/map/jsdemo.htm下一步,就是把例子中的代码,移动TWebBrower中。 unit Unit
    02-09
  • JavaScript面向对象轻松入门之抽象(demo by ES5
    抽象的概念  狭义的抽象,也就是代码里的抽象,就是把一些相关联的业务逻辑分离成属性和方法(行为),这些属性和方法就可以构成一个对象。  这种抽象是为了把难以理解的代码归纳成与现实世界关联的概念,比如小狗这样一个对象:属性可以归纳出“毛色”、
    02-09
  • Java与Objective-C的渊源 objective-c和c++的区
    java创始成员Patrick Naughton回忆,通常人们会认为Java是学Modula-3和C+,其实这些都是谣传,而对Java影响比较大的则是Objective-C:单 继承、动态绑定和加载、类对象、纯虚函数、反射、原始类型包装类等。Java的接口直接抄自OC的协议。  Objective-C是扩
    02-09
  • Java项目导出数据为 PDF 文件的操作代码
    Java项目导出数据为 PDF 文件的操作代码
    目录Java项目如何导出数据为 PDF 文件?一、代码结构如下二、代码说明1、添加依赖 pom.xml2、HTML模板文件 audit_order_record.html3、添加字体4、PDF 导出工具类5、导出接口6、打开浏览器测试三、效果图Java项目如何导出数据为 PDF 文件?一个小需求,需要将
  • 盘点Java中延时任务的多种实现方式 java 延时队列怎么实现
    盘点Java中延时任务的多种实现方式 java 延时队
    目录场景描述实现方式一、挂起线程二、ScheduledExecutorService 延迟任务线程池三、DelayQueue(延时队列)四、Redis-为key指定超时时长,并监听失效key五、时间轮六、消息队列-延迟队列场景描述①需要实现一个定时发布系统通告的功能,如何实现? ②支付超时
  • Java Semaphore信号量使用分析讲解
    Java Semaphore信号量使用分析讲解
    目录前言介绍和使用API介绍基本使用原理介绍获取许可acquire()释放许可release()总结前言大家应该都用过synchronized 关键字加锁,用来保证某个时刻只允许一个线程运行。那么如果控制某个时刻允许指定数量的线程执行,有什么好的办法呢? 答案就是JUC提供的信
  • 【Java并发入门】03 互斥锁(上):解决原子性问题
    【Java并发入门】03 互斥锁(上):解决原子性
    原子性问题的源头是线程切换Q:如果禁用 CPU 线程切换是不是就解决这个问题了?A:单核 CPU 可行,但到了多核 CPU 的时候,有可能是不同的核在处理同一个变量,即便不切换线程,也有问题。所以,解决原子性的关键是「同一时刻只有一个线程处理该变量,也被称
    02-09
点击排行