Trie图 和 AC自动机

Trie图 和 AC自动机

标签(空格分隔): 数据结构 编程 算法



在学习 Trie图 和 AC自动机 之前,你需要了解以下知识: Trie树(字典树)KMP。所以,如果你还没有学过以上算法,可以先简单了解以上算法。我的博客里也有这两个的介绍: Trie树 [KMP][2] 非常抱歉,KMP的还未写完~~~

如果你比较着急,可以直接看总结。

现在,让我们进入AC自动机的世界吧。

1. 算法由来

1.1 解决对象

给出一个原串s和n个模式串T[1..n],询问原串s中是否存在T[1..n]中任意一个子串。

1.2 例子

现在给出原串s=cbcbacc,模式串T[1…3]=cbab,baa,bab 作为例子

1.3 一些可能的算法

令 m=sigma{ strlen(T[i]) }

  • 如果我们采用暴力方法一一匹配,时间复杂度为 O(n*m)
  • 如果我们把T[]构建成一颗Trie树,在这棵树上单串查询,时间复杂度为 O(n*m)
  • 如果我们用KMP一一匹配,时间复杂度为 O(n*m)

可以看到,所有算法的最坏时间复杂度都为 O(n*m),当n和m很大的时候,这个时间复杂度并不是很优秀,有没有更加优秀的算法呢?

1.4 优化

我们可以观察一些Trie树的匹配过程,以此来获得一些灵感。请你拿出草稿纸,和我一起模拟接下来的过程,否则你很可能会看的一脸懵逼~

(请使用例子)
我们首先把T[1…n]建成一颗Trie树,根节点root=0,把模式串的最后一个字符的val标记为1(如果你不知道val是什么,其实它就是Trie树中每个节点所拥有的一个权值,我们假定它不会是0,所以一个不存在的节点的val为0)。接下来我们枚举原串s的每一个位置作为开头,在Trie树中进行遍历,以下是和建成的Trie树遍历的过程。

Trie树:
0 (c)-> 1 (b)->2 (a)->3 (b)->4
  (b)-> 5 (a)->6 (a)->7
                 (b)->8
注:()中的字母表示边的编号,自然数表示点的编号*

遍历过程:
作为开头的位置  依次访问的节点编号
      1           0->1->2 (Fail)
      2           0->1 (Fail)
      3           0->1->2->3 (Fail)
      4           0->5->6 (Fail)
      5           0 (Fail)
      6           0->1 (Fail)
      7           0->1 (Fail)

我们发现:以3为开头的位置时,遍历到cba就结束了,直接从头开始遍历;但是cba的后缀字符串ba恰好是T[]中baa的前缀,能不能利用呢?

(……)

2. 算法思想

利用已知后缀探索未知前缀,这是KMP的基本思想。具体来说,就是KMP中next数组的含义。我们可以在Trie树中,为每个节点增设一个fail数组,表示当前节点所对应的字符串的后缀中,是某个模式串的前缀的最大长度。(这句话可能有点绕)

3. 算法的实现

在 2.算法思想 中,我们已经知道了fail数组的含义,类似于KMP的next数组,我们也可以用递归的思想来得到fail数组,但是那有点儿麻烦,读者不妨自己去思考一下,我们这里介绍一种BFS(广度优先搜索)的方法。

3.1 实现方法的推演*

如果我们要求一个字符串的最长后缀,最简单的方法就是在它的前缀节点的最长后缀后边加一个字符©,这又是KMP的思想。
那么,前缀节点的最长后缀节点加这个字符c是否一定存在呢,答案是否定的。

  • 如果字符c边存在,那么我们的工作就很简单轻松了。Fail[u]=Ch[ Fail[ father[u]] ][c] (注:ch表示children,含义见Trie树
  • 如果字符c不存在,就有点儿麻烦了。一种普遍的思维是用类似于递归的方式,把问题转为求这个并不存在的节点的后缀,利用类似于递归的方法可解,但是很麻烦,不太好。
  • 我们就要想想了,为什么递归比较麻烦。
3.1.1 基本思想:已知<-未知

为什么递归比较麻烦呢?因为当你使用递归这个东西的时候,你所需要使用的东西是未知的,这就不太好了,因为你可能还需要先把那东西求出来,才能进一步求你想要的。那么,有没有什么方法可以吧未知转化为已知呢?

这有点类似于DP中提到的枚举顺序的问题,一种是 自顶向下,一种是 自底向上,这就分别对应了从 已知到未知 和 从未知到已知。这两种的唯一区别是什么呢?就是枚举顺序的不同。

所以,我们这里也可以用一下这个,已知节点的后缀节点在树中的深度一定比它小,也就是说,它一定在当前节点的上面。如果我们能把后缀节点先生成好,就不需要递归了。怎么生成呢?

3.1.2 BFS序!

按层次访问节点即可。BFS(广度优先搜索)就是一种按层次访问节点的好方法。

3.2 实现方法的主要步骤 (By Mr. Zhu and lots of excellent classmates)

  1. 建立Trie树
  2. 根节点的后缀是根节点
  3. 根节点的儿子后缀是根节点,根节点的儿子入队列que
  4. (访问que中的所有节点),访问队列que中的头结点,假定当前节点为r
  5. 生成r的字符集条边,如果边c指向的节点u存在,Fail[u]=Ch[Fail[r]][c],并将它加入队列;如果如果边c指向的节点u不存在,Ch[r][u]=Ch[Fail[r]][u];
  6. 重复步骤4,直到队列为空

3.3 时间、空间复杂度的分析

因为每个节点只会入队一次,出队一次,所以时间复杂度为 O(节点数 * 字符集大小)。
空间有点大,因为上述步骤补齐了所有虚边,空间复杂度为 O(节点数 * 字符集大小)。
需要指出的是,这个算法的空间复杂度可以被优化,具体来讲,我们没有必要生成所有虚边,可以用一个函数来代替。

3.4 空间上的优化(摘自王赟的PPT)

Trie树中的边自然是要存储的,但新建的边则不必存储。如果不存储新建的边,那么如何实现状态间的转移呢?我们用一个函数child(x,c)来获得结点x的c孩子。函数内部的程序其实完全是按照加边的原则编写的:如果x本来就有c孩子,那么就返回这个孩子;如果x没有c孩子,根据加边的原则,函数应该返回x的后缀结点的c孩子,也就是令x为它的后缀结点,重新执行函数。如果x变成了根结点仍然没有c孩子,同样根据加边的原则,函数的返回值就应该是根结点本身。

经过这样的处理,算法的空间复杂度由O(L1a)降到了O(L1),对于本题来说是足够低的了。但是,由于child函数的执行时间的不确定性,我们对算法的时间复杂度产生了疑问。其实,算法的时间复杂度为O(L1+L2),数量级并没有受到影响,只是增加了一点常数系数。为什么呢?显然,在调用child(x,c)的时候,只有当x没有c孩子,需要重复执行child函数时运行时间才会增加。我们分别讨论增加的这点时间对建图过程和文本检查过程所需时间的影响:

建图过程:由于我们并不存储一个结点的所有孩子指针,所以建图的过程其实就是求每个结点的危险性及后缀结点的过程。若b是trie树中a结点的一个孩子,那么b的后缀结点的深度至多比a的后缀结点大1。如果把trie树中某条路径上的结点的后缀结点的深度排成一个数列,那么相邻两项中,后一项减一项的差一定小于等于1。当后一项减前一项的差小于1时,child函数就会被重复执行。但是,child函数回溯的次数不会超过trie树的深度,所以建图过程的时间复杂度为O(L1)。

文本检查过程:把这个过程看成是一个光标在安全图中漫游的过程。因为光标如果往下走,它只能走1步,所以若把光标经过的位置的深度也排成一个数列,这个数列与上一段提到的数列具有相同的性质:增长是缓慢的。同理,文本检查过程的时间复杂度为O(L2)。

综上所述,改进后的trie图的时间复杂度为O(L1+L2)。无论从时间复杂度上看还是从空间复杂度上看,改进后的算法都明显优于改进前。

其实,改进后的算法已经就是用于多模式串匹配的改进KMP算法了。

对于什么样的题目需要用改进的trie图,在此作一下总结:
纯粹的多模式匹配问题:当题目中的字符集大小有限且较小时,不必用改进的trie图,因为内存够用,若进行改进,增加的常数系数可能反而大于字符集的大小a。如果字符集较大甚至无限(汉字的多模式匹配系统的字符集几乎可以认为是无限的),就必须使用改进的trie图。
用到图中每一条边的题目:一般用未改进的trie图。在内存十分紧张的情况下,可以采用改进的trie图,用时间换空间,但这样做时间复杂度仍为O(L1a+L2),且常数系数较未改进时大。

4. 总结

4.1 算法由来: Trie树+KMP

4.2 算法解决对象: 多模式串匹配问题

4.3 算法流程

  1. 建立Trie树
  2. 根节点的后缀是根节点
  3. 根节点的儿子后缀是根节点,根节点的儿子入队列que
  4. (访问que中的所有节点),访问队列que中的头结点,假定当前节点为r
  5. 生成r的字符集条边,如果边c指向的节点u存在,Fail[u]=Ch[Fail[r]][c],并将它加入队列;如果如果边c指向的节点u不存在,Ch[r][u]=Ch[Fail[r]][u];
  6. 重复步骤4,直到队列为空

4.4 我的理解

  • 运用了未知->已知的思想,将递归转化为BFS
  • 大量运用[KMP][2]的思想

That’s all,thank you!

By Gary,an oier.
2018.5.13

[2]: KMP URL

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值