数据结构:红黑树(Red-Black Tree)

目录

从AVL树的"烦恼"说起

[如何用"颜色"来定义"大致平衡"?------ 红黑树的五个规则](#如何用“颜色”来定义“大致平衡”?—— 红黑树的五个规则)

五个规则如何保证"大致平衡"?

[用 C/C++ 代码定义红黑树的结构](#用 C/C++ 代码定义红黑树的结构)

定义颜色和节点结构

定义树的结构和哨兵节点

[为什么要定义 RedBlackTree 结构体?](#为什么要定义 RedBlackTree 结构体?)

[为什么需要 NIL 哨兵节点?](#为什么需要 NIL 哨兵节点?)


从AVL树的"烦恼"说起

我们从已经了解的 AVL 树出发。AVL 树的出发点是什么?

是为了解决二叉搜索树(Binary Search Tree, BST)在最坏情况下退化成链表的问题。它的核心思想是:强制平衡。它有一个非常严格的规定:"任意节点左右子树的高度差 ≤ 1"。

这个规定很有效,保证了树的高度始终在 O(logN) 级别,因此查找效率非常高。但它的"烦恼"也来源于此:

📌 为了维持这个严格的平衡,AVL 树的插入和删除操作可能会导致频繁的旋转 (Rotation)。有时候,仅仅插入一个节点,就可能需要从插入点一直回溯到根节点,进行多次旋转。旋转操作本身是有开销的。

既然"绝对平衡"的维护成本有点高,我们能不能稍微"放松"一点要求?我们不追求"完美身高",只追求"身材匀称",只要最长路径和最短路径的长度别差得太离谱,那它的查找效率不也能保证在 O(logN) 吗?❓❓❓

这个"放松要求,换取插入/删除时更少操作"的想法,就是红黑树诞生的根本动机。

第一性原理推导:

  1. 目标: 保持树的查找效率,即树的高度维持在 O(logN)。

  2. 现有方案: AVL 树通过严格限制左右子树高度差来实现。

  3. 痛点: 维护严格平衡的成本(旋转次数)较高。

  4. 新思路: 寻找一种弱一点的平衡标准。这个标准必须足够强,以保证树高为 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) 判断,因为它的 leftright 总是指向一个有效的(哨兵)节点。这纯粹是为了简化代码实现。

至此,我们有了基本的颜色框架。但这些还不足以保证平衡。现在,我们需要引入真正限制树"形状"的规则。

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原有的 keyleftright 指针,我们还需要 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,任何一个节点的 leftright 指针,要么指向一个有效的数据节点,要么指向 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;
}

到目前为止,我们已经成功地:

  1. 从第一性原理推导出了红黑树存在的必要性。

  2. 推导出了定义红黑树的5个核心规则。

  3. 证明了这5个规则如何保证树的"大致平衡"。

  4. 用基础的 C/C++ 代码定义了红黑树的节点、树结构以及核心的 NIL 哨兵节点。

相关推荐
Univin12 分钟前
8.25作业
数据结构·windows
胡萝卜3.02 小时前
数据结构初阶:详解单链表(一)
数据结构·笔记·学习·单链表
墨染点香4 小时前
LeetCode 刷题【53. 最大子数组和】
数据结构·算法·leetcode
NekoCNN5 小时前
现代视角下的线性表全解
数据结构
工藤新一¹5 小时前
C/C++ 数据结构 —— 树(2)
c语言·数据结构·c++·二叉树··c/c++
七十二小時6 小时前
力扣热题——前K个高频元素
数据结构·算法·leetcode
空白到白7 小时前
算法练习-合并两个有序数组
数据结构·python·算法
花开富贵ii10 小时前
代码随想录算法训练营四十九天|图论part07
java·数据结构·算法·图论·prim·kruscal
张同学的IT技术日记10 小时前
数据结构初学者必用:手把手教你写可复用代码模板(附完整示例)
数据结构