上一篇介绍了 BF、RK、KMP、BM,它们主要解决的是单模式字符串匹配。
但实际开发中,我们经常会遇到另一类问题:
给你一段文本,同时匹配大量关键词。
例如:
文本:
我喜欢 Java、Go 和 Python,也在学习 Rust。
关键词:
Java
Go
Python
Rust
C++
最直接的做法,是拿每一个关键词分别去匹配文本。
但如果关键词有几万、几十万个,这种方式效率就比较低了。
有没有办法:
只扫描一次文本,同时匹配多个关键词?
这就是多模式字符串匹配。
常见的数据结构和算法就是:
Trie
↓
Trie + Failure 指针
↓
AC 自动机
1. Trie:先把关键词组织起来
Trie,也叫字典树。
它的核心思想很简单:
把多个字符串按照公共前缀组织起来。
例如有三个关键词:
bash
cat
car
dog
构建 Trie:
yaml
root
/ \
c d
| |
a o
/ \ |
t r g
cat 和 car 都有:
ca
这个公共前缀,所以可以复用节点。
相比直接保存字符串,Trie 可以把公共前缀压缩掉。
Trie 特别适合:
- 前缀搜索
- 自动补全
- 字典查询
- IP 路由
- 关键词匹配
但是,仅仅有 Trie 还不够。
2. Trie 的问题:匹配失败怎么办?
假设关键词:
he
she
his
hers
我们构建 Trie 后开始匹配文本:
ushers
当匹配到:
she
如果后面继续匹配失败了,怎么办?
最简单的想法:
匹配失败
↓
回到 root
↓
重新匹配
这样虽然能够完成匹配,但是会产生大量重复操作。
更重要的是:
当前已经匹配到的字符,其实可能还有利用价值。
例如:
she
它的后缀:
he
本身就是一个关键词。
所以匹配失败之后,我们没必要全部回退。
能不能直接跳到:
he
继续匹配?
可以。
这就是 Failure 指针。
3. AC 自动机 = Trie + Failure 指针
AC 自动机可以简单理解成:
AC 自动机 = Trie + Failure 指针
其中:
Trie 负责什么?
Trie 负责:
保存多个模式串,并表示字符之间的匹配关系。
Failure 指针负责什么?
Failure 指针负责:
匹配失败时,告诉我们应该跳到哪里继续匹配。
这个思想其实和 KMP 非常像。
KMP:
lua
一个模式串
↓
next 数组
↓
匹配失败
↓
跳到合适的位置
AC 自动机:
markdown
多个模式串
↓
Trie
↓
Failure 指针
↓
匹配失败
↓
跳到合适的节点
所以可以把 AC 自动机理解成:
把 KMP 的失败跳转思想扩展到了 Trie 上。
4. Failure 指针到底是什么?
Failure 指针是 AC 自动机最核心的部分。
简单来说:
一个节点的 Failure 指针,指向当前字符串的最长可匹配后缀。
还是看:
she
它的后缀有:
he
e
如果:
he
也是 Trie 中存在的路径,那么:
she
↓
failure
↓
he
这样当当前路径无法继续匹配时,就可以跳到 he。
也就是说:
markdown
当前匹配失败
↓
不要从 root 重新开始
↓
找到最长可复用后缀
↓
继续匹配
这就是 Failure 指针的价值。
5. Failure 指针和 KMP 的关系
如果你已经理解 KMP,那么理解 AC 会容易很多。
KMP 解决的是:
diff
一个字符串
+
一个模式串
它通过 next 数组保存:
当前匹配失败之后,最多可以保留多少已经匹配的信息。
AC 自动机解决的是:
diff
一个字符串
+
很多模式串
它通过 Trie 保存所有模式串,再通过 Failure 指针保存:
当前节点匹配失败后,可以跳到哪个节点继续匹配。
可以简单记成:
lua
KMP
↓
next
↓
单模式串失败跳转
AC
↓
Trie + Failure
↓
多模式串失败跳转
所以从思想上看,AC 自动机并不是凭空出现的新东西。
它实际上是在:
Trie 的基础上加入类似 KMP 的失败跳转思想。
6. AC 自动机是怎么构建 Failure 指针的?
Failure 指针通常使用 BFS 来构建。
为什么使用 BFS?
因为一个节点的 Failure 指针,依赖于它的父节点 Failure 指针。
例如:
css
root
↓
a
↓
b
↓
c
计算 c 的 Failure 时,需要先知道:
css
b.failure
所以需要按照:
root
↓
第一层
↓
第二层
↓
第三层
这样的顺序处理。
因此一般使用队列:
markdown
root 入队
while queue 不为空:
取出一个节点
遍历它的子节点
计算子节点的 failure
子节点入队
这也是为什么 AC 自动机的 Failure 构建通常会使用 BFS。
7. 一个简单的匹配过程
假设关键词:
he
she
his
hers
文本:
ushers
Trie 中已经建立好:
markdown
h
├── e
│ └── r
│ └── s
└── i
└── s
s
└── h
└── e
扫描:
u s h e r s
首先:
u
没有匹配路径。
继续:
s → h → e
匹配到了:
she
同时,由于:
she
的 Failure 可以指向:
he
所以还可以识别:
he
继续向后:
r → s
最终还可以匹配:
hers
一次扫描,就可以得到多个匹配结果。
8. 为什么 AC 自动机适合大量关键词?
假设现在有:
10 万个关键词
文本长度:
yaml
1000 万字符
如果每个关键词都单独进行匹配,就需要反复扫描文本。
而 AC 自动机可以:
markdown
所有关键词
↓
一次构建 Trie
↓
建立 Failure
↓
扫描文本
↓
一次完成多关键词匹配
这也是 AC 自动机最大的优势:
关键词很多,但文本只需要扫描一遍。
9. 时间复杂度
假设:
ini
n = 文本长度
m = 所有模式串的总长度
z = 最终匹配结果数量
那么:
scss
Trie 构建:
O(m)
Failure 指针构建通常可以做到:
scss
O(m)
文本匹配:
scss
O(n + z)
因此整体可以理解为:
scss
构建:
O(m)
匹配:
O(n + z)
其中 z 是输出结果数量。
为什么需要加 z?
因为如果文本中真的出现了大量匹配结果,那么算法至少需要把这些结果输出出来。
例如:
erlang
关键词:
a
aa
aaa
aaaa
...
文本:
aaaaaaaaaa...
可能产生大量匹配结果。
所以不能简单地说匹配永远严格是 O(n),更准确的是:
scss
O(n + z)
10. AC 自动机适合哪些场景?
AC 自动机的典型应用就是:
敏感词过滤
例如:
关键词:
赌博
诈骗
色情
广告
对一篇文章进行一次扫描,就可以找到所有关键词。
搜索系统
例如:
关键词:
Java
Go
Python
Rust
C++
对文章内容进行多关键词匹配。
日志分析
例如监控日志:
bash
ERROR
WARN
timeout
connection refused
OOM
一次扫描日志,就可以找到多个异常关键词。
内容审核
例如:
违规词
品牌词
广告词
敏感词
都可以放进同一个 AC 自动机。
11. Trie、KMP、AC 到底是什么关系?
可以用一张图记住:
markdown
字符串匹配
│
├── 单模式匹配
│ ├── BF
│ ├── RK
│ ├── KMP
│ └── BM
│
└── 多模式匹配
│
└── Trie
│
└── Failure 指针
│
└── AC 自动机
其中:
Trie
解决的是:
如何组织大量模式串?
而:
Failure
解决的是:
匹配失败之后如何快速跳转?
最终:
Trie + Failure
形成:
AC 自动机
实现多模式字符串匹配。
总结
如果只记住几个关键词:
Trie
↓
多个字符串共享前缀
Failure
↓
匹配失败快速跳转
AC 自动机
↓
Trie + Failure
↓
一次扫描匹配多个关键词
再和 KMP 联系起来:
lua
KMP:
单模式 + next
AC:
多模式 + Trie + Failure
一句话总结:
AC 自动机本质上就是把 Trie 和 KMP 的失败跳转思想结合起来,让我们能够在一次文本扫描中高效匹配大量关键词。