红黑树很多资料上写的非常繁杂,初次接触真的难以理解。写本文也就是为了记录一些思考和想法,并不会记录如何使用代码实现。
不记录代码还有个原因:黑红树的算法就是根据各种情况进行一些操作,情况很复杂,分插入的和删除的,有的是资料记录这些,但是为什么要进行这些操作,为什么这些操作那么像但又略有不同却没多少人说明清楚。所以本文主要是记录想法的。
平衡树的作用
红黑树是平衡二叉树的一种,自然也是平衡树的一种。那平衡树是干什么的呢?
平衡树在很多教材的介绍都是限制子节点的高度差,保持树的平衡(根节点的左、右子树差不多大)。但其实仔细想想,这就是在尽量减少树的高度,直到最小(左右高度差最大为 1 嘛)。
为什么要减少树的高度呢?
树往往用来表示一个动态集合,集合不光有元素,也有元素之间的关系,还有对元素的操作,比如增、删、查。(树很少有“改”的情况,执行改的效率不如执行等价操作:删和增)
因为无论是删和增,还是查,都是按照树的结构,从根往下走,范围越来越小,只涉及一个子树。而改的时候,可能要从一个子树到另外一个子树。
而这些操作与树的高度有关。因为我们常说的树其实是一个有向图:父节点和子节点,二者是有顺序的。那么从根节点出发,进入每一个子树之后,就和其他的子树无关了,直到叶节点结束。
所以,降低树的高度,可以加快这些操作的平均时间。
红黑树又是干嘛的
红黑树相比平衡二叉树,对于高度的限制没有那么严苛,不考虑总高度,而是考虑黑节点和红节点之间的关系。放宽限制,会使得操作节点(增删改)的时候所需的时间减少许多。如下是算法导论中的一个红黑树,可以很明显看到高度差超过 1 不少:
红黑树要满足以下条件:
- 节点是红色或黑色(不然怎么叫红黑树)。
- 根节点是黑色。
- 所有叶节点(也就是最终的
NIL
节点)是黑色。 - 红色节点的子节点必须是黑色(红下一个必是紫,但是黑的下一个可红可黑)。
- 从任一节点到其每个叶子节点的所有路径上必须具有相同数量的黑色节点(这话不理解没关系,下面解释完你就懂了)。
红黑树的绝大部分操作都是在维持上述性质,真正操作所需的时间并不多。所以这个性质是红黑树的核心。
最后那句很绕到话其实就是红黑树的最重要的概念:黑高。
黑高:一个节点到叶节点的简单路径中,黑节点的总数。
所谓简单路径就是不重复、没有回路(树本身就没回路)。
什么意思呢,就是说任何一个节点到最后的NIL
节点的时候,中间经过的黑节点数量必须一样。这就是放宽之后的限制,黑高也就是红黑树的限制。
由于NIL
节点也是黑的,所以所有非叶节点的黑高至少为 1,而叶节点(NIL
节点)的黑高为 0。但是要注意,比如上图中最左边的7
,自己是黑的,但是黑高为 1(不算自己,只算叶节点)。
为什么要弄黑高这么个玩意呢?
请你看一个非常简单的红黑树:
现在,把这棵树所有黑色节点和它的红色子节点写成一个节点,NIL
节点就是最后的“小脚”:
这里没有出现 4 个“小脚”的情况属于特殊,你看看图会发现如果一个黑节点有两个红子节点,那么就会出现 4 个小脚。
这时候你会发现,在这种画法下,NIL
节点前的一个节点必然有2、3 或 4 个NIL
节点。这就是很多资料中写到的 2-3-4 树。
此外,画成这样,你可以很清楚的看到黑高的作用,因为我们将黑高一样的关联的黑红节点放到一起了。
红黑树上的操作
在理解上面的** 2-3-4 树**之后,如何进行一系列操作就是易如反掌了。查就是二叉排序树(BST)的算法,因为并不需要维护红黑树的性质,所以这里就不说了。
增(插入)
插入算法的思路就是:
后面四个步骤,两两组合就是为了修复(维护)红黑树的性质。所以请记住重新着色-旋转
这个组合操作是维护红黑树的非常重要的步骤(后续你可以看到)。
插入
插入首先按照树找到插哪里,因为平衡树本身就有搜索的能力,比如下面的这个树,我们插入4
,设置为红色,根据树搜索发现插入位置在z
处,这是当前操作处x
(由于根据设计需要看叔节点,所以有个y
指针指向叔节点):
为什么设计为红色后面会解释。
写成** 2-3-4 树**如下:
会发现红色处的“小脚”数量不对,原来是 4 个,但是插入后却应该是 5 个,也就是多了一个脚出来,说明此处需要对树进行调整了。
要知道红黑树在插入之前是完全符合性质的,也就是说,插入节点的父、叔节点的父节点,必然是黑的。如果当初插入的时候,
4
设置为黑的,那么** 2-3-4 树**就没有问题啦,也就是说这棵树没有符合黑高的要求。
但是在这种情况下,树的高是最小的嘛?别忘了我们搞黑红树的目的,就是为了在高度差限制较松的情况下,尽量减少树的高度。
所以新插入的节点必须是红色的。
调整分两步:
- 调整为合适的颜色;
- 调整为合适的位置。
多一个脚,就将4/5/7/8
这个节点拆成两个子树,言外之意就是要有 2 个黑节点了。这里你可以自己想想更改方法,发现不唯一。
你后续可能会注意到:维护黑高的性质就是通过重新着色做到的,而旋转是为了“红色节点的子节点必须是黑色”这个性质。
第一次重新着色-旋转
在这种情况下,红黑树的要求是看插入位置的叔节点(父节点的兄弟节点)的颜色,如果插入点的叔节点也是红的,那么父节点和叔节点全改为黑的,原本是黑的爷节点改为红。
这个时候会发现,往上又不对了:此时看7
的叔节点14
,发现是黑的,与父节点2
的颜色不同。和上面的情况不一样了。
此时是
7
是因为下面的已经处理好了,符合性质了,所以往上走了。
这时候再画个** 2-3-4 树**:
第二次重新着色-旋转
这个时候黑高已经最小了,但树高并没有变化,而且还违反性质。根据规定,此时需要对整棵树进行一次左旋(因为7
在右边,再往右没啥用)。左旋后如下:
此时继续重复操作,发现和上面的情况一样,叔节点14
的颜色和父节点7
不同,那么此时由于2
在7
的左边中,所以整棵树右旋,并且父节点7
和爷节点11
交换颜色,结果如下:
此时你会发现树高从 5 变成了 4,而且完美符合红黑色性质,插入这才算结束了。
“父节点
7
和爷节点11
交换颜色”是为了使得这部分子树的根节点为黑的。不过在上图中,很巧合地是整棵树的根节点。
所以你可以看到,两次重新着色-旋转
就维护好了红黑树的性质。第一次是维护黑高的性质,第二次是维护“红色节点的子节点必须是黑色”这个性质。
删除
删除同样需要维护上述性质。关于修改颜色方面,其实就是保持黑高。同样当(子)树左边重就右旋,右边重就左旋。使得树尽量平衡。
删除有一点不同:以下图(来自https://iq.opengenus.org/red-black-tree-deletion/)为例,虽然删除了10
,但是依旧要处理10
的关系,所以需要一个辅助指针替代10
,这里记为NULL
。()
可以看到删除完之后,右边重,就左旋。左旋完之后,要维持黑高的性质,就修改(互选)颜色。只不过在删除的时候,父、子、叔、爷的颜色和位置情况比较多,所以你会发现很多算法书都是按照情况进行介绍的。
这些情况你写代码的时候看书就行了,为什么要换颜色和旋转那样很多教材中并没有点透。这点算法导论中虽然没说,但是课上确实说了。
希望能帮到有需要的人~