一直知道红黑树及其特点,但是对具体的算法没有研究,最近心血来潮,打算整理一下具体的特点和算法。看了很多文章,大多数只是讲了操作方式,而没有讲述背后的原因、操作的原理,因此即使背过了,也是知其然而不知其所以然。本篇文章的目的,是希望能用最清晰的表达,描述红黑树的操作和原理。为了方便验证想法,特制作红黑树演示网页,可以对照着文章进行实践操作,希望大家可以在其中感受红黑树的优美:红黑树演示
- 本文章版权归原作者 木木田(YT315) 所有;
- 未经原作者允许不得转载本文内容,否则将视为侵权;
- 转载或者引用本文内容请注明来源及原作者;
- 对于不遵守此声明或者其他违法使用本文内容者,本人依法保留追究权等。
简介:
为什么要有二叉树:
为了实现快速查找。查找是在软件开发中经常需要的,因此人们为了查找速度更快,提出了各种数据结构,如哈希表,B树,排序二叉树等,本文讲的红黑树属于排序二叉树也属于B树。大家常说的二叉树一般指排序二叉树,后面我们都简称为二叉树。
下面举例描述二叉树如何提高查询速度:
首先我们有一组数据 [18,13,30,10,15,20,50],组成一个数组。
从其中查找一个数,一般操作是遍历数组,依次判断是不是目标数。假设想要查找50,则从18开始,依次判断,需要判断7次才能找到。在真实应用中,当数据量庞大时,需要判断非常多次,这样会非常耗时,时间复杂度是O(N)。
二叉树是由无数节点构成,每个节点都会指向另外两个子节点,左子节点比自己小,右子节点比自己大,最上面的节点是根节点。如果我们把上述数据存储在二叉树中,将如下图所示。同样,如果要查50号数据,从根节点18开始:
-
判断50大于18,查找18的右子节点得到30
-
判断50大于30,查找30的右子节点得到50
-
判断50等于50,说明找到目标节点了
一共判断了3次,就找到了目标数据,时间复杂度是O(logN),底数为2。
上面演示为了方便只用了7个数据,二叉树比数组查询只是少了4次判断。但如果有100万个数据,用平衡二叉树最多只需要判断20次,而数组最多要判断100万次。以该例子可看出,当数据量庞大时,可大大缩短查询时间。
什么是平衡二叉树:
如果有同学仔细看上面那句话,会发现我用的是“平衡二叉树”而不是“二叉树”,两者有什么区别呢?
只要满足每个节点有两个子节点,左子节点比自己小,右子节点的比自己大,且子节点也可以为空的条件的树都可以称为二叉树(排序二叉树)。如图所示的数据结构也可以称为二叉树,但是它的查询效率和数组一样,时间复杂度是O(N),没有达到快速查询的目标。
平衡二叉树的特点是,左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。在构建二叉树时,每当添加新的节点时,需要对二叉树是否平衡进行检查,然后考虑如何旋转来达到平衡(旋转会在后面章节讲解),从而构建出如下图所示的平衡二叉树。
综上所述,平衡二叉树属于二叉树,平衡二叉树能够快速查询目标。
红黑树又是什么:
红黑树如下图所示,是一种特殊平衡二叉树,但是没有平衡二叉树那么严格(左右子树高度差的绝对值可以超过1),主要有以下五个性质(建议先复制或截图,放到一边参考,后面内容会多次引用到):
-
每个节点都是红色或者黑色
-
根节点是黑色
-
所有叶子节点都是黑色并且是空的
-
每个红色节点的子节点都是黑色
-
从红黑树的任一节点,到其任意叶子节点的所有路径,均包含相同数目的黑色节点
如下图,从根节点到叶子节点一共有以下几条路径,其中加粗斜体为黑色节点,可以看出每条路径黑色节点数量相同,均为2个:
13-11-10-9
13-11-12
13-15-14
13-15-17-16
13-15-17-18
刚看到这五个性质一定会有点懵,不过前3个性质都是字面意思,后2个性质我们来分析一下:
-
由性质4可以得出推论:不存在两个连续的红色节点,因为红色节点不允许成为红色节点的子节点。
-
由性质5结合性质4可以得出推论:红黑树根节点的两个子树极限差距如下图所示,左子树全都是黑色节点,右子树为红黑相间,右子树的高度是左子树的2倍,所以红黑树为结构不严格的平衡二叉树。
黑高是什么:
任何一个节点(不包含该节点)到达其任意叶子节点的路径上,黑色结点的个数即称为该结点的黑高,对应上述性质5可知,红黑树每个节点的黑高是一个确定的数。黑高是红黑树中一个很重要的概念。
如下图所示,22号节点到叶子节点的路径如下:
-
22-24-26-27
-
22-24-26-25
-
22-24-23
-
22-21
其中加粗的为经过的黑色节点(不包含该结点),可见经过的黑色节点数量都是1,即22号节点的黑高为1。
二叉树旋转操作
前面讲过,平衡二叉树会通过旋转来达到平衡。我们所说的二叉树指排序二叉树,即左子节点比当前节点小,右子节点比当前节点大,旋转操作不能破坏此原则。
左旋:
如下图所示为父节点1号节点左旋过程,步骤如下:
-
父节点的右子节点代替自己的位置 (3号节点代替1号节点位置)
-
右子节点的左子节点成为父节点的右子节点(3号节点原来的左子节点2号,成为1号节点的右子节点)
-
父节点成为右子节点的左子节点(1号节点成为3号节点的左子节点)
可见旋转前后依然遵循排序。
代码:
function leftRotate(node) {
let newNode = node.right
if (newNode == null) return
//移交父
newNode.parent = node.parent
if (node.parent != null) {
if (node.parent.left == node) node.parent.left = newNode
else if (node.parent.right == node) node.parent.right = newNode
} else {
rootNode = newNode
}
//移交左弟
node.right = newNode.left
if (newNode.left != null) {
newNode.left.parent = node
}
//相互关系
newNode.left = node
node.parent = newNode
}
右旋:
右旋和左旋对称,如下图所示父节点3号节点右旋过程:
-
父节点的左子节点代替自己的位置(1号节点代替3号节点位置)
-
左子节点的右子节点成为父节点的左节点(1号节点原来的右子节点2号,成为3号节点的左子节点)
-
父节点成为左子节点的右子节点(3号节点成为1号节点的右子节点)
代码:
function rightRotate(node) {
let newNode = node.left
if (newNode == null) return
//移交父
newNode.parent = node.parent
if (node.parent != null) {
if (node.parent.left == node) node.parent.left = newNode
else if (node.parent.right == node) node.parent.right = newNode
} else {
rootNode = newNode
}
//移交右弟
node.left = newNode.right
if (newNode.right != null) {
newNode.right.parent = node
}
//相互关系
newNode.right = node
node.parent = newNode
}
红黑树调整的算法:
对于红黑树的操作主要有插入节点和删除节点,但是当插入或者删除的时候可能会打破红黑树的5个性质,因此需要调整算法来维护红黑树。在调整前,我们认为树当前是平衡的,符合红黑树的5个性质。
插入节点:
情况分析
二叉树的节点只能插入到叶子节点位置,我们将要插入的节点默认初始为红色,插入节点可能会出现以下情况:
-
树还是空的,此时插入的节点直接作为根节点,并把颜色更改为黑色。
-
父节点为黑色,此时直接插入,不影响树的5个性质,不影响黑高,不需要调整。如下图所示当需要插入7时:
-
父节点为红色,当前节点也是红色,此时就违反了第4性质,此时需要变色和旋转配合调整来使树再次满足性质,此时会出现以下几种情况:
-
情况: 当前节点是父节点的左子节点,父节点是爷节点的左子节点,爷节点的右子节点为黑色或者空(此时爷节点一定是黑色,因为父节点是红,根据性质4)。
操作: 将爷节点右旋,然后将父节点设置为黑色,爷节点设置为红色
原理: 此时为了满足性质4,通过右旋和变色,让左子树留一个红色节点,并且给右子树一个红色节点,这样两边的黑高都没有受到影响。
如果叔节点为空,同理
-
情况: 当前节点是父节点的右子节点,父节点是爷节点的左子节点,爷节点的右子节点为黑色或者空(此时爷节点一定是黑色,因为父节点是红,根据性质4)。
操作: 将父节点左旋,则可以变成情况1的样子,然后使用情况1方式调整。
原理: 同情况1
-
情况: 当前节点是父节点的左/右子节点,父节点是爷节点的左子节点,爷节点的右子节点为红(此时爷节点一定是黑色,因为父节点是红,根据性质4)。
操作: 将父节点和叔节点都变为黑色,将爷节点变为红色,然后将爷节点设为目标节点递归调用调整算法,直到满足红黑性质,或者当前节点递归为根节点时,将颜色变为黑色,则停止调整。
原理: 此时3个红色一个黑色,无论怎么旋转都不能满足性质4,因此将父节点和叔节点都变为黑色,将爷节点变为红色,则可满足红黑树性质,但是爷节点的父节点有可能也是红色,因此需将当前节点设置为爷节点,再使用此算法递归进行调整。此套算法的目的就是假设目标节点为新添加的节点且是红色,然后判断当前是否满足红黑树性质,如果不满足则通过调整树使树满足红黑树性质。
以下3种情况和以上3种对称,这里不在多赘述原因
-
情况: 当前节点是父节点的右子节点,父节点是爷节点的右子节点,爷节点的左子节点为黑色或者空(此时爷节点一定是黑色,因为父节点是红,根据性质4).
操作: 将爷节点左旋,然后将父节点设置为黑色,爷节点设置为红色
原理: 同情况1
如果叔节点为空,同理
-
-
情况: 当前节点是父节点的左子节点,父节点是爷节点的右子节点,爷节点的左子节点为黑色或者空(此时爷节点一定是黑色,因为父节点是红,根据性质4)。
操作: 将父节点右旋,则可以变成情况4的样子,然后使用情况4方式调整
原理: 同情况2
-
情况: 当前节点是父节点的右/左子节点,父节点是爷节点的右子节点,爷节点的左子节点为红(此时爷节点一定是黑色,因为父节点是红,根据性质4).
操作: 将父节点和叔节点都变为黑色,将爷节点变为红色,然后将爷节点设为目标节点递归调用调整算法,直到满足红黑性质,或者当前节点为根节点时,将颜色变为黑色,则停止调整.
原理: 同情况3
总结
对于红黑树的插入算法难点主要在插入时父节点是红色的情况,因为如果父节点是黑色,插入红色,依然符合红黑树的性质,而父节点是红色则不满足性质4,需要进行调整至符合性质。
对于父节点是红色,有6种情况,后3种和前3种对称,前3种为父节点是爷节点的左子节点的情况,后3种相反。用前三种举例,由于第2种情况对父节点进行左旋后就变成第1种情况了,因此对于父节点为红色主要可以总结为2种情况:
-
叔节点为黑色。此时可以通过变色和旋转,将父节点侧子树的一个红色移动到叔节点侧,然后达到平衡,同时根节点(爷节点原位置)的颜色依然可以保持为黑色不变,因此不会影响上面的树,对应情况1,2,4,5。
-
叔节点为红色。此时通过把父节点和叔节点变为黑色,并将爷节点变为红色,即可达到平衡,但是爷节点的父节点可能也是红色,因此需要将目标节点设置为爷节点,继续调用此算法递归,直到平衡,对应情况3,6。
举例
当前树如下图所示,现需要添加一个新的节点,值为11。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZA1FZvWw-1688871662296)(.\P35.jpg)]
一、寻找新节点放置位置的步骤如下:
-
从根节点开始11>3,再比较右节点 5
-
11>5,比较右节点 12
-
11<12,比较左节点 9
-
11>9,比较左节点 10
-
11>10,比较右节点为空
-
将新节点添加到10号的右子节点位置,如下图所示:
二、调整平衡至符合性质的步骤如下:
- 上图中,以11为当前节点,符合情况6,对应操作后得到下图,9号变为当前节点。
- 上图中,9为当前节点,符合情况5,对应操作后得到下图,12号变为当前节点。
- 上图中,12为当前节点,符合情况4,对应操作后得到下图,已经调整完成。
代码
function afterAdd(node) {
let startNode
do {
startNode = node
if (node.parent == null) {//添加到根节点,或者向上递归到根节点
rootNode = node
node?.setColor("black")
} else {//非根节点
if (node.parent.isRed) {//父节点红色
let grand = node.parent.parent;//爷节点
let parent = node.parent; //父节点
let parentIsLeft = (grand.left == parent)//父节点是否为爷节点的**左**节点
let uncle = parentIsLeft ? grand.right : grand.left//叔节点
if (uncle == null || !uncle.isRed) {
if (parentIsLeft) {
if (node == parent.left) {
parent?.setColor("black")
grand?.setColor("red")
rightRotate(grand)
} else {
leftRotate(parent)
node = parent
}
} else {
if (node == parent.right) {
parent?.setColor("black")
grand?.setColor("red")
leftRotate(grand)
} else {
rightRotate(parent)
node = parent
}
}
} else {//叔父节点红色
grand?.setColor("red")
parent?.setColor("black")
uncle?.setColor("black")
node = grand;
}
} else {//parent 是黑色
//直接添加,不用调整修复。就满足了红黑树的性质
}
}
} while (startNode != node);//node被赋了新的值,则说明需要继续递归
}
删除节点:
删除节点主要步骤为:
-
删除节点的位置结构需维持树形状
-
删除节点后调整后需符合红黑树性质
删除第一步:维持树形状
根据删除节点所在树中的位置结构,会有不同的删除方式,如:
-
删除的节点没有子节点,树形状不变。
-
删除的节点有1个子节点,该情况需要用子节点替换自己。
-
删除的节点有2个子节点,直接删除后子节点会脱离树结构,所以不可直接删除,需通过节点替换方式调整。
针对删除节点有2个子节点的情况分析:
从上述三点可看出,第三种情况无法直接删除,需要通过节点替换方式调整,找符合前两种情况的节点顶替自己,则删除情况又可以回归前两种。以下图为例:
-
目标:删除11节点。
-
11节点不可直接删除的原因:从图示可知,11节点有父节点且有2个子节点,若直接删除,则子节点直接脱离树结构,树结构破坏了。
-
关键:如果能将老父亲和两个小孩托付给别人,自己就可以放心的离开。故删除的前提为寻找合适的节点替代自己(找人托付,照顾老小)。
-
找节点:找到合适的替换节点(12节点)。
-
调整树:删除后根据算法调整至符合树的性质。
如何寻找替换节点
合适的替换节点需要满足的要求为:
-
没有子节点,或者只有一个子节点。
-
替换后依然满足排序二叉树的性质。
由二叉树的性质(左子树小于自己,右子树大于自己,同时自己也根据位置大于或小于父节点)可推算出,适合的节点就是被删除节点的前驱节点或后继节点。
前驱节点与后继节点是二叉树进行中序遍历时,当前节点的前一个与后一个。寻找这两个节点的方式很多,有需要也可再去查阅资料。下面简单描述一下寻找的算法
-
寻找前驱节点举例
如下图,若需要寻找23节点的前驱节点,步骤如下:
-
获取此节点的左子节点(19)
-
然后从此节点开始递归寻找右子节点,(19-21-22)
-
直到找到一个没有右子节点的节点(22),即22节点为23节点的前驱节点
可以理解为,所有比当前节点小的子节点(左子树)中最大的一个,这个节点一定可以来代替自己的位置,因为它比自己的所有左子树都大,同样也比自己的所有右子树都小。
let Node= Node.left
while (Node.right!= null) {
Node= Node.right
}
-
寻找后继节点举例
如下图,若需要寻找23节点的前驱节点,寻找的方式如下:
-
获取此节点的右子节点(27)
-
然后从此节点开始递归寻找右子节点,(27-25-24)
-
直到找到一个没有右子节点的节点(24),即24节点为23节点的后继节点
可以理解为,所有比当前节点大的子节点(右子树)中最小的一个,这个节点一定可以来代替自己的位置,因为它比自己的所有左子树都大,同样也比自己的所有右子树都小。
let Node= Node.right
while (Node.left != null) {
Node= Node.left
}
删除第二步:调整至符合红黑树性质
经过上述章节,删除节点的第一步骤以及完成,即树的形状已经确定。接下来需要进行第二步骤,即调整颜色至符合红黑树性质。
颜色主要有以下情况:
-
目标节点是红色,删除后不影响红黑树性质,故可直接删除。
-
目标节点是黑色,删除后会改变黑高,打破红黑树性质,所以需要进一步调整。
针对黑色的删除节点情况分析:
-
情况: 目标节点是父节点的左子节点,兄弟节点是黑色,兄节点的子节点都是黑色或空,父节点颜色不限制。
操作: 将兄节点变为红色,然后把目标节点设置为父节点,然后继续递归调整算法。
原因: 当前目标节点为黑色,删除后父节点的左子树黑高相当于减一。将兄节点变为红色,则父节点的右子树黑高相当于减一。通过该种方法,则父节点恢复平衡,但父节点的黑高整体减一,爷节点失衡,此时需要将父节点当作目标节点,循环递归此算法。
此套算法的目的就是假设目标节点处要删除一个节点,然后通过调整树使树继续遵循红黑树性质。
-
情况: 目标节点是父节点的左子节点,兄弟节点是黑色,兄弟节点的右子节点是红色,左子节和父节点颜色不限制。
操作: 把父节点左旋后设为黑色,兄弟节点设为父节点的颜色(图中用蓝色),兄弟节点的右子节点设为黑色。
原因:当前目标节点为黑色,删除后,父节点的左子树黑高减一。此时通过父节点左旋操作后,将父节点变为黑色,左侧黑高恢复。兄弟节点替换父节点位置,变为父节点颜色,右侧黑高减一,此时将右侄由红色变黑色后,右侧黑高恢复。对于新的父节点而言,左右黑高恢复平衡。对于左侄,和根节点的距离依然是经过一个黑色节点,所以不影响。
-
情况: 目标节点是父节点的左子节点,兄弟节点是黑色,兄弟节点的左子节点是红色右子节点为黑色或空,父节点颜色不限制。
操作: 将兄节点变为红色,左侄变为黑色,然后右旋兄节点,然后继续递归调整算法。
原因: 因为这样操作后,变成了情况2,所以继续递归使用情况2的操作完成调整。深究其原因,此时删除当前节点则父节点左子树黑色高度减一,父节点的右子树有一个红色节点(左侄),但是直接左旋的话,此节点和父节点都会变为左子树,而右子树的高度就不够了,因为兄节点已经变成到父节点位置.因此不能直接左旋,而是通过右旋兄节点将红色节点转到右边,形成情况2。
-
情况: 目标节点是父节点的左子节点,兄弟节点是红色(由性质4推算出,父节点绝对黑色),(由性质5推算出,兄弟节点一定有两个黑色子节点)
操作: 将兄弟节点变为黑色,父节点变为红色,将父节点左旋,然后继续递归调整算法。
原因: 因为这样操作后,目标节点不变,根据左侄的子节点的情况,一定是上面情况1,2,3中的一种,然后使用对应的操作继续完成调整。
考虑另外一种方案,为什么不直接把兄节点右旋,直接变成情况2,因为这样,右侄和父节点的黑色节点距离就多了一个黑色(左侄),不符合性质5。
剩下还有4种情况,和上面个4种对称,目标节点是父节点的右子节点
-
情况: 目标节点是父节点的右子节点,兄弟节点是黑色,兄节点的子节点都是黑色或空,父节点颜色不限制
操作: 将兄节点变为红色,然后把目标节点设置为父节点.
原因: 和情况1对称
-
情况: 目标节点是父节点的右子节点,兄弟节点是黑色,兄弟节点的左子节点是红色,右子节和父节点颜色不限制
操作: 把父节点设为黑色,兄弟节点设为父节点的颜色(图中用蓝色),兄弟节点的左子节点设为黑色,右旋父节点
原因: 和情况2对称
-
情况: 目标节点是父节点的左子节点,兄弟节点是黑色,兄弟节点的左子节点是红色右子节点为黑色或空,父节点颜色不限制
操作: 将兄节点变为红色,左侄变为黑色,然后右旋兄节点,然后继续递归调整算法
原因: 和情况3对称
-
情况: 目标节点是父节点的左子节点,兄弟节点是红色(父节点绝对黑色,性质4),(兄弟节点一定右两个黑色子节点,性质5)
操作: 将兄弟节点变为黑色,让后将父节点左旋,然后继续递归调整算法
原因: 和情况4对称
总结:
对于红黑树的删除算法难点主要在,被删除节点是黑色的情况,因为如果是红色,删除后不影响黑高,依然符合红黑树的性质,而被删除节点为黑色,主要有8种情况,后4种和前4种对称,前4种为当前节点是父节点的左子节点的情况,后4种相反。对于被删除节点为黑色主要可以总结为3种情况:
-
兄弟节点及兄弟节点的子节点都是黑色,此种情况直接将兄弟节点变为红色,然后将目标节点变为父节点继续递归调用调整算法。
-
兄弟节点为黑色,但是至少有一个子节点是红色,则可以通过旋转及变色调整使之平衡。
-
兄弟节点为红色,将父节点向目标节点方向旋转,然后将兄节点变为黑色,即父节点变为红色,则情况变成上面两种情况中的一种,进而继续调整。
举例:
当前树如下图所示,需要删除7号节点
7号节点的左右子节点都不为空,因此寻找其后继节点8号,如下图
将7号节点替换成其后继节点8号,问题转化为删除8号节点原来位置的问题,当前节点为8号节点原来位置,如下图
上图中,下面的8号为当前节点,符合情况1,对应操作后得到下图,9号变为当前节点
上图中,下面的9号为当前节点,符合情况4,对应操作后得到下图,9号依然为当前节点
上图中,下面的9号为当前节点,符合情况3,对应操作后得到下图,9号依然为当前节点
上图中,下面的9号为当前节点,符合情况2,对应操作后得到下图,以及调整完成,只需要把原来8号位置的待删除节点删掉
删除待删除的8号节点,即完成删除,如下图
代码:
function afterDelete(node) {
while (node != rootNode && !node.isRed) {
let parent = node.parent;
let isLeft = (parent.left == node)
let brother = isLeft ? parent.right : parent.left
if (isLeft) {
if (brother.isRed) {
parent.setColor("red");
brother.setColor("black");
leftRotate(parent)
continue;
} else {
if ((brother.left == null || !brother.left.isRed) && (brother.right == null || !brother.right.isRed))//双黑
{
brother.setColor("red")
node = parent
continue;
} else if (brother.left?.isRed && (brother.right == null || !brother.right.isRed))//红黑
{
brother.left.setColor("black")
brother.setColor("red")
rightRotate(brother)
continue;
} else if (brother.right?.isRed) {
brother.setColor(parent.color)
parent.setColor("black")
brother.right.setColor("black")
leftRotate(parent)
node = rootNode
}
}
} else {
if (brother.isRed) {
parent.setColor("red");
brother.setColor("black");
rightRotate(parent)
continue;
} else {
if ((brother.left == null || !brother.left.isRed) && (brother.right == null || !brother.right.isRed))//双黑
{
brother.setColor("red")
node = parent
continue;
} else if (brother.right?.isRed && (brother.left == null || !brother.left.isRed))//红黑
{
brother.right.setColor("black")
brother.setColor("red")
leftRotate(brother)
continue;
} else if (brother.left?.isRed) {
brother.setColor(parent.color)
parent.setColor("black")
brother.left.setColor("black")
rightRotate(parent)
node = rootNode
}
}
}
}
node.setColor("black")
}