前言
上篇博客学习了平衡二叉搜索树(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). 测试
- 四. 迭代器
- (1). 迭代器的结构体
- (2). 迭代器的访问
- 运算符重载
- 结束语
一. 什么是红黑树
首先,红黑树,是一种二叉搜索树
,但在每个结点上增加一个存储位表示结点的颜色
,可以是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
// 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记录黑色结点的个数
,我们在检查前,先遍历最左路径,计算出黑色结点个数,然后拿着这个值去每一条路径比较,如果有不一样的,要么最左路径出问题,要么该路径出问题
四. 迭代器
红黑树因其特殊结构和高效查找,插入,删除效率而应用广泛。C++STL中的map和set的底层就是使用的红黑树。所以我们要为红黑树提供访问的各种接口,迭代器就是其中最为重要的访问方式
。
迭代器本质就是结点的指针,但是是将结点指针和方法结合起来的结构体。
但是因为红黑树和线性表并不同,并不是连续的存储结构,结点之间是依靠指针连接的,所以迭代器的实现并不容易。那么接下来,我们就来一点点设计红黑树的迭代器
(1). 迭代器的结构体
首先,迭代器需要应用于抽象数据,所以我们需要使用模板,同时迭代器是一个结构体,所以我们需要重载一些运算符,来实现其效果
以下是迭代器结构体的代码:
//迭代器
template<class T,class Ref,class Ptr>
struct _RBTreeIterator
{
typedef RBTreeNode<T> Node;
typedef _RBTreeIterator<T, Ref, Ptr> Self;
public:
//构造函数
_RBTreeIterator(const Node*node)
:_node(node)
{}
private:
Node*_node;
};
模板参数:
class T
:红黑树结点的数据类型
class Ref
:返回迭代器内部指针解引用的成员变量类型的引用
适配*运算符的重载
class Ptr
:返回迭代器内部的指针
适配->运算符的重载
(2). 迭代器的访问
我们预期的迭代器的使用方式是这样的:我们拿set作例子
void test_set1()
{
set<int> s;
s.Insert(1);
s.Insert(2);
s.Insert(3);
set<int>::iterator it = s.begin();
while (it != s.end())
{
cout << *it << " ";
++it;
}
cout << endl;
}
需要可以一个结点一个结点的遍历set中的元素,因为底层红黑树是树结构,中序遍历是一次性全访问完,而迭代器实现的就是像线性结构那样,一个结点一个结点的访问。
set和map的大部分功能实现,其实都是直接使用红黑树的功能
begin()
首先使用迭代器遍历,我们需要确定红黑树遍历的起始位置
,因为中序遍历出来的是升序,所以我们第一个访问的结点就是最小结点
。而最小结点在哪呢?其实红黑树的最左结点就是最小结点
,所以我们只需要遍历到最左结点就好了。代码如下:
//返回最左结点
iterator begin()
{
Node*cur = _root;
//可能是空树,所以要判断一下
while (cur&&cur->_left)
cur = cur->_left;
return iterator(cur);
}
需要注意的点是当前树可能是空树
,所以不能一开始就解引用,可能出现空指针解引用的情况
begin()返回的位置如下
end()
end直接返回空就好了,因为各结点的链接关系都完整,遍历结束后,会访问到最右结点的下一个结点
,也就是空结点
iterator end()
{
return iterator(nullptr);
}
接下来是几个运算符重载,因为迭代器不是内置类型,所以对于迭代器的!=
,*
,++
,编译器都是无法识别的
运算符重载
!=运算符重载
因为迭代器内部就一个指针类型,所以判断是否相同,其实就是用指针比较
代码如下:
//判断
//Self是重命名的迭代器类型
bool operator!=(const Self&s)
{
return _node != s._node;
}
*运算符重载
因为*的作用是解引用,返回存储的数据,并且支持修改,所以我们需要使用引用返回
代码如下:
//返回解引用的数据
//Ref是T&或者const T&
Ref operator*()
{
return _node->_data;
}
++运算符重载
比如这样一棵树,begin()返回的位置如图,我们要使用++来遍历这棵树,分为以下几种情况
- 当前结点的
右结点不为空
,如果接下来要访问的就是右子树的最左节点
右结点为空
,再分两类(1). 如果当前访问的结点是
父亲结点的右结点
,那么父亲结点就一定访问过了
,我们访问父亲结点的父亲结点,也就是祖父结点,同时也需要判断父亲结点是否是祖父结点的右结点
(2). 如果当前访问的结点是父亲结点的左结点
,那么父亲结点就是下一个访问的结点
代码如下:
//迭代器++
Self&operator++()
{
//右不为空,下一个就是右子树的最左结点
if (_node->_right)
{
Node*subLeft = _node->_right;
while (subLeft->_left)
subLeft = subLeft->_left;
_node=subLeft;
}
else
{
//右为空,向上找,找孩子是父亲结点的左孩子
Node*parent = _node->_parent;
while (parent&&parent->_right == _node)
{
_node = parent;
parent = parent->_parent;
}
_node = parent;
}
return *this;
}
--运算符
--运算符
的大逻辑跟++运算符相同,只不过逻辑条件不一样
- 如果当前结点的左结点不为空,则下一个访问的是左结点的最右结点
- 如果当前结点的左结点为空,则同样分为两类
(1). 结点是父亲结点的左结点,那么父亲结点已经访问过了
(2). 结点是父亲结点的右结点,那么父亲结点就是下一个访问的结点
代码如下:
//迭代器--
Self&operator--()
{
//左结点不为空,找左结点的最右结点
if (_node->_left)
{
Node*subRight = _node->_left;
while (subRight->_right)
{
subRight = subRight->_right;
}
_node = subRight;
}
else
{
//左结点为空,向上找结点是其父亲结点的右孩子
Node*cur = _node;
Node*parent = cur->_parent;
//找结点是其父亲结点的右孩子
while (parent&&cur == parent->_left)
{
cur = parent;
parent = parent->_parent;
}
_node = parent;
}
return *this;
}
结束语
本篇博客内容暂时到此,红黑树结点的删除,较为复杂,后续再继续补充
本篇内容到此就结束了,感谢你的阅读!
如果有补充或者纠正的地方,欢迎评论区补充,纠错。如果觉得本篇文章对你有所帮助的话,不妨点个赞支持一下博主,拜托啦,这对我真的很重要。