此文为学习何晗老师《自然语言处理入门》笔记
分词匹配算法的瓶颈之一在于如何判断集合(词典)中是否含有字符串。如果有序集合(TreeMap)的话,复杂度是)(n是词典大小);如果用散列表(Java的HashMap,Python的dict)的话,牺牲了内存,增加了速度。
字典树 Trie
字符串集合常用字典树存储。结构如下:
- 字典树每条边对应一个字
- 从根节点往下的路径构成一个个字符串
字典树并不直接在节点上存储字符串,而是将词语视作根节点到某节点之间的一条路径。
字典树是一种有限自动机(DFA)
如上图,深色标记该节点是一个词的结尾。数字是认为编号。
| 词语 | 路径 |
|---|---|
| 入门 | 0 - 1 - 2 |
| 自然 | 0 - 3 - 4 |
| 自然人 | 0 - 3 - 4 - 5 |
| 自然语言 | 0 -3 - 4 - 6 - 7 |
| 自语 | 0 - 3 - 8 |
Python代码实现
节点Node表示
class Node(object):
def __init__(self, value):
self._children = {}
self._value = value
def _add_child(self, char, value, overwrite=False):
child = self._children.get(char)
if child is None:
child = Node(value)
self._children[char] = child
elif overwrite:
# 深色节点赋值
child._value = value
return child
字典树CRUD
class Trie(Node):
def __init__(self):
super().__init__(None)
def __contains__(self, key):
# 此处将调用__getitem__方法
return self[key] is not None
def __getitem__(self, key):
state = self
for char in key:
# 状态流转
state = state._children.get(char)
if state is None:
# 没有找到
return None
# 返回最后一个节点值
return state._value
def __setitem__(self, key, value):
state = self
for i, char in enumerate(key):
if i < len(key) - 1:
# 还没有到最后一个字符
state = state._add_child(char, None, False)
else:
# 最后一个字符,保存词语
state = state._add_child(char, value, True)
首字符散列其余二分的字典树 BinTree
选择合适的散列函数
假设完美散列函数的输出是区间[0, 65536]内的正整数,可以用来索引子节点,将子节点对应的字符整形下标放入数组中。 这样每次转移状态时,仅需访问下标即可,速度极快。
但如果词典词语的长度最长为l,则最坏情况下,字典树l层的数组容量之和为。内存指数膨胀。
折中的实现
考虑到汉语中二字词最多,一个通用的方法是在根节点实施散列策略。如下:
BinTree缺点
- 只有根节点是完美散列
- 其余节点使用二分查找
当存在c个子节点时,每次状态转移的复杂度为,当c很大时,依然很慢。
前缀树的妙用
前缀树:前缀相同的词语必然经过同一个节点
字典树就是一棵前缀树。比如“自然人”和“自然语言”拥有共同的前缀 “0 - 3 - 4”。
利用字典树的概念,可以加快分词速度
比如在扫描“自然语言处理”的时候,朴素实现会依次查询 “自” “自然” “自然语言” 等词语是否在词典中。但事实上,如果 “自然” 这条路径不存在前缀树中,则可断定一切以 “自然” 开头的词语都不存在。
双数组字典树 DAT
双数组字典树(Double Array Trie, DAT)是一种状态转移复杂度为常数的数据结构
-
Double Array Trie是TRIE树的一种变形,它是在保证TRIE树检索速度的前提下,提高空间利用率而提出的一种数据结构,本质上是一个确定有限自动机(deterministic finite automaton,简称DFA)
-
所谓的DFA就是一个能实现状态转移的自动机。对于一个给定的属于该自动机的状态和一个属于该自动机字母表Σ的字符,它都能根据事先给定的转移函数转移到下一个状态。
-
对于Double Array Trie(以下简称DAT),每个节点代表自动机的一个状态,根据变量的不同,进行状态转移,当到达结束状态或者无法转移的时候,完成查询。
DAT定义
DAT是采用两个线性数组(base[]和check[]),base和check数组拥有一致的下标,(下标)即DFA中的每一个状态,也即TRIE树中所说的节点,base数组用于确定状态的转移,check数组用于检验转移的正确性。因此,从状态b输入c到状态p的一个转移必须满足如下条件:
p = base[b] + c
check[p] = base[b]
这是一个变种的实现
例如当前状态为 “自然”(状态由一个整数下标表示),我们想要知道是否可以转移到 “自然人” :
- 执行
- 检查
是否成立,据此判断转移是否成功
也就是说,我们仅仅执行一次加法和一次整数比较就能进行状态转移,花费常数时间。
构造DAT
DAT的构造是普通字典树上的深度优先遍历问题:
- 为字典树的每一个节点分配一个双数组中的下标,并维护双数组的值。
- 为根节点分配下标
, 初始化
;
。约定
代表
空闲。
- 以根节点为最初的父节点开始深度优先遍历,兄弟节点按照字符的散列值(记为code)升序排列
- 寻找空闲下标,维护check数组,建立子与父多对一的关系。检查父节点p的子节点列表
- 寻找一个起始下标b,使得所有
。也就是找到n个空闲下标插入这群子节点。
- 执行
,即分配这n个空闲空间给这群子节点。
- 这样n个子节点
就链接到父节点b,建立了父子一对多的关系。
- 寻找一个起始下标b,使得所有
- 维护base数组,建立父与子一对多关系。检查每个子节点
- 若他没有孩子,也就是上面Trie图中的叶子节点(蓝色),则将它的base设为负值,以存储它所对应单词的字典序index,即
;
- 若他有孩子,则跳转到步骤3,递归插入。
- 记
的子节点们的起始下标为h,执行
。这样父节点
就链接到了子节点插入的起始下标
- 若他没有孩子,也就是上面Trie图中的叶子节点(蓝色),则将它的base设为负值,以存储它所对应单词的字典序index,即
根据定义,转移时先根据base提供的父子关系尝试转移,然后还需要根据check数组校验子父关系。
待构造DAT的字典树
状态转移
考虑到不是所有的节点都对应词语终点,只有字典树中的终点节点(蓝色节点)才对应一个词语。为了区分它们,实现上在每个字符串末尾加一个散列值等于0的\0。即\0充当了蓝色节点的角色,这样普通节点就不需要分配内存标记自己的颜色了。
考虑到用户输入的文本中也可能含有\0,为了避免与此混淆,只需要将文本字符的hashCode加1即可。一个兼容\0的转移函数实现如下:
def transition(self, c, b):
"""
状态转移
:param c: 字符
:param b: 初始状态
:return 转移后的状态, -1表示失败
"""
p = self.base[b] + self.char_hash(c) + 1
if self.base[b] == self.check[p]:
return p
else:
return -1
查询
有了转移函数,对键key的查询就是至多len(key) + 1次状态转移,多出来一次针对\0。
计算过程:
- 先查询到键的字典序
- 用字典序做下标去value数组中取值。
- 将字典序作为自然数赋予作为单词结尾的那些节点。
约定当状态p满足base[p] < 0时,该状态对应单词结尾,且单词的字典序为-base[p] - 1。
def __getitem__(self, key):
b = 0
for i in range(0, len(key)):
p = self.transition(key[i], b)
if p is not -1:
# 未到结束节点
b = p
else:
# 未找到词语
return None
p = self.base[b]
n = self.base[p]
# 状态转移成功
if p == self.check[p] and n < 0:
# 取得字典序
index = -n - 1
return self.value[index]
return None
DAT优点
- 能在
(l是模式字符串)时间内高速完成单串匹配
- 内存消耗可控
DAT缺点
- DAT的构造比较麻烦
- 虽然每次状态转移的时间复杂度都是常数,但全切分长度为n的文本时,复杂度是
。这是因为在扫描的时候,要不断的挪动起点,发起新的查询。
最坏的情况下,例如对文本 “123” 扫描一共发起了6次,即“123”的组合, 1, 12, 123; 2, 23,3;3。则长度为n的文本扫描, 次状态转移。
AC自动机
AC Aho-Corasick 是一种复杂度的算法。广泛应用于多字符串搜索。
字典树的问题
扫描 “自然语言处理入门” 这句话时,只有 “自然” 转移成功时,“自然语言” “自然语言处理” 才可能存在。但算法以 “自”为起点扫描后,又得回退到 “然” 继续扫描 “然语” “然语言” ....
如果能扫描到 “自然语言” 的同时知道, “然语言” “语言” “言” 不在字典树中,就可以省略查询,他们共享递进式的后缀,首尾对调后(“言” “言语” “言语言”)恰好可以用另一棵前缀树索引,称它为后缀树。
AC自动机在前缀树的基础上,为前缀树上的每个节点建立了一颗后缀树,节省了大量查询。
AC自动机实现
AC自动机由goto表、fail表及output表组成,类似于前缀树和后缀树。 以模式串 {he, she, his, hers}为例
goto表
- goto表与前缀树一致
- 根节点不光可以按照s,h转移,还可以接受其他任意字符,转移到自己。这样goto表树就变为了一个有向有环图。可以用“状态”来称呼节点。
output表
给定一个状态,可以知道这个状态对应某个或某些字符串,这种关联结构即为output表。
| 状态编号 | output |
|---|---|
| 2 | he |
| 5 | he, she |
| 7 | his |
| 9 | hers |
- 从初始路径到当前状态前缀本身的模式串(比如2号)
- 后缀的路径对应的模式串(比如5号的he)
output表构造
- 与字典树类似,记录完整路径对应的模式串
- 找出所有路径的后缀模式串,这一步可以与fail表同时构造
fail表
fail表保存的是状态之间的一对一关系。 存储转移失败后应该回退到最佳状态。
- 最佳状态指的是能记住已匹配上的字符串的最长后缀的那个状态 例如匹配she到状态5,再来一个字符,goto失败。由于当前she的最长后缀为he,对应路径0 - 1 - 2, 因此2状态就是状态5 fail的最佳选择! 类似的状态7的fail最佳状态是3。
fail表构造
记S.goto(c)表示状态S按字符c转移后的状态。步骤如下:
- 初始状态goto表示满的,因此没有fail指针。与初始状态直接相连的所有状态,其fail指针都指向初始状态
- 从初始状态开始广度遍历(BFS),当前状态S接受c直达状态为T,则沿着S的fail指针回溯,直到找到一个前驱状态F,是的F.goto(c) != null,将T的fail指针指向F.goto(c)即可
- 由于F的路径是T路径的后缀,即T中一定包含F,因而T的output也应该包含F的output,更新T的output
- AC自动机不如双数组字典树快
基于双数组树的AC自动机(ACDAT)
ACDAT的基本原理是替换AC自动机的goto(一颗Trie树)表
构建原理:为每个状态(base[i]和check[i])构建output[i][]和fail[i]
步骤:
- 构建一棵普通的字典树,让终止节点记住对应模式串的字典序
- 构建双数组字典树,让每个状态映射到双数组时,让它记住自己在双数组中的下标
- 构建AC自动机,此时fail表中存储的就是状态的下标
当词汇不长时,这使得前缀树的优势占了较大比重,AC自动机的fail机制没有太大的用武之地。
- 当有短模式串时,优先使用DAT,否则优先使用ACDAT