词典的存储和搜索
好久没更新,这是一篇长文。也是一篇比较硬的文章,比较烧脑。在硬长文里,可能很难找到这么有趣的。在有趣的文章里,可能很难找到这么硬的。这就是大半夜在写文章的价值,不是么?嗯,大概就是这样吧。
在自然语言处理领域,分词是最基本的任务。不管是传统的基于词典的分词算法还是现代的基于统计语言模型的分词算法,都需要词典作为输入。本文介绍 Trie 算法,用来存储词典,并提供高效的搜索功能。
词典的格式
这里的词典比你书架上的现代汉语词典要简单很多,因为没有释义,只有光溜溜的一个词在那。这也好理解,释义是给人看的,而计算机根本就看不懂释义。计算机顶多只会“算”,但它算的真的很快很快,快到让人感觉它真的有智能。比如下面就是一个分词时用到的词典:
阿
阿巴丹
阿爸
...
...
...
做贼心虚
做主
做作
当然,基于现代的统计语言模型的分词算法,还需要存储词的常用程度。所以需要对词典里的词做一些标注,比如:
反 1205
作战股 1
先人后己 1
传媒者 1
由此可见,词典的数据其实是很简单的,就是一个词加上与这个词对应的一个整形数值。在实际应用中,需要非常频繁地查找词典,比如在分词算法里,我们找到两个字后,需要判断这两个字是否在词典里,如果在,就说明这是一个整体的词,如果不在,那么就不能组成一个词。所以词典的格式应该要满足快速查找的要求。那么怎么样保存词典以便查找速度最快呢?直接把词典按照线性放在数组里显然是不行的。
Trie 数据结构
Trie 的读音和 Tree 相同,也有人读作 Try ,是为了和 Tree 区分开。因为在 Tree 也是数据结构的一种,容易让人误解。
话说,怎么样存储词典呢?科学家们发明了一种叫做叫 Trie 的数据结构来保存词典:
Trie图一:图片来自 wikipedia
上图展示了 "A", "t", "to", "tea", "ted", "ten", "i", "in", "inn"。在这样的数据结构里,要查找某个词时,基本上和词典的大小无关,而只与要查找的词的大小有关。查找的速度基本上达到 O(1)。比如,我们要找 "tea" 这个词,从开始状态起步,我们的第一个字母是 "t" 则沿着根节点最左边的子树上前进到达其对应的子节点,接着是字母 "e" ,找到对应的子节点,再接着字母 "a" 找到 "tea" 这个单词。再如,我们要找 "too" 这个单词,在到达 "to" 这个节点时,还剩下一个 "o" 没有消化掉。所以 "too" 这个单词就不在上图表示的词典里。
从另外一个角度看这个图,实际上这也是个确定有限状态机(DFA - Deterministic Finite Automaton)。实际上针对 Trie 算法的实现,就是基于 DFA 的的原理进行的,所以要理解 Trie 算法,本质上需要先理解 DFA。
确定有限状态机
有限状态机的定义是非常严谨的,它包含一个五元组 (Q, Σ, δ, q0, F),其中:
- Q 是一个有限的状态集合
- Σ 是一个有限的输入事件集合
- δ 是一个状态转移函数,当某个状态遇到某个事件时,会引起状态转换,跳到另外一个状态 (δ : Q × Σ → Q)
- q0 是一个起始状态 (q0 ∈ Q)
- F 是一组可接受的终止状态 (F ⊆ Q)
不得不说,数学家这个群体还是很令人佩服的。他们把很好理解的概念抽象抽象再抽象,抽象到我等智商平平的人看不懂的程度。当然,数学家的本意并非让我等看不懂,而是为了计算方便。另外一些人和数学家干的事正好相反,他们把很复杂的数学原理和算法,解释得通俗易懂,让大部分资质平平,没经过专业训练的人也能感受到数学之美。比如吴军老师的《数学之美》就是这类的典范。
跑题结束,我们说回 DFA。上文讲过,我们可以把 Trie 数据结构看成是一个 DFA 。那么词典和数学家定义的 DFA 有什么关系呢?
还是以图一为例,我们看一下对应关系:
- Q 是有限状态集合。上图所有的圆圈构成了一个有限状态集合
- Σ 是有限的输入事件集合。上图中,引起状态转移,标注在状态转移线段上的字母就是输入事件集合,t, o, e, a, d, n, A, i, n 构成输入事件集合
- q0 是一个起始状态。上图中,根节点就是起始状态。
- F 是一组可接受的终止状态。上图中,有标注数字的圆圈就代表一个可接受的终止状态,to, tea, ted, ten, A, i, in, inn。从词典的角度考虑,所有构成合法单词的状态就是可接受的终止状态。
上述对应关系里,我们漏了状态转移函数。确定有限状态机的关键点在状态转移上,如果当前状态遇到一个确定的事件时,最多只能转移到一个确定的状态上,那么这就是一个确定有限状态机,简称 DFA。为什么有最多一个确定状态这一说法?因为可能没有下一个状态,对词典的例子来说,如果没有下一个状态,就说明要查找的词不在词典里。比如我们要查找 "too" 这个单词,当消耗完 "t", "o" 两个字母后,还剩下一个 "o" ,这个时候应该还要有下一个由 "o" 触发的确定的状态才对,但在上图中找不到,说明 "too" 这个单词不在上图表示的词典里。
聪明的你可能会问,如果一个输入事件,导致可能转移到不同的状态去,每种状态有不同的概率,这是什么?答案是不确定有限状态机。感兴趣的搜索一下马尔可夫链。等你研究完马尔可夫链会发现,下一步要看隐马尔可夫模型了。咳咳,这段文字纯属装逼,我们还是就此打住,继续今天的课题吧。
状态转移表
我们终于把 Trie 和 DFA 联系起来了。在 DFA 里,状态转移函数一般使用状态转移表来描述。这是个二维的表格,行用来表示所有的状态集合,列用来表示输入事件集合。
我们来看一个最简单的使用 Trie 描述的词典:
t o
begin(0) ---> t(1) ---> to(2:F)
|
| e a
----> te(3) ---> tea(4:F)
|
| d
---> ted(5:F)
|
| n
---> ten(6:F)
这是本文示例图片的一小部分,这个词典只包含四个单词,分别是 to, tea, ted, ten。从 DFA 的角度来看,它总共有 7 个状态,包含一个状态为 0 的起始状态。输入事件集合是 [t, o, e, a, d, n]。可接受的终止状态集合就是上图中标注着 ":F" 字样的状态,就是四个有效的单词。那么上述词典所代表的 DFA 的的状态转移表长什么样呢?
state | t | o | e | a | d | n |
---|---|---|---|---|---|---|
0 | 1 | x | x | x | x | x |
1 | x | 2 | 3 | x | x | x |
2 | x | x | x | x | x | x |
3 | x | x | x | 4 | 5 | 6 |
4 | x | x | x | x | x | x |
5 | x | x | x | x | x | x |
6 | x | x | x | x | x | x |
表一:状态转移表
这就是上述 DFA 的状态转移表。从表中,我们可以清晰地看到。[0, 1, 2, 3, 4, 5, 6] 表示我们的 DFA 中的 7 个状态。而 [t, o, e, a, d, n] 表示我们的输入事件。表格中的数字表示当前状态遇到输入事件后能正确转移的下一个状态,其中 x 表示出错,即无法转移到下一个有效状态。比如当状态为 0 时,遇到 t 即可转移到状态 1,而遇到其他的输入事件,则无法转移到有效状态。聪明如你,试着画一下图一的状态转移表吧。
双数组 Trie
状态转移表很好很强大,可以实现 O(1) 的搜索速度。但其不足非常明显,如表一所示,表中 x 非常多。即针对词典来说,其 DFA 的状态转移表中,有效的状态转移只占少数,大部分都是无效的状态转移。聪明的科学家们哪能放过这个扬名立万的机会?特别是在计算机的早期,内存非常宝贵,往往以字节计算价格,哪像现在,各位的手机动不动就有几个 G 的内存。
在 1985 年科学家发明了一个压缩算法,可以用三个数组即可表达状态转移表。1989 年更进一步压缩到只用两个数组即可表达状态转移表。详细信息可以阅读 An Implementation of Double-Array Trie。这个页面里包含两个实现,一个是早期的使用 C++ 模板类的实现,称为 midatrie ,网上比较著名的开源分词算法库 LibMMSeg 就使用这一实现。另外一个实现是使用 C 语言重写的,称为 libdatrie,它更通用,可读性也比较强。
要使用两个数组实现 Tire 并非简单的事情,要理解这一过程没有烧些脑细胞估计很难做到,除非你是个天才。除此之外,还有一些细节需要了解,对理解代码不无益处:
- 上文讨论中,我们都使用英文作为词典保存对象。输入事件集合就是一个个字母。实际上,中文的处理方法类似,如果是 utf-8 编码,中文会把一个字拆成三个字节,然后以字节为单位,作为输入事件集合中的一个元素。比如“中文”这两个字的 utf-8 编码是 E4 B8 AD E6 96 87,其实际上被解读为 6 个输入事件。由此可见,对任何语言,输入事件总数不会超过 255 个。可以使用一个字节来表示。
- 输入事件并非连续的,所以在实现 Tire 时,一般会有个映射关系,比如把 a 映射到 1,把 b 映射到 2 等。这是为了更进一步节省空调。
应用场景
Trie 构造的词典除了在分词算法里使用外,在自动完成 Auto Complete,拼写纠正等领域都有非常广泛的应用。