双数组Trie树

引子

搜索了很多解说双数组Trie树的博客, 很多上来就说双数组Trie树是一种有限状态自动机,  然后列出两个状态方程:

1. Base[t] + c.code = tc
2. Check[tc] = t
不敢说看懂了, 也不能说没收获, 始终有种迷迷糊糊的感觉. 就想自己试着写一下, 理清下思路.

1. 假设

  • 现有字符集: A, B, C, D
  • 字符编码为: A-0, B-1, C-2, D-3
  • 词典: dict = ["ABC", "ACB","ACD", "ADA"]

2. 经典Trie树

2.1 经典Trie树的结构

在经典Trie树中除根结点Root外, 每一个结点都是一个数组,数组长度等于字符集长度, 比如本例中为4. 数组以每个字符的编码为索引,在对应的元素下存有子结点的地址, 即子结点的数组起始位置. 如果为空, 说明词条不存在.词典Dict的经典Trie树结构如下图, 图中字符后面的数字标识字符出现的层.

根结点Root保存有第一层数组的起始地址.因为A的编码为0,所以在Root[0]的地方存有A1的信息,包括A1子结点的起始地址,即第二层数组的地址. 我们可以推知B2的信息在Root[0][1]中, Root[0]表示A1子结点的起始位置, 1为字符B的编码.

2.2 动机

经典Trie树的空间复杂度高, 利用率低. 从其结构来看,许多空间未被使用, 比如在第一层结点中我们只用了1个位置,另外3位置完全被浪费了.第三层结点也浪费严重. 为了降低其空间复杂度, 我们会问可不可以将这些未被使用的空间重新利用起来呢?譬如将第二层结点的三个字符放在第一层未被使用的三个空间中.个人理解, 双数组Trie树就是在干这个事的. 那么它到底是怎么干的呢?

2.3 内存结构

我们知道内存地址是线性增长的, Trie树在内存中实际存储的结构应该如下图. 其中`...`占据的空间表示数组在内存中的地址是不连续的. 黑色箭头表示下面的一段内存空间是紧跟着上面的那一段内存空间的. 彩色箭头表示Trie树中的指向各个子结点的指针.
 

2.4 数组结构

现在假设我们自己维护一个足够大的数组, 在创建Trie树的时候, 我们在该数组上为每个结点分配空间, 并且不以绝对地址保存每个结点的位置, 而保存其在数组中的索引位置. 则Trie树在内存中的结构变成下图所示:

3. Base数组

3.1 再看状态方程:Base[t]+c.code=tc

在该状态方程中, ttc分别是表示两种状态, 而c是表示一个字符输入,可以使状态从t变成tc, 即`状态转移`. Base[t]表示状态t的转移基数. 有些博客中将方程写成Base[t]+c.code= tc, 不懂为什么等式右边也是Base[tc],而不是tc, 如果也表示为转移基数, 方程我看不懂, 很多博客里等式我也算不对, 若将其理解为状态, 则很容易与前面的转移基数混淆. 而c.code表示输入字符c的编码. 现在, 我们将状态方程往Trie树的数组结构中套一下, 对词"ACB"进行测试.
1. 起始状态0: Base[0] = 1;
2. 输入字符A, 状态转移至A: Base[0] + A.code = 1 + 0 = 1.
3. 输入字符C,状态转移至AC:Base[A] + C.code = 5 + 2 = 7.
4. 输入字符B,状态转移至ACB:Base[AC] + B.code = 13 + 1 = 14.
神奇啊, 居然可以满足状态方程! 我们将数组中的每个元素改称状态, 而子结点指针换个名字, 叫做转义基数. 那么我们是不是就有了双数组Trie树中的Base数组? ^_^

3.2 问题1: 子结点的的位置怎么确定

首先, 根结点始终占据数组的第0位置, 其子结点的起始位置最小值只能为1. 所以我们在第0位置设置子结点位置(换个说法叫转移基数)为1.
其次,每一层子结点最多可能有4个字符(因为在我们假设里,字符集就只有4个). 所以我们为每一个子结点分配4个地址空间. 其元素的子结点位置必须从其所在子节点的位置+4往上找,直到找到能容纳连续(因为我们编码是连续的)4个未被分配的空间为止.
以上图为例, 根结点在第0位置, 子结点起始位置为1, 并为第一层子结点分配4个位置. 因为0~4位置都以被分配走了, 所以我们将A1的子结点的起始位置设置为5, 并将位置5~8都分配给A1的子结点.另外, 在第一层子结点中除A1外, 其他元素都不包含子结点, 所以未在数组中为其分配子结点. 这就使得B2的子结点起始位置为9成为可能,而不必为更大的值. 其他子结点的位置以此类推.

3.3 问题2: 空间复杂度高, 利用率低怎么解决

在上一节中, 我们为所有的子结点都分配了连续4个位置. 其实, 对于第一层结点,我们并不需要4个位置, 只分配1个就够用了. 其他结点就可以向前移动, 这样就可以达到空间压缩的目的.对于其他结点,可以做类似操作. 还是本文的例子, 理想情况下, 我们可以将数组压缩成如下所示:
用状态方程重新检验"ACB":
1. 起始状态0: Base[0] = 1;
2. 输入字符A, 状态转移至A: Base[0] + A.code = 1 + 0 = 1.
3. 输入字符C,状态转移至AC:Base[A] + C.code = 2 + 2 = 4.
4. 输入字符B,状态转移至ACB:Base[AC] + B.code = 6 + 1 = 7.
5. 状态ACB, 确实在位置7

注意图中状态A和状态AD的转移基数是相同的,都等于2. 这是因为在假设词典中, 不存在以"AA"开头的词, 而且以"AD"开头的词只有"ADA". 都以2为转移基址并不会造成某些状态无处安放. 事实上, 我们只需要保证每个子结点中需要的位置为空就可以了. 我们再以"ADA"为输入和状态方程检验一下该数组:
1. 起始状态0: Base[0] = 1;
2. 输入字符A, 状态转移至A: Base[0] + A.code = 1 + 0 = 1.
3. 输入字符D,状态转移至AD:Base[A] + D.code = 2 + 3 = 5.
4. 输入字符A,状态转移至ADA:Base[AD] + B.code = 2 + 0 = 2.
5. 状态ADA, 确实在位置2

哈哈, 看看, 内存空间可以从21个位置压缩到9.

3.4 词的结束

在上一节中, 我们并没设置状态"ABC", "ACB", "ACD"和"ADA"的转移基数. 当我们检查"ABC"是否为词时, 因为在C3位置没有任何信息, 我们看不到其与那些不存在的词的任何不同. 我们需要存点什么, 以便查找时对存在词与不存在词进行区别. 这里, 我们暂时先存本状态的地址+1. 则Base数组的值变成下图.在对词进行检测时, 检查Base数组中的值是否为空, 若是, 我们认为该词不存在. 否则, 我们认为存在该词.

3.5 小结Base数组

在以上讨论中,我们认识了双数组Trie树中的Base数组以及转移基数. 可以知道: 使用双数组Trie树来压缩经典Trie树的内存空间的关键在于转移基数的计算. 计算转移基数的基本思路是: 在Base数组中, 从某一地址开始, 向上找, 直到找到一个地址, 使得其子结点中所有需要的位置[可达状态]未被占用. 不被需要的位置是不存在的状态, 或称不可达状态, 比如"AA"状态, 其可以被其他可达状态占用.

4. Check数组

4.1引入Check数组

当我们对词"AA"进行检测时,其过程如下:
1. 起始状态0: Base[0] = 1;
2. 输入字符A, 状态转移至A: Base[0] + A.code = 1 + 0 = 1.
3. 输入字符A,状态转移至AA:Base[A] + A.code = 2 + 0 = 2.
4. Base[AA] != null, 所以"AA"存在.
以上结论显然是错误的, 在我们的假设词典中, 并不存在"AA"词条. "AA"状态是不可达的. 而现在"AA"状态所在位置, 保存的是状态"ADA". 那怎么来区分它们呢?
我们知道状态"ADA", 是在状态"AD"下, 输入"A"转移过来的. 而状态"AA"是在状态"A"下输入"A"转移过来的. 这两个状态的上一个状态并不一样. 所以我们可以维护另外一个与Base数组长度相同的数组, 即Check数组. 在Check数组中, 我们在每个状态对应位置保存该状态上一状态. 我们在判断一个词是否存在, 不只判断Base位置是否为空, 还要判断Check数组中对应位置上的值是否指向该状态的上一状态. 有的博客中在Check数组中保存的是上一状态的转移基数, 而本文保存上一状态的位置.
重新对词"AA"进行检测:
1. 起始状态0: Base[0] = 1;
2. 输入字符A, 状态转移至A: Base[0] + A.code = 1 + 0 = 1.
3. 输入字符A,状态转移至AA:Base[A] + A.code = 2 + 0 = 2.
4. Base[AA]不为空, 可能是一个词, 需要与Check数组比较确认.
5. Check[AA] = 5 = Base[AD] != Base[A], 所以"AA"不存在.

4.2 再论词尾

当我们对词"AB"进行检测时,其过程如下:
1. 起始状态0: Base[0] = 1;
2. 输入字符A, 状态转移至A: Base[0] + A.code = 1 + 0 = 1.
3. 输入字符B,状态转移至AB:Base[A] + B.code = 2 + 1 = 3.
4. Base[AB]不为空, 可能是一个词, 需要与Check数组比较确认.
5. Check[AB] = 1 = Base[A], 所以"AB"是一个词.
显然上面的结论也是错误的, 因为在我们的假设词典里并没有词"AB". "AB"只是我们假设词典里的词"ABC"的一个前缀. 仅仅在Base数组里存放一个非空值并且在Check数组保存上一状态, 也还是不够的. 通常做法是用一个负值标志词尾. 有些博客是在Base数组中用负值标志词尾.本文在Check数组中用负值标志词尾. 所以当我们检测一个词的时候, 还需要确认在其状态的Check数组中的值是否为负值.
重新对词"AB"进行检测时:
1. 起始状态0: Base[0] = 1;
2. 输入字符A, 状态转移至A: Base[0] + A.code = 1 + 0 = 1.
3. 输入字符B,状态转移至AB:Base[A] + B.code = 2 + 1 = 3.
4. Base[AB]不为空, 可能是一个词, 需要与Check数组比较确认.
5. Check[AB] = 1 = Base[A], "AB"可能是一个词,需要进行词尾验证.
6. 因为Check[AB] > 0, 所以"AB"不是一个词, 只是某一个词条的前缀.

结尾

本文仅是个人对双数组的Trie树理解的一个梳理, 目前尚未自己写过实现.之前阅读很多博客, 但没有记录, 所以列不出链接地址. 而具体的实现代码主要参考开源项目:HandLP里面的双数组Trie树实现. 其实, 与双数组Trie树的接触也是起始于该项目.
posted @ 2019-05-20 21:57  start_over  阅读(1174)  评论(5编辑  收藏  举报