亲自动手绘图——红黑树,我不信还手撕不清楚

Posted 天道酬勤——傻子王(AME)

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了亲自动手绘图——红黑树,我不信还手撕不清楚相关的知识,希望对你有一定的参考价值。

前言

红黑树是自平衡的二叉查找树,在许多地方都有实际应用比如JAVA的HashMap,在链表长度大于8就会转化为红黑树;在linux经典的epoll中也使用了红黑树来保存文件描述符的插入删除操作。如果频繁地对数据进行插入删除,还要保证效率,使用红黑树是比较好的选择。

红黑树在实际表现上,还是一颗二叉树。在许多性质上和二叉树一样,所以要从二叉树查找开始讲起。

二叉查找树

二叉查找树是最简单的实现,规定左子节点一定小于右子节点。

插入情况只需要对当前节点比大小,不断的递归直到遇到空节点插入。

查询和插入相同,不断比大小即可,在这一点上红黑树和二叉查找树是一致的。

通过这个性质可以轻松写出找到最大值、最小值,删除最大值、最小值的代码,就是一直遍历左或者右节点就能找到最值

//最小值
func min(x *node) *node {
	if x.left == nil {
		return x
	}
	return Min(x.left)
}
//最大值
func max(x *node) *node {
	if x.right == nil {
		return x
	}
	return Max(x.right)
}
//删除最小值
func deleteMin(x *node) *node {
	if x.left == nil {
		return nil
	}
	x.left = deleteMin(x.left)
	x.n = size(x.left) + size(x.right) + 1
}
//删除最大值只需要改变代码中left为right

删除最大值或最小值就是找到那个节点然后断开,再把那个节点的子节点连接上它的父节点,因为最小值那个节点的子节点只可能有一个

稍微复杂一点的就是删除操作,对于任意一个节点,删除意味着断开这个点的所有连接,如果这个点有子节点,那么需要另外一个点来替代它,这个点添加进去后要使得二叉查找树性质不变。根据性质可以找到这个点就是被删除节点的右子节点的最小值

如图,删除4这个点,需要用它的右子节点6后的最小节点,也就是5来填充4这个位置,代码如下:

 public Node delete(Node x, Key key) {
        if (x == null) {
            return null;
        }
        int cmp = key.compareTo(x.key);
        if (cmp > 0) {
            x.right = delete(x.right, key);
        } else if (cmp < 0) {
            x.left = delete(x.left, key);
        } else {
            Node t = x;
            x = min(t.right);
            x.right = deleteMin(t.right);
            x.left = t.left;
        }
        x.n = size(x.left) + size(x.right) + 1;
        return x;
    }

不断递归找到需要删除的点,然后断开该点的前后连接,删除它右子节点的最小节点来替代原来的位置

删除节点有一个核心思路就是要找到被删除节点的右子节点后的最小节点,在二叉查找树可以很容易地把点移动,但是对于红黑树来说,就不能简单的移动某个点,但是都需要经过这个过程。

二叉查找树的查询速度看起来和二叉查找相似,但是插入和删除不需要像数组那样移动元素,听起来效率挺高,但是存在最坏情况是:插入一些已经排序过的节点,比如0-100,在每次插入过程都插入右节点,这样就查找树就退化成普通链表。

2-3树

普通二叉查找树无法适应最坏情况,如果有一种树能够适应各种不同的数据情况,让运行情况都在对数级别,就能够解决二叉树查找树不稳定的缺点。

为了解决这种情况,保证树的平衡性,适当地改造一下二叉树,普通的二叉树只保存一个值和两个左右节点,现在将树改造成一个节点能够保存2个值,而有三条指针指向其他节点,形成左-中-右节点,这样的节点称为3-节点

如图,一棵树存在以上两种节点,3-节点中间的节点表示:左值<中节点<右值

对于这样的节点,在插入节点的时候需要一些变换才能保证树的平衡性

情况1:插入的节点是2-节点

直接将2-节点变成3-节点

情况2:插入的节点是个3-节点

如图,重新构造3-节点,浮动到父节点

情况3:父节点是3-节点,对父节点插入

父节点是3-节点,对其进行插入,会使得父节点分裂成3个2-节点

上面只是展示了几种情况,实际上如果在树中间插入一个节点,在节点变换的时候还要考虑子节点的情况,添加一个新的3-节点,需要将原有的2-节点进行复制,维护两种不同的节点,增加了额外的开销,实在是有些得不偿失

红黑树

对于2-3树需要新增3-节点的数据结构,虽然有缺点,但是理解实现起来并不复杂,完全可以用标准二叉树的结构,只需要增加额外的信息就可以来表示3-节点。

在内部使用一条红色的节点来表示3-节点,a这个节点的为红节点

//节点表示代码 go语言实现,后面方一个java版本
const (
	BLACK = false
	RED   = true
)
//节点
type node struct {
	key         int
	val         interface{}
	left, right *node
	n           int
	color       bool
}

func newRedNode(key int, val interface{}) *node {
	return &node{
		key:   key,
		val:   val,
		n:     1,
		color: RED,
	}
}
//红黑树结构
type RedBlackTree struct {
	root *node
}

func (b *RedBlackTree) size(x *node) int {
	if x == nil {
		return 0
	}
	return x.n
}

func (b *RedBlackTree) Size() int {
	return b.size(b.root)
}

n表示该节点下面还有多少节点,插入新节点总是红色,用布尔类型来表示红黑

把红黑树比作2-3树的表示方式,那么红黑树是平衡的,红黑树满足以下条件:

  1. 红链接均为左边(ps:方便3-节点的表示)
  2. 任何一个节点不能同时和红链接连接(ps:不然一个节点变成了4-树)
  3. 跟节点不能是红链接

以上这三条规则共同组成了红黑树的规则:++任意一条空链接到root节点的距离都是相等的。(只算黑链接的距离,不计算红链接)++

红黑树的插入情况和上面图片中2-3树的三种情况一样,但是只需要维护红链接的规则即可,规则3很容易解决,只需要将root节点的连接设置为BLACK就行了,主要是考虑另外两种情况

违反规则1

对于(1)情况,只需要将节点插入就行了,不需要做其他操作,因为插入总是在树底插入,而且红链接也是在左边,符合规则。

对于(2)情况,插入是在右边,就违背了规则1,需要将(2)变为(1)相同的结构,需要一个操作叫旋转。

左旋代码很简单,只需改变一下连接关系

func (b *RedBlackTree) rotateLeft(h *node) *node {
	x := h.right
	h.right = x.left
	x.left = h
	x.color = h.color
	h.color = RED
	x.n = h.n
	h.n = b.size(h.left) + b.size(h.right) + 1
	return x
}

注意旋转要改变节点的数量,目标节点会增加

违反规则2

违反规则2在插入时候可能会出现(1)(2)两种情况,最终是要变为(3)这种情况(如果3情况是跟节点,只需要将头部的红色链接变为黑色),如果是(1)情况,需要转变为(2),再转换为(3),这个时候需要与刚才相反的操作——右旋

与左旋完全相反的操作

func (b *RedBlackTree) rotateRight(h *node) *node {
	x := h.left
	h.left = x.right
	x.right = h
	x.color = h.color
	h.color = RED
	x.n = h.n
	h.n = b.size(h.left) + b.size(h.right) + 1
	return x
}

注意:左旋和右旋都是对子节点的判断

总结一下插入操作只需要在插入节点后递归向上处理是否违反规则情况,在规则1处理后有可能会违反规则2,所以顺序处理:

  1. 如果左边不是为红链接,右节点为红————>左旋
  2. 如果左节点为红,左节点的左节点为红————>右旋转
  3. 如果左右都为红节点————> 变成黑色

代码:

//判断节点颜色
func isRed(x *node) bool {
	if x == nil {
		return false
	}
	return x.color == RED
}

//变换反色
func reverseColor(x *node) {
	x.color = !x.color
}

//如果跟节点为红,子节点为黑或者跟节点为黑子节点为红就取反色
func flipColor(x *node) {
	rootRed := isRed(x) && !isRed(x.left) && !isRed(x.right)
	rootBlack := !isRed(x) && isRed(x.left) && isRed(x.right)
	if rootRed || rootBlack {
		reverseColor(x)
		reverseColor(x.left)
		reverseColor(x.right)
	}
}

//插入方法的平衡树方法,基于上面叙述翻译
func (b *RedBlackTree) balance(x *node) *node {
	if isRed(x.right) && !isRed(x.left) {
		x = b.rotateLeft(x)
	}
	if isRed(x.left) && isRed(x.left.left) {
		x = b.rotateRight(x)
	}
	if isRed(x.left) && isRed(x.right) {
		flipColor(x)
	}
	return x
}

插入方法

//跟节点永远都是黑色,key用int表示方便一点
func (b *RedBlackTree) Put(key int, val interface{}) {
	b.root = b.put(b.root, key, val)
	b.root.color = BLACK
}
func (b *BlackReadTree) put(x *node, key int, val interface{}) *node {
	if x == nil {
		return newRedNode(key, val)
	}
	compare := x.key - key
	if compare > 0 {
		x.left = b.put(x.left, key, val)
	} else if compare < 0 {
		x.right = b.put(x.right, key, val)
	} else {
		x.val = val
	}

	return b.balance(x)
}

插入方法和普通二叉树查找树一致的,只是最后需要平衡树操作,所以需要递归自下而上平衡每个节点,这一个平衡操作不管是插入删除都需要用到。

删除

红黑树的删除是最复杂的操作,相比于红黑树插入只需要找到节点然后自下而上平衡即可。删除操作需要找到目标点,然后像二叉查找树一样删除右子节点的最小节点来填充被删除的部分,但却并不能随意的删除一个节点,因为这样做会导致一个空链接出现,破坏了红黑树的完美平衡性。

首先要介绍删除最小节点和最大节点的方法,因为删除操作中需要用到该方法,通过以下几种情况来了解删除操作可能发生的情况。

情况1:删除的节点是一个2-节点

删除一个2-节,不能直接把这个节点删除,因为删除一个节点后会导致红黑树完美平衡被破坏,所以需要一个操作:++++,为了保证平衡所以需要像右边借一个节点来左边替代被删除节点的位置。

但是像如图这种情况明显借不到一个节点能够代替被删除节点,那么只能把它变为4-节点,然后删除一个节点使得4-节点变为3-节点,相当于降低树的层数。

如果可以借到节点呢?

如果右边有一个红色的节点,可以经过两次旋转操作就借到了节点,也不会破坏树的平衡性。

情况2:删除的节点是3-节点

如果是3-节点直接删除即可

以上是删除2-节点最简单的情况(3-节点很简单),如果稍微复杂一点呢?

对于这样一棵树删除a节点就比较复杂了,如果只看abc这三个节点,和情况1是一模一样的,可以变换为b-c形式,但是却不能直接和剩下的节点连接。

这种情况说明了一个问题,在删除最小节点时候不能在找到了该节点的时候再做变换,而是从一开始就要准备删除节点时候的变换。换句话来说:借一个节点要趁早,不要等到找到了最小节点才去右边借,而是一开始能借就借,借不到就变为4-节点,节点怎么变化只要符合二叉树原理即可。这时候你会问,如果后面能借到节点,前面借的节点怎么办?

没关系,用balance还回去就行了,一个借4-节点如果没有被使用,只需要把链接都变为黑色就变回了4-节点,这一点和插入使用的balance方法是一样的。

流程为:

  1. 判断左子是不是2-节点(当前节点的left和left.left是不是黑色,空节点为黑色)
  2. 如果是一个2-节点,就判断能不能从右边借一个节点(right.left是不是红链接)
  3. 如果能借到节点就进过两次旋转,如果借不到就变成一个4-节点,等待下面删除
  4. 不断递归调用balance方法平衡树

其中需要注意的一点就是,为了保证平衡操作能够正常运行,不管有没有++借++到节点,都将他们变为4-节点(左右子节点都是红链接),这样可以保证balance操作的时候能够正确平衡节点

那么整个操作流程图为:

  1. 从跟节点c开始,判断是需要借一个节点的,先把节点变为4-节点,但是没有借到,继续向下走
  2. 到了e节点,判断是需要借一个节点,先把节点变为4-节点(需要将自己的链接变为黑色,不然就是5-节点),发现借不到,继续向下
  3. 找到了a节点,发现a节点的链接已经是红色了,直接删除即可
  4. 递归到b节点开始平衡,按照平衡规则右边不能出现红链接,左旋
  5. 递归到e节点开始平衡,按照平衡规则右边不能出现红链接,左旋

除此之外,删除一个最小节点并没有太多情况,因为如果一颗完美平衡的红黑树,在每一层往下走的时候,就只有能借到节点、借不到节点、不需要借的情况,仔细思考一下。从右子节点拿一个红链接的节点对树的平衡没有什么影响,能拿就拿,不能拿就降低树的层数(变为3-节点),反正如果借用的节点没有用到,balance会还回去,这就是删除最小节点的思路

代码

func (b *RedBlackTree) borrowLeft(x *node) *node {
	//节点变换为4-节点 
	flipColor(x)
	//能借到节点就经过两次旋转
	if !isRed(x.left) && isRed(x.right.left) {
		x.right = b.rotateRight(x.right)
		x = b.rotateLeft(x)
	}
	return b.balance(x)
}

func (b *BlackReadTree) DeleteMin() {
	if !isRed(b.root.left) && !isRed(b.root.right) {
		b.root.color = BLACK
	}
	b.root = b.deleteMin(b.root)
	if !b.Empty() {
		b.root.color = RED
	}
}

//主要逻辑
func (b *RedBlackTree) deleteMin(x *node) *node {
	if x.left == nil {
		return nil
	}
	//判断是否需要借一个节点
	if !isRed(x.left) && !isRed(x.left.left) {
		x = b.borrowLeft(x)
	}
	x.left = b.deleteMin(x.left)

	return b.balance(x)
}

func (b *RedBlackTree) Empty() bool {
	return b.root == nil
}

删除最大值和删除最小值类似,删除最小值需要向右边借用一个节点,而删除最大值向左边借节点,向左边借节点只需要一次循转,但是左边有两种情况

  1. 在自己的左边,右旋自己
  2. 在父节点的左边有红链接,右旋父节点

整个流程和删除最小值区别主要是在于对于“借”节点的判断,代码如下:

func (b *RedBlackTree) borrowRight(x *node) *node {
	flipColor(x)
	//如果左边能借向左边借
	if isRed(x.left.left) {
		x = b.rotateRight(x)
	}
	return b.balance(x)
}
func (b *RedBlackTree) DeleteMax() {
	if !isRed(b.root.left) && !isRed(b.root.right) {
		b.root.color = BLACK
	}
	b.root = b.deleteMax(b.root)
	if !b.Empty() {
		b.root.color = RED
	}

}

func (b *RedBlackTree) deleteMax(x *node) *node {
	//自己左边是红链接直接旋转
	if isRed(x.left) {
		x = b.rotateRight(x)
	}
	if x.right == nil {
		return nil
	}
	if !isRed(x.right) && !isRed(x.right.left) {
		x = b.borrowRight(x)
	}
	x.right = b.deleteMax(x.right)
	return b.balance(x)
}

删除最大和最小键可以得出一个思路,不断的借用一个节点来保证当前节点不是2-节点,而是一个3-节点或者4-节点,这样可以安全的删除一个节点而不用担心删除一个2-节点导致树平衡问题,然后自底向上平衡树。

最复杂的红黑树删除来了,删除操作结合删除最大值、最小值和普通二叉查找树的思想,由于每一轮向下遍历树的时候是不确定删除的节点最终是在树的哪个位置(删除最大最小值可以确定),所以需要结合删除最值的特性。

  • 二叉查找树特性:找到删除节点后,需要拿到右子节点的最小值填充,如果右子节点不存在可以直接删除
  • 每一轮遍历的时候,将删除最大值最小值判断结合起来,能向左边借就向左边借节点,能向右边借就向右边借,最后再平衡

融合代码如下:

func (b *RedBlackTree) delete(x *node, key int) *node {
	//借右节点
	if key <x.key {
		if !isRed(x.left) && !isRed(x.left.left) {
			x = b.borrowLeft(x)
		}
		x.left = b.delete(x.left, key)
	} else {
		//借右边第一种情况
		if isRed(x.left) {
			x = b.rotateRight(x)
		}
		//二叉查找树无右节点删除情况
		if key == x.key && x.right == nil {
			return nil
		}
		//借右边第二种情况
		if !isRed(x.right) && !isRed(x.right.left) {
			x = b.borrowRight(x)
		}
		//二叉查找树删除方法
		if key == x.key {
			x.key = min(x.right).key
			x.val = min(x.right).val
			x.right = b.deleteMin(x.right)
		//继续递归
		} else {
			x.right = b.delete(x.right, key)
		}
	}
	return b.balance(x)
}

完整删除方法

func (b *RedBlackTree) Delete(key int) {
	if !isRed(b.root.left) && !isRed(b.root.right) {
		b.root.color = BLACK
	}
	b.root = b.delete(b.root, key)
	if !b.Empty() {
		b.root.color = RED
	}
}
func min(x *node) *node {
	if x.left != nil {
		return min(x.left)
	}
	return x
}

最后贴一个JAVA代码

public class RedBlackTreeMap<Key extends Comparable<Key>, Value> {
    private final boolean RED = true;
    private final boolean BLACK = false;

    private Node root;

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

        public Node(Key key, Value val

以上是关于亲自动手绘图——红黑树,我不信还手撕不清楚的主要内容,如果未能解决你的问题,请参考以下文章

亲自动手绘图——红黑树,我不信还手撕不清楚

红黑树的删除真的很难吗?其实是你没找到好的解题思路,不信你点击进来看看,建议收藏哦!!!

红黑树的删除真的很难吗?其实是你没找到好的解题思路,不信你点击进来看看,建议收藏哦!!!

掌握了2-3-4树也就掌握了红黑树,不信进来看看,建议收藏!

掌握了2-3-4树也就掌握了红黑树,不信进来看看,建议收藏!

[转]红黑树讲解