红黑树了解和手动实现

992 阅读9分钟

「这是我参与2022首次更文挑战的第28天,活动详情查看:2022首次更文挑战

前言

下面简单的对红黑树做下介绍,不然直接看一些使用到红黑树的源码会比较懵。会对一些关键的步骤根据概念进行手动实现。

简介

红黑树(Red-black-tree)是一种自平衡二叉查找树,结构复杂,但拥有着良好的最坏情况运行时间,并且在实践中高效:它可以在O(logn)时间内完成查找、插入和删除。红黑树相对于AVL数来说,牺牲了部分平衡性以换取插入/删除操作时少量的旋转操作,整体来说性能要优于AVL树。

性质

红黑树首先是一个二叉查找树,同时需要符合以下特性:

  1. 节点是红色或者黑色。
  2. 根是黑色。
  3. 所有叶子都是黑色( 注意: 这里的叶子是NIL节点,表示树在此结束,需要注意这点不然很容易以为下面的内容不符合红黑树特性。)。
  4. 每个红色节点必须有两个黑色的子节点。(从每个叶子到根的所有路径上不能有两个连续的红色节点。)
  5. 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。

image.png

这个约束确保了红黑树的关键特性:从根到叶子的最长的可能路径不多于最短的可能路径的两倍长,如下图:

image.png

public class MyRedBlackTree<T extends Comparable<T>> {
    private Node<T> root;

    class Node<T> {
        private T value;
        private Node<T> parent;
        private boolean isRed;
        private Node<T> left;
        public Node<T> right;

        public T getValue() {
            return value;
        }

        public void setValue(T value) {
            this.value = value;
        }

        public Node<T> getParent() {
            return parent;
        }

        public void setParent(Node<T> parent) {
            this.parent = parent;
        }

        public boolean isRed() {
            return isRed;
        }

        public void setRed(boolean red) {
            isRed = red;
        }

        public Node<T> getLeft() {
            return left;
        }

        public void setLeft(Node<T> left) {
            this.left = left;
        }

        public Node<T> getRight() {
            return right;
        }

        public void setRight(Node<T> right) {
            this.right = right;
        }
    }

旋转

树旋转是对二叉树的一种操作,不影响元素的顺序(树的中序遍历中序遍历是一样的),但会改变树的结构,将一个节点上移、一个节点下移。树旋转绘改变树的形状,因此常被用来将较小的子树下移、较大的子树上移,从而降低树的高度、提升许多树的操作效率。

image.png

上图右旋操作以Q为根、P为转轴,会将树顺时针旋转。相应的逆操作作为左旋转,以P为根、Q为轴。

在旋转过程中也始终受二叉搜索树的主要性质约束:右子树比父节点大,左子树比父节点小。

右旋

进行右旋时:旋转前根的左节点的右节点(例如上图中Q为根的B节点)会变成根的左节点,根本身则在旋转后变成新的根的右节点。

/**
 * 右旋:p为旋转轴;p的父节点为局部当前根节点,下面直接称为根节点
 *
 * @param p
 */
void rotateRight(Node p) {
    //先判断需要进行旋转节点的父节点是不是为空,即p是不是root节点
    if (p.getParent() == null) {
        root = p;
        return;
    }

    //取出节点的祖父节点
    Node grandparent = grandparent(p);
    //取出节点的父节点
    Node parent = p.getParent();
    //p的右节点-即根节点的左节点的右节点
    Node y = p.getRight();

    //根节点的的左节点变成根节点的左节点的右节点
    parent.setLeft(y);

    if (y != null) {
        //即为根节点的右节点的左节点的父节点自然会变成当前根
        y.setParent(parent);
    }

    //当前根变成了p的右节点
    p.setRight(parent);
    //自然旋转轴的父节点的父节点变成了旋转轴
    parent.setParent(p);

    //根节点进行更换
    if (root == parent) {
        root = p;
    }
    
    //根节点的夫节点当然是之前的父节点的父亲节点即之前的祖父节点
    p.setParent(grandparent);


    if (grandparent != null) {
        //祖父节点的子节点要更换为p
        if (grandparent.getLeft() == parent) {
            grandparent.setLeft(p);
        } else {
            grandparent.setRight(p);
        }
    }
}

左旋

进行左旋时:根的右节点的左节点,会变成根的右节点,根本身在旋转后变成根的左节点。

/**
 * 左旋:p为旋转轴;p的父节点为局部当前根节点,下面直接称为根节点
 *
 * @param p
 */
void rotateLeft(Node p) {
    //先判断需要进行旋转节点的父节点是不是为空
    if (p.getParent() == null) {
        root = p;
        return;
    }

    //取出节点的祖父节点
    Node grandparent = grandparent(p);
    //取出节点的父节点
    Node parent = p.getParent();
    //取出节点的左节点-即为根节点的右节点的左节点
    Node y = p.getLeft();

    //根节点的右节点变成根节点的右节点
    parent.setRight(y);

    if (y != null) {
        //即为根节点的右节点的左节点的父节点自然会变成当前根
        y.setParent(parent);
    }

    //当前根变成旋转轴节点的左节点
    p.setLeft(parent);
    //自然旋转轴的父节点的父节点变成了旋转轴
    parent.setParent(p);

    //根节点进行更换
    if (root == parent) {
        root = p;
    }

    //根节点的夫节点当然是之前的父节点的父亲节点即之前的祖父节点
    p.setParent(grandparent);


    if (grandparent != null) {
        //祖父节点的子节点要更换为p
        if (grandparent.getLeft() == parent) {
            grandparent.setLeft(p);
        } else {
            grandparent.setRight(p);
        }
    }
}

查询

因为红黑树是一个特殊的二叉查找树,因此红黑树的查询和二叉树一样。

    public T select(T key) {
        while (root != null) {
            int cmp = root.getValue().compareTo(key);
            if (cmp < 0) {
                root = root.getRight();
            } else if (cmp > 0) {
                root = root.getLeft();
            } else {
                return root.getValue();
            }
        }
        return null;
    }
}

插入

以二叉树查找的方法增加节点并标记它为红色。(如果设为黑色,就会导致根到叶子的路径上有一条路上,多一个额外的黑节点,这个很难调整的。但是设为红色节点后,可能会导致两个连续红色节点的冲突,那么可以通过颜色调换(color flips)和树旋转来调整。)下面要进行什么操作取决于其他临近节点的颜色。注意:

  • 性质1和性质3总是要保持着。
  • 性质4只在增加红色节点、重绘黑色节点为红色,或做旋转时受到威胁。
  • 性质5只在增加黑色节点、重绘红色节点为黑色,或做旋转时受到威胁。

下面的描述N为要插入的节点,P为N的父节点,G为N的祖父节点,U为N的祖父节点。

获得祖父、叔父节点

/**
 * 获得祖父
 *
 * @param n
 * @return
 */
private Node grandparent(Node n) {
    return n.getParent().getParent();
}

/**
 * 获得叔父节点
 *
 * @param n
 * @return
 */
private Node uncle(Node n) {
    Node grandparent = grandparent(n);
    if (n.getParent() == grandparent.getLeft()) {
        return grandparent.getRight();
    } else {
        return grandparent.getLeft();
    }
}

情形1

新节点N位于树的根上,没有父节点。在这种情形下,我们需要把它的重绘黑色以满足性质2。因为它在每个路径上对黑节点数目增加一,性质5符合。

void insertCase1(Node n) {
    //root节点为空
    if (n.getParent() == null) {
        n.setRed(false);
        root = n;
    } else {
        insertCase2(n);
    }
}

情形2

新节点的父亲节点P是黑色,所以性质4没有失效(新节点是红色的)。符合所有红黑树特性。

void insertCase2(Node n) {
    //如果父节点是黑色,不会破快红黑树特性
    if (n.getParent().isRed == false) {
        return;
    } else {
        inserCase3(n);
    }
}

情形3

image.png

如果父节点P和叔父节点U二者都是红色(P是黑色情形2已经考虑到了),则可以重绘P和U为黑色并重绘祖父节点G为红色(用来保持性质5)。但此时G如果是根节点就违反了性质2,也有可能是G的父节点是红色的,违反了性质4。未来解决这个问题我们可以直接把G传给情形1进行递归处理,进而做各种情形的检查。

/**
 * 如果父节点P和叔父节点U二者都是红色,则可以重绘P和U为黑色并重绘祖父节点G为红色(用来保持性质5)。
 * 但此时G如果是根节点就违反了性质2,也有可能是G的父节点是红色的,违反了性质4。
 * 未来解决这个问题我们可以直接把G传给情形1进行递归处理,进而做各种情形的检查。
 *
 * @param n
 */
void inserCase3(Node n) {
    Node uncle = uncle(n);
    if (uncle != null && uncle.isRed) {
        n.getParent().setRed(false);
        n.setRed(false);
        Node grandparent = grandparent(n);
        grandparent.setRed(true);
        insertCase1(n);
    } else {
        insertCase4(n);
    }
}

情形4

image.png

父节点P是红色而叔父节点U是黑色或缺少(U是红色情形3已经考虑到了,在插入之前就要符合红黑树的特性所以U在这只能是NIL节点),并且新节点是N是其父节点P的右节点父亲节点P又是其父节点的左子节点。在这种情形下,我们进行一次左旋转调换新节点和其父节点的角色;可以发现不满足特性4,将按情型5(旋转后P就是情形5的N了)进行处理。特性5是满足的。

void insertCase4(Node n) {
    Node grandparent = grandparent(n);
    if (n == n.getParent().getRight() && n.getParent() == grandparent.getLeft()) {
        rotateLeft(n);
        //现在可以理解P是新增加的,方便情形5继续处理
        n = n.getLeft();
    } else if (n == n.getParent().getLeft() && n.getParent() == grandparent.getRight()) {
        rotateRight(n);
        //现在可以理解P是新增加的,方便情形5继续处理
        n = n.getRight();
    }

    insertCase5(n);
}

情形5

image.png

父节点P是红色(P是红色G肯定是黑色)而叔父节点U是黑色或缺少(在插入之前就要符合红黑树的特性所以U在这只能是NIL节点),新节点N是其父节点的左子节点,而父节点P又是其父节点的左子节点。在这种情况下,我们进行针对祖父节点G的一次右旋转;现在P是局部局部根节点顶替了G的位置,但是如果G的父节点是红色就破坏了特性4,所以直接给P、G互换颜色。

void insertCase5(Node n) {
    n.getParent().setRed(false);
    Node grandparent = grandparent(n);
    if (n == n.getParent().getLeft() && n.getParent() == grandparent.getLeft()) {
        rotateRight(n.getParent());
    } else {
        rotateLeft(n.getParent());
    }
}

删除

删除和插入类似,也分很多情形,情形和情形嵌套、情形解决部分转换成另一种情形继续处理,都是围绕红黑树的特性归纳总结出来的,这里暂时不进行下去,以后有机会补上。

参考

红黑树详细分析

红黑树深入剖析及Java实现

维基百科-红黑树