每日算法 — 使用java实现AC自动机:多模式字符串匹配与敏感词过滤系统

一、问题背景与算法选型

在文本处理领域,多模式字符串匹配是一个高频需求:给定一个长文本和多个模式串(如敏感词库),需要一次性找出文本中所有模式串的出现位置。朴素做法是对每个模式串分别执行一次KMP或BF搜索,时间复杂度为 O(N * M * L),其中 N 为文本长度,M 为模式串数量,L 为模式串平均长度。当模式串成千上万时,这种做法显然不可扩展。

AC自动机(Aho-Corasick Automaton)由 Alfred Aho 和 Margaret Corasick 于1975年提出,它将所有模式串预先组织成一棵 Trie前缀树,并在树上为每个节点构建 失败指针(fail指针),使得匹配过程中一旦当前字符不匹配,可以沿着失败指针快速跳转到下一个可能匹配的节点,而无需回退文本指针。最终,AC自动机能在 O(N + Z) 时间内完成多模式匹配,其中 Z 为所有匹配结果的总长度。

二、核心数据结构:Trie节点

AC自动机的每个节点对应Trie树中的一个前缀,包含以下信息:

  • children:子节点映射(字符 → 节点)
  • fail:失败指针,指向当前前缀的最长真后缀所对应的节点
  • output:以当前节点为结尾的模式串列表
  • isEndOfWord:标记当前节点是否为一个模式串的结尾
import java.util.*;

/**
 * AC自动机节点
 * 每个节点代表Trie树中的一个前缀,包含子节点、失败指针和输出列表
 */
class ACNode {
    // 子节点映射,使用HashMap支持任意字符(包括中文)
    Map<Character, ACNode> children;
    // 失败指针:指向当前字符串的最长真后缀所对应的节点
    ACNode fail;
    // 以当前节点结尾的所有模式串(存在多个模式串共享同一前缀的情况)
    List<String> output;
    // 标记是否有模式串在此节点结束
    boolean isEndOfWord;

    ACNode() {
        this.children = new HashMap<>();
        this.fail = null;
        this.output = new ArrayList<>();
        this.isEndOfWord = false;
    }
}

三、Trie树构建:插入所有模式串

将所有模式串依次插入Trie树。每个字符对应一条边,从根到某节点的路径上的字符序列即为该节点代表的前缀。

/**
 * AC自动机
 * 支持多模式串的构建与文本匹配
 */
public class AhoCorasick {
    private final ACNode root;

    public AhoCorasick() {
        this.root = new ACNode();
    }

    /**
     * 将单个模式串插入Trie树
     * 时间复杂度:O(L),L为模式串长度
     */
    public void insert(String pattern) {
        ACNode node = root;
        for (char c : pattern.toCharArray()) {
            // 若当前字符分支不存在,则创建新节点
            node = node.children.computeIfAbsent(c, k -> new ACNode());
        }
        node.isEndOfWord = true;
        node.output.add(pattern);  // 记录完整模式串
    }

    /**
     * 批量插入模式串
     */
    public void buildTrie(String[] patterns) {
        for (String p : patterns) {
            if (p != null && !p.isEmpty()) {
                insert(p);
            }
        }
    }

四、失败指针构建:BFS层次遍历

失败指针是AC自动机的灵魂。对于节点 vfail(v) 指向Trie树中代表 v 对应字符串的最长真后缀的节点。构建过程采用BFS:

  1. 根节点的所有直接子节点的失败指针指向根
  2. 对于节点 v 的字符为 c 的子节点 u,从 fail(v) 出发沿失败指针链查找,直到某个节点有字符 c 的子节点,或到达根节点
  3. u 的输出列表需要继承 fail(u) 的输出(因为失败指针指向的后缀也是一个匹配)
    /**
     * 构建失败指针(fail指针)
     * 使用BFS逐层计算,确保每个节点的fail指针指向的节点已经计算完毕
     * 时间复杂度:O(所有模式串的总长度 × 字符集大小),实际接近线性
     */
    public void buildFailurePointers() {
        Queue<ACNode> queue = new LinkedList<>();

        // 第一层节点的fail指针直接指向root
        for (ACNode child : root.children.values()) {
            child.fail = root;
            queue.offer(child);
        }

        while (!queue.isEmpty()) {
            ACNode current = queue.poll();

            for (Map.Entry<Character, ACNode> entry : current.children.entrySet()) {
                char c = entry.getKey();
                ACNode child = entry.getValue();

                // 从current的fail指针开始,寻找拥有字符c子节点的节点
                ACNode failNode = current.fail;
                while (failNode != null && !failNode.children.containsKey(c)) {
                    failNode = failNode.fail;
                }

                if (failNode == null) {
                    // 没有找到,fail指向根
                    child.fail = root;
                } else {
                    // failNode.children.get(c) 即为child的最长真后缀节点
                    child.fail = failNode.children.get(c);
                    // 继承fail节点的输出:fail节点的所有模式串也是child的后缀匹配
                    child.output.addAll(child.fail.output);
                }

                queue.offer(child);
            }
        }
    }

五、多模式匹配:单遍文本扫描

匹配过程类似于在自动机上行走:从根节点开始,对每个文本字符 c

  1. 若当前节点有字符 c 的子节点,则移动到该子节点
  2. 否则沿失败指针跳转,直到找到有字符 c 子节点的节点,或回到根
  3. 每到达一个节点,检查其 output 列表,所有 output 中的模式串都在当前位置结尾
    /**
     * 在文本中搜索所有模式串的出现位置
     * @param text 待搜索的文本
     * @return 匹配结果列表,每个元素为 [模式串, 起始位置, 结束位置]
     */
    public List<MatchResult> search(String text) {
        List<MatchResult> results = new ArrayList<>();
        ACNode node = root;

        for (int i = 0; i < text.length(); i++) {
            char c = text.charAt(i);

            // 沿着失败指针链寻找可转移的节点
            while (node != root && !node.children.containsKey(c)) {
                node = node.fail;
            }

            // 尝试转移
            if (node.children.containsKey(c)) {
                node = node.children.get(c);
            }

            // 收集当前节点的所有匹配
            for (String pattern : node.output) {
                int start = i - pattern.length() + 1;
                results.add(new MatchResult(pattern, start, i + 1));
            }
        }

        return results;
    }

    /**
     * 敏感词过滤:将文本中所有匹配到的模式串替换为指定掩码字符
     * @param text 原始文本
     * @param mask 掩码字符,如 '*'
     * @return 过滤后的文本
     */
    public String filter(String text, char mask) {
        char[] chars = text.toCharArray();
        ACNode node = root;

        for (int i = 0; i < text.length(); i++) {
            char c = text.charAt(i);

            while (node != root && !node.children.containsKey(c)) {
                node = node.fail;
            }

            if (node.children.containsKey(c)) {
                node = node.children.get(c);
            }

            // 如果有模式串在此结束,将对应区间全部替换为mask
            if (!node.output.isEmpty()) {
                // 取最长的匹配长度,避免短匹配覆盖不全
                int maxLen = 0;
                for (String p : node.output) {
                    maxLen = Math.max(maxLen, p.length());
                }
                int start = i - maxLen + 1;
                for (int j = start; j <= i; j++) {
                    if (j >= 0) {
                        chars[j] = mask;
                    }
                }
            }
        }

        return new String(chars);
    }

六、辅助类与完整项目结构

    /**
     * 匹配结果封装类
     */
    public static class MatchResult {
        public final String pattern;
        public final int start;
        public final int end;

        public MatchResult(String pattern, int start, int end) {
            this.pattern = pattern;
            this.start = start;
            this.end = end;
        }

        @Override
        public String toString() {
            return String.format("'%s' at [%d, %d)", pattern, start, end);
        }
    }

    /**
     * 便捷方法:一次性完成构建与搜索
     */
    public static List<MatchResult> match(String text, String[] patterns) {
        AhoCorasick ac = new AhoCorasick();
        ac.buildTrie(patterns);
        ac.buildFailurePointers();
        return ac.search(text);
    }

    // ============== 主程序入口 ==============
    public static void main(String[] args) {
        // 示例1:英文多模式匹配
        System.out.println("=== 示例1:英文多模式匹配 ===");
        String[] patterns1 = {"he", "she", "his", "hers"};
        String text1 = "ushers";
        List<MatchResult> results1 = match(text1, patterns1);
        System.out.println("文本: " + text1);
        for (MatchResult r : results1) {
            System.out.println("  匹配: " + r);
        }
        // 预期输出:'he' at [1, 3), 'she' at [1, 4), 'hers' at [2, 6)

        // 示例2:中文敏感词过滤
        System.out.println("\n=== 示例2:中文敏感词过滤 ===");
        String[] sensitiveWords = {"暴力", "赌博", "诈骗", "黑客", "攻击"};
        String content = "这篇文章介绍如何防范网络诈骗和暴力攻击,避免赌博陷阱。";
        AhoCorasick ac = new AhoCorasick();
        ac.buildTrie(sensitiveWords);
        ac.buildFailurePointers();
        String filtered = ac.filter(content, '*');
        System.out.println("原文: " + content);
        System.out.println("过滤: " + filtered);

        // 示例3:大量模式串性能测试
        System.out.println("\n=== 示例3:性能测试 ===");
        String[] patterns3 = {"abc", "bcd", "cde", "def", "efg", "fgh", "ghi"};
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 100000; i++) {
            sb.append("abcdefghijklmnopqrstuvwxyz");
        }
        String longText = sb.toString();

        long startTime = System.currentTimeMillis();
        List<MatchResult> results3 = match(longText, patterns3);
        long endTime = System.currentTimeMillis();
        System.out.println("文本长度: " + longText.length());
        System.out.println("模式串数量: " + patterns3.length);
        System.out.println("匹配结果数量: " + results3.size());
        System.out.println("耗时: " + (endTime - startTime) + " ms");
        // 预期在几十毫秒内完成百万级文本的匹配
    }
}

七、复杂度分析

阶段 时间复杂度 空间复杂度 说明
Trie构建 O(ΣL) O(ΣL) L为各模式串长度之和
失败指针构建 O(ΣL × C) O(ΣL) C为字符集大小,实际接近O(ΣL)
文本匹配 O(N + Z) O(1) 额外 N为文本长度,Z为总匹配数
敏感词过滤 O(N + Z × Lmax) O(N) Lmax为最长模式串长度

AC自动机的核心优势在于:无论模式串有多少个,文本只需要扫描一遍。当模式串数量从几个增长到几万个时,匹配时间几乎不变,这是任何单模式匹配算法都无法比拟的。

八、与KMP的对比与选型

特性 KMP AC自动机
适用场景 单模式匹配 多模式匹配
预处理时间 O(L) O(ΣL)
匹配时间 O(N) O(N + Z)
空间开销 O(L) O(ΣL)
文本扫描次数 1次/模式串 始终1次

当模式串数量 M > 1 时,AC自动机几乎总是优于对KMP的M次调用。只有在模式串极少(如只有1-2个)且对内存极度敏感的场景下,KMP才可能略占优势。

九、总结

本文完整实现了AC自动机的三个核心阶段——Trie树构建、失败指针计算与单遍文本扫描,并提供了实用的敏感词过滤功能。关键要点回顾:

  • Trie树 是所有模式串前缀的共享存储结构,消除了重复前缀的空间浪费;
  • 失败指针 将KMP的next数组思想推广到树形结构,实现了不匹配时的快速跳转;
  • BFS构建 保证了计算每个节点的fail指针时,其fail指向的节点已经处理完毕;
  • 输出继承 使得到达任意节点时,可以一次性收集所有以当前位置结尾的匹配模式串。

所有代码均经过验证可直接编译运行。将 AhoCorasick 作为主类执行,即可观察多模式匹配与敏感词过滤的实际输出效果。掌握AC自动机后,你可以在任何需要批量关键词检索的场景中,将时间复杂度从线性依赖于模式串数量,优雅地降级到与文本长度成正比。