AC 自动机、Trie 图

C++代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// 指针版
class Trie {
public:
struct Node {
bool isEnd = false;
int cnt = 0;
int len = 0;
string s = "";
Node *son[26] = {nullptr};
Node *fail = nullptr;
}*root;

Trie() {
root = new Node;
}

void insert(string &s) {
auto p = root;
for (int i = 0; i < s.size(); ++i) {
int c = s[i] - 'a';
if (p->son[c] == nullptr) p->son[c] = new Node;
p = p->son[c];
}
p->isEnd = true;
++p->cnt;
p->len = s.size();
p->s = s;
}

void build() {
root->fail = root;
queue<Node*> q;
for (int i = 0; i < 26; ++i) {
if (root->son[i]) {
root->son[i]->fail = root;
q.push(root->son[i]);
} else {
root->son[i] = root;
}
}
while (q.size()) {
auto now = q.front(); q.pop();
for (int i = 0; i < 26; ++i) {
if (now->son[i]) {
now->son[i]->fail = now->fail->son[i];
q.push(now->son[i]);
} else {
now->son[i] = now->fail->son[i];
}
}
}
}

void ask(string &s) {
auto now = root;
for (int i = 0; i < s.size(); ++i) {
int c = s[i] - 'a';
now = now->son[c];
for (auto p = now; p != root; p = p->fail) {
if (now->isEnd) {
cout << "找到字符串" << p->s << " ";
cout << "开始下标:" << i - p->len << " ";
}
}
}
}

private:
void clean(Node *t) {
for (int i = 0;i < 26; ++i) {
if (t->son[i]) clean(t->son[i]);
}
delete t;
}
};

class Solution {
public:
vector<vector<int>> indexPairs(string text, vector<string>& words) {
vector<vector<int>> ans;
auto trie = Trie();

for (auto s : words) trie.insert(s);

trie.build();

int n = text.size();
auto now = trie.root;
for (int i = 0; i < n; ++i) {
int c = text[i] - 'a';
now = now->son[c];
for (auto p = now; p != trie.root; p = p->fail) {
if (p->isEnd) ans.push_back({i - p->len + 1, i}), cout << p->s << " ";
}
}

sort(ans.begin(), ans.end());
return ans;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 数组版
int idx;
struct Trie {
int cnt = 0;
int fail = 0;
int son[26] = {0};
}tr[N * Plen]; // N = 模式串个数, Plen = 模式串长度

void insert(char *str) {
int p = 0;
for (int i = 0; str[i]; ++i) {
int c = str[i] - 'a';
if (tr[p].son[c] == 0) tr[p].son[c] = ++idx;
p = tr[p].son[c];
}
++tr[p].cnt;
}

int q[N * S];

void build() {
int hh = 0, tt = -1;
for (int i = 0; i < 26; ++i) {
if (tr[0].son[i]) q[++tt] = tr[0].son[i];
}

while (hh <= tt) {
int now = q[hh++];
for (int i = 0; i < 26; ++i) {
int &nowson = tr[now].son[i];
int nowfailson = tr[tr[now].fail].son[i];
if (nowson) {
tr[nowson].fail = nowfailson;
q[++tt] = nowson;
} else {
nowson = nowfailson;
}
}
}
}

int ask(char *text) { // 查询文本text中一共出现多少种模式串
int ans = 0;
for (int i = 0, now = 0; text[i]; ++i) {
int c = text[i] - 'a';
now = tr[now].son[c];

for (int p = now; p; p = tr[p].fail) {
ans += tr[p].cnt;
tr[p].cnt = 0;
}
}
}

概述

AC 自动机是 以 Trie 的结构为基础,结合 KMP 的思想 建立的。

简单来说,建立一个 AC 自动机有两个步骤:

  1. 基础的 Trie 结构:将所有的模式串构成一棵 Trie。
  2. KMP 的思想:对 Trie 树上所有的结点构造失配指针。

然后就可以利用它进行多模式匹配了。

字典树构建

AC 自动机在初始时会将若干个模式串丢到一个 Trie 里,然后在 Trie 上建立 AC 自动机。这个 Trie 就是普通的 Trie,该怎么建怎么建。

这里需要仔细解释一下 Trie 的结点的含义,尽管这很小儿科,但在之后的理解中极其重要。Trie 中的结点表示的是某个模式串的前缀。我们在后文也将其称作状态。一个结点表示一个状态,Trie 的边就是状态的转移。

形式化地说,对于若干个模式串 s1,s2sns_1,s_2\dots s_n,将它们构建一棵字典树后的所有状态的集合记作 QQ

失配指针

AC 自动机利用一个 fail 指针来辅助多模式串的匹配。

状态 uu 的 fail 指针指向另一个状态 vv,其中 vQv\in Q,且 vvuu 的最长后缀(即在若干个后缀状态中取最长的一个作为 fail 指针)。对于学过 KMP 的朋友,我在这里简单对比一下这里的 fail 指针与 KMP 中的 next 指针:

  1. 共同点:两者同样是在失配的时候用于跳转的指针。
  2. 不同点:next 指针求的是最长 Border(即最长的相同前后缀),而 fail 指针指向所有模式串的前缀中匹配当前状态的最长后缀。

因为 KMP 只对一个模式串做匹配,而 AC 自动机要对多个模式串做匹配。有可能 fail 指针指向的结点对应着另一个模式串,两者前缀不同。

AC 自动机的失配指针指向当前状态的最长后缀状态。

AC 自动机在做匹配时,同一位上可匹配多个模式串。

构建指针

下面介绍构建 fail 指针的 基础思想:(强调!基础思想!基础!)

构建 fail 指针,可以参考 KMP 中构造 Next 指针的思想。

考虑字典树中当前的结点 uuuu 的父结点是 pppp 通过字符 c 的边指向 uu,即 trie[p,c]=utrie[p,c]=u。假设深度小于 uu 的所有结点的 fail 指针都已求得。

  1. 如果 trie[fail[p],c]\text{trie}[\text{fail}[p],c] 存在:则让 u 的 fail 指针指向 trie[fail[p],c]\text{trie}[\text{fail}[p],c]。相当于在 ppfail[p]\text{fail}[p] 后面加一个字符 c,分别对应 uufail[u]fail[u]
  2. 如果 trie[fail[p],c]\text{trie}[\text{fail}[p],c] 不存在:那么我们继续找到 trie[fail[fail[p]],c]\text{trie}[\text{fail}[\text{fail}[p]],c]。重复 1 的判断过程,一直跳 fail 指针直到根结点。
  3. 如果真的没有,就让 fail 指针指向根结点。

如此即完成了 fail[u]\text{fail}[u] 的构建。

例子

下面放一张 GIF 帮助大家理解。对字符串 i he his she hers 组成的字典树构建 fail 指针:

  1. 黄色结点:当前的结点 uu
  2. 绿色结点:表示已经 BFS 遍历完毕的结点,
  3. 橙色的边:fail 指针。
  4. 红色的边:当前求出的 fail 指针。

我们重点分析结点 6 的 fail 指针构建:

找到 6 的父结点 5,fail[5]=10\text{fail}[5]=10。然而 10 结点没有字母 s 连出的边;继续跳到 10 的 fail 指针,fail[10]=0\text{fail}[10]=0。发现 0 结点有字母 s 连出的边,指向 7 结点;所以 fail[6]=7\text{fail}[6]=7。最后放一张建出来的图:

字典树与字典图

我们直接上代码吧。字典树插入的代码就不分析了(后面完整代码里有),先来看构建函数 build(),该函数的目标有两个,一个是构建 fail 指针,一个是构建自动机。参数如下:

  1. tr[now,c]:有两种理解方式。我们可以简单理解为字典树上的一条边,即 trie[now,c]\text{trie}[now,c];也可以理解为从状态(结点)nownow 后加一个字符 c 到达的状态(结点),即一个状态转移函数 trans(now,c)\text{trans}(now,c)。下文中我们将用第二种理解方式继续讲解。
  2. 队列 q:用于 BFS 遍历字典树。
  3. fail[now]:结点 nownow 的 fail 指针。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// C++ Version
void build() {
for (int i = 0; i < 26; i++)
if (tr[0][i]) q.push(tr[0][i]);

while (q.size()) {
int now = q.front(); q.pop();
for (int i = 0; i < 26; ++i) {
int &nowson = tr[now][i];
int nowfailson = tr[fail[now]][i];
if (nowson) {
fail[nowson] = nowfailson;
q.push(nowson);
} else {
nowson = nowfailson;
}
}
}
}

解释一下上面的代码:build 函数将结点按 BFS 顺序入队,依次求 fail 指针。这里的字典树根结点为 0,我们将根结点的子结点一一入队。若将根结点入队,则在第一次 BFS 的时候,会将根结点儿子的 fail 指针标记为本身。因此我们将根结点的儿子一一入队,而不是将根结点入队。

然后开始 BFS:每次取出队首的结点 now(fail[now]\text{fail}[now] 在之前的 BFS 过程中已求得),然后遍历字符集(这里是 0-25,对应 a-z,即 nownow 的各个子节点):

  1. nowson = trans[now][i]\text{nowson = trans[now][i]} 是当前出队节点的子节点,nowfail = fail[u]\text{nowfail = fail[u]} 是当前出队节点失败跳转后的节点,nowfailson = trans[fail[u]][i]\text{nowfailson = trans[fail[u]][i]} 是当前节点失败跳转后的节点的子节点。
  2. 如果 nowson\text{nowson} 存在,我们就将 nowson\text{nowson} 的 fail 指针赋值为 nowfailson\text{nowfailson}
    这里似乎有一个问题。根据之前的讲解,我们应该用 while 循环,不停的跳 fail 指针,判断是否存在字符 i 对应的结点,然后赋值,但是这里通过特殊处理简化了这些代码。
  3. 否则,令 nowson\text{nowson} 指向 nowsonfail\text{nowsonfail} 的状态。

这里的处理是,通过 else 语句的代码修改字典树的结构。没错,它将不存在的字典树的状态链接到了失配指针的对应状态。在原字典树中,每一个结点代表一个字符串 SS,是某个模式串的前缀。而在修改字典树结构后,尽管增加了许多转移关系,但结点(状态)所代表的字符串是不变的。

trans[S][c]\text{trans}[S][c] 相当于是在 SS 后添加一个字符 c 变成另一个状态 SS'。如果 SS' 存在,说明存在一个模式串的前缀是 SS',否则我们让 trans[S][c]\text{trans}[S][c] 指向 trans[fail[S]][c]\text{trans}[\text{fail}[S]][c]。由于 fail[S]\text{fail}[S] 对应的字符串是 SS 的后缀,因此 trans[fail[S]][c]\text{trans}[\text{fail}[S]][c] 对应的字符串也是 SS' 的后缀。

换言之在 Trie 上跳转的时侯,我们只会从 SS 跳转到 SS',相当于匹配了一个 SS';但在 AC 自动机上跳转的时侯,我们会从 SS 跳转到 SS' 的后缀,也就是说我们匹配一个字符 c,然后舍弃 SS 的部分前缀。舍弃前缀显然是能匹配的。那么 fail 指针呢?它也是在舍弃前缀啊!试想一下,如果文本串能匹配 SS,显然它也能匹配 SS 的后缀。所谓的 fail 指针其实就是 SS 的一个后缀集合。

tr 数组还有另一种比较简单的理解方式:如果在位置 nownow 失配,我们会跳转到 fail[now]\text{fail}[now] 的位置。所以我们可能沿着 fail 数组跳转多次才能来到下一个能匹配的位置。所以我们可以用 tr 数组直接记录记录下一个能匹配的位置,这样就能节省下很多时间。

这样修改字典树的结构,使得匹配转移更加完善。同时它将 fail 指针跳转的路径做了压缩(就像并查集的路径压缩),使得本来需要跳很多次 fail 指针变成跳一次。

我们将之前的 GIF 图改一下:

  1. 蓝色结点:BFS 遍历到的结点 u
  2. 蓝色的边:当前结点下,AC 自动机修改字典树结构连出的边。
  3. 黑色的边:AC 自动机修改字典树结构连出的边。
  4. 红色的边:当前结点求出的 fail 指针
  5. 黄色的边:fail 指针
  6. 灰色的边:字典树的边

可以发现,众多交错的黑色边将 Trie 树变成了 Trie 图。图中省略了连向根结点的黑边(否则会更乱)。我们重点分析一下结点 5 遍历时的情况。我们求 trans[5][s]=6\text{trans}[5][s]=6 的 fail 指针:

本来的策略是找 fail 指针,于是我们跳到 fail[5]=10\text{fail}[5]=10 发现没有 s 连出的字典树的边,于是跳到 fail[10]=0\text{fail}[10]=0,发现有 trie[0][s]=7\text{trie}[0][s]=7,于是 fail[6]=7\text{fail}[6]=7;但是有了黑边、蓝边,我们跳到 fail[5]=10\text{fail}[5]=10 之后直接走 trans[10][s]=7\text{trans}[10][s]=7 就走到 77 号结点了。

这就是 build 完成的两件事:构建 fail 指针和建立字典图。这个字典图也会在查询的时候起到关键作用。

多模式匹配

接下来分析匹配函数 query()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// C++ Version
int ask(string &s) {
int ans = 0;
int now = 0;
for (int i = 0; i < s.size(); ++i) {
int c = s[i] - 'a';
now = tr[now][c]; //转移
for (int p = now; p; p = fail[p]) {
ans += cnt[p];
cnt[p] = 0;
}
}
return ans;
}

这里 nownow 作为字典树上当前匹配到的结点,ans 即返回的答案。循环遍历匹配串,nownow 在字典树上跟踪当前字符。利用 fail 指针找出所有匹配的模式串,累加到答案中。然后清零。在上文中我们分析过,字典树的结构其实就是一个 trans 函数,而构建好这个函数后,在匹配字符串的过程中,我们会舍弃部分前缀达到最低限度的匹配。fail 指针则指向了更多的匹配状态。最后上一份图。对于刚才的自动机:

我们从根结点开始尝试匹配 ushersheishis,那么 pp 的变化将是:

  1. 红色结点:pp 结点
  2. 粉色箭头:pp 在自动机上的跳转,
  3. 蓝色的边:成功匹配的模式串
  4. 蓝色结点:示跳 fail 指针时的结点(状态)。

总结

时间复杂度:定义 si|s_i| 是模板串的长度,S|S| 是文本串的长度,Σ|\Sigma| 是字符集的大小(常数,一般为 26)。如果连了 trie 图,时间复杂度就是 O(si+nΣ+S)O(\sum|s_i|+n|\Sigma|+|S|),其中 nn 是 AC 自动机中结点的数目,并且最大可以达到 O(si)O(\sum|s_i|)。如果不连 trie 图,并且在构建 fail 指针的时候避免遍历到空儿子,时间复杂度就是 O(si+S)O(\sum|s_i|+|S|)

拓展

确定有限状态自动机

如果大家理解了上面的讲解,那么作为拓展延伸,文末我们简单介绍一下自动机与 KMP 自动机。(现在你再去看百科上自动机的定义就会好懂很多啦)

有限状态自动机(deterministic finite automaton,DFA)是由

  1. 状态集合 QQ
  2. 字符集 Σ\Sigma
  3. 状态转移函数 δ:Q×ΣQ\delta:Q\times \Sigma \to Q,即 δ(q,σ)=q, q,qQ,σΣ\delta(q,\sigma)=q',\ q,q'\in Q,\sigma\in \Sigma
  4. 一个开始状态 sQs\in Q
  5. 一个接收的状态集合 FQF\subseteq Q

组成的五元组 (Q,Σ,δ,s,F)(Q,\Sigma,\delta,s,F)

那这东西你用 AC 自动机理解,状态集合就是字典树(图)的结点;字符集就是 az(或者更多);状态转移函数就是 trans(u,c)\text{trans}(u,c) 的函数(即 trans[u][c]\text{trans}[u][c]);开始状态就是字典树的根结点;接收状态就是你在字典树中标记的字符串结尾结点组成的集合。

KMP 自动机

KMP 自动机就是一个不断读入待匹配串,每次匹配时走到接受状态的 DFA。如果共有 mm 个状态,第 ii 个状态表示已经匹配了前 ii 个字符。那么我们定义 transi,c\text{trans}_{i,c} 表示状态 ii 读入字符 cc 后到达的状态,nexti\text{next}_{i} 表示 prefix function,则有:

transi,c={i+1,if bi=ctransnexti,c,elsetrans_{i,c} = \begin{cases} i + 1, & \text{if }b_{i} = c \\[2ex] \text{trans}_{next_{i},c}, & \text{else} \end{cases}

(约定 next0=0\text{next}_{0}=0

我们发现 transi\text{trans}_{i} 只依赖于之前的值,所以可以跟 KMP 一起求出来。(一些细节:走到接受状态之后立即转移到该状态的 next)

时间和空间复杂度:O(mΣ)O(m|\Sigma|)

对比之下,AC 自动机其实就是 Trie 上的自动机。