树形数据结构总结二(AVL,2-3树,红黑树,B树,B+树)

9,455 阅读13分钟

AVL

AVL(平衡二叉树),它也是一种二分搜索树。它的特点是每个节点的左右子树之差不超过1。在某种特殊的情况下,普通的二分搜索树可能退化为链表,例如加入的元素顺序为1,2,3,4,5。这个时候查询的效率会从O(logn)退化为O(n)。而我们解决这种特定的情况就需要采用平衡二叉树来解决这个问题。

AVL的定义

AVL的每个节点的左子树小于(大于)该节点,右子树大于(小于)该节点,每个节点的左右子树的深度之差不超过1。节点的左右子树深度之差叫平衡银子

如图:

image

AVL的操作

增删改查操作和二分搜索树类似,但是要多考虑的就是对节点的平衡考虑,如果一串数字的插入顺序为3,4,5。那么这棵树结构就会退化为一个链表。而这时候AVL就会对这个树进行旋转操作来达到平衡,所以,我们就知道旋转的操作会在增加,删除,修改这三个地方进行旋转。旋转操作分为下面四种情况

LL右单旋转

image

如图,8的左子树已经退化为链表,并且5,8这两个节点不再平衡,这时我们先找到深度最深的不平衡节点5,对节点5进行LL旋转操作,在如图的这种情况下,得到右图的结构

RR左单旋转

image

如图,当插入顺序为当插入顺序为8,3,10,13,15的时候,树的结构变成左边的样子,这时10节点和8节点已经不平衡,为了保持AVL的平衡,我们要对10节点进行RR旋转,如右图所示

LR先左后右

image

如图。5,8节点已经不平衡,这时要对5节点做平衡处理,首先将5进行RR左旋转,7的左节点也变为5的右节点。

image

这时7,8还是不平衡的,对8进行右旋转,8的右节点也变为8的左节点,如图。

RL先右后左

image

如左图,8,13节点不平衡,对13节点进行LL右旋转,得到右图

image

这时8,10是不平衡的,对8节点进行RR左旋转,得到右图。

以上就是保持平衡的方式。

插入

插入操作和平衡二叉树类似,不过在插入之后要保持树的平衡,针对以上四种情况保持。

删除

删除操作和平衡二叉树类似,在删除的时候当把子节点移到删除节点位置后也可能对树进行旋转保持平衡。

AVL的增删改查的平均时间复杂度都是O(logn),他比二分搜索树的好处在于他能够对树结构进行一个平衡,而不让他退化为链表

这里如何判断一个节点是否平衡呢?这里看一下AVL中每个节点的结构

    private class Node{
        public K key;
        public V value;
        public Node left, right;
        public int height;//节点的当前高度
    }

判断一个节点是否平衡,即计算一个节点的平衡因子

    private int getBalanceFactor(Node node){
        if(node == null)
            return 0;
        //返回平衡因子,如果结果小于-1说明右倾;结果大于1说明左倾;结果在-1到1之间说明节点平衡
        return node.left.height - node.right.height;
    }

总的来说增加和删除要比二分搜索树多考虑的就是增加和删除功能

2-3树

2-3树是一种绝对平衡树。它的节点的元素个数可以为1个或2个。如图,就是一棵2-3树:

image

2-3树中的2代表一个节点有两个孩子,3代表一个节点有三个孩子。

2-3树的操作

插入

这里结合一个例子来查看2-3树是如何实现绝对平衡的。例如,现在我们要依次增加1,2,3,4,5,6,7这7个元素,如图

image

如图所示,下面一个步骤一个步骤的分析:

  1. 插入1,判断无根节点,直接将1封装为节点并设置为根节点
  2. 插入2,这时因为1节点没有孩子并且只有1节点,所以直接将2加入节点1中
  3. 插入3,和2节点一样,将3节点放入根节点中,这时根节点有3个元素了,就需要变化为步骤4的样子。可以理解成将1,2,3的中间元素提取成根节点,也就是将2提出来,1作为左孩子,3为右孩子
  4. 插入4,4比2大,增加到节点3
  5. 插入5,5比2大,增加到节点3,4中,这时节点3,4变为节点3,4,5,将节点3,4,5按照第三步将中间元素提取为双亲结点,而4提取出来的4回去找双亲节点2,2节点只有一个元素,所以4加入到2节点中
  6. 插入6,6大于根节点的2和4,进入最右边,5没有孩子只有一个元素,加入6到5节点
  7. 插入7,这时5,6,7将6提取出放入6的双亲结点,6的双亲节点(根节点)变为2,4,6。2,4,6提取出4变成最后的样子

总的来说,插入方法和二分搜索树相似。但是每个节点可以有1-2个元素,当节点元素个数为3时,就会分成3个节点并向上合并,直到合并完成。

查找

2-3树的查找和二分搜索树类似,不过因为一个节点可能有2个元素,需要对这两个元素进行比较,分别前往这个节点的左,中,右孩子继续进行比较

删除

2-3树的删除稍微复杂一点,删除可分为两大情况,就是删除叶子节点和非叶子节点。

这里只说理论情况,不结合代码实现,实际上代码实现会变得复杂也只是因为考虑的东西更多,代码实现会变得复杂。

删除叶子节点
  • 当前节点是3节点:直接删除
  • 当前节点是2节点:删除并判断
    • 双亲是2节点,判断兄弟节点
      • 兄弟节点是3节点,将兄弟节点移到双亲节点,再将双亲节点的另一个元素移动到当前节点
      • 兄弟节点是2节点,先通过移动兄弟节点的中序遍历直接后驱到兄弟节点,以使兄弟节点变为3节点,再进行删除
    • 双亲是3节点,拆分双亲节点使其成为2节点,再将再将双亲节点中最接近的一个拆分key与中孩子合并,将合并后的节点作为当前节点
  • 若2-3树是棵满二叉树,删除节点,将2-3树层树减少,并将兄弟节点合并到双亲节点中,同时将双亲节点的所有兄弟节点合并到双亲节点的双亲节点中,如果变为4节点,就做分解操作
删除非叶子节点

使用中序遍历下的直接后继节点key来覆盖当前节点key,再删除用来覆盖的后继节点key

了解完2-3树之后我们可以很轻松的了解和实现红黑树和B-树

红黑树

在开始红黑树之前,我们要知道红黑树并非只有2-3树这一种实现方式,虽然2-3树实现红黑树比较方便。事实是只要满足5个定义的树都是红黑树,以下是红黑树的定义:

  • 每个节点是红色或者黑色
  • 根节点是黑色
  • 每一个叶子节点是黑色
  • 如果一个节点是红色,它的孩子节点就为黑色
  • 从任意一个节点到叶子节点,经过的黑色节点是一样的

红黑树的每个节点有两种颜色,红色和黑色,如下面两图,就是两种不同实现的红黑树,我们可以看到当最后叶子节点都会增加NIL让叶子节点统一为黑色节点,图二只是没有加上最后的黑色叶子节点

image

image

实际上,以上两个图中第二个图就是用2-3树实现的红黑树

2-3树和红黑树的关系

image

如图,我们可以看到,可以将2-3树中的3节点中的左元素弄成一个新节点,这个节点就是红黑树中的红节点,并且将红节点统一进行左偏向,得出右边的红黑树,这样的红黑树也叫左倾红黑树

在了解了红黑树和2-3树之间的关系之后我们就可以看红黑树是如何实现的了。

红黑树的操作

红黑树的节点结构

    private class Node{
        public K key;//排序也是通过key进行排序
        public V value;
        public Node left, right;
        public boolean color;//红为true,黑为false,默认节点为红
    }

总的来说,对于添加一个节点,操作逻辑和2-3树相同,不过是把2-3树中的3节点的左元素变为新的节点,这个节点为红色并且左倾。

红黑树要对一个插入操作进行维护,会进行左旋转右旋转颜色翻转,如下图

image

因为我们默认新添加一个节点的时候是红色,我们要使节点满足上述5点红黑树的定义,首先,我们需要像AVL那样将图2的树形态旋转为3形态,再想AVL一样右旋转为图四状态,这时虽然达到平衡但是反转颜色(令双亲结点为红,孩子节点为黑),最后将根节点变为黑色即可。

左旋转

    //   node                     x
    //  /   \     左旋转         /  \
    // T1   x   --------->   node   T3
    //     / \              /   \
    //    T2 T3            T1   T2
    private Node leftRotate(Node node){
        Node x = node.right;
        // 左旋转
        node.right = x.left;
        x.left = node;
        x.color = node.color;
        node.color = RED;
        return x;
    }

右旋转

    //     node                   x
    //    /   \     右旋转       /  \
    //   x    T2   ------->   y   node
    //  / \                       /  \
    // y  T1                     T1  T2
    private Node rightRotate(Node node){
        Node x = node.left;
        // 右旋转
        node.left = x.right;
        x.right = node;
        x.color = node.color;
        node.color = RED;
        return x;
    }

颜色翻转

    // 颜色翻转
    private void flipColors(Node node){
        node.color = RED;
        node.left.color = BLACK;
        node.right.color = BLACK;
    }

为什么有了AVL还需要有红黑树?

红黑树并没有像AVL追求平衡,他不像AVL要求每个节点的平衡因子绝对值必须小于等于1。这样相对于AVL来说红黑树的旋转操作会更少,例如删除,插入节点操作,AVL是要从删除,增加节点到根节点的所有节点进行平衡旋转(O(logn)),而红黑树最多只需要3次就可以实现平衡O(1)(虽然通过上文实现的红黑树并不能做到,但有实现是可以的),所以红黑树更适合增删多的场景。

所以,在增删多的场景适合红黑树,查找多的场景适合AVL。

B树

B树和2-3树的原理相同,B树也可以是2-4树,2-M树 关于B树有如下的定义,如果一棵B树有M阶(层):

  • 根节点至少有两个孩子节点
  • 每个节点有M-1个关键字key(节点中的每一个元素叫关键字),并且以升序排列
  • 除去叶子节点和根节点其它节点至少有M/2个孩子节点

如下图是一个3阶B树:

image

B树的操作

对应的这个3阶树就可以看成2-3树,操作也是相似的

B树有什么用?

B树大多用在磁盘上用于查找磁盘的地址。因为磁盘会有大量的数据,有可能没有办法一次将需要的所有数据加入到内存中,所以只能逐一加载磁盘页,每个磁盘页就对应一个节点,而对于B树来说,B树很好的将树的高度降低了,这样就会减少IO查询次数,虽然一次加载到内存的数据变多了,但速度绝对快于AVL或是红黑树的。

B+树

B+树和B树类似,但多了几条规则

  • 非叶子结点的子树指针个数与关键字(节点中的元素个数)个数相同
  • 非叶子结点的子树指针P[i],指向关键字值属于[K[i], K[i+1])的子树(B-树是开区间)
  • 所有叶子结点有一个链指针
  • 所有关键字都在叶子结点出现
  • 只有叶子节点有Data域

如图

image

可以看到最主要的区别就是在叶子节点,叶子节点通过一个sqt将所有叶子节点链成一个链表,并且只有叶子节点有data域(data域就是索引指向的磁盘地址)。

B+树的操作

B+树和B树的操作的优势在于B+树的查找效率上,所以下面具体看查询

  • B树中每个节点的关键字都有data域,而B+树除了叶子节点,其他节点只有索引,也就是说同样的磁盘页B+树可以容纳更多的元素。
  • B+树的范围查询更加方便,可以先找到范围下限,然后通过叶子结点的链表顺序遍历,直至找到上限即可。而B树只能先找到下限,通过中序遍历查找,直到找到上限。

至于其他操作就和2-3树相似

mysql的Innodb引擎采用B+树的索引方式

总结了这些树形结构,再对mysql中innodb存储引擎为什么使用B+树作为索引做个总结

为什么不用AVL或红黑树?

我们假设B+树一个节点可以有100个关键字,那么3层的B树可以容纳大概1000000多个关键字(100+101*100+101*101*100)。而红黑树要存储这么多至少要20层。所以使用B树相对于红黑树和AVL可以减少IO操作

为什么不用数组或链表?

链表查询速度慢。数组要开辟连续空间不可能。

为什么不用哈希表?

Innodb能够进行范围查询,哈希表无法实现。例如LIKE很难用哈希表实现

为什么不用B树?

B+树只有叶子节点存放数据,而其他节点只存放索引,而B树每个节点都有Data域。所以相同大小的节点B+树包含的索引比B树的索引更多(因为B树每个节点还有Data域)

还有就是B+树的叶子节点是通过链表连接的,所以找到下限后能很快进行区间查询,比B树中序遍历快

综上所述,mysql的Innodb引擎采用的是B+树的索引方式

最后是之前每种数据结构的实现 github.com/PatrickLee6…