多模式字符串匹配:Trie 与 AC 自动机

20 阅读6分钟

上一篇介绍了 BF、RK、KMP、BM,它们主要解决的是单模式字符串匹配

但实际开发中,我们经常会遇到另一类问题:

给你一段文本,同时匹配大量关键词。

例如:

文本:
我喜欢 Java、Go 和 Python,也在学习 Rust。

关键词:
Java
Go
Python
Rust
C++

最直接的做法,是拿每一个关键词分别去匹配文本。

但如果关键词有几万、几十万个,这种方式效率就比较低了。

有没有办法:

只扫描一次文本,同时匹配多个关键词?

这就是多模式字符串匹配

常见的数据结构和算法就是:

Trie
 ↓
Trie + Failure 指针
 ↓
AC 自动机

1. Trie:先把关键词组织起来

Trie,也叫字典树

它的核心思想很简单:

把多个字符串按照公共前缀组织起来。

例如有三个关键词:

cat
car
dog

构建 Trie:

       root
      /    \
     c      d
     |      |
     a      o
    / \     |
   t   r    g

catcar 都有:

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:

一个模式串
     ↓
next 数组
     ↓
匹配失败
     ↓
跳到合适的位置

AC 自动机:

多个模式串
     ↓
Trie
     ↓
Failure 指针
     ↓
匹配失败
     ↓
跳到合适的节点

所以可以把 AC 自动机理解成:

把 KMP 的失败跳转思想扩展到了 Trie 上。


4. Failure 指针到底是什么?

Failure 指针是 AC 自动机最核心的部分。

简单来说:

一个节点的 Failure 指针,指向当前字符串的最长可匹配后缀

还是看:

she

它的后缀有:

he
e

如果:

he

也是 Trie 中存在的路径,那么:

she
 ↓
failure
 ↓
he

这样当当前路径无法继续匹配时,就可以跳到 he

也就是说:

当前匹配失败
      ↓
不要从 root 重新开始
      ↓
找到最长可复用后缀
      ↓
继续匹配

这就是 Failure 指针的价值。


5. Failure 指针和 KMP 的关系

如果你已经理解 KMP,那么理解 AC 会容易很多。

KMP 解决的是:

一个字符串
+
一个模式串

它通过 next 数组保存:

当前匹配失败之后,最多可以保留多少已经匹配的信息。

AC 自动机解决的是:

一个字符串
+
很多模式串

它通过 Trie 保存所有模式串,再通过 Failure 指针保存:

当前节点匹配失败后,可以跳到哪个节点继续匹配。

可以简单记成:

KMP
  ↓
next
  ↓
单模式串失败跳转

AC
  ↓
Trie + Failure
  ↓
多模式串失败跳转

所以从思想上看,AC 自动机并不是凭空出现的新东西。

它实际上是在:

Trie 的基础上加入类似 KMP 的失败跳转思想。


6. AC 自动机是怎么构建 Failure 指针的?

Failure 指针通常使用 BFS 来构建。

为什么使用 BFS?

因为一个节点的 Failure 指针,依赖于它的父节点 Failure 指针。

例如:

root
 ↓
ab
 ↓
c

计算 c 的 Failure 时,需要先知道:

b.failure

所以需要按照:

root
 ↓
第一层
 ↓
第二层
 ↓
第三层

这样的顺序处理。

因此一般使用队列:

root 入队

while queue 不为空:

    取出一个节点

    遍历它的子节点

    计算子节点的 failure

    子节点入队

这也是为什么 AC 自动机的 Failure 构建通常会使用 BFS。


7. 一个简单的匹配过程

假设关键词:

he
she
his
hers

文本:

ushers

Trie 中已经建立好:

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 万个关键词

文本长度:

1000 万字符

如果每个关键词都单独进行匹配,就需要反复扫描文本。

而 AC 自动机可以:

所有关键词
    ↓
一次构建 Trie
    ↓
建立 Failure
    ↓
扫描文本
    ↓
一次完成多关键词匹配

这也是 AC 自动机最大的优势:

关键词很多,但文本只需要扫描一遍。


9. 时间复杂度

假设:

n = 文本长度

m = 所有模式串的总长度

z = 最终匹配结果数量

那么:

Trie 构建:

O(m)

Failure 指针构建通常可以做到:

O(m)

文本匹配:

O(n + z)

因此整体可以理解为:

构建:
O(m)

匹配:
O(n + z)

其中 z 是输出结果数量。

为什么需要加 z

因为如果文本中真的出现了大量匹配结果,那么算法至少需要把这些结果输出出来。

例如:

关键词:
a
aa
aaa
aaaa
...

文本:
aaaaaaaaaa...

可能产生大量匹配结果。

所以不能简单地说匹配永远严格是 O(n),更准确的是:

O(n + z)

10. AC 自动机适合哪些场景?

AC 自动机的典型应用就是:

敏感词过滤

例如:

关键词:
赌博
诈骗
色情
广告

对一篇文章进行一次扫描,就可以找到所有关键词。

搜索系统

例如:

关键词:
Java
Go
Python
Rust
C++

对文章内容进行多关键词匹配。

日志分析

例如监控日志:

ERROR
WARN
timeout
connection refused
OOM

一次扫描日志,就可以找到多个异常关键词。

内容审核

例如:

违规词
品牌词
广告词
敏感词

都可以放进同一个 AC 自动机。


11. Trie、KMP、AC 到底是什么关系?

可以用一张图记住:

字符串匹配
│
├── 单模式匹配
│   ├── BF
│   ├── RK
│   ├── KMP
│   └── BM
│
└── 多模式匹配
    │
    └── Trie
         │
         └── Failure 指针
              │
              └── AC 自动机

其中:

Trie

解决的是:

如何组织大量模式串?

而:

Failure

解决的是:

匹配失败之后如何快速跳转?

最终:

Trie + Failure

形成:

AC 自动机

实现多模式字符串匹配。


总结

如果只记住几个关键词:

Trie
  ↓
多个字符串共享前缀

Failure
  ↓
匹配失败快速跳转

AC 自动机
  ↓
Trie + Failure
  ↓
一次扫描匹配多个关键词

再和 KMP 联系起来:

KMP:
单模式 + next

AC:
多模式 + Trie + Failure

一句话总结:

AC 自动机本质上就是把 Trie 和 KMP 的失败跳转思想结合起来,让我们能够在一次文本扫描中高效匹配大量关键词。