前缀树

2,643 阅读3分钟

问题

已知一个字符串数组words=['accommodate','accompany','accord','account','accurate','adjoin','bang','bare','duplicate','migration','account','accurate','adjoin','bang' ]要解决下面几个问题

  • 对字符串数组words进行排序
  • 统计字符串'bang'在数组words里出现的次数
  • 统计字符串数组words前缀为'ba'的字符串个数

也许你能想出好几种解决方案,有些方案也很不错。但想过没有他们其实可以一种方案去解决,效果并且都还不错,这种方案就是前缀树。

什么是前缀树

前缀树也叫Trie、字典树、单词查找树,它是由海量的单词构成多子节点的树。
性质:

  1. 根节点不包含任何key
  2. 每个子节点包含的key都不相同

核心思想:利用相同前缀,减少重复比较,提高查找效率
缺点:Trie树的空间消耗大(如果一个节点有26个孩子,每个孩子又有26个孩子 26 * 26 * 26 *……,指数级增加)
优点:利用相同前缀,提高比对效率,减少无用功

构建前缀树

利用字符串数组words去构建一个前缀树

图形展示前缀树

这张图就是字符串数组构建前缀树,没有标注path和end

代码实现

代码的实现没什么复杂逻辑,就是将字符串拆成单个字符,然后按照一条路径去插入

//节点信息
   private  char key;//
    private int path;//表示路过该节点
    private int end;//表示以该节点作为结尾
    private TrieNode[] childs = new TrieNode[26];

    public  TrieNode getIndexNode(int index){
        return childs[index];
    }
    public  TrieNode getIndexNodeNoEmpty(int index){
        if(childs[index] ==null){
            childs[index] = new TrieNode();
        }

        return childs[index];
    }
    public void updatePath(boolean isUp){
        if(isUp){
            path++;
        }else {
            path--;
        }

    }
    public  void updateEnd( boolean isUp){
        if(isUp) {
            end++;
        }else {
            end --;
        }

    }

节点信息里,path是用来统计经过该节点的单词个数;end表示以该节点结尾单词个数;childs是所有的孩子节点;

//构建前缀树,也是插入字符串
 public  void  buildTrie(String value){
        if(value == null ||value.length() == 0){
            return;
        }
        if(root == null){
            root = new TrieNode();
        }
      char[] chars =  value.toCharArray();
        TrieNode current = root;
        int length = chars.length;
        for(int i = 0;i<length;i++){
            TrieNode   node = current.getIndexNodeNoEmpty(chars[i]-'a');
            node.setKey(chars[i]);
            node.updatePath(true);//增加途经的次数
            current =node;
        }
        current.updateEnd(true);//增加结尾的个数

    }

Trie代码实现就是将单词拆分成字符数组,然后顺着字符的路径去创建或者公共已有的节点就可以了

Trie的基础操作和应用

基础操作主要增加、删除、查找,外加一个遍历

  • 增加:增加就是也就是将字符串加入前缀树里,也就是构建,参考上一节
  • 更改: 更改可以看做删除和增加的和操作
  • 查找和删除:这俩个操作的代码实现的逻辑和增加操作差不多,查找只返回结果就可以了,删除是将经过的路径进行减一操作
  • 遍历:跟树遍历大同小异

查找

 public int  searchValue(String value){
        if(value == null ||value.length() == 0){
            return 0;
        }
        if(root == null){
            return  0;
        }
        char[] chars =  value.toCharArray();
        TrieNode current = root;
        int length = chars.length;
        for(int i = 0;i<length;i++){
            TrieNode   node = current.getIndexNode(chars[i]-'a');
              if(node == null){
                  return  0;
              }
              current = node;
        }
        return  current.getEnd();
    }

删除

 public  void delete(String value){
        if(value == null ||value.length() == 0){
            return;
        }
        char[] chars =  value.toCharArray();
        TrieNode current = root;
        int length = chars.length;
        for(int i = 0;i<length;i++){
            TrieNode   node = current.getIndexNode(chars[i]-'a');
            if(node !=null) {
                node.updatePath(false);//减少途经的次数
                current = node;
            }
        }
        current.updateEnd(false);//减少结尾的个数

    }

遍历

这里遍历排除了重复字符串,如果需要包含所有字符串,添加到list的时候循环一下end个数就可以了

//挨个遍历root的子孩子
  public  List travelTrie(){
        if(root == null){
            return null;
        }
        if(result == null){
            result = new ArrayList<>();
        }
        result.clear();
        for(int i = 0;i<26;i++){
            travel(root.getIndexNode(i),"");
        }
        return  result;
    }
//递归遍历
 private   void travel(TrieNode node ,String value){
        if(node == null){
            return;
        }
        value = value+node.getKey();
        if(node.getEnd()>0){
            result.add(value);
        }
        for(int i =0;i<26;i++){
            if(node.getIndexNode(i)!=null){
                travel(node.getIndexNode(i),value);
            }
        }

    }

应用(解决文章开头的问题)

看到这里,也许也没感觉到开头问题用前缀树去解决的优越性,假如words数组变成几千甚至几万并且数组会随时增加减少,查询的字符串也在变化,前缀树的优越性优越性是不是就体现出来了

  • 问题1 :对字符串数组words进行排序 解决方案: 保证childs是有序的,通过遍历Trie,将走过路径合成字符串,根据结束标签输出字符串,就可以得到一个有序的字符串数组
  • 问题2: 统计字符串'bang'在数组words里出现的次数 解决方案:这个就比较好办了,就是查找然后输出end就可以了,就是searchValue操作就可以
  • 问题3: 统计字符串数组words前缀为'ba'的字符串个数
    解决方案:和问题2很相似,只是将end改成path就可以了

压缩

看着第一张图是不是节点很多,有些节点就没有共用。像单词migration整个路径上就它自己,没有分叉,可以把那些连续没有分叉的节点合成一个节点,来节省空间,这就是空间的优化。这样优化的好处很明显,空间优化很大,坏处也很明显会增加编码的工作量。下图就是上面那张图压缩后的效果图

这张图是空间压缩之后的结果

总结

前缀树是处理字符串的利器,能解决包括但不限于文章中提到的问题,它的效率不错,算法理解起来也很容易,重点和难点是如何将遇到的问题抽象成前缀树,用前缀树的思想去解决遇到的问题。