剑指Offer——Trie树(字典树)

剑指Offer——Trie树(字典树)

Trie树

    Trie树,即字典树,又称单词查找树或键树,是一种树形结构,是一种哈希树的变种。典型应用是统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高。

    Trie的核心思想是空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。

    Trie树也有它的缺点,Trie树的内存消耗非常大。当然,或许用左儿子右兄弟的方法建树的话,可能会好点。可见,优化的点存在于建树过程中。

 和二叉查找树不同,在trie树中,每个结点上并非存储一个元素。trie树把要查找的关键词看作一个字符序列,并根据构成关键词字符的先后顺序构造用于检索的树结构。在trie树上进行检索类似于查阅英语词典。

3个基本性质

   1.根节点不包含字符,每条边代表一个字符。

   2.从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。

   3.每个节点的所有子节点包含的字符都不相同。

字典树的构建

  题目:给你100000个长度不超过10的单词。对于每一个单词,我们要判断他出没出现过,如果出现了,求第一次出现在第几个位置。

  分析:这题当然可以用hash来解决,但是本文重点介绍的是trie树,因为在某些方面它的用途更大。比如说对于某一个单词,我们要询问它的前缀是否出现过。这样hash就不好搞了,而用trie还是很简单。

  假设我要查询的单词是abcd,那么在他前面的单词中,以b,c,d,f之类开头的我显然不必考虑。而只要找以a开头的中是否存在abcd就可以了。同样的,在以a开头中的单词中,我们只要考虑以b作为第二个字母的,一次次缩小范围和提高针对性,这样一个树的模型就渐渐清晰了。

  好比假设有b,abc,abd,bcd,abcd,efg,hii 这6个单词,我们构建的树就是如下图这样的(图片来自百度百科):

    如上图所示,对于每一个节点,从根遍历到他的过程就是一个单词,如果这个节点被标记为红色,就表示这个单词存在,否则不存在。

    那么,对于一个单词,我只要顺着他从根走到对应的节点,再看这个节点是否被标记为红色就可以知道它是否出现过了。把这个节点标记为红色,就相当于插入了这个单词。

    这样一来我们查询和插入可以一起完成(重点体会这个查询和插入是如何一起完成的,稍后,下文具体解释)。

  我们可以看到,trie树每一层的节点数是26^i(26个英文字母)级别的。所以为了节省空间,我们用动态链表,或者用数组来模拟。空间的花费,不会超过单词数×单词长度。

  已知n个由小写字母构成的平均长度为10的单词,判断其中是否存在某个串为另一个串的前缀子串。下面对比3种方法:

  最容易想到的:1.即从字符串集中从头往后搜,看每个字符串是否为字符串集中某个字符串的前缀,复杂度为O(n^2)。

  2.使用hash:我们用hash存下所有字符串的所有前缀子串,建立存有子串hash的复杂度为O(n*len),而查询的复杂度为O(n)* O(1)= O(n)。

  3.使用trie:因为当查询如字符串abc是否为某个字符串的前缀时,显然以b,c,d....等不是以a开头的字符串就不用查找了。所以建立trie的复杂度为O(n*len),而建立+查询在trie中是可以同时执行的,建立的过程也就可以称为查询的过程,hash就不能实现这个功能。所以总的复杂度为O(n*len),实际查询的复杂度也只是O(len)。(说白了,就是Trie树的平均高度h为len,所以Trie树的查询复杂度为O(h)=O(len)。好比一棵二叉平衡树的高度为logN,则其查询,插入的平均时间复杂度亦为O(logN))。

查询

    Trie树是简单但实用的数据结构,通常用于实现字典查询。我们做即时响应用户输入的AJAX搜索框时,就是Trie树。本质上,Trie是一棵存储多个字符串的树。相邻节点间的边代表一个字符,这样树的每条分支代表一则子串,而树的叶节点则代表完整的字符串。和普通树不同的地方是,相同的字符串前缀共享同一条分支。下面,再举一个例子。给出一组单词,inn, int, at, age, adv, ant, 我们可以得到下面的Trie:

    可以看出:

    每条边对应一个字母。

    每个节点对应一项前缀。叶节点对应最长前缀,即单词本身。

  单词inn与单词int有共同的前缀“in”, 因此他们共享左边的一条分支,root->i->in。同理,ate, age, adv, 和ant共享前缀"a",所以他们共享从根节点到节点"a"的边。

  查询操纵非常简单。比如要查找int,顺着路径i -> in -> int就找到了。

  搭建Trie的基本算法也很简单,无非是逐一把每个单词的每个字母插入Trie。插入前先看前缀是否存在。如果存在,就共享,否则创建对应的节点和边。比如要插入单词add,就有下面几步:

  考察前缀"a",发现边a已经存在。于是顺着边a走到节点a。

  考察剩下的字符串"dd"的前缀"d",发现从节点a出发,已经有边d存在。于是顺着边d走到节点ad

  考察最后一个字符"d",这下从节点ad出发没有边d了,于是创建节点ad的子节点add,并把边ad->add标记为d。

查找分析

  在trie树中查找一个关键字的时间和树中包含的结点数无关,而取决于组成关键字的字符数。而二叉查找树的查找时间和树中的结点数有关O(log2n)。

    如果要查找的关键字可以分解成字符序列且不是很长,利用trie树查找速度优于二叉查找树。例如:若关键字长度最大是5,则利用trie树,利用5次比较可以从26^5=11881376个可能的关键字中检索出指定的关键字。而利用二叉查找树至少要进行次比较。

应用

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

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

   举例:

    1、有一个1G大小的一个文件,里面每一行是一个词,词的大小不超过16字节,内存限制大小是1M。返回频数最高的100个词。

    2、给出N 个单词组成的熟词表,以及一篇全用小写英文书写的文章,请你按最早出现的顺序写出所有不在熟词表中的生词。

    3、给出一个词典,其中的单词为不良单词。单词均为小写字母。再给出一段文本,文本的每一行也由小写字母构成。判断文本中是否含有任何不良单词。例如,若rob是不良单词,那么文本problem含有不良单词。

    4、1000万字符串,其中有些是重复的,需要把重复的全部去掉,保留没有重复的字符串。请怎么设计和实现?

    5、一个文本文件,大约有一万行,每行一个词,要求统计出其中最频繁出现的前10个词,请给出思想,给出时间复杂度分析。

    6、寻找热门查询:搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的长度为1-255字节。假设目前有一千万个记录,这些查询串的重复读比较高,虽然总数是1千万,但是如果去除重复和,不超过3百万个。一个查询串的重复度越高,说明查询它的用户越多,也就越热门。请你统计最热门的10个查询串,要求使用的内存不能超过1G(京东笔试题简答题与此类似)。

    (1) 请描述你解决这个问题的思路;

    (2) 请给出主要的处理流程,算法,以及算法的复杂度。

2. 字符串最长公共前缀

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

    1) 给出N 个小写英文字母串,以及Q 个询问,即询问某两个串的最长公共前缀的长度是多少.  解决方案:

  首先对所有的串建立其对应的字母树。此时发现,对于两个串的最长公共前缀的长度即它们所在结点的公共祖先个数,于是,问题就转化为了离线(Offline)的最近公共祖先(Least Common Ancestor,简称LCA)问题。

    而最近公共祖先问题同样是一个经典问题,可以用下面几种方法:

      1. 利用并查集(Disjoint Set),可以采用经典的Tarjan 算法;

      2. 求出字母树的欧拉序列(Euler Sequence )后,就可以转为经典的最小值查询(Range Minimum Query,简称RMQ)问题了;

3.  排序

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

      举例:给你N 个互不相同的仅由一个单词构成的英文名,让你将它们按字典序从小到大排序输出。

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

   如后缀树,AC自动机等。

举例

   下面以字典树的构建与单词查找为例。

 

TrieTreeNode.java

 

 

package cn.edu.ujn.trieTree;
 
public class TrieTreeNode {
    int nCount;	//记录该字符出现次数  
    char ch; //记录该字符  
    TrieTreeNode[] child;	// 记录子节点  
    final int MAX_SIZE = 26;
    public TrieTreeNode() {  
        nCount=1;  
        child = new TrieTreeNode[MAX_SIZE];
    }  
}

 

TrieTree.java

 

package cn.edu.ujn.trieTree;
 
public class TrieTree {
    //字典树的插入和构建  
    public void createTrie(TrieTreeNode node,String str){  
        if (str == null || str.length() == 0) {  
            return;
        }
        char[] letters = str.toCharArray();
        for (int i = 0; i < letters.length; i++) {  
            int pos = letters[i] - 'a';	  // 用相对于a字母的值作为下标索引,也隐式地记录了该字母的值  
            if (node.child[pos] == null) {
                node.child[pos] = new TrieTreeNode();
            }else {
                node.child[pos].nCount++;
            }
            node.ch = letters[i];              
            node = node.child[pos];              
        }  
    }  
    
    //字典树的查找  
    public int findCount(TrieTreeNode node,String str){  
        if (str == null || str.length() == 0) {  
            return -1;
        }  
        char[] letters = str.toCharArray();
        for (int i = 0; i < letters.length; i++) {  
            int pos = letters[i] - 'a';
            if (node.child[pos] == null) {    
                return 0;     
            }else {  
                node = node.child[pos];  
            }           
        }
        return node.nCount;
    }  
 
}

 

Test.java

 

package cn.edu.ujn.trieTree;
 
public class Test {
public static void main(String[] args) {
/**
 * Problem Description 老师交给他很多单词(只有小写字母组成,不会有重复的单词出现),现在老师要他统计
 * 出以某个字符串为前缀的单词数量(单词本身也是自己的前缀).
 */
String[] strs = { "banana", "band", "bee", "absolute", "acm" };
String[] prefix = { "ba", "b", "band", "abc" };
TrieTree tree = new TrieTree();
TrieTreeNode root = new TrieTreeNode();
 
for (String s : strs) {
tree.createTrie(root, s);
}
// tree.printAllWords();
for (String pre : prefix) {
int num = tree.findCount(root, pre);
System.out.println(pre + " " + num);
}
}
}

 

小结

  看过上面的代码,是否发现这个代码有什么问题呢?尽管这个实现方式查找的效率很高,时间复杂度是O(m),m是要查找的单词中包含的字母的个数。但是确浪费大量存放空指针的存储空间。因为不可能每个节点的子节点都包含26个字母的。所以对于这个问题,字典树存在的意义是解决快速搜索的问题,所以采取以空间换时间的作法也毋庸置疑。

    Trie树占用内存较大,例如:处理最大长度为20、全部为小写字母的一组字符串,则可能需要 2620 个节点来保存数据。而这样的树实际上稀疏的十分厉害,可以采用左儿子右兄弟的方式来改善,也可以采用需要多少子节点则添加多少子节点来解决(不要类似网上的示例,每个节点初始化时就申请一个长度为26的数组)。

Wiki上提到了采用三数组Trie(Tripple-Array Trie)和二数组Trie(Double-Array Trie)来解决该问题,此外还有压缩等方式来缓解该问题。

示例优化

TrieTreeNode.java

 

 

package cn.edu.ujn.trieTreeMap;
 
import java.util.HashMap;
import java.util.Map;
 
public class TrieNode {
    int nCount;	//记录该字符出现次数  
    Map<Character, TrieNode> childdren;	// 记录子节点  
    public TrieNode() {  
        nCount = 1;  
        childdren = new HashMap<Character, TrieNode>();
    }  
}

 

TrieTree.java

 

 

package cn.edu.ujn.trieTreeMap;
 
// 利用Map动态创建节点
public class TrieTree {
 
    // 字典树的插入和构建
    public void insert(TrieNode node, String word) {
        for (int i = 0; i < word.length(); i++) {
            Character c = new Character(word.charAt(i));
            if (!node.childdren.containsKey(c)) {
                node.childdren.put(c, new TrieNode());
            }else{
            	node.childdren.get(c).nCount++;
            }
            node = node.childdren.get(c);
        }
    }
 
    // 字典树的查找
    public int search(TrieNode node, String word) {
        for (int i = 0; i < word.length(); i++) {
            Character c = new Character(word.charAt(i));
            if (!node.childdren.containsKey(c)) {
                return 0;
            }
            node = node.childdren.get(c);
        }
        return node.nCount;
    }
 
}

 

Test.java

 

package cn.edu.ujn.trieTreeMap;
 
public class Test {
public static void main(String[] args) {
/**
 * Problem Description 老师交给他很多单词(只有小写字母组成,不会有重复的单词出现),现在老师要他统计
 * 出以某个字符串为前缀的单词数量(单词本身也是自己的前缀).
 */
String[] strs = { "banana", "band", "bee", "absolute", "acm" };
String[] prefix = { "ba", "b", "band", "abc" };
TrieTree tree = new TrieTree();
TrieNode node = new TrieNode();
for (String s : strs) {
tree.insert(node, s);
}
// tree.printAllWords();
for (String pre : prefix) {
int num = tree.search(node, pre);
System.out.println(pre + " " + num);
}
}
}

 

计算结果如下:

  经过以上方法的改进,可避免冗余节点的存在。将字典树的优势进一步放大。当然,也可以使用左儿子右兄弟的形式创建字典树。此方法后续介绍~

文件读入

 

package cn.edu.ujn.trieTreeMap;
 
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStreamReader;
 
public class Test {
public static void main(String[] args) {
/**
 * Problem Description 老师交给他很多单词(只有小写字母组成,不会有重复的单词出现),现在老师要他统计
 * 出以某个字符串为前缀的单词数量(单词本身也是自己的前缀).
 */
String[] strs = { "banana", "band", "bee", "absolute", "acm" };
String[] prefix = { "网易", "软件", "band", "abc" };
TrieTree tree = new TrieTree();
TrieNode node = new TrieNode();
        BufferedReader br = null;  
        try {  
            File file= new File("C://Users//SHQ//Desktop//Offer.txt");  
            //读取语料库words.txt  
            br = new BufferedReader(new InputStreamReader(new FileInputStream(file.getAbsolutePath()),"GBK"));  
            String word="";  
            while ((word = br.readLine()) != null) {  
            	tree.insert(node, word);
            }
        }catch (Exception e) {
// TODO: handle exception
        	e.printStackTrace();
}
/*for (String s : strs) {
tree.insert(node, s);
}*/
// tree.printAllWords();
for (String pre : prefix) {
int num = tree.search(node, pre);
System.out.println(pre + " " + num);
}
}
}

 

计算结果如下:

    Offer.txt文本内容如下:

    可知计算结果正确。而且出现了中文字符,对于数字的操作同理。而利用第一种方法就无法实现固定分配内存。只能使用动态分配机制。

美文美图

 

©️2020 CSDN 皮肤主题: 终极编程指南 设计师:CSDN官方博客 返回首页