【alg4-查找】平衡查找树

在一棵含有N个结点的树中,我们希望树高为~lgN,这样我们就能保证所有查找都能在~lgN次比较内结束。

2-3查找树

一棵2-3查找树或为一棵空树,或由以下结点组成:

  • 2-结点,含有一个键(及其对应的值)和两条链接,左链接指向的2-3树中的键都小于该结点,右链接指向的2-3树中的键都大于该结点。
  • 3-结点,含有两个键(及其对应的值)和三条链接,左链接指向的2-3树中的键都小于该结点,中链接指向的2-3树中的键都位于该结点的两个键之间,右链接指向的2-3树中的键都大于该结点。

和以前一样,我们将指向一棵空树的链接称为空链接。
在这里插入图片描述
一棵完美平衡的2-3查找树中的所有空链接到根结点的距离都应该是相同的。

基本操作

查找

要判断一个键是否在树中,我们先将它和根结点中的键比较。如果它和其中任意一个相等,查找命中;否则我们就根据比较结果找到指向相应区间的链接,并在其指向的子树中递归地继续查找。如果这是个空链接,查找未命中。
在这里插入图片描述

向2-结点中插入新键

要在2-3树中插入一个新结点,我们可以和二叉查找树一样先进行一次未命中的查找,然后把新结点挂在树的底部。但这样的话树无法保持完美平衡性。我们使用2-3树的主要原因就在于它能够在插入后继续保持平衡。如果未命中的查找结束于一个2-结点,我们只要把这个2-结点替换为一个3-结点,将要插入的键保存在其中即可。
在这里插入图片描述

向一棵只含有一个3-结点的树中插入新键

在考虑一般情况之前,先假设完美需要向一棵只含有一个3-结点的树中插入一个新键。为了将新键插入,我们先临时将新键存入该结点中,使之成为一个4-结点。很容易将它转换为一棵由3个2-结点组成的2-3树,其中一个结点(根)含有中键,一个结点含有3个键中的最小者(和根结点的左链接相连),一个结点含有3个键中的最大者(和根结点的右链接相连)。
在这里插入图片描述

向一个父结点为2-结点的3-结点中插入新键

假设未命中的查找结束于一个3-结点,而它的父结点是一个2-结点。在这种情况下我们需要在维持树的完美平衡的前提下为新键腾出空间。我们先像刚才一样构造一个临时的4-结点并将其分解,但此时我们不会为中建创建一个新结点,而是将其移动至原来的父结点中。你可以将这次转换看成将指向原3-结点的一条链接替换为新父结点中的原中键左右两边的两条链接,并分别指向两个新的2-结点。此时2-3树仍然是完美平衡的。
在这里插入图片描述

向一个父结点为3-结点的3-结点中插入新键

现假设未命中的查找结束于一个父结点为3-结点的结点。我们再次和刚才一样构造一个临时的4-结点并分解它,然后将它的中键插入它的父结点中。但父结点也是一个3-结点,因此我们再用这个中键构造一个新的临时4-结点,然后在这个结点上进行相同变换,即分解这个父结点并将它的中键插入到它的父结点中去。推广到一般情况下,我们就这样一直向上不断分解临时的4-结点并将中键插入更高层的父结点,直至遇到一个2-结点并将它替换为一个不需要继续分解的3-结点,或者是到达3-结点的根。
在这里插入图片描述

分解根结点

如果从插入结点到根结点的路径上全都是3-结点,我们的根结点最终变成一个临时的4-结点。此时我们可以按照向一棵只有一个3-结点的树中插入新键的方法处理这个问题。我们将临时的4-结点分解为3个2-结点,使得树高加1。
在这里插入图片描述

局部变换

将一个4-结点分解为一棵2-3树可能有6种情况,如下图。2-3树插入算法的根本在于这些变换都是局部的:除了相关的结点和链接之外不必修改或者检查树的其他部分。每次变换中,变更的链接数量不会超过一个很小的常数。需要特别指出的是,不光是在树的底部,树中任何地方只要符合相应的模式,变换都可以进行。每个变换都会将4-结点中的一个键送入它的父结点中,并重构相应的链接而不必涉及树的其他部分。
在这里插入图片描述

全局性质

这些局部变换不会影响树的全局有序性和平衡性:任意空链接到根结点的路径长度都是相等的。作为参考,下图所示是当一个4-结点是一个3-结点的中子结点时的完整变换情况。理解所有局部变换都不会影响整棵树的有序性和平衡性是理解这个算法的关键。
在这里插入图片描述
和标准的二叉查找树由上向下不同,2-3树的生长是由下向上的。
如图:
在这里插入图片描述

性能分析

在一棵大小为N的2-3树中,查找和插入操作访问的结点必然不超过lgN个。

因此我们可以确定2-3树在最坏情况下仍有较好的性能。每个操作中处理每个结点的时间都不会超过一个很小的常数,且这两个操作都只会访问一条路径上的结点,所以任何查找或者插入的成本都肯定不会超过对数级别。例如,含有10亿个结点的一棵2-3树的高度仅在19到30之间,我们最多只需要访问30个结点就能够在10亿个键中进行任意查找和插入操作。

红黑二叉查找树

我们通过一种名为红黑二叉查找树的简单数据结构来表达并实现它。

准备工作

替换3-结点

红黑二叉查找树背后的基本思想是用标准的二叉查找树(完全由2-结点构成)和一些额外的信息(替换3-结点)来表示2-3树。

我们将树中的链接分为两种类型:
红链接将两个2-结点连接起来构成一个3-结点,黑链接则是2-3树中的普通链接。确切地说,我们将3-结点表示为由一条左斜的红色链接相连的两个2-结点,如图所示。
在这里插入图片描述
这种表示法的一个优点是,我们无需修改就可以直接使用标准二叉查找树的get()方法。对于任意的2-3树,只要对结点进行转换,我们都可以立即派生出一棵对应的二叉查找树。我们将用这种方式表示2-3树的二叉查找树称为红黑二叉查找树(简称红黑树)。

一种等价的定义

红黑树的另一种定义是含有红黑链接并满足下列条件的二叉查找树:

  • 红链接均为左链接;
  • 没有任何一个结点同时和两条红链接相连;
  • 该树是完美黑色平衡的,即任意空链接到根结点的路径上的黑链接数量相同。

满足这样定义的红黑树和相应的2-3树是一一对应的。

一一对应

如果我们将一棵红黑树中的红链接画平,那么所有的空链接到根结点的距离都将是相同的。如果我们将由红链接相连的结点合并,得到的就是一棵2-3树。无论我们选择用何种方式去定义它们,红黑树都既是二叉查找树,也是2-3树,如图所示。因此我们能够在保持一一对应关系的基础上实现2-3树的插入算法,那么我们就能够将两个算法的优点结合起来:二叉查找树中简洁高效的查找方法和2-3树中高效的平衡插入算法。
在这里插入图片描述

颜色表示

我们将链接的颜色保存在表示结点的Node数据类型的布尔变量color中。如果指向它的链接是红色的,那么该变量为true,黑色则为false我们约定空链接为黑色。当我们提到一个结点的颜色时,我们指的是指向该结点的链接的颜色,反之亦然。
在这里插入图片描述

旋转

左旋转,它对应的方法接受一条指向红黑树中的某个结点的链接作为参数,假设被指向的结点的右链接是红色的,这个方法会对树进行必要的调整并返回一个指向包含同一组键的子树且其左链接为红色的根结点的链接。实现将一个红色左链接转换为一个红色右链接的一个右旋转的代码完全相同,只需要将left和right互换即可。
在这里插入图片描述
在这里插入图片描述

在旋转后重置父结点的链接

无论左旋转还是右旋转,旋转操作都会返回一条链接。我们总是会用rotateRight()或rotateLeft()的返回值重置父结点(或是根结点)中相应的链接。返回的链接可能是左链接也可能是右链接,但是我们总是会将它赋予父结点中的链接。这个链接可能是红色也可能是黑色——rotateLeft()和rotateRight()都通过将x.color设为h.color保留它原来的颜色。这可能会产生两条连续的红链接,但我们的算法会继续用旋转操作修正这种情况。

在插入新的键时我们可以使用旋转操作帮助我们保证2-3树和红黑树之间的一一对应关系,因为旋转操作可以保持红黑树的两个重要性质:有序性完美平衡性

下面展示了我们如何使用旋转操作来保持红黑树的另外两个重要性质(不存在两条连续的红链接和不存在红色的右链接)。

向单个2-结点中插入新键

一棵只含有一个键的红黑树只含有一个2-结点。插入另一个键之后,我们马上就需要将它们旋转。如果新键小于老键,我们只需新增一个红色的结点即可,新的红黑树和单个3-结点完全等价。如果新键大于老键,那么新增的红色结点将会产生一条红色的右链接。我们需要使用root=rotateLeft(root);来将其旋转为红色左链接并修正根结点的链接,插入操作才算完成。两种情况的结果均为一棵和单个3-结点等价的红黑树,其中含有两个键,一条红链接,树的黑链接高度为1,如图所示。
在这里插入图片描述

向树底部的2-结点插入新键

用和二叉查找树相同的方式向一棵红黑树中插入一个新键会在树的底部新增一个结点,但总是用红链接将新结点和它的父结点相连。如果它的父结点是一个2-结点,那么刚才讨论的两种处理方法仍然适用。如果指向新结点的是父结点的左连接,那么父结点就直接成为了一个3-结点;如果指向新结点的是父结点的右链接,这就是一个错误的3-结点,但一次左旋转就能够修正它,如图所示。
在这里插入图片描述

向一棵双键树(即一个3-结点)中插入新键

这种情况又分为三种子情况:新键小于树中的两个键,在两者之间,或是大于树中的两个键。每种情况中都会产生一个同时连接到两条红链接的结点,而我们的目标就是修正这一点:

  • 三者中最简单的情况是新键大于原树中的两个键,因此它被连接到3-结点的右链接。此时树是平衡的,根结点为中间大小的键,它有两条红链接分别和较小和较大的结点相连。如果我们将两条链接的颜色都由红变黑,那么我们就得到了一棵由三个结点组成、高为2的平衡树。它正好能够对应一棵2-3树。其他两种情况最终也会转化为这种情况。
  • 如果新键小于原树中和两个键,它会被连接到最左边的空链接,这样就产生了两条连续的红链接。此时我们只需要将上层的红链接右旋转即可得到第一种情况。
  • 如果新键介于原树中的两个键之间,这又会产生两条连续的红链接,一条红色左链接接到一条红色右链接。此时我们只需要将下层的红链接左旋转即可得到第二种情况。

在这里插入图片描述
总的来说,我们通过0次、1次和2次旋转以及颜色的变化得到了期望的结果。在2-3树中,请确认你完全理解了这些转换,它们是红黑树的动态变化的关键。

颜色转换

我们专门用一个方法flipColors()来转换一个结点的两个红色子结点的颜色,除了将子结点的颜色由红变黑之外,我们同时还要将父结点的颜色有黑变红。这项操作最重要的性质在于它和旋转操作一样是局部变换,不会影响整棵树的黑色平衡性。
在这里插入图片描述

根结点总是黑色

颜色转换会使根结点变为红色。严格地说,红色的根结点说明根结点是一个3-结点的一部分,但实际情况并不是这样。因此我们在每次插入后都会将根结点设为黑色。注意,每当根结点由红变黑时树的黑链接高度就会加1。

向树底部的3-结点插入新键

现在假设我们需要在树的底部的一个3-结点下加入一个新结点。指向新结点的链接可能是3-结点的右链接(此时我们只需要转换颜色即可),或是左连接(此时我们需要进行右旋转然后再转换颜色),或是中链接(此时我们需要先左旋转下层链接然后右旋转上层链接,最后再转换颜色)。颜色转换会使到中结点的链接变红,相当于将它送入了父结点。这意味着在父结点中继续插入一个新键,我们也会继续用相同的办法解决这个问题。
在这里插入图片描述

将红链接在树中向上传递

2-3树中的插入算法需要我们分解3-结点,将中间键插入父结点,如此这般直到遇到一个2-结点或是根结点。

我们所考虑过的所有情况都正是为了达成这个目标:每次必要的旋转之后我们都会进行颜色转换,这使得中结点变红。在父结点看来,处理这样一个红色结点的方式和处理一个新插入的红色结点完全相同,即继续把红链接转移到中结点上去。下图总结的三种情况显示了在红黑树中实现2-3树的插入算法的关键那操作所需的步骤:要在一个3-结点下插入新键,先创建一个临时的4-结点,将其分解并将红链接由中间键传递给它的父结点。重复这个过程,我们就能将红链接在树中向上传递,直至遇到一个2-结点或者根结点。
在这里插入图片描述
总之,只要谨慎地使用左旋转、右旋转和颜色转换这三个简单的操作,我们就能够保证插入操作后红黑树和2-3树的一一对应关系。在沿着插入点到根结点的路径向上移动时在所经过的每个结点中顺序完成以下操作,我们就能完成插入操作:

  • 如果右子结点是红色的而做左子结点是黑色的,进行左旋转;
  • 如果左子结点是红色的且它的左子结点也是红色的,进行右旋转;
  • 如果左右子结点均为红色,进行颜色转换。

代码

因为保持树的平衡性所需的操作是由下向上在每个所经过的结点中进行的,将它们植入我们已有的实现中十分简单:只需要在递归调用之后完成这些操作即可。

package section3_1;

import java.util.ArrayList;
import java.util.List;

public class RedBlackBST <Key extends Comparable<Key>,Value> {

    private Node root;

    private static final boolean RED = true;
    private static final boolean BLACK = false;

    private class Node {
        Key key;
        Value val;
        Node left, right;
        int N;
        boolean color;

        public Node(Key key, Value val, int n, boolean color) {
            this.key = key;
            this.val = val;
            N = n;
            this.color = color;
        }
    }

    private boolean isRed(Node x) {
        if (x == null) return false;
        return x.color == RED;
    }

    public int size() {
        return size(root);
    }

    public int size(Node x) {
        if (x == null) return 0;
        else return x.N;
    }

    public Node rotateLeft(Node h) {
        Node x = h.right;
        h.right = x.left;
        x.left = h;
        x.color = h.color;
        h.color = RED;
        x.N = h.N;
        h.N = 1 + size(h.left) + size(h.right);
        return x;
    }

    public Node rotateRight(Node h) {
        Node x = h.left;
        h.left = x.right;
        x.right = h;
        x.color = h.color;
        h.color = RED;
        x.N = h.N;
        h.N = 1 + size(h.left) + size(h.right);
        return x;
    }

    public void flipColors(Node h) {
        h.color = RED;
        h.left.color = BLACK;
        h.right.color = BLACK;
    }

    public void put(Key key,Value val) {
        root = put(root, key, val);
        root.color = BLACK;
    }

    private Node put(Node h, Key key, Value val) {
        if (h == null) {
            return new Node(key,val,1,RED);
        }
        int cmp = key.compareTo(h.key);
        if (cmp < 0) {
            h.left = put(h.left,key,val);
        } else if (cmp > 0) {
            h.right = put(h.right,key,val);
        } else {
            h.val = val;
        }

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

    public Value get(Key key) {
        return get(root,key);
    }

    public Value get(Node x, Key key) {
        if (x == null) return null;
        int cmp = key.compareTo(x.key);
        if (cmp > 0) {
            return get(x.right,key);
        } else if (cmp < 0) {
            return get(x.left,key);
        } else {
            return x.val;
        }
    }

    public Key min() {
        return min(root).key;
    }

    private Node min(Node x) {
        if (x.left == null) return x;
        return min(x.left);
    }

    public Key max() {
        return max(root).key;
    }

    private Node max(Node x) {
        if (x.right == null) return x;
        return max(x.right);
    }

    public Iterable<Key> keys() {
        return keys(min(),max());
    }

    public Iterable<Key> keys(Key lo, Key hi) {
        List<Key> list = new ArrayList<>();
        keys(root,list,lo,hi);
        return list;
    }

    private void keys(Node x, List<Key> list, Key lo, Key hi) {
        if (x == null) return;
        int cmplo = lo.compareTo(x.key);
        int cmphi = hi.compareTo(x.key);
        if (cmplo < 0) keys(x.left,list,lo,hi);
        if (cmplo <= 0 && cmphi >= 0) list.add(x.key);
        if (cmphi > 0) keys(x.right,list,lo,hi);
    }

    public static void main(String[] args) {
        RedBlackBST<String,Integer> rdbst = new RedBlackBST<>();
        String[] strings = new String[]{"S","E","A","R","C","H","X","M","P","L"};
        for (int i = 0;i < strings.length;i++) {
            rdbst.put(strings[i],i);
        }

        int len = 0;
        for (String x:rdbst.keys()) {
            System.out.println(x + " " + rdbst.get(x));
            len++;
        }
        System.out.println(len);

    }
}

在这里插入图片描述

红黑树的性质

所有基于红黑树的符号表实现都能保证操作的运行时间为对数级别。

首先,无论键的插入顺序如何,红黑树都几乎是完美平衡的。

一棵大小为N的红黑树的高度不会超过2lgN。

一棵大小为N的红黑树中,根结点到任意结点的平均路径长度为~1.00lgN。

每个键只会被插入一次,但却可能被查找无数次,因此最后我们只用了很小的代价就取得了和最优情况近似的查找时间。

它通过了各种应用的考验,包括许多库实现。

有序符号表API

红黑树最吸引人的一点是它的实现中最复杂的代码仅限于put()(和删除)方法。二叉查找树中的查找最大和最小键、select()、rank()、floor()、ceiling()和范围查找方法不做任何变动即可继续使用,因为红黑树也是二叉查找树而这些操作也不会涉及结点的颜色。这些方法都能从红黑树近乎完美的平衡性中收益,因为它们最多所需的时间都和树高成正比。

在一棵红黑树中,以下操作在最坏情况下所需的时间是对数级别的:查找、插入、查找最小键、查找最大键、floor()、ceiling()、rank()、select()、删除最小键、删除最大键、删除和范围查询。

想想看,这样的保证是一个非凡的成就。在信息世界的汪洋大海中,表的大小可能上千亿,但我们仍能够确保在几十次比较之内就完成这些操作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值