博客主页:【夜泉_ly】
本文专栏:【数据结构】
欢迎点赞👍收藏⭐关注❤️
文章目录
- 💡红黑树
-
- [📖 简介](#📖 简介)
-
- [✨ 特点](#✨ 特点)
- [📏 规则](#📏 规则)
- [🛤️ 路径](#🛤️ 路径)
- [➕ 插入](#➕ 插入)
-
- [📏 规则](#📏 规则)
- [📝 具体示例](#📝 具体示例)
- [💻 代码实现](#💻 代码实现)
- 🖼️示意图
💡红黑树
📖 简介
RB
指 red
和 black
,是的,本文讲红黑树。
和AVL树一样,红黑树也是一颗二叉平衡搜索树。因此,如果对二叉搜索树不熟悉的,可以先去看看我写的数据结构 -Binary Search Tree -1 ;如果对二叉平衡搜索树不熟悉的,可以先去看看我写的数据结构 -AVL Tree。
✨ 特点
相较于AVL树,红黑树并没有特别严格的控制左右子树的高度差,而是规定最长路径不超过最短路径的二倍 需注意,这个不超过二倍不是通过控制具体高度得来的,而是在红黑树的一系列规则下自然达到的,因此,只要在插入删除时满足红黑树的规则,就可以达到不超过二倍的效果。而在之后的操作中,我们只需要关注局部符合 红黑树的性质,就能保证整体符合红黑树的性质。
📏 规则
红黑树通过颜色将所有节点分为两类:红节点和黑节点。并通过颜色控制树整体的形状。
现在简单介绍一下红黑树的规则:
性质1. 结点是红色或黑色。
性质2. 根结点是黑色。
性质3. 每个红色结点的两个子结点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色结点)
性质4. 从任一结点到其每个叶子的所有路径都包含相同数目的黑色结点。
性质5. 所有叶子都是黑色。(叶子是NIL结点)
CV自:百度百科--红黑树
🛤️ 路径
这里重点解释一下性质5。
首先需要知道,NIL节点在此处就是指空节点。
那么为什么要特意强调空节点为黑呢?
这是大佬想让我们正确的认识路径,例如下面这棵树就不是红黑树:
因为如果将空节点补出来,那么将不满足所有路径黑节点相同这一规则:
再来理解一下为什么路径差可以不超过二倍:
首先,有几颗纯黑的树(由于需要满足黑节点数相同,因此都是完全二叉树):
然后在符合规矩的情况下随意插入红色节点,或改变黑节点的颜色:
由于必须满足各路径黑节点数相同 + 红节点不能连续出现:
- 最长路径:红黑相间
- 最短路径:全黑
因此,只需要保证颜色符合红黑树的规定,那么高度就自然维持了平衡。
而空节点主要起辅助作用,可以帮助我们更好的理解路径,但在改变颜色和旋转二叉树这块不太需要在意空节点,因此,在之后的图中,我会省略空节点。
➕ 插入
在浅浅的理解了路径后,我们来玩牌吧。
现在花火会不断向我们发牌,而我们需要按红黑树的规则将牌摆放在正确的位置。
📏 规则
这里重新声明一下游戏规则:
- 规则一 :牌面向下对应黑色节点,牌面向上对应红色节点
- 规则二 :放置在最上方的手牌,牌面向下
- 规则三 :不能出现连续两张牌面向上的牌(可以认为当放置了一张牌面向上的牌,那么其下面两张牌必定牌面向下)
错误示例 :
- 规则四 :对于每张手牌,左右路径 牌面向下的手牌数相等
📝 具体示例
在插入的过程中,首先需要明确一点------手牌的初始为牌面向上 。(毕竟牌面向下都不能看到点数)
现在花火发下了第一张牌:
7
是根,因此我们把它翻一下:
规律一 :是的,从第一张牌上就能总结出一个规律------如果一张牌为根,即没有父亲牌,则将其置为牌面向下。
第二张牌:
按二叉搜索树的规定,将它摆在 7
的左下。又因为没有违反红黑树的规定,因此维持牌面向上:
第三张牌:
与第二张牌类似,直接放在正确位置即可:
规律二:现在我们可以总结出第二个规律了------如果插入牌的 父亲牌 为 牌面向下,则不用做其他调整。
花火发下第四张牌:
插入后,发现出事了:
按照规定,两张连续的手牌不能同时牌面向上。因此这里必须改变手牌的状态,改 Q
明显不行,因为 9
和 7
都会违反左右路径 牌面向下的手牌数相同 这条游戏规则(之后简称游戏规则四),因此只能翻 9
:
现在只有 7
违反游戏规则四,这个简单,翻6
就行了:
规律三--其一:经过上面这一次插入,可以总结一个规律,虽然不完整,但值得记录------如果插入牌的 父亲牌 为 牌面向上 ,插入牌的 叔叔牌 也为 牌面向上,那么直接翻转父亲牌与叔叔牌。
花火发下了第五张牌:
插入后,发现又出事了:
首先,插入牌的父亲牌为牌面向上,不能用规律二,且不存在叔叔牌,因此不能用规律三,最最重要的一点:该情况违反了红黑树节点( 9
)到空节点的最长路径不超过最短路径的两倍。
这时就只能进行旋转了------在AVL世界线中,安比、妮可和铃为我们演示了四种旋转,对应一下,发现这里是左单旋,因此操作对象是插入牌的 祖先牌 9
。RotateL(Card* 9)
,就能把 9
按下去:
现在需要做的就是翻牌了,为了保持 7
的右边路径只有一张手牌的牌面向下,我们将祖先牌 9
翻转、父亲牌 Q
翻转:
规律四--其一 :依然是个不完整的规律------如果父亲牌为牌面向上,叔叔牌不存在,需要根据形状进行旋转。
规律五--其一:在AVL中,我们关注的是平衡因子,在RB中,我们需要关注牌的状态。而这条规律记录的是旋转后应该如何翻牌------如果是单旋,将祖先牌、父亲牌翻转。
花火发下了第六张牌:
没关系,先插入再说:
我们一看,父亲牌、叔叔牌都为牌面向上,这不是规律三吗,于是将这两张牌翻转:
很显然,违反了游戏规则四,这时如果改插入牌、父亲牌、叔叔牌都不行,这会否定我们总结的规律三,因此只能改祖先牌:
这次就没问题了。
规律三--其二:经过这一次插入,我们发现我们所操作的可能只是牌堆的一部分,此处的祖先牌翻转后可能又会违反游戏规则,因此在调整后可能还会继续向上调整------将祖先牌翻转,并将祖先牌作为插入牌继续调整。
花火发下了第七张牌:
这简单,直接插入就行:
可能是看我们已经很熟练了,花火嘿嘿一笑,发下了第八张牌:
老规矩,不管什么牌,先放在正确位置再谈调整:
首先,我们看到的是父亲牌、叔叔牌的牌面都向上,规律三在此处适用:
现在将 9
作为插入牌,继续调整。
父亲牌为牌面向上,再看看叔叔牌,牌面向下!?之前的规律似乎没有针对这种情况的。
看看高度,好家伙,祖先牌 7
不满足左右高度相等,需要进行旋转。
再看看形状------V字形,符合AVL世界线中的右左双旋,根据安比、妮可、铃的演示,我们对这种旋转有了清晰的认识,因此直接两步到位:
先把插入牌、父亲牌、祖先牌拆开,插入牌与其左路径、右路径也拆开:
祖先牌变插入牌的左子牌,父亲牌变插入牌的右子牌,并重新连接:
最后翻转插入牌 9
,翻转祖先牌 7
,以维持当前局部牌面向下的牌数不变:
规律四--其二 :补充的规律四------如果父亲牌的牌面向上,叔叔牌的牌面向下,也要根据形状进行旋转。
规律五--其二:应该是最后一条规律了------如果进行双旋,将祖先牌、插入牌翻转。
💻 代码实现
花火一看我们竟然完成了较为复杂的插入,于是直接扔出五张牌:
手牌的点数都改了,我们能忍吗?当然不能。
于是掏出了电脑,来搓个红黑树的插入:
首先定义 手牌 Card
:
cpp
template<class K,class V>
struct Card
{
pair<K, V> _kv;
Card<K, V>* _pLeft;
Card<K, V>* _pRight;
Card<K, V>* _pParent;
int _status;
Card(const pair<K,V>& kv)
:_kv(kv)
,_pLeft(nullptr)
,_pRight(nullptr)
,_pParent(nullptr)
,_status(UP)
{}
};
初始状态 _status
为 UP
,即牌面向上。
为了方便,我搞了几个宏定义:
cpp
#define UP 1
#define DOWN -1
#define TURN_OVER ->_status*=-1
#define IS_UP ->_status==UP
#define IS_DOWN ->_status==DOWN
UP
即牌面向上,DOWN
即牌面向下,TURN_OVER
用来翻牌,IS_UP
、IS_DOWN
用来判断手牌的状态。
然后速速搭好树的框架:
cpp
template<class K,class V>
class RBTree
{
typedef Card<K, V> Card;
public:
//Insert
private:
Card* _root = nullptr;
};
接下来开始写插入。
首先,对每张手牌,都要先按二叉搜索树的规则放在正确的位置,这里就可以CV一下AVL的了:
cpp
bool Insert(const pair<K, V>& kv)
{
if (_root == nullptr)
{
_root = new Card(kv);
_root->_status = DOWN;
return true;
}
Card* cur = _root;
Card* p = nullptr;
while (cur)
{
if (cur->_kv.first == kv.first)return false;
p = cur;
if (cur->_kv.first < kv.first)cur = cur->_pRight;
else cur = cur->_pLeft;
}
cur = new Card(kv);
if (p->_kv.first < kv.first)p->_pRight = cur;
else p->_pLeft = cur;
cur->_pParent = p;
接下来就是调整手牌的状态了。我把刚刚总结的规律汇总了一下:
规律一 :如果一张牌为根,即 没有父亲牌,则将其置为牌面向下。
规律二 :如果插入牌的 父亲牌 为 牌面向下,则不用做其他调整。
规律三 :如果插入牌的 父亲牌 为 牌面向上 ,插入牌的 叔叔牌 也为 牌面向上,那么翻转父亲牌、叔叔牌、祖先牌,并将祖先牌作为插入牌继续调整。
规律四 :如果父亲牌为牌面向上,叔叔牌 不存在(空) 或 牌面向下,需要根据形状进行旋转。
规律五:如果是单旋,将祖先牌、父亲牌翻转;如果进行双旋,将祖先牌、插入牌翻转。
因此,循环的条件可以写为父亲牌存在且牌面向上:
cpp
while (p && p IS_UP){
然后将祖先牌,叔叔牌拿到:
cpp
Card* g = p->_pParent;
Card* u = g->_pLeft == p ? g->_pRight : g->_pLeft;
如果叔叔牌存在且牌面向上,翻转三张牌,并继续向上调整(continue
)
cpp
if (u && u IS_UP)
{
u TURN_OVER, g TURN_OVER, p TURN_OVER;
cur = g; p = cur->_pParent;
continue;
}
如果叔叔牌不存在或牌面向下,需要根据形状进行旋转,并翻转对应的手牌,然后退出循环(break
):
cpp
else if (p == g->_pLeft && cur == p->_pLeft)
RotateR(g), p TURN_OVER, g TURN_OVER;
else if (p == g->_pRight && cur == p->_pRight)
RotateL(g), p TURN_OVER, g TURN_OVER;
else if (p == g->_pLeft && cur == p->_pRight)
RotateL(p), RotateR(g), cur TURN_OVER, g TURN_OVER;
else if (p == g->_pRight && cur == p->_pLeft)
RotateR(p), RotateL(g), cur TURN_OVER, g TURN_OVER;
break;
}
有三种方式到达循环外:
- 一种是进行了翻转,这种情况不需要做任何调整;
- 还有一种是父亲牌的牌面向下,这种情况也不需要做任何调整;
- 最后一种情况是没有父亲牌,此时插入的手牌为根,需要将状态调整为牌面向下。
因此,为了不做多余的判断,这里统一让 根 保持牌面向下:
cpp
_root->_status = DOWN;
return true;
}
旋转的函数去CV一下AVL的就行,记得删掉平衡因子。
🖼️示意图
最后来复个盘:
希望本篇文章对你有所帮助!并激发你进一步探索编程的兴趣!
本人仅是个C语言初学者,如果你有任何疑问或建议,欢迎随时留言讨论!让我们一起学习,共同进步!