请君浏览
前言
今天,我们继续踏入追寻C++的冒险历程。之前我们讲解了最早的自平衡二叉搜索树——AVL树,那么本章我们将讲解另一类自平衡二叉搜索树——红黑树。下面让我们一起来进入红黑树树的学习。
1. 红黑树的概念
前面我们讲解了一种自平衡二叉搜索树——AVL树,它可以使自己每一个节点的左右高度差严格保证在1之间,由于它更严格平衡,树高度较低,接近于log₂n
,所以它的旋转次数很多,实现相对复杂。除了AVL树外还有另一种自平衡二叉搜索树,也是今天我们要讲解的主角——红黑树。红黑树是一种自平衡的二叉搜索树,其由来可以追溯到1972年由Russell J. R. R. Anderson 和 Robert Sedgewick 提出的研究。与AVL树引入平衡因子来控制平衡不同,红黑树通过引入**节点的颜色(红色和黑色)**来帮助维护二叉搜索树树的平衡性。
红黑树的每个节点增加⼀个存储位来表⽰节点的颜⾊,可以是红⾊或者⿊⾊。 通过对任何⼀条从根到叶⼦的路径上各个节点的颜⾊进⾏约束,红⿊树确保没有⼀条路径会⽐其他路径⻓出2倍,因⽽是接近平衡的。
1.2 红黑树的规则
那么红黑树是如何做到确保没有⼀条路径会⽐其他路径⻓出2倍的呢?这得益于红黑树特有的规则:
- 每一个节点不是红色就是黑色。
- 根节点是黑色的。
- 任意一条路径不会有连续的红色节点,也就是说一个节点如果是红色的,那么它的孩子只能是黑色的。
- 对于任意⼀个节点,从该节点到其所有叶子节点的简单路径上,必须含有数量相同的⿊⾊节点。
这些性质保证了红黑树的高度不会超过log₂n
,从而确保了良好的性能。
下面让我们来看一看一些具体的红黑树:
观察上面的红黑树我们可以其每一个节点都符合上述的规则,同时也没有任何路径比其他路径长出两倍,这就是红黑树。
说明:
《算法导论》等书籍上补充了⼀条每个叶⼦节点(NIL)都是⿊⾊的规则。他这⾥所指的叶⼦节点不是传统的意义上的叶⼦节点,⽽是我们说的空节点,有些书籍上也把NIL叫做外部节点。NIL是为了⽅便准确的标识出所有路径,《算法导论》在后续讲解实现的细节中也忽略了NIL节点,所以我们知道⼀下这个概念即可
1.3 红黑树如何确保最长路径不超过最短路径的两倍?
由规则4可知,从根到叶子节点的每条路径都有相同数量的⿊⾊节点,所以极端场景下,最短路径就就是全是⿊⾊节点的路径,假设最短路径⻓度为bh(black height)
。
由规则2和规则3可知,任意⼀条路径不会有连续的红⾊节点,所以极端场景下,最⻓的路径就是⼀⿊⼀红间隔组成,那么最⻓路径的⻓度为2*bh
。
如下图所示:
综合红⿊树的4点规则⽽⾔,理论上的全⿊最短路径和⼀⿊⼀红的最⻓路径并不是在每棵红⿊树都存在的。假设任意⼀条从根到叶子节点路径的⻓度为x,那么bh <= h <= 2*bh
。
1.4 红黑树的效率
假设N
是红⿊树树中节点数量,h
最短路径的⻓度,那么2h - 1 <= N < 22*h> - 1, 由此推出 h ≈ log2N ,也就意味着红⿊树增删查改最坏也就是⾛最⻓路径 2 * log2N ,那么时间复杂度还是O(logN)
。
红⿊树的表达相对AVL树要抽象⼀些,AVL树通过⾼度差直观的控制了平衡。红⿊树通过4条规则的颜⾊约束,间接的实现了近似平衡,他们效率都是同⼀档次,但是相对⽽⾔,插⼊相同数量的节点,红⿊树的旋转次数是更少的,因为它对平衡的控制没那么严格。
2. 红黑树的实现
2.1 红黑树的结构
红黑树与AVL树的结构基本上相似,只是AVL树中的每个节点的平衡因子改为了红黑树中每个节点的颜色。
// 枚举值表⽰颜⾊
enum Colour
{
RED,
BLACK
};
// 这⾥我们默认按key/value结构实现
template<class K, class V>
struct RBTreeNode
{
// 这⾥更新控制平衡也要加⼊parent指针
pair<K, V> _kv;
RBTreeNode<K, V>* _left;
RBTreeNode<K, V>* _right;
RBTreeNode<K, V>* _parent;
Colour _col;
RBTreeNode(const pair<K, V>& kv)
: _kv(kv)
, _left(nullptr)
, _right(nullptr)
, _parent(nullptr)
{}
};
template<class K, class V>
class RBTree
{
typedef RBTreeNode<K, V> Node;
public:
//...
private:
Node* _root = nullptr;
}
2.2 红黑树的插入
插⼊⼀个值按⼆叉搜索树规则进⾏插⼊,插⼊后我们只需要观察是否符合红⿊树的4条规则。
为了满足规则1,在空树插⼊时,新增节点必须是⿊⾊节点。如果是⾮空树插⼊,新增节点必须红⾊节点,因为⾮空树插⼊,新增⿊⾊节点就破坏了规则4,规则4是很难维护的。
- ⾮空树插⼊后,新增节点必须红⾊节点,如果⽗亲节点是⿊⾊的,则没有违反任何规则,插⼊结束。
- ⾮空树插⼊后,新增节点必须红⾊节点,如果⽗亲节点是红⾊的,则违反规则3。进⼀步分析,c是红⾊,p是红,g必为⿊(这是因为在插入之前该树是红黑树),这三个颜⾊都固定了,关键的变化看u的情况,需要根据u分为以下⼏种情况分别处理。
说明:
下面假设我们把新增节点标识为c (cur),c的⽗亲标识为p(parent),p的⽗亲标识为g(grandfather),p的兄弟标识为u(uncle)。
例如:
// g // p u // c
情况1:变色
c为红,p为红,g为⿊,u存在且为红,则将p和u变⿊,g变红。在把g当做新的c,继续往上更新。
分析:因为p和u都是红⾊,g是⿊⾊,把p和u变⿊,左边⼦树路径各增加⼀个⿊⾊节点,g再变红,相当于保持g所在⼦树的⿊⾊节点的数量不变,同时解决了c和p连续红⾊节点的问题,需要继续往上更新是因为:g是红⾊
- 如果g的⽗亲还是红⾊,那么就还需要继续处理;
- 如果g的⽗亲是⿊⾊,则处理结束了;
- 如果g就是整棵树的根,再把g变回⿊⾊。
情况1只变⾊,不旋转。所以⽆论c是p的左还是右,p是g的左还是右,都是上⾯的变⾊处理⽅式。
情况2:单旋+变色
c为红且与p在同一条斜线上,p为红,g为⿊,u不存在或者u存在且为⿊
- u不存在,则c⼀定是新增节点。
- u存在且为⿊,则c⼀定不是新增,c之前是⿊⾊的,是在c的⼦树中插⼊,符合情况1,变⾊将c从⿊⾊变成红⾊,更新上来的。
分析上图中左侧u不存在可知,u不存在,所以以g节点到每一个叶子节点的黑色节点数目为1,而c若不是新插入的节点,则代表它是由情况1变色而来的,说明在以c为根节点的子树中还一定存在黑色节点,此时不符合红黑树的规则4,因此如果u不存在,那么c一定是新增节点。同理,右侧如果c是新增节点,同样不符合规则4,因此如果u存在且为黑色,那么c一定不是新增节点。
分析:p必须变⿊,才能解决连续红⾊节点的问题,但是由于u不存在或者是⿊⾊的,这⾥单纯的变⾊⽆法解决问题,需要进行旋转+变⾊。
- 如果p是g的左,c是p的左,那么以g为旋转点进⾏右单旋,再把p变⿊,g变红即可。p变成课这颗树新的根,这样⼦树⿊⾊节点的数量不变,没有连续的红⾊节点了,且不需要往上更新,因为p的⽗亲无论是⿊⾊还是红⾊或者空都不违反规则。
- 如果p是g的右,c是p的右,那么以g为旋转点进⾏左单旋,再把p变⿊,g变红即可。p变成课这颗树新的根,这样⼦树⿊⾊节点的数量不变,没有连续的红⾊节点了,且不需要往上更新,因为p的⽗亲是⿊⾊还是红⾊或者空都不违反规则。
这里的旋转与AVL树中的旋转一模一样,只是不再需要更新平衡因子。
无论c是否为新增节点,都不会影响我们的单旋+变色操作,与AVL树一样,对于旋转我们需要将一些子树给抽象出来,在AVL树中对于这些抽象出来的子树我们只需要知道它们的高度即可,而在红黑树中,我们需要知道这些抽象出来的子树中黑色节点的数量hb。
例如上图,我们需要对g节点进行右单旋:将g变为p的右,d变为g的左,p成为新的根,与AVL树中的右单旋一模一样。只是在红黑树中,我们进行了右单旋后还需要进行变色:旋转完后将p变为黑色,g变为红色:
当hb==0时,也就是u不存在时,也就是c为新增节点:
至于左单旋不再详细介绍,思路与右单旋一样。
情况2:双旋+变色
c为红且与p不在同一条斜线上,p为红,g为⿊,u不存在或者u存在且为⿊
- 与单旋+变色相同,u不存在,则c⼀定是新增节点,u存在且为⿊,则c⼀定不是新增节点。
分析:p必须变⿊,才能解决,连续红⾊结点的问题,u不存在或者是⿊⾊的,这⾥单纯的变⾊⽆法解决问题,需要旋转+变⾊。
- 如果p是g的左,c是p的右,那么先以p为旋转点进⾏左单旋,再以g为旋转点进⾏右单旋,再把c变⿊,g变红即可。c变成课这颗树新的根,这样⼦树⿊⾊结点的数量不变,没有连续的红⾊结点了,且不需要往上更新,因为c的⽗亲是⿊⾊还是红⾊或者空都不违反规则。
- 如果p是g的右,c是p的左,那么先以p为旋转点进⾏右单旋,再以g为旋转点进⾏左单旋,再把c变⿊,g变红即可。c变成课这颗树新的根,这样⼦树⿊⾊结点的数量不变,没有连续的红⾊结点了,且不需要往上更新,因为c的⽗亲是⿊⾊还是红⾊或者空都不违反规则。
其实我们可以发现,无论是红黑树还是AVL树,它们进行单旋还是双旋的逻辑是一样的。有了前面AVL树中的旋转基础,这里我们就不再过多解释,直接用图来带大家理解:
这里用左右双旋演示:
代码演示
下面让我们来看一看具体的插入代码:
// 旋转代码的实现跟AVL树是⼀样的,只是不需要更新平衡因⼦
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 = parent->_right;
}
else if (cur->_kv.first > kv.first)
{
parent = cur;
cur = parent->_left;
}
else
{
return false;
}
}
cur = new Node(kv);
// 新增结点。颜⾊给红⾊
cur->_col = RED;
if (parent->_kv.first > kv.first)
{
parent->_left = cur;
}
else
{
parent->_right = cur;
}
cur->_parent = parent;
while (parent && parent->_col == RED)
{
Node* grandfather = parent->_parent;
if (parent == grandfather->_left)
{
Node* uncle = grandfather->_right;
if (uncle && uncle->_col == RED)
{
// u存在且为红 -> 变⾊再继续往上处理
parent->_col = BLACK;
uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather;
parent = cur->_parent;
}
else
{
// u存在且为⿊或不存在 -> 单旋+变⾊
// g
// p u
// c
if (cur == parent->_left)
{
RotateR(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
}
// 双旋+变色
// g
// p u
// c
else
{
RoteteL(parent);
RoteteR(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
break;
}
}
else
{
Node* uncle = grandfather->_left;
if (uncle && uncle->_col == RED)
{
//u存在且为红 -> 变⾊再继续往上处理
parent->_col = BLACK;
uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather;
parent = cur->_parent;
}
else
{
// u存在且为⿊或不存在 -> 单旋+变⾊
// g
// u p
// c
if (cur == parent->_right)
{
RotateL(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
}
// 双旋+变色
// g
// u p
// c
else
{
RoteteR(parent);
RoteteL(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
break;
}
}
}
_root->_col = BLACK;
return true;
}
2.3 红黑树的查找
按⼆叉搜索树逻辑实现即可,搜索效率为 O(logN)
Node* Find(const K& key)
{
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;
}
红黑树的删除操作相对复杂,因为它需要在删除节点后维护红黑树的特性。与插入一样都需要维护节点的颜色并且还要维护树的平衡,因此这里就不再赘述,感兴趣的可以去了解一下。这里只是带大家认识一下红黑树以及体会一下其维护平衡的一些做法。
3. 红黑树的验证
那么我们如何去判断一棵树是否是红黑树呢?或者说在插入或者删除后如何检测该树是否还是红黑树。
如果只是获取最⻓路径和最短路径,检查最⻓路径不超过最短路径的2倍是不可⾏的,因为就算满⾜这个条件,红⿊树也可能颜⾊不满⾜规则,当前暂时没出问题,后续继续插⼊还是会出问题的。
所以我们还是去检查4点规则,满⾜这4点规则,⼀定能保证最⻓路径不超过最短路径的2倍。
规则1枚举颜⾊类型,天然实现保证了颜⾊不是⿊⾊就是红⾊。
规则2直接检查根即可。
规则3前序遍历检查,遇到红⾊结点查孩⼦不太⽅便,因为孩⼦有两个,且不⼀定存在,反过来检查⽗亲的颜⾊就⽅便多了。
规则4前序遍历,遍历过程中⽤形参记录跟到当前结点的
blackNum
(⿊⾊结点数量),前序遍历遇到⿊⾊结点就++blackNum
,⾛到空就计算出了⼀条路径的⿊⾊结点数量。再以任意⼀条路径⿊⾊结点数量作为参考值,依次⽐较即可。
下面让我们来看一看具体的代码实现:
bool Check(Node* root, int blackNum, const int refNum)
{
if (root == nullptr)
{
// 前序遍历⾛到空时,意味着⼀条路径⾛完了
//cout << blackNum << endl;
if (refNum != blackNum)
{
cout << "存在⿊⾊结点的数量不相等的路径" << endl;
return false;
}
return true;
}
// 检查孩⼦不太⽅便,因为孩⼦有两个,且不⼀定存在,反过来检查⽗亲就⽅便多了
if (root->_col == RED && root->_parent->_col == RED)
{
cout << root->_kv.first << "存在连续的红⾊结点" << endl;
return false;
}
if (root->_col == BLACK)
{
blackNum++;
}
return Check(root->_left, blackNum, refNum)
&& Check(root->_right, blackNum, refNum);
}
bool IsBalance()
{
if (_root == nullptr)
return true;
if (_root->_col == RED)
return false;
// 参考值
int refNum = 0;
Node* cur = _root;
while (cur)
{
if (cur->_col == BLACK)
{
++refNum;
}
cur = cur->_left;
}
return Check(_root, 0, refNum);
}
4. 红黑树与AVL树
红黑树和AVL树都是自平衡二叉搜索树,那它们之间有什么区别呢?
特性 | AVL树 | 红黑树 |
---|---|---|
平衡条件 | 每个节点的左右子树高度差最多为1 | 每条从根到叶子的路径上,黑色节点数相同,且不允许两个连续的红色节点 |
树的高度 | 更严格平衡,树高度较低,接近于log₂n | 平衡条件较松,树高度较AVL树略高,接近于2*log₂n |
旋转次数 | 插入和删除时旋转次数较多 | 插入和删除时旋转次数较少 |
实现复杂度 | 实现相对复杂,需要维护节点的高度或平衡因子 | 实现较复杂,需要维护节点的颜色属性 |
查找效率 | 查找效率较高,因为树高度更低 | 查找效率稍低,但仍为对数时间复杂度 |
删除操作性能 | 删除较复杂,可能引起多次旋转 | 删除相对简单,旋转次数较少 |
红黑树的优点:
- 插入和删除效率较高:旋转次数较少,写操作性能较好,适合写操作较频繁的场景。
- 实现灵活:由于平衡条件较松,实现和维护相对灵活。
- 广泛应用:许多库和语言(如Linux内核、Java的TreeMap等)使用红黑树作为底层数据结构。
AVL树的优点:
- 查询性能优越:由于其严格的平衡条件,AVL树的高度最小,查询操作速度更快,适合读操作多于写操作的场景。
- 平衡维护严格:高度差保证在1以内,保证了高度的最小化。
尾声
若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!