目录
[如何用"颜色"来定义"大致平衡"?------ 红黑树的五个规则](#如何用“颜色”来定义“大致平衡”?—— 红黑树的五个规则)
[用 C/C++ 代码定义红黑树的结构](#用 C/C++ 代码定义红黑树的结构)
[为什么要定义 RedBlackTree 结构体?](#为什么要定义 RedBlackTree 结构体?)
[为什么需要 NIL 哨兵节点?](#为什么需要 NIL 哨兵节点?)
从AVL树的"烦恼"说起
我们从已经了解的 AVL 树出发。AVL 树的出发点是什么?
是为了解决二叉搜索树(Binary Search Tree, BST)在最坏情况下退化成链表的问题。它的核心思想是:强制平衡。它有一个非常严格的规定:"任意节点左右子树的高度差 ≤ 1"。
这个规定很有效,保证了树的高度始终在 O(logN) 级别,因此查找效率非常高。但它的"烦恼"也来源于此:
📌 为了维持这个严格的平衡,AVL 树的插入和删除操作可能会导致频繁的旋转 (Rotation)。有时候,仅仅插入一个节点,就可能需要从插入点一直回溯到根节点,进行多次旋转。旋转操作本身是有开销的。
既然"绝对平衡"的维护成本有点高,我们能不能稍微"放松"一点要求?我们不追求"完美身高",只追求"身材匀称",只要最长路径和最短路径的长度别差得太离谱,那它的查找效率不也能保证在 O(logN) 吗?❓❓❓
这个"放松要求,换取插入/删除时更少操作"的想法,就是红黑树诞生的根本动机。
第一性原理推导:
-
目标: 保持树的查找效率,即树的高度维持在 O(logN)。
-
现有方案: AVL 树通过严格限制左右子树高度差来实现。
-
痛点: 维护严格平衡的成本(旋转次数)较高。
-
新思路: 寻找一种弱一点的平衡标准。这个标准必须足够强,以保证树高为 O(logN);又必须足够弱,以减少插入/删除时维护平衡的代价。
红黑树就是这个新思路的杰出实现。它放弃了用"高度"这个属性来做限制,而是引入了一个全新的、更巧妙的属性:颜色 (Color)。
如何用"颜色"来定义"大致平衡"?------ 红黑树的五个规则
好,我们现在给每个节点增加一个属性:color
,它可以是红色 (RED)或黑色 (BLACK)。通过一些"颜色规则 (Coloring Rules)"来限制树的形态,保证从根到任意叶子的路径长度不会差太多。
1. 二叉搜索树的复杂度依赖高度
-
查找(Search)复杂度 =
O(h)
,其中h
是树高。 -
如果
h
太大(比如退化成链表),复杂度退化为O(N)
。 -
所以我们必须保证
h = O(log N)
。
2. 最短路径长度与节点数的关系
-
在一棵二叉树里,最短路径长度(记为
h_min
)指从根到某个叶子所经过的边数。 -
如果所有路径至少有
h_min
个节点(或边),那么这棵树至少包含:2^(h_min) - 1 个节点(这是满二叉树的下界)。 -
因此:N ≥ 2^(h_min) - 1 ⇒ h_min ≤ log2(N+1)
3. 如果最长路径 ≤ 2 × 最短路径
设:
-
h_min
= 最短路径高度 -
h_max
= 最长路径高度 = 树的高度
如果能保证:h_max ≤ 2 * h_min
那么结合上面的不等式:h_min ≤ log2(N+1) ⇒ h_max ≤ 2 * log2(N+1)
于是我们得到了:h_max = O(log N)
我们的最终目标是证明:
一棵红黑树从根到最远叶子节点的路径长度,不会超过到最近叶子节点路径长度的两倍。
如果能证明这一点,就说明树的高度依然是 O(logN),我们的目标就达成了。
我们来一步步推导出这些规则:
1️⃣:每个节点要么是红色,要么是黑色。
这是最基本定义,就像说"游戏里的棋子要么是黑的,要么是白的"。没有它,后续规则无从谈起。
2️⃣:根节点是黑色。
这条规则不是强制性的,但它能简化很多问题。可以把它看作一个"基准"或"锚点"。
如果根节点是红色,它可能会违反其他规则(比如后面会提到的"红色节点的子节点不能是红色"),所以干脆定为黑色,让处理更统一。
3️⃣:所有叶子节点都是黑色。
这里的"叶子节点"比较特殊,它不是指最后一个有数据的节点,而是指其下方的 NULL
指针。
在红黑树的实现中,我们通常会用一个统一的、黑色的哨兵节点 (Sentinel Node) 来代表所有的 NULL
。
这样做的好处是,处理边界情况(比如一个节点只有一个子节点)时,我们不需要写大量的 if (node->left != NULL)
判断,因为它的 left
和 right
总是指向一个有效的(哨兵)节点。这纯粹是为了简化代码实现。
至此,我们有了基本的颜色框架。但这些还不足以保证平衡。现在,我们需要引入真正限制树"形状"的规则。
4️⃣**:红色节点的子节点必须是黑色的。** (也就是说,不能有两个连续的红色节点)
这是实现"放松平衡"的核心规则之一。如果允许红色节点串在一起(红->红->红...),那这条路径就会被无限制地拉长,树就会失衡。
这条规则强制打断了"红色路径",确保了从根到叶子的路径上,红色节点不会连续出现。
5️⃣:从任一节点到其每个叶子(NIL 哨兵节点)的所有路径,都包含相同数目的黑色节点。
这是另一条核心规则,也是最难理解但最关键的一条。它定义了一个叫做**"黑高 (Black-Height)"**的概念。
这条规则强制要求,无论你从一个节点 x
出发,走左边还是走右边,到达终点(叶子)时,路上经过的黑色节点数量必须完全一样。这就像给树建立了一个"黑色的骨架",这个骨架是完美平衡的。红色节点则可以看作是"填充"在这个黑色骨架之间的节点。

五个规则如何保证"大致平衡"?
现在我们来验证一下,这五条规则是否达成了我们的最终目标:最长路径 <= 2 * 最短路径。
📍最短路径:
考虑从根节点到一个叶子节点的最短路径。这条路径上会包含最少的节点。要让节点数最少,我们就应该尽量少放红色节点。
根据规则4,红色节点不能连续,所以最短的路径就是一条纯黑色的路径。这条路径的长度就是这棵树的黑高 (Black-Height) ,我们记为 bh
。
📌最长路径:
要让路径最长,我们就应该塞进尽可能多的红色节点。根据规则4,每两个黑色节点之间最多只能插入一个红色节点(黑 -> 红 -> 黑 -> 红 ...)。
由于规则5保证了任何路径上的黑色节点数量都是 bh
,那么在最长路径上,我们最多也就能塞进 bh
个红色节点。
所以:
-
最短路径长度 =
bh
(全是黑色节点) -
最长路径长度 <=
bh
(黑色节点) +bh
(红色节点) =2 * bh
结论 :最长路径长度 <= 2 * 最短路径长度
。
这个结论证明了红黑树的高度始终保持在 O(logN) 级别。我们成功地用五条看似无关的颜色规则,间接实现了树的平衡,而且这个平衡比 AVL 树更"宽松"。这种宽松,将为我们后续的插入和删除操作带来更低的维护成本。
用 C/C++ 代码定义红黑树的结构
好了,理论推导结束。我们现在开始写代码,一步步定义出红黑树的节点和树的结构。
定义颜色和节点结构
首先,我们需要一个 enum
来表示颜色。然后定义节点结构 RBTNode
。
除了BST原有的 key
、left
、right
指针,我们还需要 color
属性和一个指向父节点 (parent) 的指针。父节点指针在后续的旋转和调整中非常有用,可以避免复杂的递归或栈来寻找父节点。
cpp
// 使用 C 风格的代码,不涉及高级语法和STL
#include <stdio.h>
// 专有名称: 颜色 (Color)
typedef enum {
RED, // 红色
BLACK // 黑色
} Color;
// 专有名称: 红黑树节点 (Red-Black Tree Node)
typedef struct RBTNode {
int key; // 键值
Color color; // 颜色
struct RBTNode *left; // 左子节点指针
struct RBTNode *right; // 右子节点指针
struct RBTNode *parent; // 父节点指针
} RBTNode;
这段代码非常基础,就是定义了我们讨论中需要的所有元素:键值、颜色、以及三个方向的指针
定义树的结构和哨兵节点
根据规则3,我们需要一个黑色的哨兵节点来代表所有的 NULL
叶子。我们可以定义一个全局的哨兵节点 NIL
。树本身可以用一个指向根节点的指针来表示。
cpp
// 接着上面的代码
// 哨兵节点 (Sentinel Node),代表所有的NULL叶子
RBTNode* NIL;
// 红黑树结构,本质上是一个指向根节点的指针
typedef struct RedBlackTree {
RBTNode* root;
} RedBlackTree;
// 初始化函数,用于创建NIL节点
void initializeNIL() {
NIL = new RBTNode; // 在C++中用new,在C中用malloc
NIL->color = BLACK;
NIL->key = 0; // key值无所谓
NIL->left = NULL;
NIL->right = NULL;
NIL->parent = NULL;
}
为什么要定义 RedBlackTree
结构体?
这是一个关于软件设计和代码工程实践的绝佳问题。从纯粹的算法角度看,只用一个 RBTNode* root
指针来代表整棵树是完全可行的。很多教科书或简单的示例代码就是这么做的。
但是,将它封装在一个 RedBlackTree
结构体里,是更专业、更健壮、更具扩展性的做法。
1. 清晰的抽象层次 (Clear Abstraction)
-
RBTNode*
的语义是 "一个指向树节点的指针"。 -
RedBlackTree*
的语义是 "一个指向红黑树这种数据结构的指针"。 -
这两者代表不同的抽象层次。一个函数签名
void insert(RedBlackTree* tree, int key)
比RBTNode** root_ptr, int key)
要清晰得多。前者明确表示"我要对这棵树进行插入操作",后者则像是"我要修改这个指向根节点的指针的指针",心智负担更重。
2. 为元数据(Metadata)提供存放空间
-
除了根节点,我们未来可能还想知道关于这棵树的其它信息,比如:树中一共有多少个节点?树的黑高是多少❓
-
如果只用
RBTNode* root
,这些信息没地方存放。你只能把它们作为全局变量,或者在每次需要时都重新计算一遍(比如遍历全树计算节点数,效率极低)。 -
有了
RedBlackTree
结构体,我们可以非常自然地扩展它: -
这样,所有与"这棵树整体"相关的信息,都有了一个安身之所。当你调用
insert
时,你不仅可以更新root
,还可以轻松地tree->size++
。
cpp
typedef struct RedBlackTree {
RBTNode* root;
RBTNode* NIL; // 每个树实例可以有自己的NIL节点
int size; // 记录树中节点的数量
// int black_height; // 甚至可以记录黑高
} RedBlackTree;
3. 封装与未来的多实例 (Encapsulation and Multiple Instances)
-
我们之前把
NIL
哨兵节点定义成了一个全局变量。这在只有一个红黑树的程序里没问题。但如果你想同时创建和使用多个红黑树实例呢?全局变量NIL
会被它们共享,这可能会导致混乱。 -
💡一个更好的设计是,让每一个
RedBlackTree
实例都拥有自己的NIL
节点。如上面的代码所示,NIL
成为结构体的一个成员。这样,每个树实例都是完全独立和自包含的。
为什么需要 NIL
哨兵节点?
想象一下,如果没有 NIL
,当你想访问 node->left->color
时,你必须先检查 node->left
是不是 NULL
。而有了 NIL
,任何一个节点的 left
和 right
指针,要么指向一个有效的数据节点,要么指向 NIL
。
因为 NIL
是一个有确定颜色(黑色)的真实节点,所以你可以安全地访问 node->left->color
,这会让代码变得非常整洁。
一个新创建的树,其根节点应该指向 NIL
。
cpp
// 创建一棵空树
RedBlackTree* createRedBlackTree() {
RedBlackTree* tree = new RedBlackTree; // C: malloc
if (NIL == NULL) {
initializeNIL(); // 确保NIL只被初始化一次
}
tree->root = NIL; // 空树的根指向NIL
return tree;
}
到目前为止,我们已经成功地:
-
从第一性原理推导出了红黑树存在的必要性。
-
推导出了定义红黑树的5个核心规则。
-
证明了这5个规则如何保证树的"大致平衡"。
-
用基础的 C/C++ 代码定义了红黑树的节点、树结构以及核心的
NIL
哨兵节点。