红黑二叉查找树(原理、实现)

转载请注明出处!https://www.jianshu.com/p/d9d9f223f0ad

Github源码地址

此篇需要二叉树基本知识,若对二叉树不了解,请移步一篇文章搞懂二叉树!

Welcome back!

在了解了二叉树的基本知识后,我们知道,二叉树虽然可以在一般情况下控制比较次数在logN内结束,但是一旦遇到较差的情况,导致树的高度很高的时候,性能就不是很令人满意了。另外,对于插入过程中保持二叉树的完美动态平衡这一操作,代价又显得太高了。所以,我们稍微放松点完美平衡的要求,将操作的性能保持在logN内完成即可。

下面,在正式学习红黑二叉树之前,我们先学习另一种树形结构“2-3查找树(B-树)”。这种树形结构也是红黑二叉树形成的历史由来,掌握它非常简单,并且能更好的帮助我们理解红黑二叉树。

2-3查找树

2-3查找树只是高级数据结构B-树中非常简单的一种情况。之前二叉树中了解到,操作的复杂度与树的高度成正相关。那么在如何维持树的高度和平衡性,我们这里需要一些灵活性。

  • 我们允许二叉树中的一个结点保存多个键值
  • 我们将标准二叉树中的结点(1个键值)称之为2-结点。
  • 我们将含有2个键值和3个连接线(连接子结点)的称之为3-结点。

一个2-3查找树或是一个空树,或是由下列结点组成:

  • 2-结点:含有1个键与2条连接线的结点,其与标准二叉树的结点性质一样,x.left < x < x. right。
  • 3-结点:含有2个键与3条连接线的结点,其中左连接线都小于2个键,中连接线在2个键之间,右连接线都大于2个键。

《红黑二叉查找树(原理、实现)》 2-3树结构示意图

  • 一个完美的2-3平衡树,它的所有空链接到根节点的距离应该是相同的。

2-3树 查找 get:

设查找的键为key,查找的节点为x。与二叉树相似:

  • 若未命中,则返回null。
  • 若 key < x.key , 在x的左子树中寻找。
  • 若 key > x.key , 在x的右子树中寻找。
  • 若结点x是2-结点,则与二叉树一样。
  • 若结点是3-结点,则比较key与2个键的大小,看是直接命中还是在哪个连接线继续寻找。

    《红黑二叉查找树(原理、实现)》 2-3树查找

2-3树 插入 put:

  • 先进行一次未命中查找。如果命中,则替换value。如果没有命中,则创建新结点挂在树的底部。

向2-结点 h 中插入

直接挂在树的底部会有些问题,破坏了树的平衡性,我们想要保持插入也是平衡的,所以选择结点上变化。那么分两种情况:

  1. 如果h无子结点或h的子结点是2-结点,则与二叉树一样插入,2-结点变3-结点。

    《红黑二叉查找树(原理、实现)》 结点为2结点插入

  2. 如果h的子结点是3-结点,则3-结点变成4-结点了。(见下)

向3-结点 h 中插入

  1. 如果向只含有一个3-结点的树插入,则3-结点变4-结点。这个情况则需要稍微变形处理下,可以将4-结点转化为2个2-结点。

规定:4-结点中有3个键,4个连接线。中间的键可以提到上面,成为左右两个键的父结点,此时树的高度+1。由于变换的是根节点,依然维持了完美平衡性

《红黑二叉查找树(原理、实现)》 4-结点转化为2个2-结点

理解树的变化是非常重要的,这关乎2-3树如何成长。

  1. 如果向父结点为2-结点的3-结点 h插入(接上),则3-结点变4-结点。变换后,提上来的中间键与父结点合并,注意,此时仍要保持父结点内键的大小顺序。

    《红黑二叉查找树(原理、实现)》 父结点为3结点插入

  2. 如果向父结点为3-结点的3-结点 h插入,则子结点变为4-结点,变换后父结点也变为4-结点,则再次向上变换,直至没有4-结点。

    《红黑二叉查找树(原理、实现)》 父结点为3-结点

  3. 如果插入的路径向上全是3-结点,则我们的根节点变为临时的4-结点,此时我们可以向情况1.中所描述的处理,分解4-结点,树的高度+1。

    《红黑二叉查找树(原理、实现)》 根节点为4-结点

请注意,2-3树中任何4-节点的分解与转化,均是局部操作,不会影响到树的平衡性!
只要符合相应的形态,变换即可进行。

2-3树 性能:

2-3树的高度必然在 (logN)/(log3) ~ logN 之间,故增删查改的性能也在 O[ (logN)/(log3) ]~ O( logN ) 之间。

OK,2-3树的介绍就到这里,代码没写,因为我们的重点不是2-3树,而是红黑树。现在我们离红黑树只差一句话的距离了。当然,在告别2-3树之前,我们再看一眼2-3树的完美身影 :) 。

《红黑二叉查找树(原理、实现)》 一般的2-3树,优雅,美丽,请记住她!

(喝口水,休息一下)

红黑二叉查找树

首先,让我们来去掉3-结点,就从2-3树变成了红黑树。
本质上,红黑树就是利用标准二叉树中结点的额外信息表示2-3树。所以,红黑树是一个二叉树,或者说,红黑树是二叉树的子类(不太准确的说法)。

去掉3-结点

上面说的结点的额外信息,其实就是指的(结点)链接的颜色。在标准二叉树中,所有链接的颜色都是黑色,而在红黑树中,我们规定有红色和黑色两种链接。
注:不少教科书上都是用结点为红/黑来定义红黑树,这里采用的是另一种定义规则,并不冲突。

  • 黑链接是2-3树中的普通链接,也是标准二叉树中的链接。
  • 红链接是将两个2-结点,变为一个3-结点。

所以,我们只需要将3-结点还原成由红链接连接的2个2-结点,即可去掉3-结点!而对于3-结点的子结点,则分配给两个2-结点来完成。

《红黑二叉查找树(原理、实现)》 红链接是将两个2-结点,变为一个3-结点

等价定义

我们这里为了方便讨论情况,不影响性质的前提下,定义以下规则:

一个静态的红黑树:(不需要再变换的、操作完成后的)

  • 所有红链接均为左链接
  • 没有任何一个结点与两个红链接相连
  • 该树是完美黑色平衡的,即任意空结点到根结点的路径上黑链接数量相同!(黑链数目既是树的高度)

肯定有小朋友对上面三条规则不满的,这边我来解释一下:

  1. 前提是变换好的红黑树。
  2. 红链接为右链接可不可以,我说可以,但是为了统一和美观,以及减少讨论的情况和复杂度,我们统一规定左边。
  3. 为啥不能两个红链接相连?两个红链接相连其实就产生了4-结点,这在2-3树中是会被转化的,而我们红黑树是由2-3树演变而来,所以应该通过相应的转化变换解决这个问题,后面会有介绍方法,且听我细细说来。
  4. 因为完美平衡二叉树是黑色平衡的,所以我们红黑也是完美黑色平衡的。或者等会你就能明白,为什么我们不计入红色为树的高度了,看下面。

《红黑二叉查找树(原理、实现)》 红黑树与2-3树一一对应

  • 注意这里红链接横置后的连接顺序,没有改变

我们将红黑树中,所有的红链接都横过来画(或者说,将2-3树中所有3-结点内部都加上红链接),即显示了为何红黑树也是2-3树,并且红黑树的高度就是2-3树的高度,也就是空结点到根节点路径上普通黑链接的数量。

  • 红黑树既是二叉树,也是2-3树。
    所以我们在红黑树中可以使用2-3树高效的插入算法,以及二叉树中高效的查找算法。

结点构成及颜色表示

boolean RED = true
boolean BLACK = false

我们在二叉树的基础上,加入了结点的颜色来表示指向当前结点的链接颜色

《红黑二叉查找树(原理、实现)》 C和G的颜色是红的,E和J不是

    private class Node {
        private Node left;
        private Node right;
        private boolean color = BLACK;
        private Key key;
        private Value value;
        private int size;

        public Node(Key key, Value value, int size, boolean color) {
            this.key = key;
            this.value = value;
            this.size = size;
            this.color = color;
        }
    }
    
    private boolean isRed(Node h) {
        if (h == null) return false;
        return h.color;
    }

旋转 rotate:

为了将我们所有的红链接都能自由的左右旋转,于是有以下两个方法:假设结点为 h

向左旋转 rotateLeft:

左旋可以将红链从右边移至左边
这个方法多数用于删除操作和调整树的平衡,以满足我们的等价定义。

《红黑二叉查找树(原理、实现)》 左旋前

《红黑二叉查找树(原理、实现)》 左旋后

当然从视觉上来看就是h 的右子结点不动,h 自己旋转至左下方,同时,交换子结点和红链。

  • 旋转后需要重置父结点的链接。
  • 旋转后需要调整结点的大小,因为结点的高度变化了。
    private Node rotateLeft(Node h) {
        if (h == null) throw new IllegalArgumentException();
        Node x = h.right;
        h.right = x.left;
        x.left = h;
        x.color = x.left.color;//x.color = h.color
        x.left.color = RED;
        x.size = h.size;
        h.size = 1 + size(h.left) + size(h.right);
        return x;
    }

向右旋转 rotateRight:

右旋可以将红链从左边移至右边
这个方法多数临时用于删除操作中,后面我们会介绍删除操作。

《红黑二叉查找树(原理、实现)》 右旋前

《红黑二叉查找树(原理、实现)》 右旋后

操作与左旋完全相反,不再赘述。

    private Node rotateRight(Node h) {
        if (h == null) throw new IllegalArgumentException();
        Node x = h.left;
        h.left = x.right;
        x.right = h;
        x.color = x.right.color;//x.color = h.color
        x.right.color = RED;
        x.size = h.size;
        h.size = 1 + size(h.left) + size(h.right);
        return x;
    }

插入 put:

红黑树的插入是为数不多的比较复杂的实现之一(还有就是删除),联系2-3树,这里我们先分情况讨论:

  • 规定:插入的新结点的color都是RED
    这个只要想想2-3树中插入就明白了。
向单个2-结点中插入新键:

如果一个红黑树只有一个2-结点,即根节点root,那么插入的时候会有三种情况:

  1. key == root,替换root 的值。
  2. key < root,则插入左子结点,此时不需要调整。
  3. key > root,则插入右子结点,此时为了满足等价定义,rotateLeft一下就好了。
向树底部的2-结点插入新键:

和上面三个情况差不多,只要保证我们的等价定义,以及二叉树的基本定义(x.left < x < x.right),调整并更新父链接就好啦。

向一个双键树(3-结点)插入新键:

分以下三种情况:

1. 新插入的键最大:

插入右子结点x.right。则形成4-结点,此时需要进行变换。这里介绍一个flipColors方法,用来分解4-结点:

  • 一个键h 的左右子结点都是红色,h 为黑色。
  • 将h 的左右子结点都变为黑色,h 变为红色。
  • 此时树的高度+1。
  • 调整后的h 可以根据其他情况进行继续变换。

    《红黑二叉查找树(原理、实现)》 变换前

《红黑二叉查找树(原理、实现)》 变换后

    private Node flipColors(Node h) {
        if (h == null || h.left == null || h.right == null) throw new IllegalArgumentException();
        h.color = !h.color;
        h.left.color = !h.left.color;
        h.right.color = !h.right.color;
        return h;
    }
2. 新插入的键最小:

插入左子结点的子结点 x.left.left,则此时形成连续的左链接都是红色(A 插入 B-C ,形成 A-B-C)。
这时候需要我们先对C进行右旋 rotateRight(C),然后就形成了上面1.的情况。

《红黑二叉查找树(原理、实现)》 旋转后flip

3. 新插入的键大小在两个键之间:

插入左子结点的右结点 x.left.right,这种情况最为复杂。例如B插入A-C
此时需要先对A进行左旋 rotateLeft(A),然后就形成了上面2.的情况。

《红黑二叉查找树(原理、实现)》 新插入的键大小在两个键之间

  • 由此我们可以看出,插入总是在“ 情况3 -> 情况2 -> 情况1 ”之间转化。
  • 请确保完全理解了上述中树的变化,再继续看下去。
根节点总是黑色

当我们进行插入后,根节点有时候会变为红色,此时当根节点由红色转为黑色时,树的高度+1

代码实现:

插入的实现,可以参考2-3树,这里只是在插入完成后,重新调整树的结构以达到完美平衡,满足等价定义。

    public void put(Key key, Value value) {
        root = put(root, key, value);
        root.color = BLACK;//根节点总是黑色
    }

    private Node put(Node h, Key key, Value value) {
        if (key == null) throw new IllegalArgumentException();
        //创建新子结点,颜色为红色
        if (h == null) return new Node(key, value, 1, RED);

        int comp = key.compareTo(h.key);
        if (comp > 0) h.right = put(h.right, key, value);
        else if (comp < 0) h.left = put(h.left, key, value);
        else h.value = value;

        //插入完成后重新调整树以满足等价定义
        if (isRed(h.right) && !isRed(h.left)) h = rotateLeft(h);//情况3 -> 情况2
        if (isRed(h.left) && isRed(h.left.left)) h = rotateRight(h);//情况2
        if (isRed(h.left) && isRed(h.right)) h = flipColors(h);//情况1

        //调整完成后需要重新计算树的高度
        h.size = size(h.left) + size(h.right) + 1;
        return h;
    }

删除 delete:

删除任意一个键,是红黑树中最为复杂的算法。而在删除任意结点的时候,我们先想想二叉树中是如何进行删除操作的。

当x不是树的末结点,若直接删除会造成空缺,树不连续,我们需要变换一下。
则当x不是树的末结点且x有两个子结点时:
删除x后,需要寻找x的右子结点中最小的(或者x的左子结点中最大的)来代替x的位置,保证二叉树的性质“每个结点的键都大于任意左子节点而小于任意右子节点”

故我们可以这样理解,最复杂的情况下,假设x不是树的末结点且x有两个子结点:
如果我们将x先和右结点最小的(或者左结点最大的)进行交换,然后就将x变为树的末结点,此时即可直接删除。

所以,删除的操作可以简化为 删除最小值删除最大值 的操作。

删除最小值 deleteMin:

由二叉树性质可知,最小键一定是在树的最左边。并且由等价定义可知,最小值一定是在树的末端最左边

当我们进行删除末结点的时候,让我们先回归2-3树,并且稍微允许临时4-结点的存在。

  • 有时候为了让父结点变为红色,需要临时合并为4-结点。
  • 如果删除的末结点是3-结点,则直接删除即可。
  • 如果删除的末结点是2-结点,直接删除会破坏树的完美平衡。所以此时我们需要进行变换。

分以下几种情况:

  1. 如果此时要删除的结点,它的父结点、兄弟结点(父结点的右结点)都是2-结点,则可以将这三个结点flipColors,还原为一个临时的4-结点。这样在我们删除后,依然是3-结点,不会破坏完美平衡,高度-1。
    《红黑二叉查找树(原理、实现)》 情况1.父结点、兄弟结点都是2-结点

如果它的父结点不是红色,则想办法变为红色

  1. 如果此时要删除的结点,它的父结点是2-结点,而它的兄弟结点不是2-结点,则此时需要向兄弟结点借一个结点过来,形成3-结点。

    《红黑二叉查找树(原理、实现)》 从兄弟结点中借一个结点,e可有可无

  2. 如果此时要删除的结点,它的父结点不是2-结点,那么从父结点借一个结点过来,形成3-结点。

    《红黑二叉查找树(原理、实现)》 两种情况,分别是兄弟结点是否为2-结点

若兄弟结点不是2-结点,则从父结点借一个结点后,兄弟结点可以补给父结点一个最小的结点,保持树的完美平衡性。
若兄弟结点是2-结点,则父结点中最小的结点向下合并,形成临时4-结点。

待删除完成后,需要自下而上重新整理树的结构,将所有的临时4-结点分解,从而满足我们的等价定义。

代码实现:

沿着树的最左路径,一路向下的过程中,当遇到2-结点,实现一些变换moveRedLeft(),从而保证当前结点不是2-结点。
这里也就是想办法变红色,从而能够删除键而不破坏树的完美平衡。

    private Node moveRedLeft(Node h) {
        //假设h为红色,h.left 和h.left.left都是黑色(h.left和h.left.left都是2-结点)
        //将h.left 或h.left 的子结点之一变红(想办法变红,变为3-结点)
        flipColors(h);//这个方法可以在拆分4-结点和组合4-结点之间变换。

        if (isRed(h.right.left)) {
        //兄弟结点为非2-结点,此时经旋转,将红键从右往左传递。见下图。
            h.right = rotateRight(h.right);
            h = rotateLeft(h);
        }
        return h;
    }

《红黑二叉查找树(原理、实现)》 moveRedLeft 过程

    public void deleteMin() {
        if (isEmpty()) throw new NoSuchElementException();
        if (!isRed(root.left) && !isRed(root.right))
            root.color = RED;
        root = deleteMin(root);
        if (!isEmpty()) root.color = BLACK;
    }

    private Node deleteMin(Node h) {
        if (h.left == null) return null;
        if (!isRed(h.left) && !isRed(h.left.left))
            h = moveRedLeft(h);
        h.left = deleteMin(h.left);
        return balance(h);//自下而上重新整理树的结构。
    }

一开始,如果根节点的两个子键都没有红键,则需要我们临时将根节点变红,从而可以拆分出红键。
而在删除完成最后,如果树还有结点,则要将根节点还原为黑色,以满足根节点总是黑色
其中balance()方法与之前我们进行put后的操作类似,不再赘述。

    private Node balance(Node h) {
        if (!isRed(h.left) && isRed(h.right)) h = rotateLeft(h);
        if (isRed(h.left) && isRed(h.left.left)) h = rotateRight(h);
        if (isRed(h.left) && isRed(h.right)) flipColors(h);
        h.size = size(h.left) + size(h.right) + 1;
        return h;
    }

删除最大值 deleteMax:

由于我们的红链都是左链,所以这里与deleteMin稍有不同。

沿着树的最右路径,一路向下的过程中,当遇到2-结点,实现变换moveRedRight(),从而保证当前结点不是2-结点。

    private Node moveRedRight(Node h) {
        //假设h为红色,h.right 和h.right.left都是黑色(h.right和h.right.left都是2-结点)
        //将h.right 或h.right 的子结点之一变红(想办法变红,变为3-结点)
        flipColors(h);
        if (!isRed(h.left.left))//两个子结点均为2-结点
            h = rotateRight(h);
        return h;
    }

《红黑二叉查找树(原理、实现)》 两个子结点都是2-结点的删除示意

如图示不难理解,由于我们删除的是最大值,所以键一定在右子结点中,故要将红键从左往右传递。其与操作与deleteMin差不多,就不赘述了。但是要记住,我们这些局部的旋转和移动都不会改变树的组成(见2-3树性质)。

    public void deleteMax() {
        if (isEmpty()) throw new NoSuchElementException();
        if (!isRed(root.left) && !isRed(root.right))
            root.color = RED;
        root = deleteMax(root);
        if (!isEmpty()) root.color = BLACK;
    }

    private Node deleteMax(Node h) {
        if (isRed(h.left))//由于我们删除的键总在右子结点中
            h = rotateRight(h);
        if (h.right == null) return null;

        if (!isRed(h.right) && !isRed(h.right.left))//h.right是个2-结点
            h = moveRedRight(h);//此时将红键从左向右传递
        h.right = deleteMax(h.right);
        return balance(h);
    }

删除任意结点的实现:

删除任意结点就是转为为删除最小值和删除最大值的操作。

    public void delete(Key key) {
        if (key == null) throw new IllegalArgumentException();
        if (isEmpty()) throw new NoSuchElementException();
        if (!isRed(root.left) && !isRed(root.right))
            root.color = RED;
        root = delete(root, key);
        if (!isEmpty()) root.color = BLACK;
    }

上面这段代码就不解释了,和之前deleteMin的原因一样。我们主要看下面的具体实现。

    private Node delete(Node h, Key key) {
        if (key.compareTo(h.key) < 0) {
            if (!isRed(h.left) && !isRed(h.left.left))
                h = moveRedLeft(h);
            h.left = delete(h.left, key);
        } else {
            if (isRed(h.left))
                h = rotateRight(h);
            if (key.compareTo(h.key) == 0 && h.right == null)
                return null;
            if (!isRed(h.right) && !isRed(h.right.left))
                h = moveRedRight(h);
            if (key.compareTo(h.key) == 0) {
                h.value = get(h.right, min(h.right).key);
                h.key = min(h.right).key;
                h.right = deleteMin(h.right);
            } else h.right = delete(h.right, key);
        }
        return balance(h);
    }
  • 如果寻找的键在左边,则消除左边路径上的2-结点,参考deleteMin
  • 如果寻找的键在右边,则消除右边路径上的2-结点,参考deleteMax
  • 我们在这里主要采用的是寻找右子键中最小值来交换自己的位置,此时待删除结点就从树的中间部分被交换到了树的末端,从而删除右子键中的最小值,简化为删除最小值的问题。
  • 最后也要记得自下而上整理整个树的结构,满足我们的等价定义。

    《红黑二叉查找树(原理、实现)》 删除B的简易示意图

性能:

  • 无论我们如何插入键值,红黑树都是几乎完美平衡的。

这个可以通过我们2-3树的性质得到。

  • 大小为N的红黑树的高度不会超过2logN。

所以其操作复杂度始终维持在 ~O(logN)。一般来说,想构建2logN高度的红黑树比较困难,需要最左边一条路径均是3-结点,而其他的子结点都是2-结点才可以。我们在一般数据里很少能遇到。

  • 大小为N的红黑树,根节点到任意子结点的平均路径长度为 ~1.00logN

这个性质很容易得到。而红黑树相对于二叉树而言,我们提高了40%以上的性能,能够使任何操作都在 ~O(logN) 内完成。

结束语:

复杂的红黑树终于了解完了,可以看出,理解2-3树对于掌握红黑树的原理至关重要。在树的生长变换中,局部变换并不会改变整体的结构这一点非常重要,也是红黑树的灵魂。

在下一篇文章,将会带来Java中另一个最常用的数据结构——散列表。

谢谢观看。

参考文献:《算法导论》 《Algorithms, 4th Edition》

    原文作者:yifyif
    原文地址: https://www.jianshu.com/p/d9d9f223f0ad
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞