前言
上篇博客学习了平衡二叉搜索树(AVLTree)
,了解到AVL树的性质,二叉搜索树因为其独特的结构,查找、插入和删除在平均和最坏情况下都是O(logn)
。AVL树的效率就是高在这个地方。
但是在AVL树中插入或者删除结点,使得高度差的绝对值大于1
。此时,AVL树的平衡状态就被破坏,它就不再是一棵平衡二叉树;为了让它重新维持在一个平衡状态,就需要对其进行旋转处理
,但是因为每个结点的高度差的绝对值都要小于1,这个条件较为的严格,所以导致多数情况的插入和删除都需要旋转调整,导致插入和删除的效率降低。
这时红黑树应运而生,并且因为其接近平衡
的结构,使其查找效率也很高效,同时因为没有AVL树那样的严格要求,所以其插入和删除效率有时还高于AVL树,使其综合性能高于AVL树
,所以红黑树的应用十分的广泛,比如在 Java 的集合框架 (HashMap、TreeMap、TreeSet),C++ 的 STL中都有以红黑树为底层结构实现的容器
那红黑树到底是什么呢?又是如何实现的呢?
文章目录
- 前言
- 一. 什么是红黑树
- 二. 红黑树的效率
- 三. 红黑树实现
- (1). 结点结构体
- (2). 结点的插入
- 1. 情况一
- 2. 情况二
- 3. 情况三
- 4. 情况四
- 5. 小总结
- 6. 代码实现
- (3). 查找
- (4). 红黑树的销毁
- (5). 测试
- 结束语
一. 什么是红黑树
首先,红黑树,是一种二叉搜索树
,但在每个结点上增加一个存储位表示结点的颜色
,可以是Red或Black
。通过对任何一条从根到叶子的路径上各个结点着色方式的限制
,红黑树确保没有一条路径会是其他路径的两倍
,因而是接近平衡
的
AVL树是依靠每个结点的左右高度差小于1来完成整棵树的平衡,而不会出现歪脖子树的场景
但是这样的要求较为的严格,导致插入和删除效率降低,而红黑树保持接近平衡是依靠以下几个规则:
- 每个结点不是红色就是黑色
- 根结点是黑色的
- 如果一个结点是红色的,则它的两个孩子结点是黑色的
- 对于每个结点,从该节点到其后代叶子结点的简单路径上,均包含相同数目的黑色结点
- 每个叶子结点都是黑色的(此处的
叶子结点指的是空结点
)
从3规则我们可以推导出,红黑树中没有连续的红色结点
同时,红黑树中,最短路径最少是全是黑色结点的路径
,而最长路径最多就是红黑相间的结点
而每个路径的黑色结点个数相同,最长路径最多是黑红相间,所以最长路径最多是 黑+红=2黑
二. 红黑树的效率
红黑树的最短路径:全黑
红黑树的最长路径:一黑一红,红黑相间
假设总共有N个结点
那么最短路径就是以2为底的logN
最长路径就是2logN
所以红黑树的效率最差就是2logN
而AVL树的查找效率是logN,二者的效率仅是2倍
因为以2为底的logN已经是不大的数了,所以2倍差距并不大。
但是红黑树的调整没有AVL树频繁,所以综合效率红黑树更胜一筹
像这样一棵树,如果是AVL树,则右边高度比左边高2,需要旋转,但是符合红黑树的条件,不用旋转
三. 红黑树实现
(1). 结点结构体
红黑树结点的结构体大致与AVL树相同,但不需要平衡因子,而需要一个标记颜色的存储位
我们可以使用枚举体定义这个颜色的存储位。
代码如下:
//颜色的枚举体
enum Colour
{
RED,
BLACK,
};
//三叉链
//结点的结构体
template<class K, class V>
struct RBTreeNode
{
RBTreeNode<K, V>*_left;//左指针
RBTreeNode<K, V>*_right;//右指针
RBTreeNode<K, V>*_parent;//双亲指针
Colour _col;//颜色标记位
pair<K, V>_kv;//KV值
//构造函数
RBTreeNode(const pair<K, V>&kv)
:_left(nullptr)
, _right(nullptr)
, _parent(nullptr)
, _kv(kv)
, _col(RED)
{
}
};
//红黑树的类
template<class K, class V>
class RBTree
{
typedef RBTreeNode<K, V>Node;
private:
//根节点
Node*_root = nullptr;
};
(2). 结点的插入
结点的插入,首先我们面临的一个问题是,默认插入的结点为什么是红色的呢?
新插入的结点是红色还是黑色,本质是要违反规则3
,还是规则4
。
而我们为什么不能让新插入的结点为黑色的话,一定改变
路径上的黑色结点的个数,因为一次只能插入一个结点;但是如果插入红色结点,如果父亲节点是黑色
,那插入就没有问题,如果为红色,那也只是影响最多两条路径,所以新增结点默认为红色。
而红黑树也是二叉搜索树,所以最开始时,结点的插入和二叉搜索树一样,都是通过kv值找到应插入的位置
那么接下来,我们就来分析结点插入的几种可能
1. 情况一
cur是新增结点,因为parent是黑色,所以插入结束
2. 情况二
结点15是新增结点,但是parent是红色结点,违反了规则3,出现了连续的红色结点,所以我们需要调整,又因为规则4,每个路径的黑色结点的个数相同,所以我们可能会修改隔壁路径。所以我们将这几个结点命名一下
调整的方式是这样的:
为了同时满足3,4规则,我们要改变结点颜色的同时,路径上的黑色结点个数还要相同,那么其实就是一直改变颜色,并且往上更新,最后到达两个路径的公共结点,然后在公共结点更改颜色,则不会影响这两个路径。
具体方法:如果存在uncle结点,且uncle结点为红色
,则将parent结点和unclude结点都变成黑色
,然后让grandfather结点变成红色
然后因为grandfather当前为根结点,所以再将grandfather结点变成黑色
我们发现,改变后的红黑树依然满足5条规则
如果结点数更多一点呢?
比如这样一棵红黑树
28是新增的结点
我们同样使用上述方法调节颜色
可以看到以25为根结点的子树完成了调整
但是这只是完成了一颗子树,所以还需要继续向上调整
所以我们让 cur=grandfather ,parent=cur->parent
这时parent有三种情况:
- 为整棵树的根结点。调整结束
- parent结点为黑色。调整结束
- parent结点为红色,继续调整
此处parent结点是红色,所以我们还需继续调整
最后因为根节点需要是黑色的
,我们再将grandfather变成黑色
这样就完成了调整
这就是情况二:
插入结点的父亲结点是红色的,uncle结点存在且为红
则将parent和uncle都变黑
,grandfather变红
,同时还需要继续向上更新
。
3. 情况三
当我们在6的右边新增结点
此时没有uncle结点,不满足情况二。
我们将grandfather这棵子树拎出来
我们会发现,这棵树的高度很不均衡,我们可以采用旋转
的方式,降低这棵树的高度
图中这棵树的右边高度较高,所以我们要采用左单旋的方式
先将grandfather -> right = parent -> left,
将parent的左结点给grandfather的右
再 parent -> left = grandfather,将grandfather给parent的左
这样子,这棵子树的高度就被我们降低了,并且没有破坏二叉搜索树的结构
但是还不满足红黑树,所以我们还需要调色
将parent变黑,grandfather变红
然后整棵树也就符合规则
单旋即后续的双旋,更详细的讲解可以阅读【C++STL】AVL树
简单来说
当出现这样的情况时,线性使用单旋
前者左边较高
,使用右单旋
平衡高度
后着右边较高
,使用左单旋
平衡高度
出现以下情况,折线型使用双旋
前者,左右双旋:先以拐点(红点)为轴点,先进行左单旋
,变成左边高,再以黑点为轴点进行右单旋
后者,右左双旋:先以拐点(红点)为轴点,先进行右单旋
,变成右边高,再以黑点为轴点进行左单旋
这里只作大致讲解,详细讲解可以阅读上面提到的博客
红黑树右单旋的情况和左单旋类似,读者可自己先尝试一下
旋转过程如下:
然后再调色
4. 情况四
情况二是uncle结点是红色
,情况三是不存在uncle结点
。
情况四则是uncle结点为黑色
。
首先,什么时候会出现uncle结点为黑色
比如这样一棵红黑树
我们在4的左结点新增结点
那么首先看到parent和uncle都是红色,这是情况二
我们将parent和uncle变成黑色,grandfather变成红色
grandfather这棵子树完成调色,但是还需要继续向上调色
这时发现parent是黑色,插入成功,本次插入就结束了
我们在再在11的右子树新增结点
同样调整颜色,然后继续向上调整
而此时我们发现parent是红色,但是uncle却是黑色。
因为在之前的插入,uncle位置从红色变成了黑色,而parent在之后的一次插入中是grandfather,变成了红色
导致两个兄弟节点的颜色不同。
并且此时处于折现型
则我们需要使用双旋
这就是我们的情况四:parent为红,uncle存在且为黑色
双旋的过程如下:
我们先以parent为轴点,进行左单旋
先将parent->right=cur->left
; 将cur的左给parent的右
再将cur->left=parent
; 将parent变成cur的左
第一步旋转变成这样
第二步旋转,再以grandfather为轴点,进行一次右单旋
先将grandfather->left=cur->right
;将cur的右给grandfather的左
再将cur->right=grandfather
;将grandfather变成cur的右
最后将cur变成黑色,grandfather变成红色
这样,红黑树的左右双旋就完成了
5. 小总结
上述的四种情况,对应的是代码中的分类条件
及旋转后的调色方法
而单旋或双旋的使用情况,是依照树的结构:线性,则使用单旋
;折线型,则使用双旋
6. 代码实现
//插入
bool Insert(const pair<K, V>&kv)
{
if (_root == nullptr)
{
_root = new Node(kv);
//根节点是黑色的
_root->_col = BLACK;
return true;
}
Node*cur = _root;
Node*parent = nullptr;
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->_left == parent)
{
Node*uncle = grandfather->_right;
//uncle结点存在且为红
//调色,并继续向上调整
if (uncle&&uncle->_col == RED)
{
//调色
parent->_col = BLACK;
uncle->_col = BLACK;
grandfather->_col = RED;
//继续向上调色
cur = grandfather;
parent = cur->_parent;
}
else//unlce不存在,或者uncle存在且为黑
{
// g
// p u
// c
//右单旋
if (cur == parent->_left)
{
//以parent为轴点,进行右单旋
RotateR(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
break;
}
else
{
// g
// p u
// c
//左右双旋
RotateL(parent);
RotateR(grandfather);
cur->_col = BLACK;
grandfather->_col=RED;
}
break;
}
}
else //parent是grandfather的右边
{
Node*uncle = grandfather->_left;
//uncle结点存在且为红
//调色,并继续向上调整
if (uncle&&uncle->_col == RED)
{
//调色
parent->_col = BLACK;
uncle->_col = BLACK;
grandfather->_col = RED;
//继续向上调色
cur = grandfather;
parent = cur->_parent;
}
else//unlce不存在,或者uncle存在且为黑
{
// g
// u p
// c
//左单旋
if (cur == parent->_right)
{
//以parent为轴点,进行左单旋
RotateL(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
break;
}
else
{
// g
// u p
// c
//右左双旋
RotateR(parent);
RotateL(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
}
}
}
//将根的颜色变成黑色
_root->_col = BLACK;
}
//左单旋
void RotateL(Node*parent)
{
Node*subR = parent->_right;
Node*subRL = subR->_left;
//1. 将subRL变成parent的右节点
parent->_right = subRL;
if (subRL)
subRL->_parent = parent;
//记录当前子树的父节点
Node*ppnode = parent->_parent;
//2. 将parent变成subR的左节点
subR->_left = parent;
parent->_parent = subR;
//3. 链接ppnode
if (ppnode == nullptr)
{
//如果是ppnode是空,代表parent是根节点
//更新根
_root = subR;
_root->_parent = nullptr;
}
else
{
if (ppnode->_left == parent)
{
ppnode->_left = subR;
}
else
{
ppnode->_right = subR;
}
subR->_parent = ppnode;
}
}
//右单旋
void RotateR(Node*parent)
{
Node*subL = parent->_left;
Node*subLR = subL->_right;
//1.将subLR变成parent的左节点
parent->_left = subLR;
//subLR可能是NULL,不是NULL才链接
if (subLR)
subLR->_parent = parent;
//2.再将parent变成subL的右节点
Node*ppnode = parent->_parent;//因为parent不一定是根节点,所以需要记录爷爷节点
subL->_right = parent;
parent->_parent = subL;
//3.链接parent指针
if (ppnode == nullptr)
{
//如果是根节点
_root = subL;
_root->_parent = nullptr;
}
else
{
//反之不是
if (ppnode->_left == parent)
{
ppnode->_left = subL;
}
else
{
ppnode->_right = subL;
}
subL->_parent = ppnode;
}
}
(3). 查找
红黑树的查找本质就是二叉搜索树的查找,根据二叉搜索树的性质:右子树的所有结点都比当前结点的值小,左子树的所有结点都比当前结点的值大
这样我们使用循环,比较大小,就可以决定接下来要去往左子树遍历还是右子树遍历
代码如下:
//查找
Node* Find(const K&key)
{
assert(_root);
Node*cur = _root;
while (cur)
{
if (cur->_kv.first < key)
cur = cur->_right;
else if (cur->_kv.first > key)
cur = cur->_left;
else
return cur;
}
//到这就是没找到
return nullptr;
}
(4). 红黑树的销毁
销毁我们可以使用析构函数,在当前函数结束时,就自动销毁红黑树,而析构函数内部其实调用一个后续遍历依次释放每个节点
代码如下:
//析构函数
~RBTree()
{
_Destroy(_root);
_root = nullptr;
}
//销毁
void _Destroy(Node*root)
{
//后续递归销毁
if (root == nullptr)
return;
_Destroy(root->_left);
_Destroy(root->_right);
free(root);
}
(5). 测试
红黑树的测试可以分两步
第一步检查是否满足二叉搜索树的性质:我们使用中序遍历验证
第二步根据红黑树的性质:1. 根结点是黑色的 2. 每个路径的黑色结点个数相同 3. 不存在连续的红色结点 验证
中序遍历的代码如下:
//中序遍历接口
void InOrder()
{
_InOrder(_root);
cout << endl;
}
//中序遍历
void _InOrder(Node*root)
{
if (root == nullptr)
return;
_InOrder(root->_left);
cout << root->_kv.first << " ";
_InOrder(root->_right);
}
中序遍历较为简单,这里不作解释
检查红黑树性质代码如下:
//检查是否满足红黑树
bool IsBalance()
{
//检查黑色结点的个数
//最左路径的黑色结点个数
int benchMark = 0;
Node*cur = _root;
while (cur)
{
if (cur->_col == BLACK)
benchMark++;
cur = cur->_left;
}
return _Check(_root,0,benchMark);
}
//递归检查
bool _Check(Node*root,int blackNum,int benchMark)
{
//检查三个条件
//1.根结点是否是黑色
//2.是否有连续的红色结点
//3.是否每条路径的黑色结点个数相同
if (root == nullptr)
{
if (blackNum == benchMark)
return true;
else
{
cout << "黑色结点个数异常" << endl;
return false;
}
}
//1.根结点如果是红色的,代表编写异常
if (root == _root && root->_col == RED)
{
cout << "根结点颜色异常" << endl;
return false;
}
//2.如果当前结点是红色的
//则需要检查其父亲结点是否是红色
if (root->_col == RED && root->_parent->_col == RED)
{
cout << "出现连续的红色结点" << endl;
return false;
}
//如果是黑色结点,则要记录
if (root->_col == BLACK)
{
blackNum++;
}
return _Check(root->_left, blackNum, benchMark) &&
_Check(root->_right, blackNum, benchMark);
}
- 如果根结点是红色的,异常
- 如果当前结点是红色的,并且父亲结点也是红色,即出现连续的红色结点,异常
这里检查父亲呢:因为一个结点,一定有父亲结点,但是不一定有孩子结点 - blackNum记录黑色结点的个数,我们在检查前,先遍历最左路径,计算出黑色结点个数,然后拿着这个值去每一条路径比较,如果有不一样的,要么最左路径出问题,要么该路径出问题
结束语
本篇博客内容暂时到此,红黑树结点的删除,较为复杂,后续再继续补充
本篇内容到此就结束了,感谢你的阅读!
如果有补充或者纠正的地方,欢迎评论区补充,纠错。如果觉得本篇文章对你有所帮助的话,不妨点个赞支持一下博主,拜托啦,这对我真的很重要。