前言
上一章节我们了解完新增红黑树节点时遇到的所有场景以及对应场景的调整方法,那么本章节就来讲讲删除红黑树节点时会遇到的一些问题。
和上一章节就一样,这篇文章所讲解的所有内容都是基于红黑树的特性展开,即主要讲解如何利用红黑树的特性来实现一棵树的节点删除,也就是先有红黑树的特性,才有我这篇文章,因此这里不会解释红黑树的性质是怎么得出的,希望读者理清自己的学习目的。
1.红黑树的特性
这里再啰嗦一遍红黑树的特性:
- 所有节点都是红色或者黑色
- 根节点为黑色
- 所有的 NULL 叶子节点都是黑色
- 如果该节点是红色的,那么该节点的子节点一定都是黑色
- 所有的 NULL 节点到根节点的路径上的黑色节点数量一定是相同的
这些特性通过推导,可以得出 2 个结论:
- 从第 4 点可以得知,如果当前节点是红色,那他的父节点以及子节点肯定为黑色,因为红色节点的子节点为黑色,那他父节点是红色的话,当前节点也不可能是红色,即不会出现连续两个红色节点。
- 从第 5 点可以得知,我们对一棵树进行增删的时候,只要保证局部子树的黑色节点数量不变,同时符合红黑树的性质,那对整棵红黑树而言是没有任何影响的。
从上面推导的第 2 个结论可以知道,我们在对一棵红黑树进行增删的时候如果导致这棵树不符合红黑树的特性,那么我们应该是从局部开始想办法,如何保证通过变化局部的子树且不影响黑色节点的数量来达到恢复的效果,而不是直接从整棵红黑树开始解决问题,如果一直扩展到根节点前都无法调整,则可以考虑通过变色来减少/增加根节点其中一边的黑色节点。
接下来我们看个插入例子:
首先我们以插入节点 ( 下面称为 N ) 的父节点 ( 下面称为 P ) 为当前局部树的根节点;
可以看出,该节点和插入的新节点都为红色,不符合红黑树的特性,且无法通过变色和旋转达到局部平衡(变色会导致这个局部树的黑色节点增多,我们在调整局部树的时候,不能增加或减少当前局部树的黑色节点,除非局部树的根节点是整棵红黑树的根节点);
因此我们将 P 节点的父节点 ( 下面称为 G )当作局部树的根节点,可以看到,此时我们只需要基于 G 节点右旋,在将 P 节点变为黑色,便可以达到局部的平衡,此时整棵树也没有打破红黑树的特性。
可以看到,先前 G 为根节点的局部树经过调整后,符合了红黑树的特性,并且局部的黑色节点没有增加或减少,因此调整完成,无需将局部树进一步扩展。
一句话总结:
从局部进行变色和旋转进行调整,如果无法调整则扩展局部树范围,直到局部树的范围是整棵红黑树为止。
了解完调整红黑树的入口思路后,接下来,让我们正式进入编码环节。
2. 红黑树类代码
让我们回顾一下上一章节定义的红黑树结果代码,详细的旋转代码就麻烦读者自行去上一章节了解了。
- 颜色枚举类:
public enum Color {
RED,BLACK;
}
- 红黑树节点类:
public class RBN {
//节点颜色
Color color;
//节点值,这里是用基本的int作为展示,如果是要自定义值的类型
//则需要该类实现比较的方法
int value;
//左孩子
RBN left;
//右孩子
RBN right;
//父母节点
RBN parent;
//构造函数,传入对应的值,默认颜色为红色
public RBN(int value){
this.color = Color.RED;
this.value = value;
}
//方便后期打印
@Override
public String toString() {
String color = this.color == Color.RED?"R":"B";
return color + value;
}
//设置左孩子的时候,需要将左孩子的父节点设置为当前节点
public void setLeft(RBN left){
this.left = left;
if(left != null){
left.parent = this;
}
}
public void setRight(RBN right){
this.right = right;
if(right != null){
right.parent = this;
}
}
}
- 红黑树类:
public class RBT {
//红黑树的根节点
RBN root;
//设置根节点需要将当前节点颜色变为黑色
//且切断当前节点与父节点的联系
public void setRoot(RBN node){
if(node == null){
root = null;
return;
}
node.color = Color.BLACK;
node.parent = null;
root = node;
}
//右旋,参数为旋转的基点
private void turnRight(RBN node){...}
//左旋,参数为旋转的基点
private void turnLeft(RBN node){...}
//新增节点入口
public void insert(int value){...}
//新增节点并修复树的代码方法
private insertNode(RBN parentNode,RBN newNode){...}
//删除节点入口
public RBN delete(int value){...}
//删除节点具体逻辑代码方法
private RBN deleteNode(RBN node){...}
//删除节点后红黑树的修复方法
private RBN fixForDelete(RBN node){...}
}
3. 删除节点
看完基本内容,接下来让我们回归主题,看看删除节点的时候需要如何调整。
首先,删除节点不像新增节点一样可以直接从最底层的局部进行考虑,因为我删除的节点可能处于树的中间甚至树根。那这个时候我们就得变换思路,如何将删除树的某一个节点转换为删除非空叶子节点,那样我们便可以从下往上的局部树出发来进行红黑树的调整。
而这个思路的具体做法便是,找到一个可以和被删除节点交换位置的最底层节点,将两者的值进行交换,然后判断删除这个最底层的节点会不会影响红黑树的特性,如果影响则先进行调整,最后在删除这个节点。
因此我们删除节点分为三个步骤:
- 找到删除节点所在位置,没有对应节点则直接返回
- 找到删除节点后,去寻找这个节点的可替换的最底层节点(以下称为后继节点),并交换这两个节点的值;如果后继节点仍有子节点,交换后继节点和这个子节点的值,并将后继节点指向这个子节点
- 假如删除的节点为黑色,需要提前调整红黑树,然后再删除节点;否则直接删除节点。
后继节点不代表这个节点没有子节点,因为后继节点只是某个子树的最左/最右的节点,一个最左的节点可能会有一个右子节点,而一个最右的节点也可能有个左子节点,但是这个后继节点的子节点,肯定没有子节点,不然颜色会出现不平衡现象
3.1 寻找被删除节点位置以及后继节点
这里是通过二分法的方式去寻找,从根节点出发,一个个去比较当前节点的值和需要删除节点的值,如果出现值相等的节点,说明这个节点需要被删除。
获取删除节点的位置后,从这个位置出发,寻找后继节点。
寻找后继节点默认从当前删除节点的左子树开始找,如果被删除节点没有左子节点,则从右边开始找,如果也没有,说明当前删除的节点就是最底层节点。
- 如果是从左子树开始找,则找左子树最大值,即一直遍历左子树的右节点直至这个节点没有右子节点;
- 如果是从右子树开始找,则找右子树最小值,即一直遍历右子树的左节点直至这个节点没有左子节点。
这里读者可以自行思考下,为什么这个后继节点的值可以直接和被删除节点的值进行交换而不影响其二叉树的性质。
public void delete(int value){
//1.寻找删除节点
RBN deleteNode = root;
while(deleteNode != null){
if(deleteNode.value > value){
deleteNode = deleteNode.left;
}else if(deleteNode.value < value){
deleteNode = deleteNode.right;
}else{
//当前节点就是需要删除的节点
break;
}
}
//没有对应节点直接返回
if(deleteNode == null) return;
//2.寻找后继节点。_deleteNode 为后继节点
// 如果删除节点没有子节点的话,后继节点直接指向当前删除节点
RBN _deleteNode = deleteNode.left==null?
(deleteNode.right==null?deleteNode:deleteNode.right):
deleteNode.left;
if(_deleteNode == deleteNode.left){
//寻找删除节点的左子树最大节点
while(_deleteNode.right != null){
_deleteNode = _deleteNode.right;
}
}else if(_deleteNode == deleteNode.right){
//寻找右子树的最小节点
while(_deleteNode.left != null){
_deleteNode = _deleteNode.left;
}
}
//2.1.交换后继节点和需要删除的节点的值
int temp = deleteNode.value;
deleteNode.value = _deleteNode.value;
_deleteNode.value = temp;
//2.2.判断后继节点有无子节点,有的话,继续交换
if(_deleteNode.left==null && _deleteNode.right != null){
_deleteNode.value = _deleteNode.right.value;
_deleteNode = _deleteNode.right;
}else if(_deleteNode.right==null && _deleteNode.left != null){
_deleteNode.value = _deleteNode.left.value;
_deleteNode = _deleteNode.left;
}
//到这里,被删除节点不可能有子节点了
delete(_deleteNode);
}
可删除节点已经找到了,接下来便是进行节点的删除操作和红黑树调整。
3.2 删除后继节点
到这一步,我们已经将删除节点转换为删除非空叶子节点了,因此被删除的节点是不存在子节点的。接下来的删除操作,我们只需要根据被删除节点是黑色还是红色来决定是否对红黑树进行调整。
因为删除红色节点不影响黑色节点数量,直接删除就行,但是删除黑色节点会直接影响黑色数量,因此需要对此进行调整。
接下来的删除步骤如下:
- 根据删除节点的颜色进行红黑树的调整,使得这个节点的删除不会影响红黑树特性
- 步骤1后,节点的删除已经不会影响红黑树特性,直接删除节点
//这个时候删除节点为叶子节点,即没有子节点
private void delete(RBN deleteNode){
RBN parent = deleteNode.parent;
boolean isDeleteOnLeft = (parent.left == deleteNode);
//1.判断被删除节点是否影响红黑树特性,影响的话先对红黑树进行调整
fixForDelete(deleteNode);
//2.删除节点,这个时候删除这个节点不会影响红黑树的特性
if(isDeleteOnLeft){
parent.setLeft(null);
}else{
parent.setRight(null);
}
}
当然,如果节点为红色或者为根节点,那这个节点的删除默认是不会对红黑树的平衡造成影响的。
因此下面我们主要讨论,当删除节点为黑色,删除后会有多少种场景破坏红黑树的特性。当然,局部树的范围一样基于这个子节点的父节点开始即可。
同新增一样,我们先了解一些概念:
- 当前删除节点为 D 节点
- 删除节点的父节点为 P 节点
- 删除节点的兄弟节点为 B 节点
- B 节点的右子节点为 BR 节点
下面我是基于删除节点在父节点左边的场景进行探讨,而删除节点在父节点右边的场景其实就是取反操作,读者后续可以直接代码自己推导。
- 兄弟节点为黑色
兄弟节点为黑色的时候,父节点可以为红色也可以为黑色,不过下图推导默认父节点为红色,就算父节点为黑色,调整操作也是一样的。
- 1.1.兄弟节点有一个子节点
如果兄弟节点的子节点是右子节点,则将父节点的颜色给兄弟节点,然后父节点和侄子节点颜色改为黑色,最后基于父节点左旋即可,此时左右两边的黑色节点和删除节点前是一样的,因此调整完成;
如果兄弟节点的子节点是左子节点,那就基于兄弟节点进行右旋,转换为上面那种情况再进行递归处理。 - 1.2.兄弟节点有两个子节点
首先父节点的颜色给兄弟节点,然后兄弟右子节点设置为黑色,最后基于父节点进行左旋,即可完成调整。可以看到,这里的局部树的黑色节点数量也没变化。 - 1.3.兄弟节点没有子节点
可以看到,上面的调整全都是从兄弟节点中 “拿一个黑色节点” 来弥补被删除的黑色节点的空缺。目前兄弟节点也没有子节点的话,说明当前局部树无法进行调整。
既然当前局部树无法调整,那我们索性将兄弟节点也变为红色,先达到局部树的平衡,然后扩大局部树的范围,从叔叔节点中获取黑色节点。
上述过程中,局部树范围提高到祖父节点的同时,删除节点的兄弟节点也变为红色,这相当于变相删除了祖父节点左边的一个黑色节点。由于我们的修复方法是针对少了一个黑色节点进行修复的,因此我们只需要将 P 节点放入方法递归,即可完成调整。
- 兄弟节点为红色
兄弟节点为红色,那父节点肯定为黑色,且兄弟节点有两个黑色子节点,这个时候只需要交换兄弟节点和兄弟左子节点的颜色,然后基于父节点进行左旋,即可完成平衡调整。调整后的黑色节点数量没有变化,无需扩大局部树进行递归。
看完了场景分类,接下来我们来一个总结:
- 兄弟节点为黑色
看兄弟节点有没有子节点,有的话可以通过变色以及旋转父节点的方法来转移一个黑色节点到被删除节点的位置,实现平衡;如果兄弟节点没有子节点,那就先完成局部平衡,然后扩大局部树,从叔叔节点中转移一个黑色节点过来。 - 兄弟节点为红色
兄弟节点为红色,那兄弟节点肯定有子节点,既然有子节点,就可以通过变色以及旋转父节点的方式转移一个黑色节点到被删除位置。
//修复少了一个黑色节点后的红黑树
private void fixForDelete(RBN deleteNode){
//删除的是根节点或者是红色节点就不需要修复了
//只有节点是黑色节点且不为根节点,才需要调整
if(deleteNode == root || deleteNode.color == Color.RED){
return;
}
RBN parent = deleteNode.parent;
boolean isDeleteOnLeft = (parent.left == deleteNode);
RBN brother = isDeleteOnLeft?parent.right:parent.left;
//1.当前删除的节点为左节点
if(isDeleteOnLeft){
//兄弟节点为黑色
if(brother.color == Color.BLACK){
//1.1兄弟节点的子节点是右子节点
if(brother.right != null && brother.left == null){
brother.color = parent.color;
parent.color = brother.right.color = Color.BLACK;
turnLeft(parent);
return;
}
//1.2 兄弟节点的子节点是左子节点
//通过右旋兄弟节点,转换为 1.1 , 重新进入方法
if(brother.left != null && brother.right == null){
turnRight(brother);
fixForDelete(deleteNode);
return;
}
//1.3 兄弟节点有两个子节点
if(brother.left != null && brother.right != null){
brother.color = parent.color;
brother.right.color = Color.BLACK;
turnLeft(parent);
return;
}
//1.4 兄弟为非空叶子节点
// 将兄弟节点变为红色,相当于减少了局部树的一个黑色节点
// 从祖父节点看,就是左子节点或右子节点少了一个黑色
// 因此将父节点递归修复即可,因为符合该方法的调整前提
if(brother.left == null && brother.right == null){
brother.color = Color.RED;
fixForDelete(parent);
}
}else{
//1.4 兄弟节点为红色
// 父节点肯定为黑色,且兄弟节点有两个黑色子节点
brother.color = Color.BLACK;
brother.left.color = Color.RED;
turnLeft(parent);
}
}else{
//删除节点在右侧,逻辑与在左侧相对即可
if(brother.color == Color.BLACK){
if(brother.left != null && brother.right == null){
brother.color = parent.color;
parent.color = brother.left.color = Color.BLACK;
turnRight(parent);
return;
}
if(brother.right != null && brother.left == null){
turnLeft(brother);
fixForDelete(deleteNode);
return;
}
if(brother.left != null && brother.right != null){
brother.color = parent.color;
brother.left.color = Color.BLACK;
turnRight(parent);
return;
}
if(brother.left == null && brother.right == null){
brother.color = Color.RED;
fixForDelete(parent);
}
}else{
parent.color = brother.color;
brother.color = Color.BLACK;
brother.right.color = Color.RED;
turnRight(parent);
}
}
}
2.3 删除总结
最后,我们总结下删除的流程,首先我们把删除某个节点转换为删除非空叶子节点,然后判断这个叶子节点删除后会不会影响红黑树的特性,影响的话就先进行红黑树的修复,这个时候节点还没删除,等红黑树调整完后,这个节点的删除已经不影响红黑树的特性,此时我们再把这个节点删除,完成整体的删除操作。
具体的修复过程,主要是通过转移兄弟节点的黑色节点完成平衡,但是如果兄弟节点本身没有子节点,说明局部而言无法完成调整,只能扩大局部树范围,从叔叔节点中转移黑色节点。