自然语言处理入门笔记-> 字典树

1,336 阅读10分钟

此文为学习何晗老师《自然语言处理入门》笔记

分词匹配算法的瓶颈之一在于如何判断集合(词典)中是否含有字符串。如果有序集合(TreeMap)的话,复杂度是O(\log n))(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层的数组容量之和为O(65536^l)。内存指数膨胀。

折中的实现

考虑到汉语中二字词最多,一个通用的方法是在根节点实施散列策略。如下:

BinTree缺点

  • 只有根节点是完美散列
  • 其余节点使用二分查找

当存在c个子节点时,每次状态转移的复杂度为O(\log 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]

这是一个变种的实现

例如当前状态为 “自然”(状态由一个整数下标表示),我们想要知道是否可以转移到 “自然人” :

  • 执行 自然人 = base[自然] + 人
  • 检查 check[自然人] == base[自然] 是否成立,据此判断转移是否成功

也就是说,我们仅仅执行一次加法和一次整数比较就能进行状态转移,花费常数时间。

构造DAT

DAT的构造是普通字典树上的深度优先遍历问题:

  • 为字典树的每一个节点分配一个双数组中的下标,并维护双数组的值。

步骤:

  1. 为根节点分配下标0, 初始化 base[0] = 1; check[0] = 0。约定 check[i] = 0 代表i空闲。
  2. 以根节点为最初的父节点开始深度优先遍历,兄弟节点按照字符的散列值(记为code)升序排列
  3. 寻找空闲下标,维护check数组,建立子与父多对一的关系。检查父节点p的子节点列表[s_1 \ldots s_n]
    • 寻找一个起始下标b,使得所有 check[base[b] + s_i.code] == 0。也就是找到n个空闲下标插入这群子节点。
    • 执行 check[base[b] + s_i.code] = base[b],即分配这n个空闲空间给这群子节点。
    • 这样n个子节点 base[b] + s_i.code 就链接到父节点b,建立了父子一对多的关系。
  4. 维护base数组,建立父与子一对多关系。检查每个子节点s_i
    • 若他没有孩子,也就是上面Trie图中的叶子节点(蓝色),则将它的base设为负值,以存储它所对应单词的字典序index,即 base[base[b] + s_i.code] = -s_i.index - 1
    • 若他有孩子,则跳转到步骤3,递归插入。
    • s_i的子节点们的起始下标为h,执行 base[base[b] + s_i.code] = h。这样父节点 base[b] + s_i.code 就链接到了子节点插入的起始下标

根据定义,转移时先根据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优点

  • 能在O(l)(l是模式字符串)时间内高速完成单串匹配
  • 内存消耗可控

DAT缺点

  • DAT的构造比较麻烦
  • 虽然每次状态转移的时间复杂度都是常数,但全切分长度为n的文本时,复杂度是O(n^2)。这是因为在扫描的时候,要不断的挪动起点,发起新的查询。

最坏的情况下,例如对文本 “123” 扫描一共发起了6次,即“123”的组合, 1, 12, 123; 2, 23,3;3。则长度为n的文本扫描,n + (n - 1) + \ldots + 1 = (n+1)*n/2 = O(n^2) 次状态转移。

AC自动机

AC Aho-Corasick 是一种O(n)复杂度的算法。广泛应用于多字符串搜索。

字典树的问题

扫描 “自然语言处理入门” 这句话时,只有 “自然” 转移成功时,“自然语言” “自然语言处理” 才可能存在。但算法以 “自”为起点扫描后,又得回退到 “然” 继续扫描 “然语” “然语言” ....

如果能扫描到 “自然语言” 的同时知道, “然语言” “语言” “言” 不在字典树中,就可以省略查询,他们共享递进式的后缀,首尾对调后(“言” “言语” “言语言”)恰好可以用另一棵前缀树索引,称它为后缀树

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

output表中有两种数据:

  • 从初始路径到当前状态前缀本身的模式串(比如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转移后的状态。步骤如下:

  1. 初始状态goto表示满的,因此没有fail指针。与初始状态直接相连的所有状态,其fail指针都指向初始状态
  2. 从初始状态开始广度遍历(BFS),当前状态S接受c直达状态为T,则沿着S的fail指针回溯,直到找到一个前驱状态F,是的F.goto(c) != null,将T的fail指针指向F.goto(c)即可
  3. 由于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]

步骤:

  1. 构建一棵普通的字典树,让终止节点记住对应模式串的字典序
  2. 构建双数组字典树,让每个状态映射到双数组时,让它记住自己在双数组中的下标
  3. 构建AC自动机,此时fail表中存储的就是状态的下标

当词汇不长时,这使得前缀树的优势占了较大比重,AC自动机的fail机制没有太大的用武之地

  • 当有短模式串时,优先使用DAT,否则优先使用ACDAT