【C++】红黑树的性质和实现
上篇博客我们了解了 AVL 树,这篇博客就让我们来看看另外一个二叉树:红黑树
使用的编译器:VS2019
[TOC]
博客里面引用了一些百度搜到的图片(自己懒的画了,呜呜)
1. 概念
AVL 树是一个几乎完全平衡的搜素二叉树,其左右子树的高度差不会超过 1。与之相对应的,是每一次插入都有可能需要旋转多次,插入的效率较低。
而红黑树则选择了 “相对平衡”,并拥有以下的特性:
红黑树可以保证最长路径的小于最短路径的 2 倍
比如最短路径为 30,那么最长路径就不能超过 60
对于 cpu 来说,AVL 树遍历 20 次(百万级数据)和红黑树遍历 40 次的时间差距极小。所以红黑树即保持了相对平衡,又减小了 AVL 树多次旋转的消耗。
1.1 性质
其通过下面的几点来维持这一性质:
- 每一个节点不是红色就是黑色
- 根节点一定是黑色
- 每一个红节点的左右子树都是黑色
- 每一个到叶子(空节点 NIL)的支路上黑节点数量相同
- 叶子节点(空节点 NIL)视作黑色
为什么满足了这几个情况,就满足了红黑树的最长路径的小于最短路径的2倍
的性质呢?
约束 4 和 5,保证了红黑树的大致平衡:根到叶子的所有路径中,最长路径不会超过最短路径的 2 倍。
这使得红黑树在最坏的情况下,也能有 O(logN)
的查找效率
- 黑色高度为 3 时,最短路径:黑色→ 黑色 →黑色
- 最长路径:黑色→红色 →黑色 →红色 →黑色
- 此时最短路径的长度为 2(不算 Nil 的叶子节点),最长路径为 4
这里可以得出一个普遍规律,红黑树最短路径即为全黑路径。而最长路径是一黑一红间隔的情况
1.2 时间复杂度
平衡二叉树(AVL 树)和红黑树都是常用的自平衡二叉搜索树。它们的查询操作的平均时间复杂度都为 O(log n)
,其中 n 是树中节点的数量。
在平衡二叉树中,任意节点的左子树和右子树的高度差不超过 1,通过局部旋转操作可以保持平衡。这样,树的高度始终保持在 O(log n)
的水平,从而使得查询操作的时间复杂度为 O(log n)
。
根据上文提到的红黑树性质,红黑树的高度也会始终保持在 O(log n)
的水平,因此查询操作的时间复杂度也为 O(log n)
。实际上,红黑树的增删改时间复杂度都是 O(log n)
,因为本质上都是一次查找找到目标节点的位置,再进行常数级别的翻转 / 删除操作。
2. 设计一颗红黑树
在设计红黑树的时候,我们需要牢记上面的 5 点。其中前 4 点非常重要且不可以被破坏。一旦被破坏,就影响了红黑树的基本性质。
2.1 设计节点
和 AVL 树一样,我们需要把节点单独成一个类,来存放我们需要的 pair
这里就设计到了颜色的初值应该给什么。红色,还是黑色?
来看看性质 3 和 4:
- 红色的左右子树必须是黑色
- 每一个到叶子(空节点 NIL)的支路上黑节点数量相同
简单思考,即可发现,插入红节点的时候,更好控制。而插入黑节点极有可能破坏性质 4 且较难修复。
1 | //枚举,定义颜色 |
2.2 插入的几种情况
节点设计好了,我们只需要把插入的逻辑搞定,那么红黑树也就完成了!
前半部分的代码和 AVL 树完全相同,只不过我们需要手动给根节点一个黑色(默认是红色)以维持性质 2
1 | bool Insert(const pair<K, V>& kv) |
2.2.1 情况 1:无需旋转
在下面的这种情况中,我们在 p 的左边插入了一个 cur 新节点。此时违反了性质 3,红节点的孩子必须要是黑节点。
这种情况必须满足 p 和 u 都是红节点
随后我们就需要开始向上进行更新,操作如下:
- 把 p 和 u 都改成黑节点
- 把 g 改成红节点
修改之后的结果如下,即不影响性质 3;也保证了黑节点的个数不变,维持了性质 4
需要注意的是,这里的 g 不一定是根节点。所以在操作完这一课子树之后,我们需要继续向上进行操作,避免 g 的父节点是红色的情况。
代码实现如下(以父节点为 g 的左子树为例)
1 | //插入之后需要向上更新颜色,只有出现连续红色之后才需要更新 |
需要注意的是,这种调整会把 g 改成红节点。如果 G 是根节点,改成红色之后就不符合性质了。所以我们需要在操作完成之后,统一把根节点改成黑色
1 | //不管是什么情况,最后都把根改成黑,符合条件2 |
2.2.2 情况 2:需要单旋
当我们插入了一个 cur 向上更新的时候,就可能会遇到下图中间的情况。p 是红节点,违反了性质 3,而 u 是黑节点,不能简单粗暴的通过把 p 改成红来解决。
这时候我们就需要针对 g 进行一次单旋(图中是右单旋,可以简单理解为向 u 旋转)。因为 cur 和 p 形成了同侧的连续两个红节点。和 AVL 树的单旋情况相似,只有这两节点在同侧,才可以执行单旋。
旋转完毕之后,需要把 g 更新为红节点,p 更新为黑节点
2.2.3 情况 3:需要双旋
在情况 2 中,p 和 cur 都是在它们父亲的同一侧。而情况 3 就是 p 和 cur 在父亲的不同侧。
- 比如 p 是 g 的左子树,cur 是 p 的右子树
- 同时满足 p 是红,u 是黑
这时候就需要进行一次三旋,操作如下:
- 以 p 作为基点,向 cur 的另外一个方向单旋一次(上图中就是左旋)
- 旋转了之后,就会变成情况 2
- 这时候再以 g 为基点,向 u 的方向旋转一次(上图中为右旋)
- 旋转完成之后,把 g 设置为红,cur 设置为黑即可
一下是完整的三种情况代码(p 是 g 的左子树的情况)
1 | //p是g的左 |
由于篇幅限制,p 是 g 右子树的情况就不贴出来了,实际上就是把上面的代码全反过来就行了
完整代码请查看我的 gitee 仓库
2.3 查找(和 AVL 树完全相同)
1 | //因为kvl树我们需要修改value,所以返回节点的指针 |
2.4 判断是否是红黑树
有两种方案,1 是可以通过计算最大高度 / 最小高度进行间接判断,2 是以红黑树性质来验证是否满足最上面提到的 5 点
2.4.1 计算最小最大高度
这里实现递归即可,最小长度其实就是在最后 return 的判断中,把大于号改成小于号,返回小的那个子树的高度 + 1
1 | int maxHeight(){ |
只要最大长度小于最小长度的 2 倍,那么基本规则就是没有破坏的
但这还不够,我们还需要检查它是否满足红黑树的其余性质
2.4.2 递归检查性质
这里再次列出 5 点性质
- 每一个节点不是红色就是黑色
- 根节点一定是黑色
- 每一个红节点的左右子树都是黑色
- 每一个到叶子(空节点 NIL)的支路上黑节点数量相同
- 叶子节点(空节点 NIL)视作黑色
然后通过两个函数来实现,其中一个函数需要进行递归
1 | bool IsRBTree() |
可以看到,不管是随机数还是顺序插入,都通过了检查
3. 红黑树的运用
红黑树在很多地方都有使用,在 C++ 中,最为经典的便是 map 和 set 这两个容器,它们便使用了红黑树作为底层逻辑
https://gitee.com/musnow/learn_cpp_code/blob/master/STL-Sourcecode/stl_tree.h
在 stl 源码中,我们可以找到这个 tree.h
,里面便是一个红黑树的实现。而 map 和 set 就是调用了红黑树,只做了一个简单的封装
结语
在下篇博客中,我会记录 map 和 set 的基本使用,以及通过红黑树模拟实现 map 和 set
感谢大家支持!
- 最新
- 最热
- 最早
- 作者
点击重新获取 | 打开控制台