一、红黑树的概念
定义: 红黑树是一棵二叉搜索树 ,每个结点增加一个存储位表示结点的颜色,可以是红色 或黑色 。通过对任何一条从根到叶子的路径上各个结点的颜色进行约束,红黑树确保没有一条路径会比其他路径长出 2 倍 ,因而是接近平衡的,是一棵近似平衡的二叉搜索树,不是真正平衡的二叉搜索树。
空树也是红黑树。
补充:
- 红黑树中可能会出现连在一起的黑色节点,但不可能出现连续的红色节点
二、红黑树的四条规则
- 每个结点不是红色就是黑色。
- 根结点是黑色的。
- 如果一个结点是红色 的,则它的两个孩子结点必须是黑色 的,即任意一条路径不会有连续的红结点。
- 对于任意一个结点,从该结点到其所有 NULL 结点 的简单路径上,均包含相同数量的黑色结点。
补充说明: 《算法导论》等书籍补充了一条"每个叶子结点(NIL)都是黑色的"规则。这里的叶子结点不是传统意义上的叶子结点 ,而是空结点 ,有些书籍也把 NIL 叫做外部结点 。NIL 是为了方便准确地标识出所有路径,《算法导论》在后续讲解实现细节时也忽略了 NIL 结点,了解概念即可。
三、红黑树如何保证最长路径不超过最短路径的 2 倍
- 由规则 4 可知,从根到 NULL 结点的每条路径都有相同数量的黑色结点 。极端场景下,最短路径就是全黑结点的路径 ,设最短路径长度为 bh(black height)。
- 由规则 2 和规则 3 可知,任意一条路径不会有连续的红结点 。极端场景下,最长路径就是一黑一红间隔组成 ,长度为 2*bh。
- 综合四条规则:理论上的全黑最短路径和一黑一红最长路径并不是每棵红黑树都存在。假设任意一条从根到 NULL 结点路径的长度为 x,那么 bh <= h <= 2*bh。
四、红黑树的效率
- 假设 N 是红黑树中结点数量,h 是最短路径长度,那么 2^h - 1 <= N < 2^(2h) - 1 ,由此推出 h ≈ log₂N。
- 红黑树增删查改最坏也就是走最长路径 2*log₂N,时间复杂度仍是 O(log N)。
- 与 AVL 树对比: AVL 树通过高度差 直观地控制平衡;红黑树通过四条规则的颜色约束 间接实现近似平衡 。二者效率同一档次 ,但插入相同数量的结点时,红黑树的旋转次数更少,因为对平衡的控制没那么严格。
五、红黑树的结构
要点: 结点中除 _kv、_left、_right 外,还需加入 _parent 指针 (更新控制平衡时要用),以及颜色成员 _col。
cpp
// 枚举值表示颜色
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;
};
六、红黑树的插入
6.1 插入的大概过程
- 插入一个值按二叉搜索树规则进行插入,插入后只需观察是否符合红黑树的四条规则。
- 如果是空树插入 ,新增结点是黑色结点。
- 如果是非空树插入 ,新增结点必须是红色 结点,因为插入黑色结点会破坏规则 4,而规则 4 很难维护。
- 非空树插入后,若父亲结点是黑色 ,则没有违反任何规则,插入结束。
- 若父亲结点是红色 ,则违反规则 3 。进一步分析:c 是红色,p 为红,则 g 必为黑 ,这三个颜色都固定了,关键看 u 的情况,据此分情况处理。
记号约定: 新增结点记为 c(cur) ,c 的父亲记为 p(parent) ,p 的父亲记为 g(grandfather) ,p 的兄弟记为 u(uncle)。
6.2 情况 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 的左还是右,都是上面的变色处理方式。
抽象图理解: d/e/f 代表每条路径拥有 hb 个黑色结点的子树,a/b 代表每条路径拥有 hb-1 个黑色结点、根为红色的子树,hb >= 0。hb == 0 / 1 / 2 的具体组合情况(hb 等于 2 时组合情况上百亿种)只是帮助理解:不论情况多少种、多么复杂,处理方式一样,变色再继续往上处理即可,所以只需看抽象图。
6.3 情况 2:单旋 + 变色
条件: c 为红,p 为红,g 为黑,u 不存在,或 u 存在且为黑。
- u 不存在 :则 c 一定是新增结点。
- u 存在且为黑 :则 c 一定不是新增结点,c 之前是黑色的,是在 c 的子树中插入,符合情况 1,变色将 c 从黑色变成红色后更新上来的。
分析: p 必须变黑才能解决连续红色结点的问题;u 不存在或者是黑色的,单纯变色无法解决问题 ,需要旋转 + 变色。
- p 是 g 的左,c 是 p 的左: 以 g 为旋转点进行右单旋 ,再把 p 变黑、g 变红 。p 成为这棵树新的根,子树黑色结点数量不变,没有连续红色结点,且不需要往上更新(p 的父亲是黑色、红色或空都不违反规则)。
- p 是 g 的右,c 是 p 的右: 以 g 为旋转点进行左单旋 ,再把 p 变黑、g 变红 。p 成为这棵树新的根,同样不需要往上更新。
6.4 情况 3:双旋 + 变色
条件: 与情况 2 相同------c 为红,p 为红,g 为黑,u 不存在,或 u 存在且为黑(u 不存在则 c 一定是新增结点;u 存在且为黑则 c 一定不是新增结点,是情况 1 变色后更新上来的)。
分析: 同样需要旋转 + 变色,p 必须变黑。
- p 是 g 的左,c 是 p 的右: 先以 p 为旋转点左单旋 ,再以 g 为旋转点右单旋 ,然后把 c 变黑、g 变红 。c 成为这棵树新的根,子树黑色结点数量不变,没有连续红色结点,不需要往上更新。
- p 是 g 的右,c 是 p 的左: 先以 p 为旋转点右单旋 ,再以 g 为旋转点左单旋 ,然后把 c 变黑、g 变红 。c 成为这棵树新的根,同样不需要往上更新。
6.5 三种情况速记表
| 情况 | 叔叔 u 的状态 | 处理方式 | 是否继续往上更新 |
|---|---|---|---|
| 情况 1 | u 存在且为红 | p、u 变黑,g 变红 | 是(把 g 当作新的 c) |
| 情况 2 | u 不存在或为黑,且 c、p、g 在一条直线上 | 单旋 + 变色(p 变黑,g 变红) | 否 |
| 情况 3 | u 不存在或为黑,且 c、p、g 呈折线 | 双旋 + 变色(c 变黑,g 变红) | 否 |
七、红黑树插入代码实现
要点: 旋转代码的实现跟 AVL 树一样,只是不需要更新平衡因子。
cpp
bool Insert(const pair<K, V>& kv)
{
if (_root == nullptr)
{
_root = new Node(kv);
_root->_col = BLACK;
return true;
}
Node* parent = nullptr;
Node* cur = _root;
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);
// 新增结点,颜色给红色
cur->_col = RED;
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;
// g
// p u
if (parent == grandfather->_left)
{
Node* uncle = grandfather->_right;
if (uncle && uncle->_col == RED)
{
// u 存在且为红 -> 变色再继续往上处理
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather;
parent = cur->_parent;
}
else
{
// u 存在且为黑或不存在 -> 旋转 + 变色
if (cur == parent->_left)
{
// g
// p u
//c
// 单旋
RotateR(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
}
else
{
// g
// p u
// c
// 双旋
RotateL(parent);
RotateR(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
break;
}
}
else
{
// g
// u p
Node* uncle = grandfather->_left;
// 叔叔存在且为红,-> 变色即可
if (uncle && uncle->_col == RED)
{
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
// 继续往上处理
cur = grandfather;
parent = cur->_parent;
}
else // 叔叔不存在,或者存在且为黑
{
// 情况二:叔叔不存在或者存在且为黑
// 旋转 + 变色
// g
// u p
// c
if (cur == parent->_right)
{
RotateL(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
}
else
{
// g
// u p
// c
RotateR(parent);
RotateL(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
break;
}
}
}
_root->_col = BLACK;
return true;
}
八、红黑树的查找
要点: 按二叉搜索树逻辑 实现即可,搜索效率为 O(log N)。
cpp
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;
}
九、红黑树的验证
易错点: 获取最长路径和最短路径、检查最长路径不超过最短路径的 2 倍是不可行 的。因为就算满足这个条件,红黑树也可能颜色不满足规则,当前暂时没出问题,后续继续插入还是会出问题。所以应该直接检查四条规则,满足这四条规则,一定能保证最长路径不超过最短路径的 2 倍。
- **规则 1:**枚举颜色类型,天然实现保证了颜色不是黑色就是红色。
- **规则 2:**直接检查根即可。
- 规则 3: 前序遍历检查。遇到红色结点查孩子不太方便(孩子有两个,且不一定存在),反过来检查父亲的颜色更方便。
- 规则 4: 前序遍历,遍历过程中用形参记录从根到当前结点的 blackNum(黑色结点数量) ,遇到黑色结点就
++blackNum,走到空就计算出了一条路径的黑色结点数量。再以任意一条路径的黑色结点数量作为参考值,依次比较即可。
cpp
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);
}
十、红黑树的删除
红黑树的删除本章节不做讲解,有兴趣的同学可参考:《算法导论》 或者**《STL 源码剖析》**中讲解。
十一、核心要点速记
- **本质:**红黑树是加了颜色约束的二叉搜索树,近似平衡,最长路径不超过最短路径的 2 倍。
- **四条规则:**非红即黑;根为黑;红结点的孩子必为黑(无连续红);各路径黑结点数量相同。
- 效率: 增删查改均为 O(log N) ;与 AVL 树同档次,但旋转次数更少。
- **插入:**空树插黑;非空树插红;父黑直接结束;父红看叔叔。
- **叔叔为红:**变色(p、u 变黑,g 变红),g 当新 c 继续上溯。
- **叔叔为黑或不存在:**直线用单旋 + 变色,折线用双旋 + 变色,处理完即结束。
- 验证: 不要用"最长路径 ≤ 2 倍最短路径"判断,要逐条检查四条规则。