万字长文彻底搞懂二叉树

算法是面试考察的重点,数据结构是算法的根基。今天主要和大家探讨下数据结构中的二叉树,当然也不仅限于二叉树,还有其他类型的扩展。

1 基础知识

一棵树由称作跟的节点r以及0个或多个非空的树T1,T2, ...Tk组成,这些子树中每一颗的根都被来至根r的一条有向的边所连接。

深度: 对任意节点ni,ni的深度为从根到ni的唯一路径的长。因此,根的深度为0.
高: 从ni到一片树叶的最长路径的长。因此所有树叶的高都是0.一颗树的高等于它的根的高。

1.1 树的遍历

树有很多应用,流行的用法包括Unix在内的很多常用操作系统的目录结构。

遍历包括先序遍历(对节点的处理在它的诸儿子节点处理之前进行的)和后续遍历(在诸儿子节点处理之后进行)

2 二叉树

二叉树是一棵树,其中每个节点不能有多于两个的儿子。

遍历方法:

  • 先序:节点,左,右
  • 中序:左,节点,右
  • 后序:左,右,节点

满二叉树: 一颗高度为h,并且含有2^h-1个节点的二叉树称为满二叉树,即树的每一层都含有最多的节点。
完全二叉树: 设一个高度为h,有n个节点的二叉树,当且仅当其每一个节点都与高度为h的满二叉树中编号为1~n的节点一一对应时,称为完全二叉树。

struct TreeNode
{
    int val;
    TreeNode* left;   // left child
    TreeNode* right;  // right child
}

3 二叉查找树

使二叉树成为二叉查找树的性质是,对于树中的每个节点X,它的左子树中所有关键字值小于X的关键字值,而它的右子树中所有关键字值大于X的关键字值。

1 查找

注意对是否为空树进行判断,可以使用递归,使用的栈空间的量只是O(logN)。

2 查找最小值或最大值

查找最小值时,从根开始并且只要有左儿子就向左进行,终止点是最小的元素。查找最大值时与之相反。

3 插入

为了将X插入到树T中,可以像查找那样沿着树查找。如果找到X,可以什么也不做或者做一些更新。否则将X插入到遍历的路径上的最后一点上。

4 删除

分3种情况:

  • 节点是一片叶子节点:可以被立即删除;
  • 节点有一个儿子:则该节点可以在其父节点调整指针绕过该节点后被删除;

万字长文彻底搞懂二叉树_第1张图片

  • 节点有两个儿子:用其右子树中的最小的数据代替该节点的数据并递归地删除那个节点。

万字长文彻底搞懂二叉树_第2张图片

void remove(const int& x, BinaryNode* root)
{
    if (root == NULL)
        reutrn;
    else if (x < root->val)
        remove(x, root->left);
    else if (x > root->val)
        remove(x, root->right);
    else if(t->left != NULL && t->right != NULL)
    {
        root->val = findMin(root->right)->val;
        remove(root->val, root->right);
    }
    else
    {
        BinaryNode *oldNode = root;
        root = (root->left != NULL) ? root->left : root->right;
        delete oldNode;
    }
}

Java版本如下:

万字长文彻底搞懂二叉树_第3张图片

5 平均情形分析

一棵树的所有节点的深度的和称为内部路径长,二叉查找树的平均值为O(NlogN),因此任意节点期望深度为O(logN)。

4 AVL树

一颗AVL树是其每个节点的左子树和右子树的高度最多差1的二叉查找树。可以证明,一个AVL树的高度最多只比logN稍微多一点。AVL树也成为平衡二叉树。

因此,除去可能的插入外,所有树的操作都可以以时间O(logN)执行。注意,当进行插入操作时,我们需要更新通向根节点路径上的那些节点的所有平衡信息,而插入操作隐含着困难的原因在于,插入一个节点可能破坏AVL树的特性。

AVL树插入时可能出现的4种情况,把必须重新平衡的节点称为A,由于任意节点最多有两个儿子,因此高度不平衡时,A点的两颗子树的高度相差2,这种不平衡出现了下面4种情况:

  1. 对A的左儿子的左子树进行了一次插入;
  2. 对A的左儿子的右子树进行了一次插入;
    对A的右儿子的左子树进行了一次插入;
  3. 对A的右儿子的右子树进行了一次插入;

解决方案:对于1和4两种情况,插入发生在“外边”,只需通过对树的一次单旋转而完成调整;对于2和3两种情况,插入发生在“内部”,需通过对树的双旋转而完成调整(也就是先单旋转转换为插入发生在外边的情况,再使用单旋转即可);

4.1 单旋转

  • 情况1的修正(右旋)
    万字长文彻底搞懂二叉树_第4张图片
    万字长文彻底搞懂二叉树_第5张图片
  • 情况4的修正(左旋)
    万字长文彻底搞懂二叉树_第6张图片
    万字长文彻底搞懂二叉树_第7张图片

4.2 双旋转

  • 情况2的修正(左右双旋转)
    万字长文彻底搞懂二叉树_第8张图片
  • 情况3的修正(右左双旋转)
    万字长文彻底搞懂二叉树_第9张图片
    万字长文彻底搞懂二叉树_第10张图片

4.3 AVL树实现

struct AvlNode
{
    int element;
    AvlNode *left;
    AvlNode *right;
    int height;
    AvlNode(int theElement, AvlNode *lt, AvlNode *rt, int h = 0)
    : element(theElement), left(lt), right(rt), height(h) {}
}

int height (AvlNode *root) const
{
    return root == NULL ? -1 : root->height;
}

/* Insert node */
void insert(const int& x, AvlNode *root)
{
    if (root == NULL)
        root = new AvlNode(x, NULL, NULL);
    else if (x < root->element)
    {
        insert(x, root->left);
        // 需要旋转
        if (height(root->left) - height(root->right) == 2)
        {
            if (x < root->left->element)
                rotateWithLeftChild(root);
            else
                doubleWithLeftChild(root);
        }
    }
    else if (root->element < x)
    {
        insert(x, root->right);
        if (height(root->right) - height(root->left) == 2)
        {
            if (root->right->element < x)
                rotateWithRightChild(root);
            else
                doubleWithRightChild(root);        }
    }
    else
        ;  // duplicate

    root->height = max(height(root->left), height(root->right)) + 1;
}

万字长文彻底搞懂二叉树_第11张图片

/* Rotate binary tree with left child */
void rotateWithLeftChild(AvlNode* k2)
{
    AvlNode* k1 = k2->left;
    k2->left = k1->right;
    k1->right = k2;
    k2->height = max(height(k2->left), height(k2->right)) + 1;
    k1->height = max(height(k1->left), k2->height) + 1;
    k2 = k1;
}

万字长文彻底搞懂二叉树_第12张图片

/* Rotate binary tree with right child */
void rotateWithRightChild(AvlNode* k1)
{
    AvlNode* k2 = k1->right;
    k1->right = k2->left;
    k2->left = k1;
    k1->height = max(height(k1->left), height(k1->right)) + 1;
    k2->height = max(height(k2->right), k1->height) + 1;
    k1 = k2;
}

万字长文彻底搞懂二叉树_第13张图片

/* Double rotate binary tree with left child */
void rotateWithRightChild(AvlNode* k3)
{
    rotateWithRightChild(k3->left);
    rotateWithLeftChild(k3);
}

万字长文彻底搞懂二叉树_第14张图片

/* Double rotate binary tree with right child */
void doubleWithLeftChild(AvlNode* k1)
{
    rotateWithLeftChild(k1->left);
    rotateWithRightChild(k1);
}

5 高级数据结构

大规模数据存储中,实现索引查询这样一个实际背景下,树节点存储的元素数量是有限的(如果元素数量非常多的话,查找就退化成节点内部的线性查找了),这样导致二叉查找树结构由于树的深度过大而造成磁盘I/O读写过于频繁,进而导致查询效率低下,因此我们该想办法降低树的深度,从而减少磁盘查找存取的次数。 一个基本的想法就是:采用多叉树结构(由于树节点元素数量是有限的,自然该节点的子树数量也就是有限的)。

磁盘的构造:

磁盘是一个扁平的圆盘(与电唱机的唱片类似)。盘面上有许多称为磁道的圆圈,数据就记录在这些磁道上。磁盘可以是单片的,也可以是由若干盘片组成的盘组,每一盘片上有两个面。如下图所示的6片盘组为例,除去最顶端和最底端的外侧面不存储数据之外,一共有10个面可以用来保存信息。

万字长文彻底搞懂二叉树_第15张图片

磁盘的读/写原理和效率:

磁盘上数据必须用一个三维地址唯一标示:柱面号、盘面号、块号(磁道上的盘块)。

读/写磁盘上某一指定数据需要下面3个步骤:

  • 首先移动臂根据柱面号使磁头移动到所需要的柱面上,这一过程被称为定位或查找
  • 如上图所示的6盘组示意图中,所有磁头都定位到了10个盘面的10条磁道上(磁头都是双向的)。这时根据盘面号来确定指定盘面上的磁道。
  • 盘面确定以后,盘片开始旋转,将指定块号的磁道段移动至磁头下。

经过上面三个步骤,指定数据的存储位置就被找到。这时就可以开始读/写操作了。

访问某一具体信息,由3部分时间组成:

  • 查找时间(seek time) Ts: 完成上述步骤(1)所需要的时间。这部分时间代价最高,最大可达到0.1s左右。
  • 等待时间(latency time) Tl: 完成上述步骤(3)所需要的时间。由于盘片绕主轴旋转速度很快,一般为7200转/分(电脑硬盘的性能指标之一, 家用的普通硬盘的转速一般有5400rpm(笔记本)、7200rpm几种)。因此一般旋转一圈大约0.0083s。
  • 传输时间(transmission time) Tt: 数据通过系统总线传送到内存的时间,一般传输一个字节(byte)大概0.02us=2*10^(-8)s

磁盘读取数据是以盘块(block)为基本单位的。位于同一盘块中的所有数据都能被一次性全部读取出来。而磁盘IO代价主要花费在查找时间Ts上。因此我们应该尽量将相关信息存放在同一盘块,同一磁道中。或者至少放在同一柱面或相邻柱面上,以求在读/写信息时尽量减少磁头来回移动的次数,避免过多的查找时间Ts。

总结:在大规模数据存储方面,大量数据存储在外存磁盘中,而在外存磁盘中读取/写入块(block)中某数据时,首先需要定位到磁盘中的某块,如何有效地查找磁盘中的数据,需要一种合理高效的外存数据结构,就是下面所要重点阐述的B-tree结构

5.1 B树

M阶B树满足下列条件:

  • 根节点不是叶子节点,则至少有2个子节点
  • 除根节点和叶子节点外,每个节点的子节点个数在M/2(向上取整)和M之间
  • 节点的key值以升序排列,位于N-1和N key的子节点的值位于N-1和N key对应的Value之间
  • 所有叶子节点出现在同一层,叶子结点不包含任何关键字信息(可以看做是外部结点或查询失败的结点,指向这些结点的指针都为null) [叶子节点只是没有孩子和指向孩子的指针,这些节点也存在,也有元素。其实,关键是把什么当做叶子结点,因为如红黑树中,每一个NULL指针即当做叶子结点,只是没画出来而已]

下面是B树的简单例子:

万字长文彻底搞懂二叉树_第16张图片

B树相关操作:

  • 插入操作

如果插入节点后树的性质不被改变,可以直接进行插入。如果待插入的节点已满,由于一片树叶只能容纳两个或三个关键字,解决办法,先插入到指定的叶子,如果该节点有四个儿子,我们将这个节点分成两个节点,每个节点两个儿子即可。而这样分裂该节点将给它的父节点带来一个新问题(该父节点就有4个儿子),但是我们可以在通向根的路径上一直这么分下去,直到或者到达根节点,或者找到一个节点,这个节点只有两个儿子。

  • 删除操作

删除操作可以看成是插入操作的逆过程;先找到需要被删除的关键字并将其除去而完成删除操作。如果这个关键字是一个节点仅有的两个关键字中的一个,那么将他出去后就只剩一个关键字了。此时通过把这个节点与它的一个兄弟合并起来进行调整。如果这个兄弟已有3个关键字,那么可以取出一个使得两个节点各有两个关键字,如果这个兄弟只有两个关键字,那么就将这两个节点合并成一个具有3个关键字的节点。现在这个节点的父亲则失去了一个儿子,因此我们还需要向上检查直到顶部。如果根节点失去了它的第二个儿子,那么这个根也要删除,而树则减少了一层。

下面以4 阶 B 树为例,来说明B树的创建过程:

首先必须明确,对于4阶B树,每个节点最多有 4 个子树、最少有 2 个子树,由于关键字的数目比字数数目少1,所以对应的最多有3个关键字,最少有1个关键字。

  1. 添加 6,第一个节点
  2. 添加 10,根节点最多能放三个关键字,按顺序添到根节点中
    添加 4,还能放到根节点中
  3. 添加 14,这时超出了关键字最大限制,需要把 14 添加为子树,由于字树的数目比关键字

万字长文彻底搞懂二叉树_第17张图片

这个拆的过程比较复杂,首先要确定根节点保留几个关键字,由于“非叶子节点的根节点至少有 2 棵子树”的限制,那就至少需要两个关键字分出去,又因为“子树数是关键字数+1”,如果根节点有两个关键字,就得有三个子树,无法满足,所以只好把除 6 以外的三个关键字都拆为子树。
  1. 添加 5,放到 4 所在的子树上
  2. 添加 11,放在 10 和 14 所在的右子树上
  3. 添加 15,按大小应该放到 10、11 和 14 所在的子树上,但因为超过了关键字数限制,又得拆分

万字长文彻底搞懂二叉树_第18张图片

因为“根节点必须都在同一层”,因此我们不能给现有的左右子树添加子树,只能添加给 6 了;但是如果 6 有三个子树,就必须得有 2 个关键字,提升谁做关键字好呢,这得看谁做 6 中间的子树,因为右子树的所有关键字都得比父节点的关键字大,所以这个提升的关键字只能比未来右子树中的关键字都小,那就只有 10 和 11 可以考虑了。提升 10 吧,没有比它小的做子树,那就只能提升 11 了:

万字长文彻底搞懂二叉树_第19张图片

继续添加其他元素也是类似的操作。

使用场景

文件系统和数据库系统中常用的B/B+ 树,他通过对每个节点存储个数的扩展,使得对连续的数据能够进行较快的定位和访问,能够有效减少查找时间,提高存储的空间局部性从而减少IO操作。他广泛用于文件系统及数据库中,如:

  • Windows:HPFS 文件系统
  • Mac:HFS,HFS+ 文件系统
  • Linux:ResiserFS,XFS,Ext3FS,JFS 文件系统
  • 数据库:ORACLE,MYSQL,SQLSERVER 等中

文件查找:
关于内存中的文件名查找,由于是一个有序表结构,可以利用折半查找提高效率。至于IO操作是影响整个B树查找效率的决定因素。当然,如果我们使用平衡二叉树的磁盘存储结构来进行查找,磁盘4次,最多5次,而且文件越多,B树比平衡二叉树所用的磁盘IO操作次数将越少,效率也越高。

B树中的每个结点根据实际情况可以包含大量的关键字信息和分支(当然是不能超过磁盘块的大小,根据磁盘驱动(disk drives)的不同,一般块的大小在1k~4k左右);这样树的深度降低了,这就意味着查找一个元素只要很少结点从外存磁盘中读入内存,很快访问到要查找的数据。

树的高度:

当B树包含N个关键字时,B树的最大高度为l-1(因为计算B树高度时,叶结点所在层不计算在内),即:l - 1 = log┌m/2┐((N+1)/2 )+1

证明:
若B树某一非叶子节点包含N个关键字,则此非叶子节点含有N+1个孩子结点,而所有的叶子结点都在第I层,我们可以得出:
1. 因为根至少有两个孩子,因此第2层至少有两个结点。
2. 除根和叶子外,其它结点至少有┌m/2┐个孩子,
* 因此在第3层至少有2┌m/2┐个结点,
4. 在第4层至少有2*(┌m/2┐^2)个结点,
5. 在第 I 层至少有2(┌m/2┐^(l-2) )个结点,于是有: N+1 ≥ 2┌m/2┐I-2;
6. 考虑第L层的结点个数为N+1,那么2*(┌m/2┐^(l-2))≤N+1,也就是L层的最少结点数刚好达到N+1个,即: I≤ log┌m/2┐((N+1)/2 )+2;

复杂度分析:
对于一颗节点为N度为M的子树,查找和插入需要log(M-1)N ~ log(M/2)N次比较。这个很好证明,对于度为M的B树,每一个节点的子节点个数为M/2 到 M-1之间,所以树的高度在log(M-1)N至log(M/2)N之间。

5.2 B+树

B+树是B树的一个升级版,相对于B树来说B+树更充分的利用了节点的空间,让查询速度更加稳定,其速度完全接近于二分法查找。为什么说B+树查找的效率要比B树更高、更稳定;我们先看看两者的区别:

  • B+跟B树不同,B+树的非叶子节点不保存关键字记录的指针,只进行数据索引,这样使得B+树每个非叶子节点所能保存的关键字大大增加;
  • B+树叶子节点保存了父节点的所有关键字记录的指针,所有数据地址必须要到叶子节点才能获取到。 所以每次数据查询的次数都一样;
  • B+树叶子节点的关键字从小到大有序排列,左边结尾数据都会保存右边节点开始数据的指针;
  • 非叶子节点的子节点数=关键字数(来源百度百科)(根据各种资料 这里有两种算法的实现方式,另一种为非叶节点的关键字数=子节点数-1(来源维基百科),虽然他们数据排列结构不一样,但其原理还是一样的Mysql 的B+树是用第一种方式实现)。

以下来自百度百科:

万字长文彻底搞懂二叉树_第20张图片

以下来自维基百科:

万字长文彻底搞懂二叉树_第21张图片

特点:

1、B+树的层级更少:相较于B树B+每个非叶子节点存储的关键字数更多,树的层级更少所以查询数据更快;

2、B+树查询速度更稳定:B+所有关键字数据地址都存在叶子节点上,所以每次查找的次数都相同所以查询速度要比B树更稳定;

3、B+树天然具备排序功能:B+树所有的叶子节点数据构成了一个有序链表,在查询大小区间的数据时候更方便,数据紧密性很高,缓存的命中率也会比B树高。

4、B+树全节点遍历更快:B+树遍历整棵树只需要遍历所有的叶子节点即可,而不需要像B树一样需要对每一层进行遍历,这有利于数据库做全表扫描。

B树相对于B+树的优点是,如果经常访问的数据离根节点很近,而B树的非叶子节点本身存有关键字其数据的地址,所以这种数据检索的时候会要比B+树快。

用途: B树和B+广泛应用于文件存储系统以及数据库系统中。MySQL就普遍使用B+Tree实现其索引结构。索引(Index)是帮助MySQL高效获取数据的数据结构。索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘I/O消耗,相对于内存存取,I/O存取的消耗要高几个数量级,所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘I/O操作次数的渐进复杂度。换句话说,索引的结构组织要尽量减少查找过程中磁盘I/O的存取次数。

B+tree比B树更适合实际应用中操作系统的文件索引:

  • B+-tree的磁盘读写代价更低
    B+-tree的内部结点并没有指向关键字具体信息的指针。因此其内部结点相对B树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说IO读写次数也就降低了。
    举个例子,假设磁盘中的一个盘块容纳16bytes,而一个关键字2bytes,一个关键字具体信息指针2bytes。一棵9阶B-tree(一个结点最多8个关键字)的内部结点需要2个盘快。而B+ 树内部结点只需要1个盘快。当需要把内部结点读入内存中的时候,B 树就比B+ 树多一次盘块查找时间(在磁盘中就是盘片旋转的时间)。
  • B+-tree的查询效率更加稳定
    由于非终结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。

数据库索引采用B+树的主要原因是: B树在提高了磁盘IO性能的同时并没有解决元素遍历的效率低下的问题。正是为了解决这个问题,B+树应运而生。B+树只要遍历叶子节点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,而B树不支持这样的操作(或者说效率太低)。

5.3 B*树

B*树是B+tree的变体,在B+树的基础上(所有的叶子结点中包含了全部关键字的信息,及指向含有这些关键字记录的指针),

  • B树中非根和非叶子结点再增加指向兄弟的指针;B树定义了非叶子结点关键字个数至少为(2/3) M,即块的最低使用率为2/3(代替B+树的1/2),B树分配新结点的概率比B+树要低,空间使用率更高。
  • B+树节点满时就会分裂,而B*树节点满时会检查兄弟节点是否满(因为每个节点都有指向兄弟的指针),如果兄弟节点未满则向兄弟节点转移关键字,如果兄弟节点已满,则从当前节点和兄弟节点各拿出1/3的数据创建一个新的节点出来;

万字长文彻底搞懂二叉树_第22张图片

1、相同思想和策略

从平衡二叉树、B树、B+树、B*树总体来看它们的贯彻的思想是相同的,都是采用二分法和数据平衡策略来提升查找数据的速度;

2、不同的方式的磁盘空间利用

不同点是他们一个一个在演变的过程中通过IO从磁盘读取数据的原理进行一步步的演变,每一次演变都是为了让节点的空间更合理的运用起来,从而使树的层级减少达到快速查找数据的目的;

5.4 红黑树 - 算法第4版

红黑树的主要像是对2-3查找树进行编码,尤其是对2-3查找树中的3-nodes节点添加额外的信息。红黑树中将节点之间的链接分为两种不同类型,红色链接用来链接两个2-nodes节点来表示一个3-nodes节点,黑色链接用来链接普通的2-3节点。特别的,使用红色链接的两个2-nodes来表示一个3-nodes节点,并且向左倾斜,即一个2-node是另一个2-node的左子节点。这种做法的好处是查找的时候不用做任何修改,和普通的二叉查找树相同。

根据以上描述,红黑树定义如下:
红黑树是一种具有红色和黑色链接的二叉查找树,同时满足:

  • 红色节点向左倾斜
  • 一个节点不可能有两个红色链接
  • 该树是完美黑色平衡的,即任意空链接到根节点的路径上的黑色链接的个数都相同。

万字长文彻底搞懂二叉树_第23张图片

下图可以看到红黑树其实是2-3树的另外一种表现形式:如果我们将红色的连线水平绘制,那么它连接的两个2-node节点就是2-3树中的一个3-node节点了。

万字长文彻底搞懂二叉树_第24张图片

相关操作:

  • 查找
    红黑树是一种特殊的二叉查找树,他的查找方法也和二叉查找树一样,不需要做太多更改。但是由于红黑树比一般的二叉查找树具有更好的平衡,所以查找起来更快。
  • 插入
    万字长文彻底搞懂二叉树_第25张图片
    如上图所示,标准的二叉查找树遍历即可。新插入的节点标记为红色,所有情况根据需求进行左旋或者右旋。

性质

  • 在最坏的情况下,红黑树的高度不超过2lgN
  • 红黑树的平均高度大约为lgN

下图是红黑树在各种情况下的时间复杂度,可以看出红黑树是2-3查找树的一种实现,它能保证最坏情况下仍然具有对数的时间复杂度。
万字长文彻底搞懂二叉树_第26张图片

应用
红黑树这种数据结构应用十分广泛,在多种编程语言中被用作符号表的实现

  • Java中的java.util.TreeMap,java.util.TreeSet
  • C++ STL中的:map,multimap,multiset
  • .NET中的:SortedDictionary,SortedSet 等

至于为什么不选择AVL树来实现这些数据结构,主要有以下几点原因:

1. 如果插入一个node引起了树的不平衡,AVL和RB-Tree都是最多只需要2次旋转操作,即两者都是O(1);但是在删除node引起树的不平衡时,最坏情况下,AVL需要维护从被删node到root这条路径上所有node的平衡性,因此需要旋转的量级O(logN),而RB-Tree最多只需3次旋转,只需要O(1)的复杂度。
2. AVL的结构相较RB-Tree来说更为平衡,在插入和删除node更容易引起Tree的unbalance,因此在大量数据需要插入或者删除时,AVL需要rebalance的频率会更高。因此,RB-Tree在需要大量插入和删除node的场景下,效率更高。自然,由于AVL高度平衡,因此AVL的search效率更高。
** map的实现只是折衷了两者在search、insert以及delete下的效率。总体来说,RB-tree的统计性能是高于AVL的。

更详细参考:浅谈算法和数据结构: 九 平衡查找树之红黑树

5.5 红黑树 - 标准版

红黑树是一种含有红黑结点并能自平衡的二叉查找树。它必须满足下面性质:

  • 性质1:每个节点要么是黑色,要么是红色。
  • 性质2:根节点是黑色。
  • 性质3:每个叶子节点(NIL)是黑色。
  • 性质4:每个红色结点的两个子结点一定都是黑色。
  • 性质5:任意一结点到每个叶子结点的路径都包含数量相同的黑结点。

万字长文彻底搞懂二叉树_第27张图片

前面讲到红黑树能自平衡,主要靠三种操作:左旋、右旋和变色。

  • 左旋:以某个结点作为支点(旋转结点),其右子结点变为旋转结点的父结点,右子结点的左子结点变为旋转结点的右子结点,左子结点保持不变。

万字长文彻底搞懂二叉树_第28张图片

  • 右旋:以某个结点作为支点(旋转结点),其左子结点变为旋转结点的父结点,左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。

万字长文彻底搞懂二叉树_第29张图片

  • 变色:结点的颜色由红变黑或由黑变红。

下面针对红黑树的插入和删除进行分析:

红黑树的插入

Case 1,红黑树为空树:直接把插入结点作为根结点就行,但注意,根据红黑树性质2:根节点是黑色。

Case 2,插入结点的key已经存在:那么把插入结点设置为将要替代结点的颜色,再把结点的值更新就完成插入。

Case 3,插入结点的父结点为黑结点:由于插入的结点是红色的,并不会影响红黑树的平衡,而且父结点为黑色,直接插入即可,无需做自平衡。

Case 4,插入结点的父结点为红结点 根据红黑树性质2,根结点是黑色。如果插入的父结点为红结点,那么该父结点不可能为根结点,所以插入结点总是存在祖父结点。这点很重要,因为后续的旋转操作肯定需要祖父结点的参与。

具体又分为如下几种情况:

1) 叔叔结点存在并且为红结点,

万字长文彻底搞懂二叉树_第30张图片

2)插入结点位于父结点的外侧,需要进行单次旋转。

万字长文彻底搞懂二叉树_第31张图片

万字长文彻底搞懂二叉树_第32张图片

3)插入结点位于父结点的内侧,需要进行两次旋转。

万字长文彻底搞懂二叉树_第33张图片

万字长文彻底搞懂二叉树_第34张图片

红黑树的删除

首先利用右子树中的最小结点替代被删除的结点,同时循环删除替代的结点。删除结点后,为了保证红黑树的平衡, 可能出现如下几种需要调节的情况:

  • Case 1:被删除的结点是红色,直接删除;
  • Case 2:如果root结点是DB(Double Black, 删除结点的颜色与替代结点的颜色均为Black),可以直接将DB改为正常结点;
  • Case 3:如果DB结点的兄弟结点是黑色,并且兄弟结点的子结点均为黑色。
处理:移除DB;将父结点改为黑色,如果父结点本来就为黑色,则父结点变为DB结点;将兄弟结点改为红色。操作完成后,如果仍然存在DB,则依据其他Case调整。

万字长文彻底搞懂二叉树_第35张图片

在下面的例子中,就出现了这种情况,第一次调整之后, 20变成了DB;因此需要进一步调节,最后5变成红色,root结点10变成DB,根据Case 2的规则,直接将Root的DB去掉,改为正常结点即可。

万字长文彻底搞懂二叉树_第36张图片

  • Case 4: DB的兄弟结点是红色。
操作:互换父结点和兄弟结点的颜色;将父结点朝DB的方向旋转。如此之后,如需要可以依据其他Case调整。
  • Case 5: DB结点的兄弟结点是黑色,兄弟结点的子结点中,远离DB结点的子结点是黑色,靠近DB结点的子结点是红色。
操作:互换DB的兄弟结点以及兄弟结点中靠近DB的子结点的颜色;将兄弟结点朝DB的反方向旋转。如需要参考其他规则调整。
  • Case 6: DB结点的兄弟结点是黑色,兄弟结点的子结点中,远离DB结点的子结点是红色。
操作:互换父结点和兄弟结点的颜色;朝DB的方向旋转父结点;将DB改为正常结点;将原来兄弟结点中的红色子结点变为黑色。

参考:

6 Trie树

Trie树,又叫字典树、前缀树(Prefix Tree)、单词查找树或键树,是一种多叉树结构,如下图所示:
万字长文彻底搞懂二叉树_第37张图片
上图是一棵Trie树,表示了关键字集合{“a”, “to”, “tea”, “ted”, “ten”, “i”, “in”, “inn”} 。从上图可以归纳出Trie树的基本性质:

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

通常在实现的时候,会在节点结构中设置一个标志,用来标记该结点处是否构成一个单词(关键字)。可以看出,Trie树的关键字一般都是字符串,而且Trie树把每个关键字保存在一条路径上,而不是一个结点中。另外,两个有公共前缀的关键字,在Trie树中前缀部分的路径相同,所以Trie树又叫做前缀树(Prefix Tree)。

优点:

  • 插入和查询的效率很高,都为O(m),其中 m是待插入/查询的字符串的长度。

    关于查询,会有人说 hash 表时间复杂度是O(1)不是更快?但是,哈希搜索的效率通常取决于 hash 函数的好坏,若一个坏的 hash 函数导致很多的冲突,效率并不一定比Trie树高。Trie树中不同的关键字不会产生冲突。
  • Trie树只有在允许一个关键字关联多个值的情况下才有类似hash碰撞发生。
  • Trie树不用求 hash 值,对短字符串有更快的速度。通常,求hash值也是需要遍历字符串的。
  • Trie树可以对关键字按字典序排序。

缺点:

  • 当 hash 函数很好时,Trie树的查找效率会低于哈希搜索。
  • 空间消耗比较大。
  • Trie的核心思想是空间换时间,利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。
const int ALPHABET_SIZE = 26;
struct trieNode
{
    int count;  // 记录每个节点代表的单词个数
    trieNode* children[ALPHABET_SIZE];
};

trieNode* createTrieNode()
{
    trieNode* pNode = new trieNode();
    pNode->count = 0;
    for (int i = 0; i < ALPHABET_SIZE; i++)
    {
        pNode->children[i] = NULL;
    }
    return pNode;
}

void trieInsert(trieNode* root, char* key)
{
    trieNode* node = root;
    char* p = key;
    while (*p)
    {
        if (node->children[*p-'a'] == NULL)
        {
            node->children[*p-'a'] = createTrieNode();
        }
        node = node->children[*p-'a'];
        ++p;
    }
    node->count += 1;
}

/*
查询:不存在返回0,存在返回出现的次数
*/
int trieSearch(trieNode* root, char* key)
{
    trieNode* node = root;
    char* p = key;
    while (*p && node != NULL)
    {
        node = node->children[*p-'a'];
        ++p;    
    }
    if (node == NULL)
        return 0;
    else
        return node->count;
}

拓展:后缀树

后缀树(Suffix tree)是一种数据结构,能快速解决很多关于字符串的问题。
后缀,顾名思义,就是后面尾巴的意思。比如说给定一长度为n的字符串S=S1S2..Si..Sn,和整数i,1 <= i <= n,子串SiSi+1...Sn便都是字符串S的后缀。
以字符串S=XMADAMYX为例,它的长度为8,所以S[1..8], S[2..8], ... , S[8..8]都算S的后缀,我们一般还把空字串也算成后缀。这样,我们一共有如下后缀。对于后缀S[i..n],我们说这项后缀起始于i。

S[1..8], XMADAMYX, 也就是字符串本身,起始位置为1
S[2..8], MADAMYX,起始位置为2
S[.8], ADAMYX,起始位置为3
S[4..8], DAMYX,起始位置为4
S[5..8], AMYX,起始位置为5
S[6..8], MYX,起始位置为6
S[7..8], YX,起始位置为7
S[8..8], X,起始位置为8
空字串,记为$。

而后缀树,就是包含一则字符串所有后缀的压缩Trie。把上面的后缀加入Trie后,我们得到下面的结构:
万字长文彻底搞懂二叉树_第38张图片

应用:常用来查找在串S中查询字串P是否存在

7 Huffman树

树的带权路径长度:指树中所有叶子节点到根节点的路径长度与该叶子节点权值的乘积之和,如果在一棵二叉树中共有n个叶子节点,用Wi表示第i个叶子节点的权值,Li表示第i个也叶子节点到根节点的路径长度,则该二叉树的带权路径长度 WPL=W1L1 + W2L2 + ... Wn*Ln。

赫夫曼树(Huffman Tree),又称最优二叉树,是一类带权路径长度最短的树。假设有n个权值{w1,w2,...,wn},如果构造一棵有n个叶子节点的二叉树,而这n个叶子节点的权值是{w1,w2,...,wn},则所构造出的带权路径长度最小的二叉树就被称为赫夫曼树。

根据节点的个数以及权值的不同,赫夫曼树的形状也各不相同,赫夫曼树具有如下特性:

  • 对于同一组权值,所能得到的赫夫曼树不一定是唯一的。
  • 赫夫曼树的左右子树可以互换,因为这并不影响树的带权路径长度。
  • 带权值的节点都是叶子节点,不带权值的节点都是某棵子二叉树的根节点。
  • 权值越大的节点越靠近赫夫曼树的根节点,权值越小的节点越远离赫夫曼树的根节点。
  • 赫夫曼树中只有叶子节点和度为2的节点,没有度为1的节点。
  • 一棵有n个叶子节点的赫夫曼树共有2n-1个节点。

Huffman树的构建过程:

1. 将给定的n个权值看做n棵只有根节点(无左右孩子)的二叉树,组成一个集合HT,每棵树的权值为该节点的权值。
2. 从集合HT中选出**2棵权值最小的二叉树**,组成一棵新的二叉树,**其权值为这2棵二叉树的权值之和**。
 将步骤2中选出的2棵二叉树从集合HT中删去,同时将步骤2中新得到的二叉树加入到集合HT中。
4. 重复步骤2和步骤3,直到集合HT中只含一棵树,这棵树便是赫夫曼树。

举例如下:

构建出的Huffman树的可能情况有如下两种:

万字长文彻底搞懂二叉树_第39张图片

Huffman编码
赫夫曼树的应用十分广泛,比如众所周知的在通信电文中的应用。在等传送电文时,我们希望电文的总长尽可能短,因此可以对每个字符设计长度不等的编码,让电文中出现较多的字符采用尽可能短的编码。为了保证在译码时不出现歧义,我们可以采取如下图所示的编码方式:
万字长文彻底搞懂二叉树_第40张图片
对应的Huffman编码:5:11;4:10;3:00;2:011;1:010;上面构建的第二幅Huffman树的编码类似;

8 LSM树

B+树最大的性能问题是会产生大量的随机IO,随着新数据的插入,叶子节点会慢慢分裂,逻辑上连续的叶子节点在物理上往往不连续,甚至分离的很远,但做范围查询时,会产生大量读随机IO。

对于大量的随机写也一样,举一个插入key跨度很大的例子,如7->1000->3->2000 ... 新插入的数据存储在磁盘上相隔很远,会产生大量的随机写IO.

为了克服B+树的弱点,HBase引入了LSM树的概念,即Log-Structured Merge-Trees。

为了更好的说明LSM树的原理,下面举个比较极端的例子:

现在假设有1000个节点的随机key,对于磁盘来说,肯定是把这1000个节点顺序写入磁盘最快,但是这样一来,读就悲剧了,因为key在磁盘中完全无序,每次读取都要全扫描;

那么,为了让读性能尽量高,数据在磁盘中必须得有序,这就是B+树的原理,但是写就悲剧了,因为会产生大量的随机IO,磁盘寻道速度跟不上。

LSM树本质上就是在读写之间取得平衡,和B+树相比,它牺牲了部分读性能,用来大幅提高写性能。

它的原理是把一颗大树拆分成N棵小树, 它首先写入到内存中(内存没有寻道速度的问题,随机写的性能得到大幅提升),在内存中构建一颗有序小树,随着小树越来越大,内存的小树会flush到磁盘上。当读时,由于不知道数据在哪棵小树上,因此必须遍历所有的小树,但在每颗小树内部数据是有序的。

万字长文彻底搞懂二叉树_第41张图片

以上就是LSM树最本质的原理,有了原理,再看具体的技术就很简单了。

1)首先说说为什么要有WAL(Write Ahead Log),很简单,因为数据是先写到内存中,如果断电,内存中的数据会丢失,因此为了保护内存中的数据,需要在磁盘上先记录logfile,当内存中的数据flush到磁盘上时,就可以抛弃相应的Logfile。

2)什么是memstore, storefile?很简单,上面说过,LSM树就是一堆小树,在内存中的小树即memstore,每次flush,内存中的memstore变成磁盘上一个新的storefile。

3)为什么会有compact?很简单,随着小树越来越多,读的性能会越来越差,因此需要在适当的时候,对磁盘中的小树进行merge,多棵小树变成一颗大树。

关于LSM Tree,对于最简单的二层LSM Tree而言,内存中的数据和磁盘中的数据merge操作,如下图:

万字长文彻底搞懂二叉树_第42张图片

LSM Tree弄了很多个小的有序结构,比如每m个数据,在内存里排序一次,下面100个数据,再排序一次……这样依次做下去,就可以获得N/m个有序的小的有序结构。

在查询的时候,因为不知道这个数据到底是在哪里,所以就从最新的一个小的有序结构里做二分查找,找得到就返回,找不到就继续找下一个小有序结构,一直到找到为止。

很容易可以看出,这样的模式,读取的时间复杂度是(N/m)*log2N 。读取效率是会下降的。

LSM Tree优化方式:

a、Bloom filter: 就是个带随即概率的bitmap,可以快速的告诉你,某一个小的有序结构里有没有指定的那个数据的。于是就可以不用二分查找,而只需简单的计算几次就能知道数据是否在某个小集合里啦。效率得到了提升,但付出的是空间代价。

b、compact:小树合并为大树:因为小树他性能有问题,所以要有个进程不断地将小树合并到大树上,这样大部分的老数据查询也可以直接使用log2N的方式找到,不需要再进行(N/m)*log2n的查询了


欢迎关注公众号【码老思】,获取最通俗易懂的原创技术干货。

你可能感兴趣的:(java)