本文最先发布于博客园,原地址:AC自动机的实现与思想原理 - yelanyanyu - 博客园 (cnblogs.com)
有一个字典有若干的敏感词 String[] str;
,有一个大文章 string,我们要找到大文章中出现的所有的敏感词,并得知其位置,收集到每一个敏感词。
上例就是 AC 自动机的经典案例。
所谓 AC 自动机就是前缀树+KMP 算法。利用前缀树查询前缀高效的特点,配合 KMP 充分利用前后缀对称的思想,我们可以做到高效的多个模式字符的查询功能。
为了 AC 自动机,前缀树的节点结构需要做一些改变。所以我们先对前缀树进行相应的改造,然后再配合 KMP 算法思想进行讲解。
[[1-10 前缀树]]
我们用这些敏感词去构建一颗前缀树。
节点结构如下:
public static class Node {
public String end;
public boolean endUse;
public Node fail;
public Node[] nexts;
public Node() {
endUse = false;
end = null;
fail = null;
nexts = new Node[26];
}
}
给每一个节点加一个 fail 指针(按照宽度优先遍历的方式设置 fail 指针),构建流程:
我们利用 end 变量来标记每一个模式字符串的结束,并且在这个节点存储这整个模式字符串的值。
在前缀树的节点中,我们有有一个 pass 变量用来记录,到达这个节点的次数,但是在 AC 自动机中,我们并不需要得知这个单词或前缀出现了几次,我们只需要知道模式字符串是否在待匹配文章中出现,为了避免重复匹配带来的效率浪费,所以我们设置一个变量 endUse 用来标记这个单词是否已经被匹配过了。
[[KMP算法]]
具体见 3000+长文带你进入KMP算法思想 - yelanyanyu - 博客园 (cnblogs.com)。
让我们简单回顾一下,KMP 算法的思想。KMP 算法利用了模式字符串中前后缀最大对称子串来做到最大限度的利用上一次匹配的结果来方便于这一次的匹配,极大的提高了效率。
这将为 AC 自动机的构建思想提供根本上的指导。我们将在后面讲到。
由于 AC 自动机利用了改进前缀树和 KMP 算法,所以时间复杂度可以达到 O ( N ) O(N) O(N) (N 为待匹配文章的长度)。
我们先说清楚流程,再讨论其精髓。
我们借助一颗前缀树来理解(如下图所示):
需要注意,我们省略了些人为规定的 fail 指针——第一行指向 root 节点的 fail 指针。
规定,我们说节点 x 就是指入边为 x 的节点。所有黑色的节点代表一个模式字符串的结尾节点。
我们给出一个待匹配的文章 content:abckabc。模式字符串 abc,bck,ck,fail 指针用带虚线的有向边表示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lrS5Tvqa-1671333989341)(null)]
我们将利用四个变量 index,cur,follow,ans。
index 表示当前字符,即在树中,当前选择的路(边)。
cur 表示当前到达的节点。
follow 也是一个指针用来完整的走一圈 fail 指针,若碰到了黑色实心节点,就让其加入结果集 ans。
ans 表示结果集,原文 content 中已经匹配上的模式字符串。
想必大家一定有很多疑问:
由于其入边(进入该节点的边)的权值必定是相同的。所以,对于两颗前缀子树必定存在有一个字符相同。
若该节点 x x x 的父节点 f f f 恰好连上了另一颗子树的对应节点 x ′ x^{'} x′ 的父节点 f ′ f^{'} f′,那么显而易见,两个前缀子树就有两个字符是相邻且相同的了。
并且这两个字符所在的模式串有一个特点,就是以 x x x 为结尾的后缀串( y x yx yx)恰好就是以 x ′ x^{'} x′ 开头的前缀串( x ′ y ′ x^{'}y^{'} x′y′)。这就是 fail 指针的核心作用,用于构造出这样的一对相等的前后缀。
当我们利用 fail 指针进行跳转的时候,就意味着那个节点所代表的模式字符串已经匹配失败了,所以我们应该用另一个模式字符串进行匹配。这是 fail 指针的作用其二。
我们利用 fail 构建出两个模式串之间相同的前后缀,之后 KMP 算法思想才算是真正的显露出来。我们利用 fail 指针跳转到 x ′ x^{'} x′,这样就算是继承了前一个部分匹配的结果了。
现在我们来说明 AC 自动机是如何做到最大部分匹配的。我们知道只要其父亲 fail 指针指向的节点与当前节点相同,那么当前节点的 fail 指针就是指向那个相同的节点。那么对于两个模式字符串来说,其之间连续的相同的子串都会有 fail 指针相连。所以对于某一次匹配来说,这些相同的字符就不可能再多一个了,所以这就是最大的部分匹配。
还是以这颗前缀树为例子。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vnQkXcEB-1671333989533)(null)]
解读:
我们先利用前缀树方便且高效的找到了两个模式字符串的相似之处(以某个字符为结尾的前后缀相同),然后再利用 KMP 思想利用上一次匹配(上一次根据模式字符串 A 的匹配失败)的结果优化下一次匹配的过程。这就是 AC 自动机高效的本质。
/**
* 前缀树的节点
*/
public static class Node {
/**
* 如果一个node,end为空,不是结尾
* 如果end不为空,表示这个点是某个字符串的结尾,end的值就是这个字符串
*/
public String end;
/**
* 只有在上面的end变量不为空的时候,endUse才有意义
* 表示,这个字符串之前有没有加入过答案
* 防止重复收集
*/
public boolean endUse;
public Node fail;
public Node[] nexts;
public Node() {
endUse = false;
end = null;
fail = null;
nexts = new Node[26];
}
}
/**
* AC自动机
*/
public static class ACAutomation {
private Node root;
public ACAutomation() {
root = new Node();
}
/**
* 先把所有敏感词挂到前缀树上,但是不连fail指针
*
* @param s
*/
public void insertNode(String s) {
char[] str = s.toCharArray();
Node cur = root;
int index = 0;
for (int i = 0; i < str.length; i++) {
index = str[i] - 'a';
//没有路就新建一个
if (cur.nexts[index] == null) {
Node next = new Node();
cur.nexts[index] = next;
}
cur = cur.nexts[index];
}
cur.end = s;
}
/**
* 构建fail指针
*/
public void buildFail() {
//队列用于宽度优先遍历
Queue<Node> queue = new LinkedList<>();
queue.add(root);
Node cur = null;
Node cfail = null;
//任何一个父亲出来的时候,设置其子节点的fail指针
while (!queue.isEmpty()) {
/*
当前节点弹出,当前节点的所有后代加入到队列里去,
当前节点给它的子去设置fail指针
*/
cur = queue.poll();
// 所有的路
for (int i = 0; i < 26; i++) {
/*
cur -> 父亲,i号儿子,必须把i号儿子的fail指针设置好
如果真的有i号儿子
*/
if (cur.nexts[i] != null) {
cur.nexts[i].fail = root;
cfail = cur.fail;
//寻找i号儿子该连谁
while (cfail != null) {
if (cfail.nexts[i] != null) {
cur.nexts[i].fail = cfail.nexts[i];
break;
}
cfail = cfail.fail;
}
queue.add(cur.nexts[i]);
}
}
}
}
/**
* 查询是否有匹配的模式字符串
* @param content
* @return 所有匹配到的模式字符串
*/
public List<String> containWords(String content) {
char[] str = content.toCharArray();
Node cur = root;
Node follow = null;
int index = 0;
List<String> ans = new ArrayList<>();
//对于每一个字符
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;
}
}