AC自动机是一种高效的多模式字符串匹配算法,它巧妙地将 Trie树的字典结构与 KMP算法的失配指针思想相结合,能同时在一段文本中查找多个模式串的所有出现位置,广泛应用于敏感词过滤、生物信息学序列分析等领域。
1、算法概述
-
AC自动机: AC自动机是一种高效的多模式字符串匹配算法,它巧妙地将 Trie树的字典结构与 KMP算法的失配指针思想相结合,
-
能同时在一段文本中查找多个模式串的所有出现位置,广泛应用于敏感词过滤、生物信息学序列分析等领域。
-
在字符串匹配领域,我们会遇到两类问题:
*- 单模式匹配:给定一个文本字符串和一个模式字符串,判断模式字符串是否出现在文本字符串中。
- 解决方案:使用 KMP算法,具体方法可参考:《【算法笔记】KMP算法》
-
- 多模式匹配:给定一个文本字符串和多个模式字符串,判断所有模式字符串是否出现在文本字符串中。
- 解决方案: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);
}
}
}
后记
个人学习总结笔记,不能保证非常详细,轻喷