最近学习天然语言处理(NLP)相关的知识,认识了 Trie 这种树形数据结构,在 NLP 中通常会用其存储大量的字典字符以用于文本的快速分词;除此以外,典型应用场景还包括大批量文本的:词频统计、字符串查询和模糊匹配(好比关键词的模糊匹配)、字符串排序等任务;因为 Trie 大幅下降了无谓的字符串比较,所以在执行上述任务时,其效率很是的高。html
然而...它却有些复杂,特别是工程实践中常见的双数组 Trie 树,对于新手来讲若是没有好的讲解,真的很难彻底弄懂;关于这点,做为小白的我深有感触:咱搜索了度娘和谷哥可以搜到的一大波一大波解读,结果仍是以为本身实在太笨....最终只能笨办法整起,从头梳理这种结构的应用场景和实际构建中的各类问题才算有所得。这篇文章也是写给与我有一样情况的童鞋,所以行文相对繁琐,讲解也更注重原理,所以对于想快速得到 Trie 构建方法的童鞋能够绕过。git
Trie 树中文名叫字典树、前缀树(我的比较喜欢这个名字,看完下文就会明白)等等。这些名字暗示其与字符的处理有关,事实也确实如此,它主要用途就是将字符串(固然也能够不限于字符串)整合成树形。咱们先来看一下由“清华”、“清华大学”、“清新”、“中华”、“华人”五个中文词构成的 Trie 树形(为了便于叙述,下文提到该实例,以“例树”简称):github
这个树里面每个方块表明一个节点,其中 ”Root” 表示根节点,不表明任何字符;紫色表明分支节点;绿色表明叶子节点。除根节点外每个节点都只包含一个字符。从根节点到叶子节点,路径上通过的字符链接起来,构成一个词。而叶子节点内的数字表明该词在字典树中所处的链路(字典中有多少个词就有多少条链路),具备共同前缀的链路称为串。除此以外,还需特别强调 Trie 树的如下几个特色:算法
具备相同前缀的词必须位于同一个串内;例如“清华”、“清新”两个词都有“清”这个前缀,那么在 Trie 树上只需构建一个“清”节点,“华”和“新”节点共用一个父节点便可,如此两个词便只需三个节点即可存储,这在必定程度上减小了字典的存储空间。数组
Trie 树中的词只可共用前缀,不可共用词的其余部分;例如“中华”、“华人”这两个词虽然前一个词的后缀是后一个词的前缀,但在树形上必须是独立的两条链路,而不能够经过首尾交接构建这两个词,这也说明 Trie 树仅能依靠公共前缀压缩字典的存储空间,并不能共享词中的全部相同的字符;固然,这一点也有“例外”,对于复合词,可能会出现两词首尾交接的假象,好比“清华大学”这个词在上例 Trie 树中看起来彷佛是由“清华”、“大学”两词首尾交接而成,可是叶子节点的标识已经明确说明 Trie 树里面只有”清华“和”清华大学“两个词,它们之间共用了前缀,而非由“清华”和”大学“两词首尾交接所得,所以上例 Trie 树中若须要“大学”这个词则必须从根节点开始从新构建该词。数据结构
Trie 树中任何一个完整的词,都必须是从根节点开始至叶子节点结束,这意味着对一个词进行检索也必须从根节点开始,至叶子节点才算结束。函数
在 Trie 树中搜索一个字符串,会从根节点出发,沿着某条链路向下逐字比对字符串的每一个字符,直到抵达底部的叶子节点才能确认字符串为该词,这种检索方式具备如下两个优势:性能
公共前缀的词都位于同一个串内,查词范围所以被大幅缩小(好比首字不一样的字符串,都会被排除)。学习
Trie 树实质是一个有限状态自动机((Definite Automata, DFA),这就意味着从 Trie 树的一个节点(状态)转移到另外一个节点(状态)的行为彻底由状态转移函数控制,而状态转移函数本质上是一种映射,这意味着:逐字搜索 Trie 树时,从一个字符到下一个字符比对是不须要遍历该节点的全部子节点的。对于肯定性有限自动机感兴趣的同窗,能够看看如下引用[1]:测试
肯定的有限自动机 M 是一个五元组:
M = (Σ, Q, δ, q0, F)
其中,
Σ 是输入符号的有穷集合;
Q 是状态的有限集合;
δ 是 Q 与 Σ 的直积 Q × Σ 到Q (下一个状态) 的映射。它支配着有限状态控制的行为,有时也称为状态>转移函数。
q0 ∈ Q 是初始状态;
F 是终止状态集合,F ⊆ Q;
能够把DFA想象成一个单放机,插入一盘磁带,随着磁带的转动,DFA读取一个符号,依靠状态转移函数>改变本身的状态,同时磁带转到下一个字符。
这两个优势相结合能够最大限度地减小无谓的字符比较,使得搜索的时间复杂度理论上仅与检索词的长度有关:O(m),其中 m 为检索词的长度。
综上可知, Trie 树主要是利用词的公共前缀缩小查词范围、经过状态间的映射关系避免了字符的遍历,从而达到高效检索的目的。这一思想有赖于字符在词中的先后位置可以获得表达,所以其设计哲学是典型的“以信息换时间”,固然,这种优点一样是须要付出代价的:
因为结构须要记录更多的信息,所以 Trie 树的实现稍显复杂。好在这点在大多数状况下并不是不可接受。
Trie 型词典不只须要记录词,还须要记录字符之间、词之间的相关信息,所以字典构建时必须对每一个词和字逐一进行处理,而这无疑会减慢词典的构建速度。对于强调实时更新的词典而言,这点多是致命的,尤为是采用双数组实现的 Trie 树,更新词典很大几率会形成词典的所有重构,词典构建过程当中还需处理各类冲突,所以重构的时间很是长,这致使其大多用于离线;不过也有一些 Trie 能够实现实时更新,但也需付出必定的代价,所以这个缺点必定程度上影响了 Trie 树的应用范围。
公共前缀虽然能够减小必定的存储空间,但 Trie 树相比普通字典还需表达词、字之间的各类关系,其实现也更加复杂,所以实际空间消耗相对更大(大多少,得根据具体实现而定)。尤为是早期的“Array Trie”,属于典型的以空间换时间的实现,(其实 Trie 自己的实现思想是是以信息换时间,而非以空间换时间,这就给 Trie 树的改进提供了可能),然而 Trie 树现今已经获得了很好的改进,整体来讲,对于相似词典这样的应用,Trie 是一个优秀的数据结构。
不少文章里将这种实现称为“标准 Trie 树”,但其实它只是 Trie 众多实现中的一种而已,因为这种实现结构简单,检索效率很好,做为讲解示例很不错,所以特意改称其为“经典 Trie 树”,这里引用一下别人家的示例图[2]:
abc、d、da、dda 四个字符串构成的 Trie 树,若是是字符串会在节点的尾部进行标记。没有后续字符的 branch 分支指向NULL
如上图,这种实现的特色是:每一个节点都由指针数组存储,每一个节点的全部子节点都位于一个数组之中,每一个数组都是彻底同样的。对于英文而言,每一个数组有27个指针,其中一个做为词的终结符,另外 26 个依次表明字母表中的一个字母,对应指针指向下一个状态,若没有后续字符则指向NULL。因为数组取词的复杂度为O(1),所以这种实现的 Trie 树效率很是的高,好比要在一个节点中写入字符“c”,则直接在相应数组的第三个位置标入状态便可,而要肯定字母“b”是否在现有节点的子节点之中,检查子节点所在数组第二个元素是否为空便可,这种实现巧妙的利用了等长数组中元素位置和值的一一对应关系,完美的实现了了寻址、存值、取值的统一。
但其缺点也很明显,它强制要求链路每一层都要有一个数组,每一个数组都必须等长,这在实际应用中会形成大多数的数组指针空置(从上图就能够看出),事实上,对于真实的词典而言,公共前缀相对于节点数量而言仍是太少,这致使绝大多数节点下并无太多子节点。而对于中文这样具备大量单字的语言,若采起这样的实现,空置指针的数量简直不可想象。所以,经典 Trie 树是一种典型的以“空间换时间”的实现方式。通常只是拿来用于课程设计和新手练习,不多实际应用。
因为数组的长度是不可变,所以经典 Trie 树存在着明显的空间浪费。可是若是将每一层都换成可变数组(不一样语言对这种数据结构称呼不一样,好比在 Python 中为List,C# 称为 LinkedList)来存储节点(见下图[3]),每层能够根据节点的数量动态调整数组的长度,就能够避免大量的空间浪费。下图就是这种实现的图例[3]:
可是可变长数组的取词复杂度是O(d),其中 d 为数组的长度,这意味着状态转移函数没法经过映射转移到下一节点,必须先遍历数组,找到节点后再作转移,所以Trie 树实际时间复杂度变为O(m*n)(其中n为每层数组中节点的数量)。这显然下降了查询效率,所以还算不上完善。
可变数组取词速度太慢,因而就有人想起用一组键值对(Java中可用HashMap类型,Python 中为 dict 类型,C#为Dictionary类型)代替可变数组:其中每一个节点包含一组 Key-Value,每一个 Key 对应该节点下的一个子节点字符,value 则指向相应的后一个状态。这种方式能够有效的减小空间浪费,同时因为键值对本质上就是一个哈希实现,所以理论上其查词效率也很高(理想状态下取词复杂度为O(1))。
可是哈希有的缺点,这种实现的 Trie 树也会有:
为了尽量的避免键值冲突,哈希表须要额外的空间避开碰撞,所以仍有一部分的空间会被浪费;
哈希表很难作到完美,尤为是数据体量增大以后,其查词复杂度经常难以维持在O(1),同时,对哈希值的计算也须要额外的时间,所以实际查询效率要比经典实现低,其具体复杂度由相应的哈希实现来定。
与数组和可变数组实现相比,这种实现作到了空间和时间上的一种平衡,这个结果并不意外,由于哈希表自己就是平衡数组(查寻迅速、增删悲剧)和可变数组(增删迅速,查询悲剧)相应优势和缺点的一种数据结构。
整体而言,Hash Trie 结构简单,性能堪用,并且因为哈希实现能够为每一个节点分配惟一的id,所以能够作到节点的实时动态添加(这点是很是大的优点)所以对于中小规模的词典或者对词典的实时更新有需求的应用,该实现很是适合。
双数组 Trie 树是目前 Trie 树各类实现中性能和存储空间均达到很好效果的实现。但其完整的实现比较复杂,对于新手而言入手相对较难,所以本节将花费较多的篇幅对其解读。
双数组 Trie 树和经典 Trie 树同样,也是用数组实现 Trie 树。只不过它是将全部节点的状态都记录到一个数组之中(Base Array),以此避免数组的大量空置。以行文开头的示例为例,每一个字符在 Base Array 中的状态能够是这样子的:
好吧,我撒了个慌,事实上,为了能使单个数组承载更多的信息,Base Array 仅仅会经过数组的位置记录下字符的状态(节点),好比用数组中的位置 2
指代“清”节点、 位置 7
指代 “中”节点;而数组中真正存储的值实际上是一个整数,这个整数咱们称之为“转移基数”,好比位置2
的转移基数为 base[2]=3
位置7
的转移基数为base[7]=2
,所以在不考虑叶子节点的状况下, Base Array 是这样子的:
转移基数是为了在一维数组中实现 Trie 树中字符的链路关系而设计的,举例而言,若是咱们知道一个词中某个字符节点的转移基数,那么就能够据此推断出该词下一个节点在 Base Array 中的位置:好比知道 “清华”首字的转移基数为base[2]=3
,那么“华”在数组中的位置就为base[2]+code("华")
,这里的code("华")
为字符表中“华”的编码,假设例树的字符编码表为:
清-1,华-2,大-3,学-4,新-5,中-6,人-7
那么“华”的位置应该在Base Array 中的的第 5
位(base[2]+code("华")=3+2=5
):
而全部词的首字,则是经过根节点的转移基数推算而来。所以,对于字典中已有的词,只要咱们每次从根节点出发,根据词典中各个字符的编码值,结合每一个节点的转移基数,经过简单的加法,就能够在Base Array 中实现词的链路关系。如下是“清华”、“清华大学”、“清新”、“中华”、“华人”五个词在 Base Array 中的链路关系:
可见 Base Array 不只可以表达词典中每一个字符的状态,并且还能实现高效的状态转移。那么,Base Array 又是如何构造的呢?
事实上,一样一组词和字符编码,以不一样的顺序将字符写入 Trie 树中,得到的 Base Array 也是不一样的,以“清华”、“清华大学”、“清新”、“中华”、“华人”五个词,以及字符编码:[清-1,华-2,大-3,学-4,新-5,中-6,人-7] 为例,在不考虑叶子节点的状况下,两种处理方式得到的 base array 为:
首先依次处理“清华”、“清华大学”、“清新”、“中华”、“华人”五个词的首字,而后依次处理全部词的第二个字...直到依次处理完全部词的最后一个字,获得的 Base Array 为:
依次处理“清华”、“清华大学”、“清新”、“中华”、“华人”五个词中的每一个字,获得的 Base Array 为:
能够发现,不一样的字符处理顺序,获得的 Base Array 存在极大的差异:二者各状态的转移基数不只彻底不一样,并且 Base Array 的长度也有差异。然而,二者得到的方法倒是一致的,下面以第一种字符处理顺序讲解一下无叶子节点的 Base Array 构建:
首先人为赋予根节点的转移基数为1
(可自定义,详见下文),而后依次将五个词中的首字"清"、“中”、“华”写入数组之中,写入的位置由base[1]+code(字符)
肯定,每一个位置的转移基数(base[i]
)等于上一个状态的转移基数(此例也即base[1]
),这个过程未遇到冲突,最终结果见下图:
而后依次处理每一个词的第二个字,首先须要处理的是“清华”这个词的“华”字,程序先从根节点出发,经过base[1]+code(“清”)=2
找到“清”节点,而后以此计算“华”节点应写入的位置,经过计算base[2]+code(“华”)=3
寻找到位置 3
,却发现位置3
已有值,因而后挪一位,在位置4
写入“华”节点,因为“华”节点未能写入由前驱节点“清”预测的位置,所以为了保证经过“清”可以找到“华”,须要从新计算“清”节点的转移基数,计算公式为4-code(“华”)=2
,得到新的转移基数后,改写“清”节点的转移基数为2
,而后将“华”节点的转移基数与“清”节点保持一致,最终结果为:
重复上面的步骤,最终得到整个 Base Array:
经过以上步骤,能够发现 base array 的构造重点在于状态冲突的处理,对于双数组 Trie 而言,词典构造过程当中的冲突是不可避免的,冲突的产生来源于多词共字的状况,好比“中华”、“清华”、“华人”三个词中都有“华”,虽然词在 Trie 树中能够共用前缀,可是对于后缀同字或者后缀与前缀同字的状况却只能从新构造新的节点,这势必会致使冲突。一旦产生冲突,那么父节点的转移基数必须改变,以保证基于前驱节点得到的位置可以容纳下全部子节点(也即保证 base[i]+code(n1)
、base[i]+code(n2)
、base[i]+code(n3)
....都为空,其中n一、n二、n3...
为父节点的全部子节点字符,base[i]
为父节点新的转移基数,i
为父节在数组中的位置)这意味着其余已经构造好的子节点必须一并重构。
所以,双数组 Trie 树的构建时间比较长,有新词加入,运气很差的话,还可能能致使全树的重构:好比要给词典添加一个新词,新词的首字以前不曾写入过,如今写入时若出现冲突,就须要改写根节点的转移基数,那么以前构建好的词都须要重构(由于全部词的链路都是从根节点开始)。上例中,第二种字符写入顺序就遇到了这个问题,致使在词典构造过程当中,根节点转移基数被改写了两次,全树也就被重构了三次:
可见不一样的节点构建顺序,对 Base Aarry 的构建速度、空间利用率都有影响。建议实际应用中应首先构建全部词的首字,而后逐一构建各个节点的子节点,这样一旦产生冲突,能够将冲突的处理局限在单个父节点和子节点之间,而不至于致使大范围的节点重构。
上面关于 Base Array 的叙述,只涉及到了根节点、分支节点的处理,事实上,Base Array 一样也须要负责叶子节点的表达,可是因为叶子节点的处理,具体的实现各不一致,所以特意单列一节予以论述。
通常词的最后一个字都不须要再作状态转移,所以有人建议将词的最后一个节点的转移基数统一改成某个负数(好比统一设置为-2),以表示叶子节点,按照这种处理,对于示例而言,base array 是这样的:
但细心的童鞋可能会发现,“清华” 和 “清华大学” 这两个词中,只有“清华大学”有叶子节点,既是公共前缀又是单个词的“清华”实际上没法用这种方法表示出叶子节点。
也有人建议为词典中全部的词增长一个特殊词尾(好比将“清华”这个词改写为“清华\0”),再将这些词构建为树,特殊字符词尾节点的转移基数统一设置设为-2,以此做为每一个词的叶子节点[4]。这种方法的好处是不用对现有逻辑作任何改动,坏处是增长了总节点的数量,相应的会增长词典构建的时长和空间的消耗。
最后,我的给出一个新的处理方式:直接将现有 base array 中词尾节点的转移基数取负,而数组中的其余信息不用改变。
以树例为例,处理叶子节点前,Base Array 是这样子的:
处理叶子节点以后,Base Array 会是这样子的:
每一个位置的转移基数绝对值与以前是彻底相同的,只是叶子节点的转移基数变成了负数,这样作的好处是:不只标明了全部的叶子节点,并且程序只需对状态转移公式稍加改变,即可对包括“清华”、“清华大学”这种状况在内的全部状态转移作一致的处理,这样作的代价就是须要将状态转移函数base[s]+code(字符)
改成|base[s]|+code(字符)
,意味着每次转移须要多作一次取绝对值运算,不过好在这种处理对性能的影响微乎其微。
对此,其余童鞋如有更好的想法, 欢迎在底部留言!
“双数组 Trie 树”,一定是两个数组,所以单靠 Base Array 是玩不起来的....上面介绍的 Base Array 虽然解决了节点存储和状态转移两个核心问题,可是单独的 Base Array 仍然有个问题没法解决:
Base Array 仅仅记录了字符的状态,而非字符自己,虽然在 Base Array,字典中已有的任意一个词,其链路都是肯定的、惟一的,所以并不存在歧义;可是对于一个新的字符串(无论是检索字符串仍是准备为字典新增的词),Base Array 是不能肯定该词是否位于词典之中的。对于这点,咱们举个例子就知道了:
若是咱们要在例树中确认外部的一个字符串“清中”是不是一个词,按照 Trie 树的查找规则,首先要查找“清”这个字,咱们从根节点出发,得到|base[1]|+code(“清”)=3
,而后转移到“清”节点,确认清在数组中存在,咱们继续查找“中”,经过|base[3]|+code(“中”)=9
得到位置9
,字符串此时查询完毕,根据位置9
的转移基数base[9]=-2
肯定该词在此终结,从而认为字符串“清中”是一个词。而这显然是错误的!事实上咱们知道 “清中”这个词在 base array 中压根不存在,可是此时的 base array 却不能为此提供更多的信息。
为了解决这些问题,双数组 Trie 树专门设计了一个 check 数组:
check array 与 base array 等长,它的做用是标识出 base array 中每一个状态的前一个状态,以检验状态转移的正确性。
所以, 例树的 check array 应为:
如图,check array 元素与 base array 一一对应,每一个 check array 元素标明了base array 中相应节点的父节点位置,好比“清”节点对应的check[2]=0
,说明“清”节点的父节点在 base array 的0
位(也即根节点)。对于上例,程序在找到位置9
以后,会检验 check[9]==2
,以检验该节点是否与“清”节点处于同一链路,因为check[9]!=2
,那么就能够断定字符串“清中”并不在词典之中。
综上,check array 巧妙的利用了父子节点间双向关系的惟一性(公式化的表达就是base[s]+c=t & check[t]=s
是惟一的,其中 s
为父节点位置,t
为子节点位置),避免了 base array 之中单向的状态转移关系所形成的歧义(公式化的表达就是base[s]+c=t
)。
双数组 Trie 树虽然大幅改善了经典 Trie 树的空间浪费,可是因为冲突发生时,程序老是向后寻找空地址,致使数组不可避免的出现空置,所以空间上仍是会有些浪费。另外, 随着节点的增长,冲突的产生概率也会愈来愈大,字典构建的时间所以愈来愈长,为了改善这些问题,有人想到对双数组 Trie 进行尾缀压缩,具体作法是:将非公共前缀的词尾合并为一个节点(tail 节点),以此大幅减小节点总数,从而改善树的构建速度;同时将合并的词尾单独存储在另外一个数组之中(Tail array), 并经过 tail 节点的 base 值指向该数组的相应位置,以 {baby#, bachelor#, badge#, jar#}四词为例,其实现示意图以下[3]:
对于这种改进的效果,看一下别人家的测试就知道了[4]:
速度
减小了base, check的状态数,以及冲突的几率,提升了插入的速度。在本地作了一个简单测试,随机插入长度1-100的随机串10w条,no tail的算法需120秒,而tail的算法只需19秒。
查询速度没有太大差异。内存
状态数的减小的开销大于存储tail的开销,节省了内存。对于10w条线上URL,匹配12456条前缀,内存消耗9M,而no tail的大约16M删除
能很方便的实现删除,只需将tail删除便可。
对于本文的例树,若采用tail 改进,其最终效果是这一子的:
Trie 树是一种以信息换时间的数据结构,其查询的复杂度为O(m)
Trie 的单数组实现可以达到最佳的性能,可是其空间利用率极低,是典型的以空间换时间的实现
Trie 树的哈希实现能够很好的平衡性能需求和空间开销,同时可以实现词典的实时更新
Trie 树的双数组实现基本能够达到单数组实现的性能,同时可以大幅下降空间开销;可是其难以作到词典的实时更新
对双数组 Trie 进行 tail 改进能够明显改善词典的构建速度,同时进一步减小空间开销
参考文献:
(1) Trie 树详解
(2) 《统计天然语言处理》,第三章 形式语言与自动机
(3) Theppitak Karoonboonyanan, An Implementation of Double-Array Trie.
(4) 前缀树匹配(Double Array Trie)