字典树到自动机
从字典树到AC自动机
我们之前有一起了解过字典树的结构,如果感兴趣的同学可以再去看看字典树与双数组字典树(数据结构基础篇)。在之前咱们学习字典树时,我们尝试用字典树解决字符串的单模匹配问题。那么,能否使用字典树解决字符串的多模匹配问题呢?如:
已知一个字典:['say', 'she', 'shr', 'he', 'her']
要求从下面字符串中尝试查找字典中存在的单词:sasherhs
使用字典树解决多模匹配
ts
// 字典树的多模匹配
const BASE = 26;
const charCodeA = "a".charCodeAt(0);
class TrieNode {
flag: boolean;
nexts: TrieNode[];
constructor() {
this.nexts = new Array(BASE);
this.clear();
}
clear() {
this.flag = false;
}
}
class Trie {
private root: TrieNode;
constructor() {
this.root = new TrieNode();
}
public insert(s: string): void {
let p = this.root;
for(let c of s) {
// 获取每个字母的keycode,并以keycode作为字典树的边
const code = c.charCodeAt(0) - charCodeA;
// 如果不存在该条边,我们就创建一个
if(!p.nexts[code]) p.nexts[code] = new TrieNode();
// 让指针指向新的节点
p = p.nexts[code];
}
// 将当前单词标记为独立成词
p.flag = true;
}
public match(s: string): Set<string> {
// 用于记录匹配次数
let cnt = 0;
// 所有从字符串中找到的单词都放到这个集合当中
const set: Set<string> = new Set<string>();
// 遍历字符串的每一个字符
for(let i=0;i<s.length;i++) {
// 每次开始匹配一个字符时,都从字典树的根节点开始重新匹配
let p = this.root;
// 此时p的状态发生了转移,因此也算是匹配了一次,累加
cnt++;
// 从当前开始匹配的这个字符开始往后匹配
for(let j=i;s[j];j++) {
// 获取当前字符相对于a字母的偏移量
const idx = s[j].charCodeAt(0) - charCodeA;
// 如果当前字符在当前字典树的节点下无法找到,那就没必要继续匹配后续的字符了,直接跳出当前循环,继续以下一个字符重新从字典树根节点开始匹配
if(!p.nexts[idx]) break;
// 如果匹配到了,那么我们让指针指向匹配到的字符
p = p.nexts[idx];
// 此时p的状态也发生了转移,继续累加
cnt++;
// 但凡遇到独立成词的,说明我们已经找到了一个单词了,将结果加入到结果集中去
if(p.flag) set.add(s.substring(i, j+1));
}
}
console.log(`总共查找了${cnt}次`);
return set;
}
}
const mode = ['say', 'she', 'shr', 'he', 'her'];
const s = 'sasherhs';
const trie = new Trie();
mode.forEach(item => trie.insert(item));
const res = trie.match(s);
console.log(res);
// 总共查找了18次
// Set(3) { 'she', 'he', 'her' }
代码细节在上述注释中已经描述得很清楚了,这边就不再描述了。如果有对字典树概念和基本实现不太清楚的同学,可通过本文头部提供的链接先看看字典树相关的基础知识。
从上面的执行结果我们可以看到,使用字典树进行多模暴力匹配,总共要查找18次。但是,实际上,我们可以发现,在匹配过程中其实有一些是冗余的匹配过程,比如说:如果我们都匹配到了she 这个单词了,是不是我们就必然能够匹配上he这个子串的单词呢?在上面的字典树暴力匹配当中,我们依然还是从字典树的根节点开始匹配,无疑是低效的。
我们先来观察一下这个字典树是怎样的:

上图即为根据提供的字典构建出来的字典树模型,按照我们刚刚的思路,既然已经找到了she 了,其中的后缀he 其实就等价于上面字典树右边的he ,因此,当我们在匹配到了e 点时,可以直接建立一个"链接"调到右侧的e点,再继续往下匹配,这样不用每次都回到根节点重新匹配,效率是不是更高呢?

如上图中的1点和2点实际上是等价的,3点和4点实际上是等价的,当我们在3点后面找不到字母r
时,其实可以去4点后面尝试查找,只有当所有等价的点都没发找到时,再回归根节点查找。
上面所说的基于字典树构建等价链接优化查找效率的程序,有一个专属名称,叫做AC自动机(ac automation)
。那么,接下来咱们就来看一下,具体要如何将上面的字典树改造成AC自动机
呢?
AC自动机解决多模匹配问题
ts
// AC自动机实现多模匹配
const BASE = 26;
const charCodeA = "a".charCodeAt(0);
class TrieNode {
flag: boolean;
nexts: TrieNode[];
fail: TrieNode;
s: string;
constructor() {
this.nexts = new Array(BASE);
this.clear();
}
clear() {
this.flag = false;
}
}
class Trie {
private root: TrieNode;
constructor() {
this.root = new TrieNode();
}
public insert(s: string): void {
let p = this.root;
for (let c of s) {
// 获取每个字母的keycode,并以keycode作为字典树的边
const code = c.charCodeAt(0) - charCodeA;
// 如果不存在该条边,我们就创建一个
if (!p.nexts[code]) p.nexts[code] = new TrieNode();
// 让指针指向新的节点
p = p.nexts[code];
}
if (!p.flag) {
p.s = s;
// 将当前单词标记为独立成词
p.flag = true;
}
}
/**
* 通过构建fail指针将字典树的等价节点相互链接形成AC自动机
*/
public buildAC(): void {
// 由于构建AC自动机的fail指针需要对字典树进行层序遍历,因此我们需要借助一个队列
const queue: TrieNode[] = [];
// 先将根节点下面的第一层节点全部压入到队列当中
for(let i=0;i<BASE;i++) {
// 如果该节点不存在则跳过
if(!this.root.nexts[i]) continue;
// 根节点下面的第一层子节点的由于只由一个字母组成没办法形成前缀与后缀,因此他的fail直接指向根节点即可
this.root.nexts[i].fail = this.root;
// 将第一层的子节点都压入队列当中
queue.push(this.root.nexts[i]);
}
// 循环取出队列中的每一个节点,依次建立fail指针
while(queue.length) {
const now = queue.shift();
let p: TrieNode;
for(let i=0;i<BASE;i++) {
if(!now.nexts[i]) continue
// 由于建立下一层节点的fail指针需要要依据上一层的fail决定,又由于串在一起的fail都是等价的,因此,如果当前fail下没找
// 到目标字母,就一直往跟他串在一起的fail指针上找,直到回到根节点为止
p = now.fail;
while(p && !p.nexts[i]) p = p.fail;
// 如果p存在,那么我们要找的当前节点的fail指针就是p.nexts[i];
if(p) p = p.nexts[i];
// 如果不存在则fail指针应该指向根节点
else p = this.root;
// 最后把p挂到当前节点下一个节点的fail上
now.nexts[i].fail = p;
// 将当前节点下一层的节点继续加入到队列当中
queue.push(now.nexts[i]);
}
}
}
public match(s: string): Set<string> {
const set: Set<string> = new Set<string>();
let p = this.root, k: TrieNode;
let cnt = 0;
for(let c of s) {
const idx = c.charCodeAt(0) - charCodeA;
// 如果当前节点的下一层节点找不到目标字母,则去与其等价的一串fail指针的下一个节点查找
while(p && !p.nexts[idx]) p = p.fail, cnt++;
// 如果p存在的话说明在某个fail上找到了目标字母
if(p) p = p.nexts[idx], cnt++;
else p = this.root, cnt++;
// 上面我们已经找到了第一个匹配的字母了,然后从这个字母开始,不断的从fail中抽取结果
k = p;
while(k) {
// 如果独立成词就加入到结果集中
if(k.flag) set.add(k.s);
// 然后继续从等价链接中查询
k = k.fail;
}
}
console.log(`总共查找了${cnt}次`);
return set;
}
}
const mode = ['say', 'she', 'shr', 'he', 'her'];
const s = 'sasherhs';
const trie = new Trie();
mode.forEach(item => trie.insert(item));
trie.buildAC();
const res = trie.match(s);
console.log(res);
// 总共查找了12次
// Set(3) { 'she', 'he', 'her' }
从上面代码的执行结果,我们可以看出总共查询了12次,比原先的18次少了6次。当然,由于我们的测试数据比较少,这个效率的差距还不是很明显,但在实际应用中,这个差距将会被拉得很大。因此,AC自动机在解决多模匹配问题上,效率是远高于字典树的暴力搜索的。
AC自动机的优化(废物利用)
在上面的AC自动机的代码实现,其实仍然有一定的优化空间,我们继续来看下面这张图,

我们上面的代码当找到3号节点的时候,发现3号节点下面没有r节点了,所以就先跳到了4号节点,然后再从4号节点往下找r。但是,这一步的跳转真的是必须的么?我们来思考一下,我们的3号节点r这条边目前它是不是指向一个空地址,既然指向了空地址,也就是这个边并没有被利用上,那么,我们为何不废物利用一下,直接让3号节点的r边直接指向4号节点的r呢?这样当我们搜索到3号节点的时候,就不需要先跳到4号节点了,我们可以直接跳到r边所在的节点,这样就少了一步无用操作了。
ts
// AC自动机实现多模匹配
const BASE = 26;
const charCodeA = "a".charCodeAt(0);
class TrieNode {
flag: boolean;
nexts: TrieNode[];
fail: TrieNode;
s: string;
constructor() {
this.nexts = new Array(BASE);
this.clear();
}
clear() {
this.flag = false;
}
}
class Trie {
private root: TrieNode;
constructor() {
this.root = new TrieNode();
}
public insert(s: string): void {
let p = this.root;
for (let c of s) {
// 获取每个字母的keycode,并以keycode作为字典树的边
const code = c.charCodeAt(0) - charCodeA;
// 如果不存在该条边,我们就创建一个
if (!p.nexts[code]) p.nexts[code] = new TrieNode();
// 让指针指向新的节点
p = p.nexts[code];
}
if (!p.flag) {
p.s = s;
// 将当前单词标记为独立成词
p.flag = true;
}
}
/**
* 通过构建fail指针将字典树的等价节点相互链接形成AC自动机
*/
public buildAC(): void {
// 由于构建AC自动机的fail指针需要对字典树进行层序遍历,因此我们需要借助一个队列
const queue: TrieNode[] = [];
// 先将根节点下面的第一层节点全部压入到队列当中
for(let i=0;i<BASE;i++) {
// 如果该节点不存在则跳过
if(!this.root.nexts[i]) {
// new 让根节点的空边都指向自己,可以节省根节点下空边往下走非法时又要重新回到根节点的情况
this.root.nexts[i] = this.root;
continue;
};
// 根节点下面的第一层子节点的由于只由一个字母组成没办法形成前缀与后缀,因此他的fail直接指向根节点即可
this.root.nexts[i].fail = this.root;
// 将第一层的子节点都压入队列当中
queue.push(this.root.nexts[i]);
}
// 循环取出队列中的每一个节点,依次建立fail指针
while(queue.length) {
const now = queue.shift();
for(let i=0;i<BASE;i++) {
if(!now.nexts[i]) {
// new 如果当前节点的第i条边指向空,那么我们就让当前节点的第i条边指向当前节点fail指针的第i条边
// 注意,当前节点在建立时,他的fail节点指向的26条边肯定都已经没有空边了,因为都已经在之前的循环已经建立好了
now.nexts[i] = now.fail.nexts[i];
continue;
}
// // 由于建立下一层节点的fail指针需要要依据上一层的fail决定,又由于串在一起的fail都是等价的,因此,如果当前fail下没找
// // 到目标字母,就一直往跟他串在一起的fail指针上找,直到回到根节点为止
// p = now.fail;
// while(p && !p.nexts[i]) p = p.fail;
// // 如果p存在,那么我们要找的当前节点的fail指针就是p.nexts[i];
// if(p) p = p.nexts[i];
// // 如果不存在则fail指针应该指向根节点
// else p = this.root;
// new 由于当前节点的fail节点下的所有边都没有空边了,因此,我们可以直接让当前节点第i条边的fail指向当前节点fail指针指向节点的第i个节点
now.nexts[i].fail = now.fail.nexts[i];
// 将当前节点下一层的节点继续加入到队列当中
queue.push(now.nexts[i]);
// console.log(i, now);
}
}
}
public match(s: string): Set<string> {
const set: Set<string> = new Set<string>();
let p = this.root, k: TrieNode;
let cnt = 0;
for(let c of s) {
const idx = c.charCodeAt(0) - charCodeA;
// new 由于此时我们多有的边都没有废边了,因此我们可以直接让p沿着idx边一直往下走即可
p = p.nexts[idx];
cnt++;
// 上面我们已经找到了第一个匹配的字母了,然后从这个字母开始,不断的从fail中抽取结果
k = p;
while(k) {
// 如果独立成词就加入到结果集中
if(k.flag) set.add(k.s);
// 然后继续从等价链接中查询
k = k.fail;
}
}
console.log(`总共查找了${cnt}次`);
return set;
}
}
const mode = ['say', 'she', 'shr', 'he', 'her'];
const s = 'sasherhs';
const trie = new Trie();
mode.forEach(item => trie.insert(item));
trie.buildAC();
const res = trie.match(s);
console.log(res);
// 总共查找了8次
// Set(3) { 'she', 'he', 'her' }
从上面程序的执行结果可以看出,相比初版的AC自动机,优化后的自动机总共查找的次数减少到了8次,当我们的数据量足够大时,这个优化带来的性能提升还是非常可观的。
NFA与DFA
NFA
不确定的有穷自动机(Non-Deterministic Finite State Automata)。即对于一个输入符号,有两种或两种以上可能的状态,所以是不确定的
我们今天讨论的第一个版本的AC自动机,其实就可以看成是一个NFA
,即不确定的又穷自动机
因为3点和4点是等价的,因此,当我们确定了3点之后,其实也就确定了4点,也就是说,同时对应了3点和4点两个状态。
DFA
确定的又穷自动机(Deterministic Finite State Automata)
我们优化过后的AC自动机更像是DFA
,也就是输入一个状态,他会进入下一个确定的状态

结语
相较于说自动机是一种算法或者是数据结构,我认为,自动机更像是一种编程思想,像我们以前有跳过的KMP
算法和ShiftAnd
算法,都可以看做是NFA
。自动机
在计算机领域作用极大且范围很广,设置我们大多数语言解释器都有着自动机的身影,这个以后有机会再学习一下,这里仅仅是掀开了自动机的冰山一角而已。