Fork me on GitHub

数据结构-06| 字典树Trie| 并查集Disjoint Set

 

字典树Trie

1. 字典树的数据结构
2. 字典树的核心思想
3. 字典树的基本性质

1. 树Tree

 

 按层次打印一颗二叉树,

在树中深度优先搜索:ABDH I EJ  CFG

广度优先搜索:A BC DEFG HIJ

2. 二叉搜索树

 

 二叉搜索树是子树之间的关系,并不是儿子和父亲的关系。

任何一个节点它的左子树的所有节点都要小于这个根结点

                    它的右子树的所有节点都要大于根结点,且对于它的任何子树同样地以此类推,对于任何子树都满足这样的特性。

二叉搜索树是一个升序的序列,如果是中序遍历左根右: 123 5678 9 10 11 12 13 15

二叉搜索树主要解决的问题是查找的效率会更高,比如要查10,首先跟根结点比,10 > 9,左子树不用找了,一分为二,只需要找右子树即可,再比较13 > 10,左子树,11 > 10,继续左子树。

3. 字典树Trie

             

 

应用的场景:

搜索时词频的感应,由前缀来推后面的可能的词语。

搜索引擎的搜索关键词提示功能,为了方便快速输入,当在搜索引擎的搜索框中,输入要搜索的文字的某一部分的时候,搜索引擎就会自动弹出下拉框,里面是各种关键词提示。你可以直接从下拉框中选择你要

搜索的东西,而不用把所有内容都输入进去,一定程度上节省了我们的搜索时间。

像 Google、百度这样的搜索引擎,它们的关键词提示功能非常全面和精准,肯定做了很多优化,但万变不离其宗,底层基本的原理就是今天要讲的这种数据结构:Trie 树。

字典树不再是存储一个单词本身,而是把字符串拆成单个单个的字母,每个字母存在这个节点里边

3.1 基本结构

                      

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

它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高。

  节点本身不存任何单词,它只存它要去到下一个路径上面,这个路径代表的字符,每个节点依次进行。

比如ten这个单词,t -> te -> ten ,常用的字符放根结点上,根据输入的不同字符,开始分叉。Trie树不是一个二叉树而是一个多叉树。

根据走到不同地方,就开始分到不同的地方即所谓的分流,比如第一个字符是i(以i开始,后边所有可能是单词都是以i开头),如果后边路径是n,还是n ..就是inn 小酒馆,但这个节点它并不存储inn这个单词,它表这个地方是根了,且这个地方有一个单词的终止符,要看它代表哪个单词要从根节点捋下来,最后捋到这个节点它所走过的路径,任何节点最后它代表的单词就是走过的这条边

3.2 基本性质

1. 结点本身不存完整单词;

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

3. 每个结点的所有子结点路径代表的字符都不相同。

3.3 节点存储额外信息

        

 数字表示相应到这个结点所代表的单词,它统计的计数就放在这个地方,数字表这个单词出现的统计频次。按照统计的频次就可以给用户做相应的推荐。

3.4 节点的内部实现

上图只是abcdefg...,同时它还有大写的ABCDEFG...

 每个结点指向下一个节点的不同指针,这里它的存储不再是left和right来表示左右结点了,它直接用相应的字符来指向下一个结点,比如第一个字符是a,应该走到这个结点去,

如果是简单单词不分大小写,可以认为这里是26个分叉,从a一直分到z;如果是整个字符串它的ASCII域是255,即255分叉。上图可以看做是26分叉的一颗多叉树。

到最后如果是叶子节点,就指向空。

最大可能情况变成26叉树,它的空间相对来说是消耗比较大的,一层出去就是26.

它单词的长度即深度,它查询的次数即这个单词有多少个字符。比如单词to,它查t 和o,2次。

 

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

3.5 实现一棵 Trie 树? 

如何用代码来实现一个 Trie 树

Trie 树主要有两个操作,一个是将字符串集合构造成 Trie 树,即将字符串插入到 Trie 树的过程;

             另一个是在Trie树中查询一个字符串。

Trie树的存储:

Trie 树是一个多叉树。二叉树中,一个节点的左右子节点是通过两个指针来存储的。那对于多叉树来说,怎么存储一个节点的所有子节点的指针呢?

一种经典的存储方式,借助散列表的思想,通过一个下标与字符一一映射的数组,来存储子节点的指针。

假设字符串中只有从 a 到 z 这 26 个小写字母,在数组中下标为 0 的位置,存储指向子节点 a 的指针,下标为 1 的位置存储指向子节点 b 的指针,以此类推,下标为 25 的位 置,存储的是指向的子节点 z 的指针。如果某个字符的子节点不存在,就在对应下标的位置存储 null。

在 Trie 树中查找字符串的时候,就可以通过字符的 ASCII 码减去“a”的 ASCII 码迅速找到匹配的子节点的指针。比如,d 的ASCII 码减去 a的 ASCII 码就是 3,那子节点 d 的指针就存储在数组中下标为 3的位置中。

代码实现:

/**
 * Trie 树也叫“字典树”,是一种专门处理字符串匹配的树形结构,用来解决在一组字符串集合中快速查找某个字符串的问题。
 *
 */
public class Trie {
    private TrieNode root = new TrieNode('/'); // 存储无意义字符

    // 往 Trie 树中插入一个字符串
    public void insert(char[] text) {
        TrieNode p = root;
        for (int i = 0; i < text.length; ++i) {
            int index = text[i] - 'a';
            if (p.children[index] == null) {
                TrieNode newNode = new TrieNode(text[i]);
                p.children[index] = newNode;
            }
            p = p.children[index];
        }
        p.isEndingChar = true;
    }

    // 在 Trie 树中查找一个字符串
    public boolean find(char[] pattern) {
        TrieNode p = root;
        for (int i = 0; i < pattern.length; ++i) {
            int index = pattern[i] - 'a';
            if (p.children[index] == null) {
                return false; // 不存在 pattern
            }
            p = p.children[index];
        }
        return p.isEndingChar;
    }



    public class TrieNode {
        public char data;
        public TrieNode[] children = new TrieNode[26];
        public boolean isEndingChar = false;
        public TrieNode(char data) {
            this.data = data;
        }
    }


}
View Code

在 Trie 树中,查找某个字符串的时间复杂度是多少?

如果要在一组字符串中,频繁地查询某些字符串,用 Trie 树会非常高效。构建 Trie 树的过程, 需要扫描所有的字符串,时间复杂度是 O(n)(n 表示所有字符串的长度和)。

一旦构建成功,后续的查询操作会非常高效。

每次查询时,如果要查询的字符串长度是k,那只需要比对大约k 个节点,就能完成查询操作。跟原本那组字符串的长度和个数没有任何关系。所以说,构建好Trie 树后,在其中查找字符串的时间复杂度是 O(k),k 表示要查找的字符串的长度。

Trie树非常耗内存,是一种空间换时间的思路

Trie 树的实现用数组来存储一个节点的子节点的指针。如果字符串中包含从 a 到 z 这 26 个字符,那每个节点都要存储一个长度为 26 的数组,并且每个数组存储

一个 8 字节指针(或者是 4 字节,这个大小跟 CPU、操作系统、编译器等有关)。而且,即便一个节点只有很少的子节点,远小于 26 个,比如 3、4 个,也要维护一个长度为 26 的数组。
Trie 树的本质是避免重复存储一组字符串的相同前缀子串,但是现在每个字符(对应一个节点)的存储远远大于 1 个字节。按照上面举的例子,数组长度为 26,每个元

素是 8 字节,那每个节点就会额外需要 26*8=208 个字节。而且这还是只包含 26 个字符的情况。

如果字符串中不仅包含小写字母,还包含大写字母、数字、甚至是中文,那需要的存储空间就更多了。所以,也就是说,在某些情况下,Trie 树不一定会节省存储空间。在重复

的前缀并不多的情况下,Trie 树不但不能节省内存,还有可能会浪费更多的内存。

当然,Trie 树尽管有可能很浪费内存,但确实非常高效。那为了解决这个内存问题,是否有其他办法呢?

我们可以稍微牺牲一点查询的效率,将每个节点中的数组换成其他数据结构,来存储一个节点的子节点指针。比如有序数组、跳表、散列表、红黑树等。

假设用有序数组,数组中的指针按照所指向的子节点中的字符的大小顺序排列。查询的时候,可以通过二分查找的方法,快速查找到某个字符应该匹配的子节点的指针。

在往Trie 树中插入一个字符串的时候,为了维护数组中数据的有序性,就会稍微慢了点。替换成其他数据结构的思路是类似的。

实际上,Trie 树的变体有很多,都可以在一定程度上解决内存消耗的问题。比如,缩点优化,就是对只有一个子节点的节点,而且此节点不是一个串的结束节点,可以将此节点

与子节点合并。这样可以节省空间,但却增加了编码难度。
   

 

 

3.6 Trie 树与散列表、红黑树的比较

实际上,字符串的匹配问题,笼统上讲,其实就是数据的查找问题。对于支持动态数据高效操作
的数据结构,比如散列表、红黑树、跳表等等。实际上,这些数据结构也可以实现在一组字符串中查找字符串的功能。
散列表和红黑树,跟Trie 树比较,看看它们各自的优缺点和应用场景:
在刚刚讲的这个场景,在一组字符串中查找字符串,Trie 树实际上表现得并不好。它对要处理的字符串有及其严苛的要求。

  • 第一,字符串中包含的字符集不能太大。如果字符集太大,那存储空间可能就会浪费很多。即便可以优化,但也要付出牺牲查询、插入效率的代价。
  • 第二,要求字符串的前缀重合比较多,不然空间消耗会变大很多。
  • 第三,如果要用Trie 树解决问题,那我们就要自己从零开始实现一个 Trie 树,还要保证没有bug,这个在工程上是将简单问题复杂化,除非必须,一般不建议这样做。
  • 第四,我们知道,通过指针串起来的数据块是不连续的,而Trie 树中用到了指针,所以,对缓存并不友好,性能上会打个折扣。

综合这几点,针对在一组字符串中查找字符串的问题,我们在工程中,更倾向于用散列表或者红黑树。因为这两种数据结构,我们都不需要自己去实现,直接利用编程语言中提供的现成类库即可。

实际上,Trie 树只是不适合精确匹配查找,这种问题更适合用散列表或者红黑树来解决。Trie树比较适合的是查找前缀匹配的字符串,即搜索问题。

3.7 如何利用 Trie 树,实现搜索关键词的提示功能?

假设关键词库由用户的热门搜索关键词组成。我们将这个词库构建成一个 Trie树。当用户输入其中某个单词的时候,把这个词作为一个前缀子串在 Trie 树中匹配。
假设词库里只有hello、her、hi、how、so、see 这6 个关键词。当用户输入了字母h 的时候,我们就把以h 为前缀的 hello、her、hi、how 展示在搜索提示框内。

                            当用户继续键入字母e的时候,就把以he为前缀的hello、her展示在搜索提示框内,这就是搜索关键词提示的最基本的算法原理。 

  

这只是最基本的原理,如果再稍微深入一点,上面的解决办法遇到下面几个问题:

  • 刚刚的思路是针对英文的搜索关键词提示,对于更加复杂的中文来说,词库中的数据又该如何构建成Trie 树呢?
  • 如果词库中有很多关键词,在搜索提示的时候,用户输入关键词,作为前缀在Trie 树中可以匹配的关键词也有很多,如何选择展示哪些内容呢?
  • 像 Google 这样的搜索引擎,用户单词拼写错误的情况下,Google 还是可以使用正确的拼写来做关键词提示,这个又是怎么做到的呢?

实际上,Trie 树的这个应用可以扩展到更加广泛的一个应用上,就是自动输入补全,比如输入法自动补全功能、IDE 代码编辑器自动补全功能、浏览器网址输入的自动补全功能等等。

总结:

  • Trie 树是一种解决字符串快速匹配问题的数据结构。如果用来构建Trie 树的这一组字符串中,前缀重复的情况不是很多,那Trie 树这种数据结构总体上来讲是比较费内存的,是一种空间换时间的解决问题思路。
  • 尽管比较耗费内存,但是对内存不敏感或者内存消耗在接受范围内的情况下,在 Trie 树中做字符串匹配还是非常高效的,时间复杂度是 O(k),k 表示要匹配的字符串的长度。
  • 但是,Trie 树的优势并不在于,用它来做动态集合数据的查找,因为,这个工作完全可以用更加合适的散列表或者红黑树来替代。Trie 树最有优势的是查找前缀匹配的字符串,比如搜索引擎中的关键词提示功能这个场景,就比较适合用它来解决,也是 Trie 树比较经典的应用场景。

 

4. 并查集 Disjoint Set

适用场景:

  • 组团、配对问题(很快地判断这两个个体是不是在一个集合当中如你俩是不是朋友,如果是在社交网络里判断两个群体是不是一个群体以及快速地合并群组)
  • Group or not ?

如实现微信上的好友和所谓朋友圈的功能,以及分析这两个人是不是好友,

假设有0-n-1个人,快速地判断a和b到底是不是朋友,以及支持一些操作如把a变成b的朋友:

  用一个set,或者dic表示里边的人都是朋友,-----> 导致会建很多set,如两两是朋友,第三个不是他们的朋友,最后还要合并set...

 

• makeSet(s):建立一个新的并查集,其中包含 s 个单元素集合。

• unionSet(x, y):把元素 x 和元素 y 所在的集合合并,要求 x 和 y 所在的集合不相交,如果相交则不合并。

• find(x):找到元素 x 所在的集合的代表,该操作也可以用于判断两个元素是否位于同一个集合,只要将它们各自的代表比较一下就可以了。

 

   一开始每个元素拥有一个parent数组指向自己,表示它自己就是自己的集合

 

 查询,对任何一个元素,看它的parent,再看它的parent,一直往上,直到它的parent等于它自己的时候,说明找到了它的领头元素 即它的集合的代表元素,就表示这个集合是谁。

合并,找到这两个集合的领头元素,这里是a 和e,然后将parent[e] 指向a 或者将parent[a] 指向e, 要么把e的parent指向a 或者把a的parent指向e都行。 最后即把这两个合并。

 

  d的parent是c,c的parent是b,b的parent是a,那么我们可以直接把这条路上的所有元素它的parent都指向a,这样的操作还是和原来的表是一样的,但是它的查询时间会快很多。 原来要查询集合a的领头元素是谁,要往上走一步走两步走三步,压缩后只需要一步即可。

 

  
class UnionFind {
  private
int count = 0; private int[] parent; public UnionFind(int n) { count = n; parent = new int[n]; for (int i = 0; i < n; i++) { parent[i] = i; //初始化,所有并查集开始初始化; 一个指向指针的关系,和数组有异曲同工之妙 } } /* 给定任何一个p,如何找它的集合是谁; 即怎么找它集合和它集合的领头元素; 不断的往上去找,知道parent[i] = i时说明找到了它所在集合的领头元素 */ public int find(int p) { while (p != parent[p]) { parent[p] = parent[parent[p]]; p = parent[p]; } return p; }   /* 如何进行合并,调find找到它的p的集合所在的领头元素, */ public void union(int p, int q) { int rootP = find(p); int rootQ = find(q); if (rootP == rootQ) return; parent[rootP] = rootQ; count--; }
}

 

posted @ 2020-07-26 15:35  kris12  阅读(433)  评论(0编辑  收藏  举报
levels of contents