【算法笔记】AC自动机

AC自动机是一种高效的多模式字符串匹配算法,它巧妙地将 Trie树的字典结构与 KMP算法的失配指针思想相结合,能同时在一段文本中查找多个模式串的所有出现位置,广泛应用于敏感词过滤、生物信息学序列分析等领域。

1、算法概述

  • AC自动机: AC自动机是一种高效的多模式字符串匹配算法,它巧妙地将 Trie树的字典结构与 KMP算法的失配指针思想相结合,

  • 能同时在一段文本中查找多个模式串的所有出现位置,广泛应用于敏感词过滤、生物信息学序列分析等领域。

  • 在字符串匹配领域,我们会遇到两类问题:
    *

    1. 单模式匹配:给定一个文本字符串和一个模式字符串,判断模式字符串是否出现在文本字符串中。
    • 解决方案:使用 KMP算法,具体方法可参考:《【算法笔记】KMP算法》
      1. 多模式匹配:给定一个文本字符串和多个模式字符串,判断所有模式字符串是否出现在文本字符串中。
    • 解决方案:AC自动机算法。
  • AC自动机的核心思想:

    • AC自动机的核心思想是先将匹配串(敏感词)做一个预处理,生成一个前缀树,在前缀树的基础上再增加fail指针,使得匹配一个字符串的时候,从根节点出发,一次就可以匹配到所有的敏感词。
    • 这样将需要匹配的字符串扫描一遍,就可以直接匹配到所有的敏感词。
  • AC自动机的实现步骤:

    • 1、将所有的匹配串(敏感词)事先生成一个前缀树,关于前缀树的知识,可参考:《【算法笔记】前缀树》
    • 2、在前缀树的基础上,构建fail指针。
    • fail指针的含义:如果必须以当前字符结尾,当前形成的路径是str,剩下哪一个字符串的前缀和str的后缀拥有最大的匹配长度。fail指针就指向那个字符串的最后一个字符所对应的节点。
    • fail指针是AC自动机的灵魂。它为Trie树中每个节点(除根节点)维护一个指针,当在某个状态无法沿当前字符继续转移时,就跳转到Fail指针指向的状态继续匹配。
    • fail指针的构建过程(采用BFS层序遍历):
    • 2.1、根节点的fail指针指向null。
    • 2.2、对于根节点的子节点,它们的fail指针指向根节点。
    • 2.3、对于其他节点,假设当前节点为cur,它的父节点维p,通过字字符为c(例如 p -> cur的边对应的字符)到达cur节点。
    • 首先找到p的fail指针指向的节点pFail,
    • 如果pFail节点存在通过字符c转移的子节点v,那么cur的fail指针就指向v。这相当于找到了一个较短的后缀匹配路径。
    • 如果pFail节点没有字符c的转移,则继续查看pFail的fail指针指向的节点,重复此过程,直到找到满足条件的节点或回到根节点。如果最终回到根节点且根节点也没有字符c的转移,则u的fail指针指向根节点。
    • 3、在构建完前缀树和fail指针以后,如果一个字符串匹配到u节点无法往下匹配了,通过fail指针跳到fail的节点继续匹配,如果匹配成功,则记录成功匹配到的信息,
    • 如果无法匹配成功,则继续通过fail指针跳转到下一个节点,重复此过程,直到匹配成功或回到根节点。
    • 4、上面过程3是对一个字符匹配多个敏感词的情况,对于整个主串,我们只需要一次取到每一个节点,依据3的过程去匹配,即可匹配到所有的敏感词。
  • 时间复杂度:

    • 构建自动机:O(m),其中 m 是所有模式串的总长度。
    • 匹配主串:O(n + z),其中 n 是主串长度,z 是匹配到的模式串数量。匹配过程几乎是与主串长度成线性的。

2、AC自动机匹配多个关键字的实现

java 复制代码
/**
     * 匹配并输出敏感词列表的 AC自动机类
     */
    public static class AcAutomation {
        // 匹配的字符数量,这里只匹配小写字母,所以是26个
        private static final int SIZE = 26;
        private Node root;

        public AcAutomation() {
            root = new Node(SIZE);
        }

        /**
         * 向前缀树中插入匹配串(敏感词)
         * 只是构建前缀树,不进行fail指针的设置,返回类本身,方便链式编程
         */
        public AcAutomation insert(String s) {
            if (s == null) {
                return this;
            }
            // 构建前缀树
            Node cur = root;
            char[] str = s.toCharArray();
            int index = 0;
            for (int i = 0; i < str.length; i++) {
                index = str[i] - 'a';
                if (cur.nexts[index] == null) {
                    cur.nexts[index] = new Node(SIZE);
                }
                cur = cur.nexts[index];
            }
            // 标记以当前节点结尾的字符串为匹配串
            cur.end = s;
            return this;
        }

        /**
         * 构建 fail指针
         */
        public void build() {
            // 按层遍历前缀树,构建fail指针
            Queue<Node> queue = new LinkedList<>();
            queue.add(root);
            Node cur = null;
            Node fail = null;
            while (!queue.isEmpty()) {
                // 从父节点去构建子节点的 fail指针
                cur = queue.poll();
                // 遍历当前节点的所有子节点
                for (int i = 0; i < SIZE; i++) {
                    // cur -> 父亲  i号儿子,必须把i号儿子的fail指针设置好!
                    if (cur.nexts[i] == null) {
                        continue;
                    }
                    // 先将 fail指针指向根节点
                    cur.nexts[i].fail = root;
                    // 从父节点的fail开始尝试,因为我们是从父节点去设置子节点的fail指针,所以cur当前是父节点
                    fail = cur.fail;
                    while (fail != null) {
                        if (fail.nexts[i] != null) {
                            // 找到以后设置好,直接挑出while循环
                            cur.nexts[i].fail = fail.nexts[i];
                            break;
                        }
                        fail = fail.fail;
                    }
                    queue.add(cur.nexts[i]);
                }
            }

        }

        /**
         * 获取字符串 content 中包含的所有关键字字符串
         */
        public List<String> containWords(String content) {
            if (content == null || content.isEmpty()) {
                return new ArrayList<>(0);
            }
            List<String> ans = new ArrayList<>();
            char[] str = content.toCharArray();
            Node cur = root;
            Node follow = null;
            int index = 0;
            // 遍历字符串中的每个字符
            for (int i = 0; i < str.length; i++) {
                // 当前字符串的路
                index = str[i] - 'a';
                // 如果当前字符没有对应的子节点,就沿着fail指针跳转到下一个可能的匹配节点
                while (cur.nexts[index] == null && cur != root) {
                    cur = cur.fail;
                }
                // 1) 现在来到的路径,是可以继续匹配的
                // 2) 现在来到的节点,就是前缀树的根节点
                cur = cur.nexts[index] != null ? cur.nexts[index] : root;
                follow = cur;
                while (follow != root) {
                    if (follow.endUse) {
                        break;
                    }
                    // 不同的需求,在这一段之间修改
                    if (follow.end != null) {
                        ans.add(follow.end);
                        follow.endUse = true;
                    }
                    // 不同的需求,在这一段之间修改
                    follow = follow.fail;
                }
            }
            return ans;
        }

        /**
         * AC 自动机的节点类
         */
        class Node {
            // 以当前节点为结尾的字符串的整体匹配串,这是非AC自动机的必要字段,只是为了方便本示例输出匹配到的敏感词使用
            private String end;
            // 以当前节点结尾的匹配串是否已经使用过的标记,这是非AC自动机的必要字段,只是为了方便本示例输出匹配到的敏感词不重复使用
            private boolean endUse;
            // fail指针,指向当前节点的最长后缀匹配节点
            private Node fail;
            // 子节点数组,每个元素对应一个字符的转移
            private Node[] nexts;

            public Node(int size) {
                endUse = false;
                end = null;
                fail = null;
                // 这里只匹配了小写字母的示例,所以只需要26个位置
                nexts = new Node[size];
            }
        }
    }

整体和测试代码如下:

java 复制代码
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;

/**
 * AC自动机: AC自动机是一种高效的多模式字符串匹配算法,它巧妙地将 Trie树的字典结构与 KMP算法的失配指针思想相结合,
 * 能同时在一段文本中查找多个模式串的所有出现位置,广泛应用于敏感词过滤、生物信息学序列分析等领域。
 * <br>
 * 在字符串匹配领域,我们会遇到两类问题:
 * 1. 单模式匹配:给定一个文本字符串和一个模式字符串,判断模式字符串是否出现在文本字符串中。
 * 解决方案:使用 KMP算法,具体方法可参考:[《【算法笔记】KMP算法》](https://blog.csdn.net/u012559967/article/details/155490610)
 * 2. 多模式匹配:给定一个文本字符串和多个模式字符串,判断所有模式字符串是否出现在文本字符串中。
 * 解决方案:AC自动机算法。
 * <br>
 * AC自动机的核心思想:
 * AC自动机的核心思想是先将匹配串(敏感词)做一个预处理,生成一个前缀树,在前缀树的基础上再增加fail指针,使得匹配一个字符串的时候,从根节点出发,一次就可以匹配到所有的敏感词。
 * 这样将需要匹配的字符串扫描一遍,就可以直接匹配到所有的敏感词。
 * <br>
 * AC自动机的实现步骤:
 * 1、将所有的匹配串(敏感词)事先生成一个前缀树,关于前缀树的知识,可参考:[《【算法笔记】前缀树》](https://blog.csdn.net/u012559967/article/details/151823140)
 * 2、在前缀树的基础上,构建fail指针。
 * fail指针的含义:如果必须以当前字符结尾,当前形成的路径是str,剩下哪一个字符串的前缀和str的后缀拥有最大的匹配长度。fail指针就指向那个字符串的最后一个字符所对应的节点。
 * fail指针是AC自动机的灵魂。它为Trie树中每个节点(除根节点)维护一个指针,当在某个状态无法沿当前字符继续转移时,就跳转到Fail指针指向的状态继续匹配。
 * fail指针的构建过程(采用BFS层序遍历):
 * 2.1、根节点的fail指针指向null。
 * 2.2、对于根节点的子节点,它们的fail指针指向根节点。
 * 2.3、对于其他节点,假设当前节点为cur,它的父节点维p,通过字字符为c(例如 p -> cur的边对应的字符)到达cur节点。
 * 首先找到p的fail指针指向的节点pFail,
 * 如果pFail节点存在通过字符c转移的子节点v,那么cur的fail指针就指向v。这相当于找到了一个较短的后缀匹配路径。
 * 如果pFail节点没有字符c的转移,则继续查看pFail的fail指针指向的节点,重复此过程,直到找到满足条件的节点或回到根节点。如果最终回到根节点且根节点也没有字符c的转移,则u的fail指针指向根节点。
 * 3、在构建完前缀树和fail指针以后,如果一个字符串匹配到u节点无法往下匹配了,通过fail指针跳到fail的节点继续匹配,如果匹配成功,则记录成功匹配到的信息,
 * 如果无法匹配成功,则继续通过fail指针跳转到下一个节点,重复此过程,直到匹配成功或回到根节点。
 * 4、上面过程3是对一个字符匹配多个敏感词的情况,对于整个主串,我们只需要一次取到每一个节点,依据3的过程去匹配,即可匹配到所有的敏感词。
 * <br>
 * 时间复杂度:
 * 构建自动机:O(m),其中 m 是所有模式串的总长度。
 * 匹配主串:O(n + z),其中 n 是主串长度,z 是匹配到的模式串数量。匹配过程几乎是与主串长度成线性的。
 */
public class Ac {


    /**
     * 匹配并输出敏感词列表的 AC自动机类
     */
    public static class AcAutomation {
        // 匹配的字符数量,这里只匹配小写字母,所以是26个
        private static final int SIZE = 26;
        private Node root;

        public AcAutomation() {
            root = new Node(SIZE);
        }

        /**
         * 向前缀树中插入匹配串(敏感词)
         * 只是构建前缀树,不进行fail指针的设置,返回类本身,方便链式编程
         */
        public AcAutomation insert(String s) {
            if (s == null) {
                return this;
            }
            // 构建前缀树
            Node cur = root;
            char[] str = s.toCharArray();
            int index = 0;
            for (int i = 0; i < str.length; i++) {
                index = str[i] - 'a';
                if (cur.nexts[index] == null) {
                    cur.nexts[index] = new Node(SIZE);
                }
                cur = cur.nexts[index];
            }
            // 标记以当前节点结尾的字符串为匹配串
            cur.end = s;
            return this;
        }

        /**
         * 构建 fail指针
         */
        public void build() {
            // 按层遍历前缀树,构建fail指针
            Queue<Node> queue = new LinkedList<>();
            queue.add(root);
            Node cur = null;
            Node fail = null;
            while (!queue.isEmpty()) {
                // 从父节点去构建子节点的 fail指针
                cur = queue.poll();
                // 遍历当前节点的所有子节点
                for (int i = 0; i < SIZE; i++) {
                    // cur -> 父亲  i号儿子,必须把i号儿子的fail指针设置好!
                    if (cur.nexts[i] == null) {
                        continue;
                    }
                    // 先将 fail指针指向根节点
                    cur.nexts[i].fail = root;
                    // 从父节点的fail开始尝试,因为我们是从父节点去设置子节点的fail指针,所以cur当前是父节点
                    fail = cur.fail;
                    while (fail != null) {
                        if (fail.nexts[i] != null) {
                            // 找到以后设置好,直接挑出while循环
                            cur.nexts[i].fail = fail.nexts[i];
                            break;
                        }
                        fail = fail.fail;
                    }
                    queue.add(cur.nexts[i]);
                }
            }

        }

        /**
         * 获取字符串 content 中包含的所有关键字字符串
         */
        public List<String> containWords(String content) {
            if (content == null || content.isEmpty()) {
                return new ArrayList<>(0);
            }
            List<String> ans = new ArrayList<>();
            char[] str = content.toCharArray();
            Node cur = root;
            Node follow = null;
            int index = 0;
            // 遍历字符串中的每个字符
            for (int i = 0; i < str.length; i++) {
                // 当前字符串的路
                index = str[i] - 'a';
                // 如果当前字符没有对应的子节点,就沿着fail指针跳转到下一个可能的匹配节点
                while (cur.nexts[index] == null && cur != root) {
                    cur = cur.fail;
                }
                // 1) 现在来到的路径,是可以继续匹配的
                // 2) 现在来到的节点,就是前缀树的根节点
                cur = cur.nexts[index] != null ? cur.nexts[index] : root;
                follow = cur;
                while (follow != root) {
                    if (follow.endUse) {
                        break;
                    }
                    // 不同的需求,在这一段之间修改
                    if (follow.end != null) {
                        ans.add(follow.end);
                        follow.endUse = true;
                    }
                    // 不同的需求,在这一段之间修改
                    follow = follow.fail;
                }
            }
            return ans;
        }

        /**
         * AC 自动机的节点类
         */
        class Node {
            // 以当前节点为结尾的字符串的整体匹配串,这是非AC自动机的必要字段,只是为了方便本示例输出匹配到的敏感词使用
            private String end;
            // 以当前节点结尾的匹配串是否已经使用过的标记,这是非AC自动机的必要字段,只是为了方便本示例输出匹配到的敏感词不重复使用
            private boolean endUse;
            // fail指针,指向当前节点的最长后缀匹配节点
            private Node fail;
            // 子节点数组,每个元素对应一个字符的转移
            private Node[] nexts;

            public Node(int size) {
                endUse = false;
                end = null;
                fail = null;
                // 这里只匹配了小写字母的示例,所以只需要26个位置
                nexts = new Node[size];
            }
        }
    }

    public static void main(String[] args) {
        AcAutomation ac = new AcAutomation();
        // 添加匹配串(敏感词)
        ac.insert("dhe")
                .insert("he")
                .insert("hx")
                .insert("abcdheks");
        // 设置 fail指针
        ac.build();

        List<String> contains = ac.containWords("abcdhekskdjfafhadfgsdfgsdsldkflskdjhwqaeruv");
        for (String word : contains) {
            System.out.println(word);
        }
    }
}

后记

个人学习总结笔记,不能保证非常详细,轻喷

相关推荐
张工摆Bug2 小时前
《别再写满屏的if-else了!Spring Boot + 策略模式实战优化》
java
独自归家的兔2 小时前
基于GUI-PLUS 搭配 Java Robot 实现智能桌面操控
java·开发语言·人工智能
用户3721574261352 小时前
Python 实现 PDF 文档压缩:完整指南
java
ew452182 小时前
【JAVA】实现word的DOCX/DOC文档内容替换、套打、支持表格内容替换。
java·开发语言·word
IT猿手2 小时前
基于粒子群算法与动态窗口混合的无人机三维动态避障路径规划研究,MATLAB代码
算法·matlab·无人机·多目标优化算法·多目标算法
贺今宵2 小时前
装Maven并在idea上配置
java·maven·intellij-idea
民乐团扒谱机2 小时前
【微实验】仿AU音频编辑器开发实践:从零构建音频可视化工具
算法·c#·仿真·audio·fft·频谱
DanyHope2 小时前
LeetCode 283. 移动零:双指针双解法(原地交换 + 覆盖补零)全解析
数据结构·算法·leetcode
qq_12498707532 小时前
基于springboot的幼儿园家校联动小程序的设计与实现(源码+论文+部署+安装)
java·spring boot·后端·spring·微信小程序·小程序