资讯专栏INFORMATION COLUMN

一种字典树结构的高效实现

kycool / 1900人阅读

摘要:另一种高效实现下面介绍另一种实现,它将字典树用数组存储起来,不仅压缩了数组,而且不降低查找效率。这就是双数组字典树。

字典树的心得体会

常见的字典树实现方法
class Node{                            
  uint node ;
  uint[] next;  
};

或者类似如下结构

class Node{
  uint node;
  map<> next;
}

第一种保证了查找效率,但是对于字典树这种稀疏数组,空间利用率比较低,特别是遇到中文日文这种,会造成空间极大浪费。因此,部分选择了第二种实现,当然第二种可以保证空间,但是却是在牺牲了效率的基础上的改进。map的查找速度O(logn)肯定不如数组O(1)快。

另一种高效实现

下面介绍另一种实现,它将字典树用base, check数组存储起来,不仅压缩了数组,而且不降低查找效率。这就是双数组字典树(Double array trie) 。这种字典树原理很简单,即下面的等式

if (check[base[pre] + string.charAt(i) ] == pre) 
   pre = base[pre] + string.charAt(i) 

对于base和check数组虽然双数组字典树论文作者并没有特意描述它的含义,简单来说base数组和check数组更像一种父子关系。check数组存储当前节点的父亲节点。

而我那天看到的论文是在此基础上的改进。论文中配图下的小标题,作者取名为ReducedTrie
它和原生的双数组字典树的最明显的区别在于,它多了一个tail数组,这个数组的含义就像它的英文含义一样。reducedTrie的base, check数组仅存储前缀部分,而非前缀部分全部放到tail数组中。

那么如何定位tail数组的位置呢?在base数组之中,每个字符串结尾的字符的base值为其后缀在tail的下标的负值。举例说base[10] = -12 不那么说明tail[12]数组之后直到结束字符都是后缀。

好处很明显,节约了base, check的空间减少了非前缀部分的计算,同样也有缺点,最大的缺点个人觉得是在存储为文件上,由于之前只需要base, check数组,并且是两个数组成对出现,因此很容易将字典树压缩离线存储为一个文件,而tail数组和base, check数组并无关系,因此需要两个文件存储。另一点就是tail数组的碎片太多,这个后面说插入会讲到。

具体实现

其实讲了tail数组的作用之后,再结合双数组字典树,就能够很快理解这个结构了。

insert

插入是比较复杂的部分。分为四种情况处理。论文原文如下

1. Insertion of the new word when the double-array is empty. 
2. Insertion of the new word without any collisions. 
3. Insertion of the new word with a collision; in this case, additional characters must be added to the BASE and characters must be removed from the TAIL array to resolve the collision, but nothing already in the BASE array must be removed. 
4. When insertion of the new word with a collision as in case 3 occurs, values in the BASE array must be moved.

拙略地翻一下,大致是

1.当数组为空则直接插入新节点.(体现节点为空检查check数组为0即可)
2.没有任何冲突的插入.
3.插入新节点时发生了一种不需要修改base数组,但是必须修改tail和多添加base字符用以解决冲突的冲突。
4.插入新节点发生了类似3的情况,但是base数组的值必须被清空

如果你实现过双数组字典树,那么你很快就明白了作者所说的冲突原意。如果没有实现过,那么用白话说下来,就是以下两种。

当base为负值(base[i] <0 说明此节点为终结点)而需要新插入节点,那么这就算一种冲突,解决这种冲突,必须要对比tail数组。举个论文中的例子,bachelor与badge,当插入bachelor时,b节点在树中,achelor都在tail数组中,所以插入badge时候,b节点比对完了,base就为负值了,这时候就需要比对badge的a和tail中的a。由于 a == a 因此直接插入一个新节点就可以继续走下去了。

那么如果遇到节点不同呢?还是上面的例子,走完a就来到了(c,d)显然不相等,那么需要新建两个节点。原理和上面所说的相同。

当base[pre]>0时, 并且check[base[pre] + char ] != pre那么说明发生了需要更改base值的冲突。这种冲突的本质是由于两个节点的base值相同,因此必须更改其中一个值来解决冲突。需要更改的base的节点就是pre和check[ base[pre] + char] 其中的一个。

那么更改哪一个?选择从两个节点中选择孩子节点少的。因为base值改变了意味着他们的孩子节点的base值也要随着改变,如果节点的孩子也有孩子暂称为孙子吧,那么孙子节点的父亲也是要更改的。孩子节点有了新值,那么旧值就可以清0了。新节点就可以插入了

但是在这其中有个巨坑的点,就是如果冲突的两个节点刚好是父子关系,那么一定要更新好父亲的下标。

可能上面说的还是不能解惑,那我下面放出具体解决上面两种冲突的实现代码

其中xCheck(List list)是从1查找一个的值q,能够让list中的任何一个变量x都满足check[q+x]=0
moveTail(int x)即在x位置开始直到读到结束字符这段区间内,tail向左移动一位
writeTail(int[] value, int x)从value数组的x位置开始向tail数组写入。
put(int key, value) 缓存pre节点的孩子
初始化时候base[1] = 1 check[1] = 1

第一种冲突

if (base[pre] < 0) {
    //if current node is an end-point ,then separate or create a new node
    int oldBase = base[pre];
    if (tail[-oldBase] == keyValue[i]) {
        //create a new node
        base[pre] = xCheck(keyValue[i]);
        base[ base[pre]+keyValue[i] ] = oldBase;
        check[ base[pre]+keyValue[i] ] = pre;
        put(pre, keyValue[i]);
        moveTail(-oldBase);
        pre = base[pre] + keyValue[i];
        continue;
    } else {
        //separate
        List list = new ArrayList<>();
        list.add(tail[-oldBase]); list.add(keyValue[i]);
        base[pre] = xCheck(list);
        base[ base[pre]+tail[-oldBase] ] = oldBase;
        base[ base[pre]+keyValue[i] ] = -position;
        check[ base[pre]+tail[-oldBase] ] = check[ base[pre]+keyValue[i] ] = pre;
        writeTail(keyValue, i+1);
        put(pre, tail[-oldBase]);
        put(pre, keyValue[i]);
        moveTail(-oldBase);
        break;// 2 new nodes
    }
} 

第二种冲突

@param node1为当前节点位置

@param node2为另一个已存在的与node1发生冲突的节点

@param newNodeValue为需要插入的节点值

请注意巨坑点 :))))

public int processConflict(int node1, int node2, int newNodeValue) {
    int node = (lists[node1].size()+1) < lists[node2].size() ? node1 : node2;
    int oldNodeBase = base[node];
    if (node == node1) {
        base[node] = xCheck(lists[node], newNodeValue);
    } else {
        base[node] = xCheck(lists[node]);
    }
    for (int i = 0; i < lists[node].size(); i++) {
        int oldNext = oldNodeBase + lists[node].get(i);
        int newNext = base[node] + lists[node].get(i);
        if (oldNext == node1) node1 = newNext;//巨坑点
        base[newNext] = base[oldNext];
        check[newNext] = node;
        if (base[oldNext] > 0) {
            for (int j = 0; j < lists[oldNext].size(); j++) {
                check[ base[oldNext] + lists[oldNext].get(j) ] = newNext;
                put(newNext, lists[oldNext].get(j));
            }
            lists[oldNext] = null;
        }
        base[oldNext] = 0; check[oldNext] = 0;
    }
    base[ base[node1] + newNodeValue ] = -position;
    check[ base[node1] + newNodeValue ] = node1;
    put(node1, newNodeValue);
    return node;
}

如果你还是有所疑惑,建议阅读An Efficient Implementation of Trie Structures这篇原文或者翻译版。

search

搜索就很简单了,按照文章最开始的

public boolean search(int[] key) {
    int pre = 1;
    for (int i = 0; i < key.length; i++) {
        if (base[pre] < 0) {
            return compareTail(-base[pre], i, key);
        } else if (base[pre] > 0) {
            if (check[ base[pre] + key[i] ] == pre) {
                pre = base[pre] + key[i];
            } else {
                return false;
            }
        } else return false;
    }
    return true;
}
delete

对于删除操作,只需要找到每个单词最后一个节点,释放它即可。

public boolean delete(String key) {
    int []keyValue = string2IntArray(key);
    int pre = 1;
    int index = -1;
    int tempVal;
    int next;
    do {
        index++;
        tempVal = keyValue[index];
        next = base[pre] + tempVal;
        if (check[next] != pre)  {
            return false;
        }
        if (base[next] < 0) break;
        pre = next;
    } while (true);
    if (tempVal == END_FLAG || compareTail(-base[next], index+1, keyValue)) {
        for (int i = 0; i < lists[pre].size(); i++) {
            if (lists[pre].get(i) == tempVal) {
                lists[pre].remove(i);break;
            }
        }
        base[next] = 0; check[next] = 0;
        //info(String.format("%s next[%d] turn to 0",key, next));
        return true;
    }
    return false;
}
usage

字典树最常见的用途就是前缀匹配和前缀提示,trie树建立成功之后就可以根据输入的前缀去寻找从这个节点出发所有可能的字符串。这里采用深度优先遍历。

private void find( int pre, StringBuilder builder, List list) {
    int next;
    if (base[pre] < 0) {
        builder.append(readTail(-base[pre]));
        list.add(builder.toString());
        return;
    }
    for (int i = 0; i < lists[pre].size(); i++) {
        next = base[pre] + lists[pre].get(i);
        StringBuilder reserved = new StringBuilder(builder.toString());
        if (check[next] == pre) {
            if (lists[pre].get(i) == END_FLAG) {
                find(next, builder, list);
            } else {
                find(next, builder.append((char) lists[pre].get(i).intValue()), list);
            }
        }
        builder = reserved;
    }
}
总结

好了,还记得之前我所说过这种结构的缺点中的一点是建立过程tail数组的碎片化严重吗?为什么这么说呢,因为在处理第一种冲突的时候,会不断地移动的tail数组,比如bachelor和badge,原本tail数组中是achelor#由于对比a==a成功,所以向左移动一位,chelor?#而这个"?"的部分,就无法被后续的节点插入使用,当然也是有解决办法的,需要在树建立成功之后整块移动。

我在论坛上看到一种看法说这种写法在插入时发生冲突概率这么大,有什么实用性呢?其实这种结构在插入过程慢一点是无所谓的,字典树用途主要看它的查找效率。我们完全可以用长时间建立然后存储每个节点的值,第二次直接读进内存,就可以直接使用,建立过程只需要一次即可。

测试源代码:ReducedTrie

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/66777.html

相关文章

  • 准备下次编程面试前你应该知道数据结构

    摘要:以下内容编译自他的这篇准备下次编程面试前你应该知道的数据结构瑞典计算机科学家在年写了一本书,叫作算法数据结构程序。 国外 IT 教育学院 Educative.io 创始人 Fahim ul Haq 写过一篇过万赞的文章《The top data structures you should know for your next coding interview》,总结了程序员面试中需要掌...

    desdik 评论0 收藏0
  • 准备下次编程面试前你应该知道数据结构

    摘要:以下内容编译自他的这篇准备下次编程面试前你应该知道的数据结构瑞典计算机科学家在年写了一本书,叫作算法数据结构程序。 国外 IT 教育学院 Educative.io 创始人 Fahim ul Haq 写过一篇过万赞的文章《The top data structures you should know for your next coding interview》,总结了程序员面试中需要掌...

    chadLi 评论0 收藏0
  • JavaScript数据结构和算法

    摘要:栈被称为一种后入先出的数据结构。散列使用的数据结构叫做散列表。这些操作需要求助于其他数据结构,比如下面介绍的二叉查找树。 前言 在过去的几年中,得益于Node.js的兴起,JavaScript越来越广泛地用于服务器端编程。鉴于JavaScript语言已经走出了浏览器,程序员发现他们需要更多传统语言(比如C++和Java)提供的工具。这些工具包括传统的数据结构(如链表,栈,队列,图等),...

    EastWoodYang 评论0 收藏0
  • Lucene 查询原理

    摘要:介绍如何优化数值类范围查询。查询过程在中查询是基于。在中为了查询的这样一个条件,会建立基于的倒排链。在单查询上可能相比并没有明显优势,甚至会慢一些。所以为了支持高效的数值类或者多维度查询,引入类。 前言 Lucene 是一个基于 Java 的全文信息检索工具包,目前主流的搜索系统Elasticsearch和solr都是基于lucene的索引和搜索能力进行。想要理解搜索系统的实现原理,就...

    FullStackDeveloper 评论0 收藏0
  • Lucene 查询原理

    摘要:介绍如何优化数值类范围查询。查询过程在中查询是基于。在中为了查询的这样一个条件,会建立基于的倒排链。在单查询上可能相比并没有明显优势,甚至会慢一些。所以为了支持高效的数值类或者多维度查询,引入类。 前言 Lucene 是一个基于 Java 的全文信息检索工具包,目前主流的搜索系统Elasticsearch和solr都是基于lucene的索引和搜索能力进行。想要理解搜索系统的实现原理,就...

    testHs 评论0 收藏0

发表评论

0条评论

kycool

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<