✨个人主页: 北 海
🎉所属专栏: C++修行之路
🎃操作环境: Visual Studio 2019 版本 16.11.17
文章目录
- 🌇前言
- 🏙️正文
- 1、认识红黑树
- 1.1、红黑树的定义
- 1.2、红黑树的性质
- 1.3、红黑树的特点
- 2、红黑树的插入操作
- 2.1、抽象图
- 2.2、插入流程
- 2.3、单纯染色
- 2.4、左单旋 + 染色
- 2.5、右左双旋 + 染色
- 2.6、具体实现代码
- 2.6、注意事项及调试技巧
- 3、AVL树 VS 红黑树
- 3.1、红黑树的检验
- 3.2、性能对比
- 🌆总结
🌇前言
红黑树是平衡二叉搜索树中的一种,红黑树性能优异,广泛用于实践中,比如 Linux
内核中的 CFS
调度器就用到了红黑树,由此可见红黑树的重要性。红黑树在实现时仅仅依靠 红 与 黑 两种颜色控制高度,当触发特定条件时,才会采取 旋转 的方式降低树的高度,使其平衡
🏙️正文
1、认识红黑树
红黑树 由 德国·慕尼黑大学 的 Rudolf Bayer
教授于 1978
年发明,后来被 Leo J. Guibas
和 Robert Sedgewick
修改为如今的 红黑树
红黑树 在原 二叉搜索树 节点的基础上,加上了 颜色 Color
这个新成员,并通过一些规则,降低二叉树的高度
如果说 AVL
树是天才设计,那么 红黑树 就是 天才中的天才设计,不同于 AVL
树的极度自律,红黑树 只在条件符合时,才会进行 旋转降高度,因为旋转也是需要耗费时间的
红黑树在减少旋转次数时,在整体性能上仍然没有落后 AVL 树太多
先来一睹 红黑树 的样貌
注:红黑树在极限场景下,与 AVL
树的性能差不超过 2
倍
1.1、红黑树的定义
红黑树 也是 三叉链 结构,不过它没有 平衡因子,取而代之的是 颜色
红黑树 的节点定义如下:(这里是通过 枚举 定义的颜色)
//枚举出 红、黑 两种颜色
enum Color
{
RED, BLACK
};
//红黑树的节点类
template<class K, class V>
struct RBTreeNode
{
RBTreeNode(std::pair<K, V> kv)
:_left(nullptr)
, _right(nullptr)
, _parent(nullptr)
, _kv(kv)
, _col(RED) //默认新节点为红色,有几率被调整
{}
RBTreeNode<K, V>* _left;
RBTreeNode<K, V>* _right;
RBTreeNode<K, V>* _parent;
std::pair<K, V> _kv;
Color _col;
};
注意: 定义新节点时,颜色可以为 红 也可以为 黑,推荐为 红色,具体原因后面解释
1.2、红黑树的性质
结构上的绝妙注定了其离不开规则的限制,红黑树 有以下几条性质:
- 每个节点不是 红色 就是 黑色
- 根节点是 黑色 的
- 如果一个节点是 红色 的,那么它的两个孩子节点都不能是 黑色 的(不能出现连续的红节点)
- 对于每个节点,从该节点到其所有后代的
NIF
节点的简单路径上,都包含相同数目的黑色节点(每条路径上都有相同数目的 黑色 节点) - 每个叶子节点的
nullptr
称为NIF
节点,并且默认为黑色,此处黑色仅用于路径判断,不具备其他含义
在这些规则的限制之下,红黑树 就诞生了
红黑树 的性质还是比较重要的,可以花点时间结合图示深入理解
1.3、红黑树的特点
红黑树 在插入节点后,可以选择新节点为 红 或 黑
- 如果为 红 ,可能违反原则3,需要进行调整
- 如果为 黑,必然违反原则4,并且因为这一条路,影响了其他所有路径,调整起来比较麻烦
因此 推荐在插入时,新节点默认为 红色,插入后,不一定调整,即使调整,也不至于 影响全局
显然,红黑树 中每条路径都是 红黑相间 的,因为不能出现连续的 红节点,所以 黑节点的数量 >= 红节点
也就是说:红黑树中,最长路径至多为最短路径的两倍
- 最长路径:红黑相间
- 最短路径:全是黑节点
上图中的 最短路径 为 3
,最长路径 为 4
,当然,最短路径 可以为 2
对于 AVL
树来说,下面这个场景必然 旋转降高度,但 红黑树 就不必,因为 没有违背性质
综上, 红黑树 是一种折中且优雅的解决方案,不像 AVL
树 那样极端(动不动就要旋转),而是只有触发特定条件时,才会发生旋转,并且在极端场景下, 两者查询速度差异不过 2
倍,但在插入、删除、修改等可能涉及旋转的操作中,红黑树就领先太多了
假设在约
10
亿 大小的数据中进行查找
AVL
树至多需要30
次出结果- 红黑树至多需要
60
次出结果但是,区区几十次的差异,对于
CPU
来说几乎无感,反而是频繁的旋转操作令更费时间
记住:红黑树在实际中性能更好,适用性更强;AVL 树适用存储静态、不轻易修改的数据
2、红黑树的插入操作
2.1、抽象图
在演示 红黑树 的插入操作时,也需要借助 抽象图,此时的 抽象图 不再代表高度,而是代表 黑色节点 的数量
抽象图中关注的是 黑色节点 的数量
2.2、插入流程
红黑树 的插入流程也和 二叉搜索树 基本一致,先找到合适的位置,然后插入新节点,当节点插入后,需要对颜色进行判断,看看是否需要进行调整
插入流程:
- 判断根是否为空,如果为空,则进行第一次插入,成功后返回
true
- 找到合适的位置进行插入,如果待插入的值比当前节点值大,则往 右 路走,如果比当前节点值小,则往 左 路走
- 判断父节点与新节点的大小关系,根据情况判断链接至 左边 还是 右边
- 根据颜色,判断是否需要进行 染色、旋转 调整高度
整体流程如下(不包括染色调整的具体实现)
bool Insert(const std::pair<K, V> kv)
{
if (_root == nullptr)
{
_root = new Node(kv);
_root->_col = BLACK; //根节点一定是黑色
return true;
}
//寻找合适位置
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_kv.first < kv.first)
{
parent = cur;
cur = cur->_right;
}
else if (cur->_kv.first > kv.first)
{
parent = cur;
cur = cur->_left;
}
else
{
//插入失败
return false;
}
}
//插入节点
cur = new Node(kv);
if (parent->_kv.first < kv.first)
parent->_right = cur;
else
parent->_left = cur;
cur->_parent = parent;
//判断是否需要 染色、旋转
while (parent && parent->_col == RED)
{
Node* grandfather = parent->_parent; //祖父节点
//……
}
return true;
}
红黑树 如何调整取决于 叔叔,即 父亲 的 兄弟节点
并且如果 父亲 为 黑,直接插入就行了,不必调整
如果 父亲为红 并且 叔叔也为红,可以只通过 染色 解决当前问题,然后向上走,继续判断是否需要调整
如果 父亲为红 并且 叔叔为黑 或者 叔叔不存在,此时需要 旋转 + 染色,根据当前节点与父亲的位置关系,选择 单旋 或 双旋,值得一提的是 旋转 + 染色 后,不必再向上判断,可以直接结束调整
关于旋转的具体实现,这里不再展开叙述,可以复用 AVL 中的旋转代码,并且最后不需要调整平衡因子
《C++【AVL树】》
注意: 红黑树的调整可以分为 右半区 和 左半区 两个方向(根据 grandfather
与 parent
的位置关系而定),每个方向中都包含三种情况:单纯染色、单旋+染色、双旋+染色,逐一讲解费时费力,并且两个大方向的代码重复度极高,因此 下面的旋转操作基于 右半区
左半区 的操作和 右半区 基本没啥区别,可以去完整代码中求证
2.3、单纯染色
如果 父亲 为黑色,则不需要调整,不讨论这种情况,下面三种情况基本要求都是:父亲为红
当新节点插入后,如果 叔叔 节点也为 红色,那么可以通过将 祖父 节点的黑色素下放给 父亲和叔叔,祖父节点 变为 红色,这样调整仍可确保 每条路径中的黑色节点数目相同
单次染色还不够,需要从 grandfather
处继续向上判断是否需要 调整,单纯染色后,向上判断可能会变成其他情况,这是不确定的,具体情况具体分析
单纯染色 的操作如下:
注意:c
表示当前节点,p
表示父亲节点,u
表示叔叔节点,g
表示祖父节点
修正: 动图中语句修正为 “父亲为红,叔叔也为红,直接染色即可”
当 单次染色 结束后,更新 cur
至 grandfather
的位置,并同步更新 parent
,继续判断是需要进行 单纯染色、单旋 + 染色 还是 双旋 + 染色
本质:将公共的黑色下放给两个孩子
代码片段如下(右半分区)
//在右半区操作
Node* uncle = grandfather->_left; //叔叔节点
if (uncle && uncle->_col == RED)
{
//染色、向上更新即可
grandfather->_col = RED;
parent->_col = uncle->_col = BLACK;
cur = grandfather;
parent = cur->_parent;
}
else
{
//此时需要 旋转 + 染色
//……
}
叔叔 存在且为 红 很好处理,难搞的是 叔叔 不存在或 叔叔 为 黑,需要借助 旋转 降低高度
注意: 此时的五个抽象图,都代表同一个具象图;如果 parent
为空,证明 cur
为根节点,此时需要把根节点置为 黑色,在返回 true
前统一设置即可
2.4、左单旋 + 染色
单旋:右右、左左,此时在 右半区,所以当 叔叔 不存在或者为 黑色 且节点位于 父亲 的 右边 时,可以通过 左单旋 降低高度
如果在左半区,节点位于父亲的左边时,则使用 右单旋 降低高度
在高度降低后,需要使用 染色 确保符合 红黑树 的性质
旋转 思想很巧妙,在 旋转 + 染色 后,可以跳出循环,结束调整
左旋转 + 染色 的操作如下:
注意:c
表示当前节点,p
表示父亲节点,u
表示叔叔节点,g
表示祖父节点
显然,旋转 + 染色 后,parent
是一定会被修改为 黑色 的,所以不必再往上判断调整,因为现在已经很符合性质了(即使 parent
的父亲是 红色,也不会出现连续的 红色节点)
本质:将 parent
的左孩子托付给 grandfather
后,parent
往上提,并保证不违背性质
代码片段如下(右半分区)
//在右半区操作
Node* uncle = grandfather->_left; //叔叔节点
if (uncle && uncle->_col == RED)
{
//染色、向上更新即可
//……
}
else
{
//此时需要 旋转 + 染色
if (parent->_right == cur)
{
//右右,左单旋 ---> parent 被提上去了
RotateL(grandfather);
grandfather->_col = RED;
parent->_col = BLACK;
cur->_col = RED;
}
else
{
//右左,右左双旋 ---> cur 被提上去了
//……
}
//旋转后,保持平衡,可以结束调整
break;
}
注意: 这种情况多半是由 单纯染色
转变而来的,所以不同区域的抽象图有不同的情况,必须确保能符合红黑树的性质
2.5、右左双旋 + 染色
双旋:右左、左右,此时在 右半区,所以当 叔叔 不存在或者为 黑色 且节点位于 父亲 的 左边 时,可以通过 右左双旋 降低高度
如果在左半区,节点位于父亲的右边时,则使用 左右双旋 降低高度
在高度降低后,需要使用 染色 确保符合 红黑树 的性质
旋转 思想很巧妙,在 旋转 + 染色 后,可以跳出循环,结束调整
右左双旋 + 染色 的操作如下:
注意:c
表示当前节点,p
表示父亲节点,u
表示叔叔节点,g
表示祖父节点
双旋 其实就是两个不同的 单旋,不过对象不同而已,先 右旋转 parent
,再 左旋转 grandfather
就是 右左双旋
本质:将 cur
的右孩子托付给 parent
,左孩子托付给 grandfather
后,把 cur
往上提即可,并保证不违背 红黑树 的性质
代码片段如下(右半分区)
Node* grandfather = parent->_parent; //祖父节点
if (grandfather->_right == parent)
{
//在右半区操作
Node* uncle = grandfather->_left; //叔叔节点
if (uncle && uncle->_col == RED)
{
//染色、向上更新即可
//……
}
else
{
//此时需要 旋转 + 染色
if (parent->_right == cur)
{
//右右,左单旋 ---> parent 被提上去了
//……
}
else
{
//右左,右左双旋 ---> cur 被提上去了
RotateR(parent);
RotateL(grandfather);
grandfather->_col = RED;
parent->_col = RED;
cur->_col = BLACK;
}
//旋转后,保持平衡,可以结束调整
break;
}
注意: 双旋的情况也可以由 单纯变色
转变而来,同样的,不同区域的抽象图代表不同的含义;对 parent
进行右单旋,对 grandfather
进行左单旋
2.6、具体实现代码
总的来说,红黑树 的插入操作其实比 AVL
树 还要略显简单,画图分析后,确认如何 染色 就行了,下面是插入操作的完整源码(包括左、右单旋)
插入
bool Insert(const std::pair<K, V> kv)
{
if (_root == nullptr)
{
_root = new Node(kv);
_root->_col = BLACK; //根节点一定是黑色
return true;
}
//寻找合适位置
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_kv.first < kv.first)
{
parent = cur;
cur = cur->_right;
}
else if (cur->_kv.first > kv.first)
{
parent = cur;
cur = cur->_left;
}
else
{
//插入失败
return false;
}
}
//插入节点
cur = new Node(kv);
if (parent->_kv.first < kv.first)
parent->_right = cur;
else
parent->_left = cur;
cur->_parent = parent;
//判断是否需要 染色、旋转
while (parent && parent->_col == RED)
{
Node* grandfather = parent->_parent; //祖父节点
if (grandfather->_right == parent)
{
//在右半区操作
Node* uncle = grandfather->_left; //叔叔节点
if (uncle && uncle->_col == RED)
{
//染色、向上更新即可
grandfather->_col = RED;
parent->_col = uncle->_col = BLACK;
cur = grandfather;
parent = cur->_parent;
}
else
{
//此时需要 旋转 + 染色
if (parent->_right == cur)
{
//右右,左单旋 ---> parent 被提上去了
RotateL(grandfather);
grandfather->_col = RED;
parent->_col = BLACK;
cur->_col = RED;
}
else
{
//右左,右左双旋 ---> cur 被提上去了
RotateR(parent);
RotateL(grandfather);
grandfather->_col = RED;
parent->_col = RED;
cur->_col = BLACK;
}
//旋转后,保持平衡,可以结束调整
break;
}
}
else
{
//在左半区操作
Node* uncle = grandfather->_right; //叔叔节点
//同理,进行判断操作
if (uncle && uncle->_col == RED)
{
//直接染色
grandfather->_col = RED;
parent->_col = uncle->_col = BLACK;
cur = grandfather;
parent = cur->_parent;
}
else
{
//需要 旋转 + 染色
if (parent->_left == cur)
{
//左左,右单旋 ---> parent 被提上去
RotateR(grandfather);
grandfather->_col = RED;
parent->_col = BLACK;
cur->_col = RED;
}
else
{
//左右,左右双旋 ---> cur 被提上去
RotateL(parent);
RotateR(grandfather);
grandfather->_col = RED;
parent->_col = RED;
cur->_col = BLACK;
}
break;
}
}
}
//再次更新根节点的颜色,避免出问题
_root->_col = BLACK;
return true;
}
左单旋
//左单旋
void RotateL(Node* parent)
{
Node* subR = parent->_right;
Node* subRL = subR->_left;
//将 subR 的孩子交给 parent
parent->_right = subRL;
if (subRL != nullptr)
subRL->_parent = parent;
//提前保存 parent 的父节点信息
Node* pparent = parent->_parent;
//将 parent 变成 subR 的左孩子
subR->_left = parent;
parent->_parent = subR;
//更新 subR 的父亲
if (pparent == nullptr)
{
//此时 parent 为根,需要改变根
_root = subR;
_root->_parent = nullptr;
}
else
{
//根据不同情况进行链接
if (pparent->_right == parent)
pparent->_right = subR;
else
pparent->_left = subR;
subR->_parent = pparent;
}
}
右单旋
//右单旋
void RotateR(Node* parent)
{
//基本原理和左单旋一致
Node* subL = parent->_left;
Node* subLR = subL->_right;
parent->_left = subLR;
if (subLR != nullptr)
subLR->_parent = parent;
Node* pparent = parent->_parent;
subL->_right = parent;
parent->_parent = subL;
if (parent == _root)
{
_root = subL;
_root->_parent = nullptr;
}
else
{
if (pparent->_right == parent)
pparent->_right = subL;
else
pparent->_left = subL;
subL->_parent = pparent;
}
}
关于左右单旋的详细细节,可以去隔壁 AVL
树的文章查看,红黑树 这里不必阐述
2.6、注意事项及调试技巧
红黑树 这里也涉及很多等式 ==
判断,一定要多多注意,不要漏写 =
三叉链 结构,要注意 父指针 的调整
红黑树 的调整情况如下:
- 右半区
右右
左单旋右左
,右左双旋
- 左半区
左左
,右单旋左右
,左右双旋
得益于前面 AVL
树旋转操作的学习,红黑树 这里在编写 旋转 相关代码时,没什么大问题
红黑树 的 DeBug
逻辑与 AVL
树一致,这里额外分享一个 DeBug
技巧:
- 当 随机插入 数据出错时,可以借助文件读写操作,将出错的数据保存下来,然后再次输入,反复进行调试,即可找出
Bug
- 因为是 随机插入 时出现的问题,所以需要保存一下数据样本
关于 红黑树 详细操作可以参考这篇 Blog
:《红黑树(C++实现)》
3、AVL树 VS 红黑树
AVL
树 和 红黑树 是 平衡二叉搜索树 的两种优秀解决方案,既然两者功能一致,那么它们的实际表现如何呢?可以通过大量随机数插入,得出结果
当然,在切磋之前,需要先验证一下之前写的 红黑树 的正确性
3.1、红黑树的检验
可以借助红黑树 的性质,从下面这三个方面进行检验:
- 验证根节点是否为 黑色节点
- 验证是否出现连续的 红色节点
- 验证每条路径中的 黑色节点 数量是否一致
判断黑色节点数量,需要先获取 基准值
- 简单,先单独遍历一遍,其中的路径,这里选择了最左路径,将这条路径中获取的黑色节点数作为基准值,传给函数判断使用
孩子不一定存在,但父亲一定存在(当前节点为 红色 的情况下)
- 所以当节点为 红色 时,判断父亲是否为黑色,如果不是,则非法!
//合法性检验
bool IsRBTree() const
{
if (_root->_col != BLACK)
{
std::cerr << "根节点不是黑色,违反性质二" << std::endl;
return false;
}
//先统计最左路径中的黑色节点数量
int benchMark = 0; //基准值
Node* cur = _root;
while (cur)
{
if (cur->_col == BLACK)
benchMark++;
cur = cur->_left;
}
//统计每条路径的黑色节点数量,是否与基准值相同
int blackNodeSum = 0;
return _IsRBTree(_root, blackNodeSum, benchMark);
}
protected:
bool _IsRBTree(Node* root, int blackNodeSum, const int benchMark) const
{
if (root == nullptr)
{
if (blackNodeSum != benchMark)
{
std::cerr << "某条路径中的黑色节点数出现异常,违反性质四" << std::endl;
return false;
}
return true;
}
if (root->_col == BLACK)
{
blackNodeSum++;
}
else
{
//检查当前孩子的父节点是否为 黑节点
if (root->_parent->_col != BLACK)
{
std::cerr << "某条路径中出现连续的红节点,违反性质三" << std::endl;
return true;
}
}
return _IsRBTree(root->_left, blackNodeSum, benchMark) && _IsRBTree(root->_right, blackNodeSum, benchMark);
}
通过代码插入约 10000
个随机数,验证是否是红黑树
鉴定为 合法
,并且高度有 16
,比 AVL
树略高一层(情理之中)
3.2、性能对比
红黑树 不像 AVL
树那样过度自律,其主要优势体现在 插入数据 时的效率之上,可以通过程序对比一下
void RBTreeTest2()
{
srand((size_t)time(NULL));
AVLTree<int, int> av;
RBTree<int, int> rb;
int begin1, begin2, end1, end2, time1 = 0, time2 = 0;
int n = 100000;
int sum = 0;
for (int i = 0; i < n; i++)
{
int val = (rand() + i) * sum;
sum ++;
begin1 = clock();
av.Insert(make_pair(val, val));
end1 = clock();
begin2 = clock();
rb.Insert(make_pair(val, val));
end2 = clock();
time1 += (end1 - begin1);
time2 += (end2 - begin2);
}
cout << "插入 " << sum << " 个数据后" << endl;
cout << "AVLTree 耗时: " << time1 << "ms" << endl;
cout << "RBTree 耗时: " << time2 << "ms" << endl;
cout << "=================================" << endl;
cout << "AVLree: " << av.IsAVLTree() << " | " << "高度:" << av.getHeight() << endl;
cout << "RBTree: " << rb.IsRBTree() << " | " << "高度:" << rb.getHeight() << endl;
}
此时数据量太小了,还不能体现 红黑树 的价值,还好这次测试,红黑 比 AVL
强
红黑树还是有实力的
红黑树 是 set
和 map
的底层数据结构,在下篇文章中,将会进一步完善 红黑树,并用我们自己写的 红黑树 封装 set / map
,最后可以和库中的切磋一下~
本文中涉及的源码:《RBTree 博客》
🌆总结
以上就是本次关于 C++【红黑树】的全部内容了,在本文中,我们首先了解了什么是 红黑树
,然后对其进行了实现,作为数据结构中的大哥,红黑树
还是有一定难度的,作为被广泛使用的优秀数据结构,简单掌握还是很有必要的
相关文章推荐
C++ 进阶知识
C++【AVL树】
C++【set 和 map 学习及使用】
C++【二叉搜索树】
C++【多态】
C++【继承】
STL 之 泛型思想
C++【模板进阶】
C++【模板初阶】