周末,我吊死在一颗树上

平时使用双数组字典树的场景蛮多的,但是一直没有明白它的构建过程,所以通过各位大佬的文章,总结出自己可以理解的双数组字典树的构建过程,结合一些实际的例子,体会一下具体的用法。 整个文章的思路都是以Trie为基础,然后简单梳理一下。

graph LR A[Array Trie] --> B[List Trie] B --> C[Hash Trie] C --> D[Double Array Trie]

在看双数组字典数之前我们先看看什么是字典树。

字典树(Trie)

字典树的定义

字典树:又称为Trie树,前缀树,这是一种字符串上的树形数据结构。 也就是将一个字符串构建成一个树的形状,如下图。 对于有限集合 { AC,ACE,ACFF,AD,CD,CF,ZQ }。 R表示根节点。 在这里插入图片描述对于字符串的处理,我们通常有应用就是在字符串集合中判断字符串是否存在,这个也是匹配算法的一个瓶颈,那么对于普通匹配算法,如果遍历查找时间复杂度是O(n^2),用二分查找法时间复杂度是O(logn),如果用TreeMap去匹配,时间复杂度是O(logn),这里的n指的是词典的大小,如果用HashMap的话,时间复杂度是O(1),但是空间复杂度又上去了,所以,想要找到一种速度又快,同时内存又省的数据结构,来完成这个匹配操作。字典树就符合这些特征。 先简单了解一下字典树的基本原理

字典树的原理

字典树的每一个边都对应一个字,从根节点往下的路径构成一个个字符串。字典树并不直接在节点存储字符串,而是将词语视作根节点到某一节点之间的一条路径。 并且在终点节点上做个标记(该节点对应词语的结尾),字符串就是一条路径,要查询某一个单词,就需要顺着这条路径从根节点往下走,如果能走到特殊标记的节点(蓝色结点),那么说明当前字符串在集合中,否则当前字符串不在集合中。 下图中是以下词{“abc”、“abcd”、“adb”、“b”、“bcd”、“efg”、“hik”},构成的前缀树。 原图出自 在这里插入图片描述 橙色标记该节点是一个词的结尾(词的结尾不一定是到叶子节点),数字只是一个编号,这些词和对应的路径如下表所示。 |词语|路径 | |--|--| | abc|0-1-2-3 | | abcd|0-1-2-3-4 | | adb| 0-1-2-5| | b| 0-6| | bcd| 0-6-7-8 | | efg| 0-9-10-11| | hik| 0-12-13-14 |

备注:橙色=色节点不一定是叶子节点,也就是词的结尾不一定是叶子节点。 字典树的时间复杂度最坏的情况是O(logn),但是它的速度优于二分查找,毕竟随着路径的深入,前缀匹配是递进的过程,算法不必在比较字符串的前缀。

字典树的特性

  1. 以空间换时间
  2. 根节点不包含字符,除根节点外每一个节点都只包含一个字符。
  3. 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
  4. 每个节点的所有子节点包含的字符都不相同。

再简单的理解

比如现在有10000个单词列表,我们要判断student这个单词有没有出现过,遍历查找时间复杂度是O(n^2),用二分查找法时间复杂度是O(logn),用字典树也是O(logn),但是上面说了为什么字典树更加优秀,那么用字典树的查找规则就是先找到s,再去s的子树中找t,依次类推,看看能不能找到student这条路径。

字典树的实现

具体需要实现方法有以下几个

  • void insert(String word):添加word;
  • void delete(String word):删除word;
  • boolean search(String word):查询word是否在字典树中;
/**
 * 前缀树
 */
public class TrieTree {
    //字典树节点
    class TrieNode {
        public int path;
        public int end;
        public HashMap<Character, TrieNode> map;

        public TrieNode() {
            path = 0;
            end = 0;
            map = new HashMap<>();
        }
    }

    private TrieNode root;

    public TrieTree() {
        root = new TrieNode();
    }

    /**
     * 插入一个新的单词
     * @param word
     */
    public void insert(String word) {
        if (word == null)
            return;
        TrieNode node = root;
        node.path++;
        char[] words = word.toCharArray();
        for (int i = 0; i < words.length; i++) {
            if (node.map.get(words[i]) == null) {
                node.map.put(words[i], new TrieNode());
            }
            node = node.map.get(words[i]);
            node.path++;
        }
        node.end++;
    }

    public boolean search(String word) {
        if (word == null)
            return false;
        TrieNode node = root;
        char[] words = word.toCharArray();
        for (int i = 0; i < words.length; i++) {
            if (node.map.get(words[i]) == null)
                return false;
            node = node.map.get(words[i]);
        }
        return node.end > 0;
    }

    public void delete(String word) {
        if (search(word)) {
            char[] words = word.toCharArray();
            TrieNode node = root;
            node.path--;
            for (int i = 0; i < words.length; i++) {
                if (--node.map.get(words[i]).path == 0) {
                    node.map.remove(words[i]);
                    return;
                }
                node = node.map.get(words[i]);
            }//for
            node.end--;
        }//if
    }

    public int prefixNumber(String pre) {
        if (pre == null)
            return 0;
        TrieNode node = root;
        char[] pres = pre.toCharArray();
        for (int i = 0; i < pres.length; i++) {
            if (node.map.get(pres[i]) == null)
                return 0;
            node = node.map.get(pres[i]);
        }
        return node.path;
    }

    public static void main(String[] args) {
        TrieTree trie = new TrieTree();
        System.out.println(trie.search("程龙颖"));//f
        trie.insert("自然人");
        trie.insert("自然");
        trie.insert("自然语言");
        trie.insert("自语");
        trie.insert("入门");
        System.out.println(trie.search("自然"));//t
        trie.delete("自然语言");
        System.out.println(trie.search("自然语言"));//f
        trie.insert("自然语言");
        System.out.println(trie.search("自然语言"));//t
        System.out.println(trie.prefixNumber("自然"));//3
    }
}

DFA简单理解

TrieTree本质上是一个确定有限自动机(DFA)。 DFA的特征:有一个有限状态集合和一些从一个状态通向另一个状态的边,每条边上标记有一个符号,其中一个状态是初态,某些状态是终态。但不同于不确定的有限自动机,DFA中不会有从同一状态出发的两条边标志有相同的符号。 对于DFA来说,每个节点代表一个“状态”,每条边代表一个“变量”。

双数组字典树

双数组字典树(DoubleArrayTrie, DAT)是由三个日本人提出的一种字典树的高效实现,兼顾了查询效率与空间存储。DAT极大地节省了内存占用。

优点

在Trie数实现过程中,我们发现了每个节点均需要 一个数组来存储next节点,非常占用存储空间,空间复杂度大,双数组Trie树正是解决这个问题的。双数组字典树(DoubleArrayTrie)是一种空间复杂度低的Trie树,应用于字典树压缩、分词、敏感词等领域。所以,DAT是前缀树的一个变形,同样也是一个DFA。

缺点

每个状态都依赖于其他状态,所以当在词典中插入或删除词语的时候,往往需要对双数组结构进行全局调整,从而灵活性能较差。

定义

将原来需要多个数组才能表示的Trie树,使用两个数组就可以存储下来,可以极大的减小空间复杂度。由于用base和check两个数组构成,又称为双数组字典树。 具体来说就是使用两个数组base[]和check[]来维护Trie树,base[]负责记录状态,check[]用于检验状态转移的正确性,当check[i]为负值时,表示此状态为字符串的结束。 具体来说,当状态b接受字符c然后转移到状态p的时候,满足的状态转移公式如下:

p = base[b] + c
check[p] = base[c]    

构建双数组的过程

对于词典 { AC,ACE,ACFF,AD,CD,CF,ZQ },构建双数组具体过程如下。 在这里插入图片描述在构造之前,先梳理几个概念

  • STATE:状态,也就是数组的下标
  • CODE: 状态转移值,实际为字符的 ASCII码
  • BASE: 表示后继节点的基地址的数组,叶子节点没有后继,标识为字符序列的结尾标志

主要是基于 dart-java,此版本对双数组算法做了一个改进,即darts双数组中有以下的改进。

    base[0] = 1
    check[0] = 0

第二个改进就是令字符的code = ascii+1

结合两个数组的状态转移公式有以下条件

base[0] = 1
check[0] = 0
p = base[b] + c
check[p] = base[c]    

基于base和check两个数据构建双数组的流程整体如下

1、建立根节点root,令$base[root] =1$ 2、找出root的子节点集$(i = 1...n)$ , 使得 check[\(root.children_i ] = base[root] = 1\) 3、对 each element in root.children : 1)找到{\(elemenet.children_i\) }(i = 1...n) ,注意若一个字符位于字符序列的结尾,则其孩子节点包括一个空节点,其code值设置为0找到一个值begin使得每一个$check[ begin_i + element.children_i .code] = 0$   2)设置$base[element.children_i] = begin_i$   3)对$element.children_i$ 递归执行步骤3,若遍历到某个$element$,其没有$children$,即叶节点,则设置$base[element]$为负值(一般为在字典中的$index$取负)

1、根据上面的那个例子{ AC,ACE,ACFF,AD,CD,CF,ZQ }来说,最开始有

base[0] = 1
check[0] = 0

备注:ascii表格

    65     A
    66     B
    ...

此外,结合darts双数组的改进code= ascii+1, 以及i = base[0] + code可以得到下面每个字符的状态。base[0] = 1

root A C D E F Q Z
i 0 67 69 92
code 0 66 68 69 70 71 82 91

2、根据构造过程中的第二步,距离root节点深度为1的所有children其$check[root.children_i ] = base[root] = 1$,在模式串中root的三个子节点'A', 'C', 'E'的check值都是1, 假设root经过A C Z 的作用分别到达$p_1 , p_2, p_3$三个状态,可以得到下面矩阵。 | | root| A| C | Z| |--|--|--|--|--|--|--|--| |i| 0 | 67| 69 | 92 | |base| 1 | | | | |check| 0 | 1| 1| 1| 1| |state| p0 | p1| p2| p3|

3、根据构建的第三步,状态p1是由条件 'A'触发的,那么'A'的base值的计算方式需要满足以下的规则: 我们知道,对于每一个字符, 需要确定一个base值,使得对于所有以该字开头的词,在双数组中都能放下。 已知'A的子节点值为, 需要找一个begin值,使得check[begin +'C'.code] = check[begin +'D'.code] = 0满足, 即check[begin + 68] = check[begin + 69] = 0,换句话说,需要找到一个begin,从而找到之前没有使用过的空间。

a、当begin=0的时候,有check[0+ 68] 和check[0+ 69]都必须要为0, 但是begin为0的时候,check[0+ 69] 存在字符‘C’, 所以check[begin +’C'.code] = check[begin +’D’.code] = 0不成立。 b、当begin=1的时候,有check[1+ 68] 和check[1+ 69] 都必须为0, 但是check[1 + 68] 存在字符‘C’, 所以check[begin +’C'.code] = check[begin +’D’.code] = 0不成立。 c、当begin=2的时候 有check[2+ 68] 和check[2 + 69] 的值都必须为0 有check[begin + 68] = check[begin + 69] = 0 所以有base[p1] = begin = 2, 状态p1 = 67。

p4 = base[p1] + 'C'.code = 2 + 68 = 70 , p5 = base[p1] + 'D'.code = 2 + 69 = 71, check[p5] = check[p4] = base[p1] = 2, 那么有以下矩阵 | | root| A| C | Z|C|D| |--|--|--|--|--|--|--|--| |i| 0 | 67| 69 | 92 |70|71| |base| 1 | 2| | ||| |check| 0 | 1| 1| 1| 2|2| |state| p0 | p1| p2| p3|p4|p5|

4、根据上一步,继续推导。已知C的子节点是{D、F},需要找一个begin值,使得check[begin +'D'.code] = check[begin +'F'.code] = 0满足, 即check[begin + 69] = check[begin + 71] = 0 这个等式成立。 a、当begin为0的时候,check[0+ 69]和check[0+ 71]分别有字符C和字符D。 b、当begin为1的时候,check[1+69]有字符C c、当begin为2的时候,check[2+69]有字符C

字符‘C’的base[t2] = begin = 8, 下一状态,即子节点值{D, F}的t6,t7状态。   t6 = base[t2] + ‘D’.code = 8 + 69 = 77;   t7 = base[t2] + ‘F’.code = 8 + 70 = 79;   check[t6] = check[t7] = base[t2] = begin = 8;矩阵如下:    | | root| A| C | Z|C|D|D|F| |--|--|--|--|--|--|--|--|--|--| |i| 0 | 67| 69 | 92 |70|71|77|79| |base| 1 | 2| 8 | ||| |check| 0 | 1| 1| 1| 1|2|2|8|8| |state| p0 | p1| p2| p3|p4|p5| p6|p7|

最终的矩阵如下 | | root| A| C | Z|C|D|D|F| Q | E | F |F | |--|--|--|--|--|--|--|--|--|--|--|--|--| |i| 0 | 67| 69 | 92 |70|71|77|79|86 |142 |143| 74| |base| 1 | 2|8|4| 72 | 76 | 78 | 80| 83| 73 | 3 | 75| |check| 0 | 1| 1| 1| 1|2|2|8|8|4 | 72 | 72 | 3| |state| p0 | p1| p2| p3|p4|p5| p6|p7| p8| p9| p10| p11|

根据模式串补全有以下

 root  A   C   Z   C   D   D   F   Q   E   F   F  AC  AD  CD  CF  ZQ ACE ACFF
i      0   67  69  92  70  71  77  79  86  142 143 74  72  76  78  80  83  73  75
base   1    2   8   4  72  76  78  80  83  73   3  75  -1  -4  -5  -6  -7  -2  -3
check  0    1   1   1   2   2   8   8   4  72  72   3  72  76  78  80  83  73  75
state  t0  t1  t2  t3  t4  t5  t6  t7  t8  t9  t10 t11 t12 t13 t14 t15 t16 t17 t18

使用DFA的形式来描绘,节点表示state,字符作为转移条件,不同字符触发不同的state,可得到到树如下图,其中红色部分正好是第5步骤的矩阵;绿色部分是按照模式集合得到的ouput表。 在这里插入图片描述

参考

https://blog.csdn.net/u013300579/article/details/78869742 https://blog.csdn.net/zhoubl668/article/details/6957830 https://github.com/komiya-atsushi/darts-java https://linux.thai.net/~thep/datrie/datrie.html https://www.cnblogs.com/ooon/p/4883159.html https://blog.csdn.net/xlxxcc/article/details/67631988

猜你喜欢

转载自www.cnblogs.com/zhangxinying/p/12057737.html