红黑树(R-B TREE,全称:Red-Black Tree),本身是一棵二叉查找树,在其基础上附加了两个要求:
这里所指的路径,指的是从任何一个结点开始,一直到其子孙的叶子结点的长度;
接近于平衡,是指红黑树并不是平衡二叉树,只是由于对各路径的长度之差有限制,所以近似于平衡的状态。
红黑树对于结点的颜色设置不是任意的,需满足以下性质的二叉查找树才是红黑树:
NIL
的叶子结点的颜色是黑的(注意:叶子结点说的只是为空(NIL
或 NULL
)的叶子结点!)下图中这棵树,就是一颗典型的红黑树:
看完红黑树的定义是不是晕了?怎么这么多要求!!不用担心我们一条条的来分析:
第 3 条,显然这里的叶子节点不是平常我们所说的叶子节点,如图标有 NIL 的为叶子节点,为什么不按常规出牌,因为按一般的叶子节点也行,但会使算法更复杂;
第 4 条,即该树上决不允许存在两个连续的红节点;
第 5 条,比如图中红色节点 8 到左边的叶子节点 1 的路径包含 2 个黑节点,到的叶子节点 6 的路径也包含 2 个黑节点。所有性质 1 到 5 合起来约束了该树的平衡性能,即该树上的最长路径不可能会大于 2 倍最短路径。
为什么?因为第 1 条该树上的节点非红即黑,由于第 4 条该树上不允许存在两个连续的红节点,那么对于从一个节点到其叶子节点的一条最长的路径一定是红黑交错的,那么最短路径一定是纯黑色的节点;
而又因为第 5 条从任一节点到其叶子节点的所有路径上都包含相同数目的黑节点,这么来说最长路径上的黑节点的数目和最短路径上的黑节点的数目相等!
而又因为第 2 条根结点为黑、第 3 条叶子节点是黑,那么可知:最长路径小于等于最短路径的 2 倍。
思考:为什么红黑树最长路径中节点个数不会超过最短路径节点个数的两倍?
我们根据性质可以知道,红黑树当中不会出现连续的红色结点,并且从某一结点到其后代叶子结点的所有路径上包含的黑色结点的数目是相同的。
可以假设在红黑树中,从根到叶子的所有路径上包含的黑色结点的个数都是 N N N 个,那么最短路径就是全部由黑色结点构成的路径,即长度为 N N N。
而最长可能路径就是由一黑一红结点构成的路径,该路径当中黑色结点与红色结点的数目相同,即长度为 2 N 2N 2N。
因此,红黑树从根到叶子的最长可能路径不会超过最短可能路径的两倍,所以对于一棵具有 n 个结点的红黑树,树的高度至多为:2log(n+1)
。
由此可推出红黑树进行查找操作时的时间复杂度为 O(logN)
,因为对于高度为 h 的二叉查找树的运行时间为 O(h)
,而包含有 n 个结点的红黑树本身就是最高为 logN
(简化之后)的查找树(h=logN
),所以红黑树的时间复杂度为 O(logN)
。
这里和AVL树一样用 KV 模型来实现红黑树,为了方便后序的旋转操作,将红黑树的结点定义为三叉链结构,除此之外还新加入了一个成员变量,用于表示结点的颜色。使用枚举来定义结点的颜色,这样可以增加代码的可读性和可维护性,并且便于后序的调试操作。
// 定义结点的颜色
enum Colour
{
RED,
BLACK,
};
// 红黑树结点的定义
template<class K, class V>
struct RBTreeNode
{
// 三叉链
RBTreeNode<K, V>* _left; // 节点的左孩子
RBTreeNode<K, V>* _right; // 节点的右孩子
RBTreeNode<K, V>* _parent; // 节点的双亲(红黑树需要旋转,为了实现简单给出该字段)
// 存储的键值对
pair<K, V> _kv;
// 结点的颜色
Colour _col;
// 构造函数
RBTreeNode(const pair<K, V>& kv)
:_kv(kv)
, _left(nullptr)
, _right(nullptr)
, _parent(nullptr)
, _col(RED)
{}
};
思考:在节点的定义中,为什么要将节点的默认颜色给成红色的?
如果设为黑色,就会导致根到叶子的路径上有一条路上,多一个额外的黑节点,这个是很难调整的。但是设为红色节点后,可能会导致出现两个连续红色节点的冲突,那么可以通过颜色调换和树旋转来调整。
也就是说:
权衡利弊后,我们在构造结点进行插入时,默认将结点的颜色设置为红色。
当使用红黑树进行插入或者删除结点的操作时,可能会破坏红黑树的 5 条性质,从而变成了一棵普通树,此时就可以通过对树中的某些子树进行旋转,从而使整棵树重新变为一棵红黑树。
旋转操作分为左旋和右旋,同二叉排序树转平衡二叉树的旋转原理完全相同。例如图所示的是对一棵二叉查找树中局部子树进行左旋和右旋操作:
左旋: 如图所示,左旋时 y 结点变为该部分子树的根结点,同时 x 结点(连同其左子树 a)移动至 y 结点的左孩子。若 y 结点有左孩子 b,由于 x 结点需占用其位置,所以调整至 x 结点的右孩子处。
左单旋代码:
public:
// 左单旋(右边高需要左单旋)
void RotateL(Node* parent)
{
Node* subR = parent->_right;
Node* subRL = subR->_left;
Node* ppNode = parent->_parent; // 先保存parent的parent
// 1.建立parent和subRL之间的关系
parent->_right = subRL;
if (subRL) // 如果subRL节点不为空,那么要更新它的parent
{
subRL->_parent = parent;
}
// 2.建立subR和parent之间的关系
subR->_left = parent;
parent->_parent = subR;
// 3.建立ppNode和subR之间的关系(分情况讨论parent是整颗树的根,还是局部子树)
if (parent == _root) // 当parent是根节点时
{
_root = subR; // subR就变成了新的根节点
_root->_parent = nullptr; // 根节点的的parent为空
}
else // 当parent是整个树的局部子树时
{
if (parent == ppNode->_left) // 如果parent在ppNode的左边
{
ppNode->_left = subR; // 那么subR就是parent的左子树
}
else // 如果parent在ppNode的右边
{
ppNode->_right = subR; // 那么subR就是parent的右子树
}
subR->_parent = ppNode; // subR的parent还要指向ppNode
}
}
右旋: 如图所示,同左旋是同样的道理,x 结点变为根结点,同时 y 结点连同其右子树 c 作为 x 结点的右子树,原 x 结点的右子树 b 变为 y 结点的左子树。
右单旋代码:
public:
// 右单旋(左边高就右单旋)
void RotateR(Node* parent)
{
Node* subL = parent->_left;
Node* subLR = subL->_right;
//Node* subLR = subL->_right;
Node* ppNode = parent->_parent;
// 1.建立parent和subLR之间的关系
parent->_left = subLR;
if (subLR) // 如果subLR节点不为空,那么要更新它的parent
{
subLR->_parent = parent;
}
// 2.建立subL和parent之间的关系
subL->_right = parent;
parent->_parent = subL;
// 3.建立ppNode和subL之间的关系(分情况讨论parent是整颗树的根,还是局部子树)
if (parent == _root) // 当parent是根节点时
{
_root = subL; // subL就变成了新的根节点
_root->_parent = nullptr; // 根节点的的parent为空
}
else // 当parent是整个树的局部子树时
{
if (parent == ppNode->_left) // 如果parent在ppNode的左边
{
ppNode->_left = subL; // 那么subL就是parent的左子树
}
else // 如果parent在ppNode的右边
{
ppNode->_right = subL; // 那么subL就是parent的右子树
}
subL->_parent = ppNode; // subR的parent还要指向ppNode
}
}
关于旋转操作可以参考 C/C++数据结构(十一)—— 平衡二叉树(AVL树)
当创建一个红黑树或者向已有红黑树中插入新的数据时,只需要执行以下 3 步:
为了区分这些节点,我们统一给这些结点命名:
cur
cur
的父节点标为 p
(parent)cur
的祖父节点标为 g
(grandfather)cur
的叔树节点标为 u
(uncle)。如下图所示:
插入结点的第 1 步和第 2 步都非常简单,关键在于最后一步对树的调整!在红黑树中插入结点时,根据插入位置的不同可分为以下 3 种情况。
插入位置为整棵树的树根。处理办法:只需要将插入结点的颜色改为黑色即可。
如果新插入节点 cur
位于树的根上,且没有父节点,那么直接插入,并且把该节点的颜色设置为黑色(满足性质 2),并且它在每个路径上对黑节点数目增加一,性质 5 符合。
插入位置的父亲结点的颜色为黑色。处理方法:此种情况不需要做任何工作,新插入的颜色为红色的结点不会破坏红黑树的性质。
如果新插入节点 cur
的父节点 p
是黑色,那么性质 4 没有失效(新节点是红色的)。
在这种情形下,树仍是有效的。性质 5 也未受到威胁,尽管新节点 cur
有两个黑色叶子子节点;但由于新节点 cur
是红色,通过它的每个子节点的路径就都有同通过它所取代的黑色的叶子的路径同样数目的黑色节点,所以依然满足这个性质。
插入位置的父亲结点的颜色为红色。处理方法:由于插入结点颜色为红色,其父亲结点也为红色,破坏了红黑树第 4 条性质,此时需要结合其祖父结点和叔叔结点的状态,分为 3 种情况讨论。
如果新插入结点 cur
的父亲结点 p
为红色,其祖父结点 g
为黑色,同时其叔叔结点 u
一定存在,且为红色。那么此时破坏了红黑树的第 4 条性质。
解决方案为:将父结点颜色改为黑色;将叔叔结点颜色改为黑色;将祖父结点颜色改为红色;下一步将祖父结点认做当前结点,继续判断,处理结果如下图所示:
分析:
这种情况下,由于父结点和当前结点颜色都是红色,所以为了不产生冲突,将父结点的颜色改为黑色。虽然避免了破坏第 4 条,但是却导致该条路径上的黑高度增加了 1 ,破坏了第 5 条性质。于是再将祖父结点颜色改为红色、叔叔结点颜色改为黑色后,该部分子树没有破坏第 5 条性质。
但是由于将祖父结点的颜色改变,还需判断是否破坏了上层树的结构,所以需要将祖父结点看做当前结点,继续判断。也就是需要将祖父结点当作新插入的结点,再判断其父结点是否为红色,若其父结点也是红色,那么又需要根据其叔叔的不同,进而进行不同的调整操作。
如果祖父结点是根结点,那我们直接再将祖父结点变成黑色即可,此时相当于每条路径黑色结点的数目都增加了一个。
也就是说,为了解决这个问题,需把 g
作为起始点,即把 g
看做一个插入的红色节点继续向上检索,属于哪种情况,按那种情况操作,要么中间就结束,要么就得找到根结点。
如果新插入结点 cur
的颜色为红色,其父亲结点 p
也为红色,其祖父结点 g
为黑色,其叔叔结点 u
存在且为黑色。
如果叔叔结点 u
节点存在,那么一定是黑色的,并且插入结点 cur
原来的颜色也一定是黑色的,现在 cur
之所以为红色,是因为 cur
的子树在调整的过程中将 cur
节点的颜色由黑色改成了红色。此时单纯使用变色已经无法处理了,我们需要进行旋转处理。
如果祖孙三代的关系是直线,也就是说 cur
、parent
、grandfather
这三个结点在同一条直线上,那么我们需要先进行单旋操作,再进行颜色调整,颜色调整后这棵被旋转子树的根结点就是黑色的,因此无需继续往上进行处理。
如下图所示(直线关系一):
如果祖孙三代的关系是折线,也就是说 cur
、parent
、grandfather
这三个结点不在同一条直线上,那么我们需要先进行双旋操作,再进行颜色调整,颜色调整后这棵被旋转子树的根就是黑色的,因此无需继续往上进行处理。
如下图所示(折线关系一):
如果新插入节点 cur
为红色,其父亲结点 p
也为红色,其祖父结点 g
为黑色,其叔叔结点 u
不存在。
当叔叔结点 u
节点不存在时,那么 cur
一定是新插入节点,如果 cur
不是新插入节点的话,那么 cur
和 p
一定有一个节点的颜色是黑色,就不满足性质 5(每条路径黑色节点个数相同)。
如果在插入前父亲结点 p
下面再挂黑色结点的话,就会导致图中两条路径黑色结点的数目不相同,而父亲结点 p
是红色的,因此父亲结点 p
下面自然也不能挂红色结点,所以说这种情况下的 cur
结点一定是新插入的结点。
和上面一样,若祖孙三代的关系是直线,也就是说 cur
、parent
、grandfather
这三个结点在同一条直线上,那么我们需要先进行单旋操作,再进行颜色调整,颜色调整后这棵被旋转子树的根结点就是黑色的,因此无需继续往上进行处理
如下图所示(直线关系一):
如果祖孙三代的关系是折线,也就是说 cur
、parent
、grandfather
这三个结点在同一条折线上,那么我们需要先进行双旋操作,再进行颜色调整,颜色调整后这棵被旋转子树的根是黑色的,因此无需继续往上进行处理。
如下图所示(折线关系一):
红黑树中插入结点的具体实现代码:
public:
// 插入函数
bool Insert(const pair<K, V>& kv)
{
// 如果红黑树是空树,把插入节点直接作为根节点
if (_root == nullptr)
{
_root = new Node(kv);
_root->_col = BLACK; // 根结点必须是黑色
return true; // 插入成功
}
// 1.按照二叉搜索树的规则插入
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_kv.first < kv.first) // 待插入节点的key值大于当前节点的key值
{
// 往右子树走
parent = cur;
cur = cur->_right;
}
else if (cur->_kv.first > kv.first) // 待插入节点的key值小于当前节点的key值
{
// 往左子树走
parent = cur;
cur = cur->_left;
}
else // 待插入节点的key值等于当前节点的key值
{
return false; // 插入失败,返回false
}
}
// 2.当循环结束,说明cur找到了空的位置,那么就插入
cur = new Node(kv); // 构造一个新节点
cur->_col = RED; // 插入结点的颜色设置为红色
if (parent->_kv.first < kv.first) // 如果新节点的key值大于当前parent节点的key值
{
// 就把新节点链接到parent的右边
parent->_right = cur;
}
else // 如果新节点的key值小于当前parent节点的key值
{
// 就把新节点链接到parent的左边
parent->_left = cur;
}
cur->_parent = parent; // 别忘了把新节点里面的_parent指向parent(因为我们定义的是一个三叉链)
// 3.若插入结点的父结点是红色的,则需要对红黑树进行调整(存在连续红色节点的情况)
while (parent && parent->_col == RED)
{
Node* grandfather = parent->_parent; // 如果parent是红色,那么其父结点一定存在
if (grandfather->_left == parent) // 当parent是grandfather的左孩子
{
Node* uncle = grandfather->_right; // uncle一定是grandfather的右孩子
//情况一:uncle存在且为红
if (uncle && uncle->_col == RED)
{
// 调整颜色
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
// 继续往上调整
cur = grandfather;
parent = cur->_parent;
}
else // 情况二 + 情况三:uncle不存在 + uncle存在且为黑
{
if (cur == parent->_left)
{
// g
// p
// c
RotateR(grandfather); //右单旋
// 颜色调整
parent->_col = BLACK;
grandfather->_col = RED;
}
else // cur == parent->_right
{
// g
// p
// c
// 左右双旋
RotateL(parent);
RotateR(grandfather);
// 颜色调整
cur->_col = BLACK;
grandfather->_col = RED;
}
// 子树旋转后,该子树的根变成了黑色,无需继续往上进行处理
break;
}
}
else // parent是grandfather的右孩子
{
Node* uncle = grandfather->_left; // uncle是grandfather的左孩子
// 情况一:uncle存在且为红
if (uncle && uncle->_col == RED)
{
// 变色
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
// 继续往上处理
cur = grandfather;
parent = cur->_parent;
}
else // 情况二 + 情况三:uncle不存在 + uncle存在且为黑
{
if (cur == parent->_right)
{
// g
// p
// c
RotateL(grandfather); // 左单旋
// 颜色调整
parent->_col = BLACK;
grandfather->_col = RED;
}
else // cur == parent->_left
{
// g
// p
// c
// 右左双旋
RotateR(parent);
RotateL(grandfather);
// 颜色调整
cur->_col = BLACK;
grandfather->_col = RED;
}
// 子树旋转后,该子树的根变成了黑色,无需继续往上进行处理
break;
}
}
}
_root->_col = BLACK; // 根结点的颜色为黑色(可能被情况一变成了红色,需要变回黑色)
return true; // 插入成功
}
在红黑树中删除结点,思路更简单,只需要完成 2 步操作:
在二叉查找树删除结点时,分为 3 种情况:
以上三种情况最终都需要删除某个结点,此时需要判断删除该结点是否会破坏红黑树的性质。判断的依据是:
第一种: 删除结点的兄弟结点颜色是红色,调整措施为:将兄弟结点颜色改为黑色,父亲结点改为红色,以父亲结点来进行左旋操作,同时更新删除结点的兄弟结点(左旋后兄弟结点发生了变化),如下图所示:
第二种: 删除结点的兄弟结点及其孩子全部都是黑色的,调整措施为:将删除结点的兄弟结点设为红色,同时设置删除结点的父结点标记为新的结点,继续判断;
第三种: 删除结点的兄弟结点是黑色,其左孩子是红色,右孩子是黑色。调整措施为:将兄弟结点设为红色,兄弟结点的左孩子结点设为黑色,以兄弟结点为准进行右旋操作,最终更新删除结点的兄弟结点;
第四种: 删除结点的兄弟结点是黑色,其右孩子是红色(左孩子不管是什么颜色),调整措施为:将删除结点的父结点的颜色赋值给其兄弟结点,然后再设置父结点颜色为黑色,兄弟结点的右孩子结点为黑色,根据其父结点做左旋操作,最后设置替换删除结点的结点为根结点;
关于红黑树删除结点具体实现代码大家可以去查阅一下资料,主要是理解思想!!!
中序遍历和二叉树的中序实现一样,只不过因为中序是递归遍历,涉及到传参,所以需要写一个子函数。
代码示例
private:
// 中序遍历(子函数)(子函数)
void _InOrder(Node* root)
{
if (root == nullptr)
return;
_InOrder(root->_left);
cout << root->_kv.first << " ";
_InOrder(root->_right);
}
public:
// 中序遍历
void InOrder()
{
_InOrder(_root);
cout << endl;
}
AVL树的查找与二叉搜索树一样:
代码示例
public:
// 查找函数
Node* Find(const K& key)
{
Node* cur = _root;
while (cur)
{
if (cur->_kv.first < key) // key值大于该结点的值
{
cur = cur->_left; // 在该结点的右子树当中查找
}
else if (cur->_kv.first < key) // key值小于该结点的值
{
cur = cur->_right; // 在该结点的左子树当中查找
}
else // 当前节点的值等于key值
{
return cur; //返回该结点
}
}
return nullptr; //查找失败
}
因为后面要验证最长路径是否会超过最短路径的 2 倍,所以我们要分别求树的最长路径和最短路径
代码示例
private:
// 计算红黑色的最长路径(子函数)
int _maxHeight(Node* root)
{
if (root == nullptr)
return 0;
int lh = _maxHeight(root->_left);
int rh = _maxHeight(root->_right);
return lh > rh ? lh + 1 : rh + 1;
}
// 计算红黑色的最短路径(子函数)
int _minHeight(Node* root)
{
if (root == nullptr)
return 0;
int lh = _minHeight(root->_left);
int rh = _minHeight(root->_right);
return lh < rh ? lh + 1 : rh + 1;
}
public:
// 计算高度
void Height()
{
cout << "最长路径:" << _maxHeight(_root) << endl;
cout << "最短路径:" << _minHeight(_root) << endl;
}
红黑树的检测分为两步:
代码示例
private:
// 判断是否为红黑树(子函数)
bool _IsValidRBTree(Node* pRoot, size_t k, const size_t blackCount)
{
//走到null之后,判断k和black是否相等
if (nullptr == pRoot)
{
if (k != blackCount)
{
cout << "违反性质四:每条路径中黑色节点的个数必须相同" << endl;
return false;
}
return true;
}
// 统计黑色节点的个数
if (BLACK == pRoot->_col)
k++;
// 检测当前节点与其双亲是否都为红色
if (RED == pRoot->_col && pRoot->_parent && pRoot->_parent->_col == RED)
{
cout << "违反性质三:存在连在一起的红色节点" << endl;
return false;
}
return _IsValidRBTree(pRoot->_left, k, blackCount) &&
_IsValidRBTree(pRoot->_right, k, blackCount);
}
public:
// 判断是否为红黑树
bool IsBalanceTree()
{
// 检查红黑树几条规则
Node* pRoot = _root;
// 空树也是红黑树
if (nullptr == pRoot)
return true;
// 检测根节点是否满足情况
if (BLACK != pRoot->_col)
{
cout << "违反红黑树性质二:根节点必须为黑色" << endl;
return false;
}
// 获取任意一条路径中黑色节点的个数 -- 比较基准值
size_t blackCount = 0;
Node* pCur = pRoot;
while (pCur)
{
if (BLACK == pCur->_col)
blackCount++;
pCur = pCur->_left;
}
// 检测是否满足红黑树的性质,k用来记录路径中黑色节点的个数
size_t k = 0;
return _IsValidRBTree(pRoot, k, blackCount);
}
对红黑树的分析其实就是对 2-3 查找树的分析,红黑树能够保证符号表的所有操作即使在最坏的情况下都能保证对数的时间复杂度,也就是树的高度。
在分析之前,为了更加直观,下面是以升序,降序和随机构建一颗红黑树的动画。
从上面三张动画效果中,可以很直观的看出,红黑树在各种情况下都能维护良好的平衡性,从而能够保证最差情况下的查找,插入效率。
下面来详细分析下红黑树的效率。
(1)在最坏的情况下,红黑树的高度不超过 O ( 2 l o g N ) O(2logN) O(2logN)
最坏的情况就是,红黑树中除了最左侧路径全部是由 3-node 节点组成,即红黑相间的路径长度是全黑路径长度的 2 倍。
下图是一个典型的红黑树,从中可以看到最长的路径(红黑相间的路径)是最短路径的 2 倍:
(2)红黑树的平均高度大约为 O ( l o g N ) O(logN) O(logN)
红黑树和AVL树都是高效的平衡二叉树,增删查改的时间复杂度都是 O ( l o g N ) O(logN) O(logN),但红黑树和AVL树控制二叉树平衡的方式不同:
相对于AVL树来说,红黑树降低了插入结点时需要进行的旋转的次数,所以在经常进行增删的结构中性能比AVL树更优,实际运用时也大多用的是红黑树。红黑树可以保证在最坏情况下的时间复杂度仍为 O ( l o g N ) O(logN) O(logN)。当数据量多到一定程度时,使用红黑树比二叉查找树的效率要高。
并且同平衡二叉树相比较,红黑树没有像平衡二叉树对平衡性要求的那么苛刻,虽然两者的时间复杂度相同,但是红黑树在实际测算中的速度要更胜一筹!
红黑树的应用:
map/set
、mutil_map/mutil_set
另外强烈推荐在 wikipedia – RBTree 中红黑树的这篇讲解文章。