1. 基本介绍
本文最先发布于博客园,原地址:AC自动机的实现与思想原理 - yelanyanyu - 博客园 (cnblogs.com)
1.1案例引入
有一个字典有若干的敏感词 String[] str;
,有一个大文章 string,我们要找到大文章中出现的所有的敏感词,并得知其位置,收集到每一个敏感词。
上例就是 AC 自动机的经典案例。
1.2 约定
- 入边与出边。假设有一个节点 x,那么进入这个节点的边就称为入边 ,出这个节点的就是出边。
1.3 介绍
所谓 AC 自动机就是前缀树+KMP 算法。利用前缀树查询前缀高效的特点,配合 KMP 充分利用前后缀对称的思想,我们可以做到高效的多个模式字符的查询功能。
为了 AC 自动机,前缀树的节点结构需要做一些改变。所以我们先对前缀树进行相应的改造,然后再配合 KMP 算法思想进行讲解。
1.4 前缀树
[[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];
}
}
1.4.1 fail 指针
给每一个节点加一个 fail 指针(按照宽度优先遍历的方式设置 fail 指针),构建流程:
- 头节点的 fail 指针指向空(人为规定);
- 头节点的子节点(第一级节点)一律指向头部(人为规定);
- 对于任意一级的节点 x,看其父亲的 fail 指针指向的节点y是否与 x 相连:
- 不相连,就看 y 的 fail 指向的节点z是否与 x 相连,一直循环遍历,若直到头节点都没有与 x 相连就让 x 指向头;
- 相连,就将 x 的 fail 指针指向 x;
故从上可以看出 fail 指针有一个特点,就是一个节点的 fail 指针所指向的节点满足:其入边(进入该节点的边)的权值必定是相同的。
1.4.2 end 变量
我们利用 end 变量来标记每一个模式字符串的结束,并且在这个节点存储这整个模式字符串的值。
1.4.3 endUse 变量
在前缀树的节点中,我们有有一个 pass 变量用来记录,到达这个节点的次数,但是在 AC 自动机中,我们并不需要得知这个单词或前缀出现了几次,我们只需要知道模式字符串是否在待匹配文章中出现,为了避免重复匹配带来的效率浪费,所以我们设置一个变量 endUse 用来标记这个单词是否已经被匹配过了。
1.5 KMP 算法
[[KMP算法]]
具体见 3000+长文带你进入KMP算法思想 - yelanyanyu - 博客园 (cnblogs.com)。
让我们简单回顾一下,KMP 算法的思想。KMP 算法利用了模式字符串中前后缀最大对称子串来做到最大限度的利用上一次匹配的结果来方便于这一次的匹配,极大的提高了效率。
这将为 AC 自动机的构建思想提供根本上的指导。我们将在后面讲到。
1.6 时间复杂度
由于 AC 自动机利用了改进前缀树和 KMP 算法,所以时间复杂度可以达到 O ( N ) O(N) O(N) (N 为待匹配文章的长度)。
2. AC 自动机的实现流程
我们先说清楚流程,再讨论其精髓。
我们借助一颗前缀树来理解(如下图所示):
需要注意,我们省略了些人为规定的 fail 指针——第一行指向 root 节点的 fail 指针。
规定,我们说节点 x 就是指入边为 x 的节点。所有黑色的节点代表一个模式字符串的结尾节点。
我们给出一个待匹配的文章 content:abckabc。模式字符串 abc,bck,ck,fail 指针用带虚线的有向边表示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lrS5Tvqa-1671333989341)(null)]
2.1 变量指定
我们将利用四个变量 index,cur,follow,ans。
index 表示当前字符,即在树中,当前选择的路(边)。
cur 表示当前到达的节点。
follow 也是一个指针用来完整的走一圈 fail 指针,若碰到了黑色实心节点,就让其加入结果集 ans。
ans 表示结果集,原文 content 中已经匹配上的模式字符串。
2.2 过程描述
- 对于每一个节点 x,设当前需要走的路为 index,那么就看 x 是否有 index 的路可以走:
- x 有 index 的路可以走。更新 cur 到下一个节点,同时 follow 指向当前的 cur(转到步骤 2);
- x 没有 index 的路可以走,并且 x 不是 root。沿着 fail 指针更新 cur,直到有 index 的路可以走为止;若一直都没有路走,就会回到并且停在 root。
- x 就为 root。判断有没有 index 路可以走,没有走就停在 root。
- 对于每一个节点 x,都有一个 follow 指针指向更新后的 cur。随后 follow 绕着当前节点的 fail 指针遍历,直到遇到 root 时停止行动。过程中,若碰到了黑色的节点就记录当前模式字符串到 ans 中,若遇到的黑色节点已经走过,就直接结束遍历。
以下表格就是该实例下的演示。其中->表示各个指针的移动轨迹。
3. AC 自动机思想原理
想必大家一定有很多疑问:
- 为什么要设置 fail 指针?fail 指针有什么用?
- 前缀树是怎么与 KMP 算法进行结合的?
- AC 自动机究竟是怎么做到高效的?
我们将针对上述问题来阐述 AC 自动机的思想原理。
3.1 fail 指针的作用
由于其入边(进入该节点的边)的权值必定是相同的。所以,对于两颗前缀子树必定存在有一个字符相同。
若该节点
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 指针的作用其二。
3.2 前缀树和 KMP 算法思想
我们利用 fail 构建出两个模式串之间相同的前后缀,之后 KMP 算法思想才算是真正的显露出来。我们利用 fail 指针跳转到 x ′ x^{'} x′,这样就算是继承了前一个部分匹配的结果了。
现在我们来说明 AC 自动机是如何做到最大部分匹配的。我们知道只要其父亲 fail 指针指向的节点与当前节点相同,那么当前节点的 fail 指针就是指向那个相同的节点。那么对于两个模式字符串来说,其之间连续的相同的子串都会有 fail 指针相连。所以对于某一次匹配来说,这些相同的字符就不可能再多一个了,所以这就是最大的部分匹配。
3.3 实例说明
还是以这颗前缀树为例子。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vnQkXcEB-1671333989533)(null)]
解读:
- 假设当我们走到了 c1 节点就走投无路了,那么根据流程我们就会走到 c2 节点。此时我们可以发现 a1b1c1 与 b2c2k2 这两颗前缀子树所代表的字符串 abc 与 bck,恰好分别有以 c 为结尾的后缀子串 bc 和前缀子串 bc,这两个子串是相同的。当我们根据 fail 指针走向 c2 时就意味着 abc 模式匹配的失败,我们继承了其后缀 bc 转而向含有相同子串 bc 的模式串 bck 尝试进行匹配——原有的后缀变成了前缀;
- 若我们在 bck 模式仍然匹配失败,那么我们就会根据 fail 指针继承后缀 ck,尝试去匹配第三个模式 ck;
- 若这三个模式都没有加入 ans 的行为,则说明没有在 content 中找到模式串。
- 不过我们也需要明确,若 b1 的 fail 指针没有指向 b2(即 b2 节点不再是 b2 指向自身了),那么 c1 的 fail 指针就不会指向 c2,而是指向 c3 了。因为在这种情况下,b1 的 fail 指针只能指向 root 节点,c1 自然也只能找到 c3 了。所以,当 b1 不指向 b2 的时候,以 c 结尾的后缀串 bc 就不能转化成为 2 号子树的前缀串。这种情况进一步的说明了,利用 fail 指针进行前后缀匹配的正确性。
3.4 AC 自动机的高效本质
我们先利用前缀树方便且高效的找到了两个模式字符串的相似之处(以某个字符为结尾的前后缀相同),然后再利用 KMP 思想利用上一次匹配(上一次根据模式字符串 A 的匹配失败)的结果优化下一次匹配的过程。这就是 AC 自动机高效的本质。
4. 代码实现
/**
* 前缀树的节点
*/
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;
}
}