前缀树专项

1,448 阅读8分钟

前缀树是什么?

前缀树又称单词查找树,Trie树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。

它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。

前缀树长啥样?

前缀树本身是一个多叉树, 除了root节点不含有字符外, 其它节点, 每个节点都包含一个字符, 且一个节点的多个子节点, 是不会出现重复的字符的, 但是对于整体多叉树来说, 同一层节点是可能会有重复字符的. 正是通过这个特点, 前缀树节省了大量用来存储相同前缀的空间.

image.png

一个前缀树从root节点到最下一层的叶子节点, 所有的字符拼接, 就是一个完整的字符串.

同一个节点A分叉的子节点B, C, D, 对于最后构成的字符串来说, A就是B, C, D的前缀.

前缀树示例1

例如我对[苍老师, 苍老师的样子, 苍老师的同义词, 苍老师的反义词]这一组字符串构建前缀树, 就可以得到一个如下结构:

image.png

前缀树示例2

假设有5个字符串,它们分别是:code,cook,five,file,fat构建前缀树

image.png

前缀树的特点

  • 根节点不包含字符,除根节点外每一个节点都只包含一个字符
  • 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串
  • 每个节点的所有子节点包含的字符都不相同

前缀树的构建逻辑

构建前缀树, 只需要将参与的字符串, 逐个的插入到树中. 当所有字符串插入完毕, 前缀树也就构建完毕

对于单个字符串来讲, 每个字符在前缀树中位于第一层是可以预知的, 假设root算第0层.

那么对于字符串"talent", t一定在第一层, a一定在第二层, l一定在第三层, 依次类推

一个字符串的插入距离逻辑如下:

(1) 判断第一层中是否存在字符串第一个字符的节点, 如果存在, 直接选择, 入股不存在, 创建该节点, 作为上一层节点的孩子节点, 然后进入该节点

(2) 上一层节点的所有孩子节点作为第二层, 在第二层中判断字符串中的第二个字符的节点是否存在, 重复1的过程

(3) 直到将字符串完整的插入.

前缀树的效率

字典树的查询时间复杂度是O(logL),L是字符串的长度。所以效率还是比较高的。字典树的效率比hash表高。

hash表:

通过hash函数把所有的单词分别hash成key值,查询的时候直接通过hash函数即可,都知道hash表的效率是非常高的为O(1),当然这是对于如果我们hash函数选取的好,计算量少,且冲突少,那单词查询速度肯定是非常快的。那如果hash函数的计算量相对大呢,且冲突律高呢?这些都是要考虑的因素。

还有就是hash表不支持动态查询,什么叫动态查询,当我们要查询单词apple时,hash表必须等待用户把单词apple输入完毕才能hash查询。当你输入到appl时肯定不可能hash吧。

字典树(tries树):

前缀树其实就典型的空间换时间的数据结构.

对于单词查询这种,还是用字典树比较好,但也是有前提的,空间大小允许,字典树的空间相比较hash还是比较浪费的,毕竟hash可以用bit数组。

应用场景

搜索框弹提示词

image.png

字符串快检 (更适合前缀匹配)

通过遍历前缀树, 可以很快的判断出某个字符串是存在.

例如我判断"aaabc"是否存在, 检查的方式可能是遍历一遍所有字符串, 挨个比较, 或者说比较是不是map的key. 但是对于前缀来说, 只需要判断每一层是不是存在对应的字符就行, 即第一层有没有a, 第二层有没有a, 以此类推.

特别是如果是判读aaabc作为前缀的字符串是否存在时, hash的方式就无法判断了.

字符串检索,词频统计,搜索引擎的热门查询

事先将已知的一些字符串(字典)的有关信息保存到trie树里,查找另外一些未知字符串是否出现过或者出现频率。

统计频率的时候, 我们使得每个节点都可以记录以当前节点作为终点的字符串的数目, 因为对于前缀树中的任何一个节点作为终点, 对应的字符串都是唯一的. 因此可以直接在节点中记录字符串出现的次数.

举例:

  • 1)有一个1G大小的一个文件,里面每一行是一个词,词的大小不超过16字节,内存限制大小是1M。返回频数最高的100个词。 如果词的重复性非常高, 就可以用前缀树来实现, 遍历一整棵前缀树, 得到每个节点的统计数量, 然后再排序得到符合要求的词
  • 2)给出N 个单词组成的熟词表,以及一篇全用小写英文书写的文章,请你按最早出现的顺序写出所有不在熟词表中的生词。
  • 3)给出一个词典,其中的单词为不良单词。单词均为小写字母。再给出一段文本,文本的每一行也由小写字母构成。判断文本中是否含有任何不良单词。例如,若rob是不良单词,那么文本problem含有不良单词。
  • 4)1000万字符串,其中有些是重复的,需要把重复的全部去掉,保留没有重复的字符串
  • 5)寻找热门查询:搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的长度为1-255字节。假设目前有一千万个记录,这些查询串的重复读比较高,虽然总数是1千万,但是如果去除重复和,不超过3百万个。一个查询串的重复度越高,说明查询它的用户越多,也就越热门。请你统计最热门的10个查询串,要求使用的内存不能超过1G。

字符串最长公共前缀

Trie树利用多个字符串的公共前缀来节省存储空间,反之,当我们把大量字符串存储到一棵trie树上时,我们可以快速得到某些字符串的公共前缀。举例:

举例例如给出N个字符串, 寻找任意两个字符串的公共前缀.

将N个字符串插入到前缀树中, 然后寻找公共前缀, 其实就是寻找两个节点的最近公共节点.

排序

Trie树是一棵多叉树,只要先序遍历整棵树,输出相应的字符串便是按字典序排序的结果。

前缀树每一层节点是有序的, 即是按照字典序的, 因此对前缀树进行一次前缀遍历是可以得到所有字符串的字典序的排序结果的.

作为其他数据结构和算法的辅助结构

如后缀树,AC自动机等。

实现前缀树

题目

image.png

版本1 正确

public class Trie {
    private Trie[] children;
    private boolean isEnd;

    public Trie() {
        // 题目要求插入的字符只可能是26个小写字母
        // 因此数组就初始化为26
        children = new Trie[26];
        isEnd = false;
    }

    public void insert(String word) {
        Trie node = this;
        for (int i = 0; i < word.length(); i++) {
            char ch = word.charAt(i);
            int index = ch - 'a';
            if (node.children[index] == null) {
                node.children[index] = new Trie();
            }
            node = node.children[index];
        }
        node.isEnd = true;
    }

    public boolean search(String word) {
        Trie node = searchPrefix(word);
        return node != null && node.isEnd;
    }

    public boolean startsWith(String prefix) {
        return searchPrefix(prefix) != null;
    }

    private Trie searchPrefix(String prefix) {
        Trie node = this;
        for (int i = 0; i < prefix.length(); i++) {
            char ch = prefix.charAt(i);
            int index = ch - 'a';
            if (node.children[index] == null) {
                return null;
            }
            node = node.children[index];
        }
        return node;
    }

}

正确的原因

(1) 对于前缀树的每一层, 其实就是对于Trie指针重新指向下一层对象, 即node = node.children[index];

数组中两个数的最大异或值

题目

image.png

版本1 正确

    TrieNode root;
    public int findMaximumXOR(int[] nums) {
        int len = nums.length;
        root = new TrieNode(new TrieNode[2]);  //创建根节点。
        // 构建前缀树
        for (int i = 0; i < len;i++) {
            build(nums[i]);
        }

        // 将每个数字在二叉树中匹配
        int res = 0;
        for (int i = 0; i < len;i++) {
            int t = query(nums[i]);
            res = Math.max(res,t);
        }
        return res;
    }

    public void build(int x){
        TrieNode now = root;
        // 32位数字第一位是符号位, 其它的才是有效数字位, 因此遍历31位即可

        for (int i = 30;i >= 0; i --) {
            // 将原数字每次右移一位, 得到每一位二进制
            int u = x >> i & 1;
            if(now.son[u] == null) {
                // 该节点如果不存在, 创建
                now.son[u] = new TrieNode(new TrieNode[2]);
            }
            // 进入下一节点
            now = now.son[u];
        }
    }
    public int query(int x){
        TrieNode now = root;
        int res = 0;
        for (int i = 30; i >= 0 ; i --) {
            // 逐位获取每位二进制
            int u = x >> i & 1;
            // 将每一位取反了进行匹配
            if(now.son[1 - u] != null) {
                // 如果匹配上了, 就计算一次该位为1的值
                // 即res = res + (第i位为1的值)
                res += 1 << i;
                now = now.son[1 - u];
            } else {
                // 没匹配上 继续匹配
                now = now.son[u];
            }
        }
        return res;
    }

    // 简单前缀树的结构
    class TrieNode{
        TrieNode son[];
        public TrieNode(TrieNode[] son) {
            this.son = son;
        }
    }

正确的原因

(1) 将每个数子, 看成是一个32位的字符串, 即int数字对应32位二进制数字, 每一位是0或者1, 这样就将每个数字变成了一个字符串, 然后构建一颗前缀树. 如下图所示.

image.png

(2) 然后再把每个数字, 每一位取反, 然后取二叉树中匹配, 即原数字是7, 对应的二进制是000111, 然后每一位取反完就是111000, 然后拿111000去前缀树中匹配, 得到的数字就是该数字和数组中任一数字异或的最大值, 这样取所有数字异或的最大值, 就是最终答案.