数据结构---红黑树

在讨论红黑树之前,我们必须先回顾普通二叉搜索树(BST)的痛点。二叉搜索树满足一个基本性质:左子树所有key < 当前节点key < 右子树所有key

如果插入顺序比较均匀,比如:8, 4, 12, 2, 6, 10, 14,树会保持相对平衡,查找效率接近O(log n)。但如果插入顺序是有序的:1, 2, 3, 4, 5, 6, 7,普通BST会退化成链表:

text 复制代码
1
 \
  2
   \
    3
     \
      4
       \
        5

此时查找复杂度从理想的O(log n)骤降到O(n)。

红黑树的使命:在保持二叉搜索树有序性的同时,通过颜色规则和旋转操作,让树的高度始终保持在O(log n)级别,从而稳定支持各种操作。


一、红黑树

红黑树是一种自平衡二叉搜索树。它由两个部分组成:

组成部分 作用
二叉搜索树性质 保证中序遍历有序,支持高效查找
红黑颜色规则 限制树的高度,防止退化成链表

每个节点除了存储key/value、左右孩子指针和父节点指针外,还额外存储一个颜色标记:

cpp 复制代码
enum class Color {
    Red,
    Black
};

template <typename K, typename V>
struct RBNode {
    K key;
    V value;
    Color color;
    
    RBNode* parent;
    RBNode* left;
    RBNode* right;
};

二、红黑树的五条性质

红黑树通过五条严格的性质来保证树的平衡性。

性质1:每个节点非红即黑

text 复制代码
node.color ∈ { Red, Black }

这是最基本的定义,每个节点都必须有且只有一种颜色。

性质2:根节点是黑色

text 复制代码
root.color == Black

这条规则主要是为了统一定义。即使插入时根节点被染成红色,最后也会强制改回黑色。

性质3:所有叶子NIL节点都是黑色

注意,这里的"叶子"不是普通意义上的数据节点,而是空指针位置上的哨兵节点。例如:

text 复制代码
    10
   /  \
  5   15
 / \
NIL NIL

实际实现中,大多数红黑树不会为每个空孩子单独分配节点,而是使用一个共享的黑色哨兵节点nil

性质4:红色节点的孩子必须是黑色

树中不能出现连续两个红色节点

  • ❌ 错误情况:10(B) -> 5(R) -> 3(R)
  • ✅ 正确情况:10(B) -> 5(R) -> 3(B)

这条规则从根本上限制了红色节点不能连续堆叠。

性质5:从任一节点到其所有后代NIL节点的路径上,黑色节点数量相同

这个黑色节点的数量被称为黑高(black-height)

例如,从根节点到每个NIL节点的路径上,黑色节点的数量必须完全相同。这条性质保证了树的所有路径在黑色节点数量上是平衡的。


三、红黑树保证O(log n)

  1. 红节点不能连续出现(性质4)
  2. 每条路径上的黑节点数量相同(性质5)
  3. 因此,最长路径不会超过最短路径的两倍
  4. 所以树高最多是2 * log2(n + 1)级别

换句话说,红黑树不是严格平衡树,它允许一定程度的不平衡。与AVL树的对比:

对比项 红黑树 AVL树
平衡标准 最长路径 ≤ 2 × 最短路径 左右子树高度差 ≤ 1
平衡程度 近似平衡 高度平衡
查找速度 稍慢于AVL 通常更快
插入删除 修复成本较低,平均旋转次数更少 旋转调整更多
工程应用 std::map/std::set常见实现 查询密集型场景

工程上之所以更常选择红黑树,是因为它在查找、插入、删除之间取得了非常好的综合平衡


四、红黑树的两个基本操作:旋转

红黑树调整平衡主要依靠两类操作:左旋右旋

旋转的特点是:不会破坏二叉搜索树的有序性,只会改变局部结构

4.1 左旋(Left Rotate)

假设对节点x进行左旋:

text 复制代码
    x                y
     \              / \
      y     =>     x   T3
     / \            \
    T2 T3            T2

注意中序关系仍然保持不变:x < T2 < y < T3

C++伪代码实现:

cpp 复制代码
void leftRotate(Node* x) {
    Node* y = x->right;
    
    // 把y的左孩子变成x的右孩子
    x->right = y->left;
    if (y->left != nil) {
        y->left->parent = x;
    }
    
    // 把x的父节点变成y的父节点
    y->parent = x->parent;
    
    // 更新x父节点的孩子指针
    if (x->parent == nil) {
        root = y;
    } else if (x == x->parent->left) {
        x->parent->left = y;
    } else {
        x->parent->right = y;
    }
    
    // 把x变成y的左孩子
    y->left = x;
    x->parent = y;
}

4.2 右旋(Right Rotate)

右旋是左旋的镜像操作。对节点y进行右旋:

text 复制代码
      y              x
     /              / \
    x       =>     T1  y
   / \                /
  T1 T2              T2

中序关系仍然保持:T1 < x < T2 < y


五、插入操作

5.1 插入的两步流程

红黑树插入分为两个清晰的步骤:

  1. 第一步:按照普通BST规则插入新节点
  2. 第二步:修复可能被破坏的红黑性质

关键设计决策新插入的节点默认染成红色

因为插入红色节点不会改变任何路径上的黑色节点数量,因此不会破坏性质5(黑高一致)。如果插入黑色节点,会直接导致某条路径黑高加1,修复起来会复杂得多。

5.2 插入修复

插入红色节点后,唯一可能被破坏的性质是性质4:不能有连续红色节点。也就是说,当新节点的父节点也是红色时,我们需要进行修复。

插入修复时,我们关注四个角色:

  • N:当前插入的新节点
  • P:父节点(Parent)
  • G:祖父节点(Grandparent)
  • U:叔叔节点(Uncle)

5.3 插入修复的三种Case

Case 1:叔叔节点是红色
text 复制代码
        G(B)               G(R)
       /   \              /   \
    P(R)   U(R)   =>   P(B)   U(B)
    /                  /
  N(R)               N(R)

处理方法

  • 父节点P变黑
  • 叔叔节点U变黑
  • 祖父节点G变红
  • 把G当成新的N,继续向上修复

本质:通过重新染色,把红色冲突向上传递。这样做不会改变任何路径的黑高。

Case 2:叔叔节点是黑色,且是内侧结构

以内侧左-右结构为例:

text 复制代码
        G(B)               G(B)
       /   \              /   \
    P(R)   U(B)   =>   N(R)   U(B)
       \              /
        N(R)       P(R)

处理方法:先对父节点P进行左旋,将其转换为外侧结构,然后进入Case 3。

本质:先旋转成一条直线,再统一处理。

Case 3:叔叔节点是黑色,且是外侧结构

外侧左-左结构:

text 复制代码
        G(B)                   P(B)
       /   \                  /   \
    P(R)   U(B)     =>     N(R)   G(R)
   /                              \
 N(R)                             U(B)

处理方法

  • 父节点P变黑
  • 祖父节点G变红
  • 对祖父节点G进行右旋

右-右结构是镜像处理:对G进行左旋。

本质:通过一次旋转和简单的颜色变换,彻底解决连续红色问题,同时保持黑高不变。

5.4 插入修复总结

插入修复可以压缩成一句话:

父红叔红:变色上推;父红叔黑:旋转加变色

情况 操作
父节点是黑色 不需要任何修复
父红,叔红 父叔变黑,祖父变红,继续向上
父红,叔黑,内侧 先旋转父节点,转成外侧
父红,叔黑,外侧 父变黑,祖父变红,旋转祖父

最后一步:无论如何,最终都要把根节点染成黑色。


六、删除操作

6.1 删除的三步流程

红黑树删除分为三个步骤:

  1. 第一步:按照普通BST规则删除节点
  2. 第二步:记录被真正删除节点的颜色
  3. 第三步:如果删除的是黑色节点,进行删除修复

BST删除本身有三种情况:

  • 删除节点没有孩子:直接删除
  • 删除节点只有一个孩子:用孩子替代它
  • 删除节点有两个孩子:找后继节点替代,再删除后继

关键点 :只有当被真正删除的物理节点是黑色时,才需要进行修复。

真正被物理删掉的那个节点,才是判断删红还是删黑的依据;原本要删的节点只是逻辑值被覆盖,本身没有被从树里摘除,不算 "被真正删除节点"

当待删节点有两个子节点时:后继节点才是被真正物理删除的节点

6.2 删除修复

删除比插入复杂得多,因为删除黑色节点可能破坏性质5:黑高一致

例如,删除一个黑色叶子节点:

text 复制代码
    10(B)              10(B)
   /    \       =>         \
 5(B)  15(B)              15(B)

左边路径少了一个黑色节点,黑高不一致。

删除修复的核心是处理双重黑 (double black)问题。这不是节点真的有两个颜色,而是表示:某条路径少了一个黑色,需要从兄弟、父亲或子树中"借"一个黑色,或者把问题向上传递

6.3 删除修复的四种Case

假设当前需要修复的节点是x,它的兄弟节点是w。我们以x是父节点左孩子为例,右侧情况完全镜像。

Case 1:兄弟节点w是红色
text 复制代码
      P(B)               w(B)
     /   \              /   \
    x    w(R)   =>    P(R)  wr(B)
        /   \        /
      wl(B) wr(B)  x   wl(B)

处理方法

  • w变黑
  • P变红
  • 对P左旋

本质:把红色兄弟转换成黑色兄弟情况,转入后续case处理。

Case 2:兄弟w是黑色,且w的两个孩子都是黑色
text 复制代码
      P(?)               P(?)
     /   \              /   \
    x    w(B)   =>     x    w(R)
        /   \
     wl(B) wr(B)

处理方法

  • w变红
  • x上移到P,继续向上修复

本质:兄弟这边也"少交"一个黑色,把黑高亏损问题向父节点传递。如果P原来是红色,问题可以在父节点处解决;如果P是黑色,可能继续向上。

Case 3:兄弟w是黑色,w的近侄子是红色,远侄子是黑色
text 复制代码
      P                  P
     / \                / \
    x   w(B)   =>      x   wl(B)
       /                    \
    wl(R)                    w(R)

处理方法

  • wl变黑
  • w变红
  • 对w右旋

本质:把结构转换成Case 4。

Case 4:兄弟w是黑色,w的远侄子是红色
text 复制代码
      P(?)               w(原P颜色)
     /   \              /   \
    x    w(B)   =>    P(B)  wr(B)
          \          /
          wr(R)    x

处理方法

  • w变成P的颜色
  • P变黑
  • wr变黑
  • 对P左旋
  • 修复结束

本质:这是删除修复中真正"还债"的情况。通过重新分配颜色和旋转,彻底弥补了黑高的亏损。

6.4 删除修复总结

情况 本质
兄弟是红色 先旋转,换成黑兄弟
兄弟黑,两个侄子黑 兄弟变红,黑高亏损向上传
兄弟黑,近侄红,远侄黑 旋转兄弟,转成远侄红
兄弟黑,远侄红 旋转父节点,重新分配黑色,修复完成

七、红黑树在C++ STL中的应用

C++标准库中,以下容器通常基于红黑树或类似的平衡搜索树实现:

cpp 复制代码
std::map
std::multimap
std::set
std::multiset

它们的共同特点是:

  • 元素按照key有序存储
  • 查找、插入、删除都是O(log n)
  • 支持高效的范围查询

7.1 std::map vs std::unordered_map

容器 底层思想 是否有序 平均查找 最坏查找
std::map 红黑树 有序 O(log n) O(log n)
std::unordered_map 哈希表 无序 O(1) O(n)

7.2 如何选择?

使用std::map的场景

  • 需要有序遍历
  • 需要lower_bound/upper_bound操作
  • 需要范围查询
  • 需要稳定的最坏时间复杂度

使用std::unordered_map的场景

  • 只关心key->value的快速查找
  • 不需要有序性
  • 数据规模较大
  • 哈希函数质量可靠

7.3 红黑树为什么适合map/set?

红黑树天然支持map/set所需的特性:

  1. 有序遍历:中序遍历就是升序
  2. 范围查询 :可以很方便地找到[a, b]范围内的所有元素
  3. 高效的插入删除:不需要移动大量已有元素
  4. 良好的迭代器稳定性:插入通常不会让已有迭代器失效;删除只会让指向被删除元素的迭代器失效

八、工程实现的注意事项

8.1 哨兵节点nil的使用

一个工业级红黑树实现通常会使用一个共享的黑色哨兵节点:

cpp 复制代码
nil->color = Color::Black;
nil->left = nil;
nil->right = nil;
nil->parent = nil;

这样做的好处:

  • 不用到处判断nullptr
  • 删除修复逻辑更统一
  • 所有叶子NIL都是黑色,符合性质3

8.2 容易出错的地方

  1. 忘记维护parent指针:旋转时不只是改左右孩子,还要改父节点关系
  2. 忘记更新root:如果旋转发生在根节点附近,根节点可能改变
  3. nil和nullptr混用:两种写法都可以,但不要混着来
  4. 删除修复时访问空指针 :如果没有哨兵节点,访问w->left->color可能崩溃
  5. 插入后忘记把根节点染黑:这是一个常见的低级错误

九、学习建议与面试要点

9.1 学习

  1. 普通BST的查找、插入、删除
  2. 左旋和右旋操作
  3. 红黑树的五条性质
  4. 插入修复逻辑
  5. 删除修复逻辑
  6. STL中map/set的工程应用
  7. 自己实现一个简化版红黑树

红黑性质约束树高 → 旋转保持BST有序 → 变色维护黑高 → 插入主要处理连续红色 → 删除主要处理黑高亏损

9.2 面试高频问题

Q1:为什么新插入节点一般是红色?

因为插入红色节点不会改变任何路径上的黑色节点数量,更容易维护黑高。如果插入黑色节点,会直接让某条路径黑高加1,修复更麻烦。

Q2:旋转会破坏二叉搜索树性质吗?

不会。旋转只改变局部父子关系,不改变中序遍历顺序。

Q3:红黑树为什么不是严格平衡?

因为它只要求黑高一致,并禁止连续红色。它不要求左右子树高度差小于等于1。这种近似平衡的设计在工程上更实用。

Q4:红黑树和哈希表怎么选?

需要有序性、范围查询、稳定最坏复杂度,用红黑树。只需要快速key-value查询,不关心顺序,用哈希表。


总结

最后用一个形象的比喻来总结:

  • 普通BST像一个随插入顺序生长的树,可能长歪
  • AVL树像严格修剪的盆景,查找很快,但维护成本较高
  • 红黑树像工程上更折中的结构:不要求绝对平衡,但绝不允许严重倾斜

红黑树牺牲了一点查询的极致性能,换来更好的插入删除综合表现。这正是它在C++ STL中被广泛使用的原因。

理解红黑树不是为了在工作中重新实现它(除非是写标准库),而是为了理解它的设计思想和性能特性,从而在实际开发中做出更明智的技术选择。

相关推荐
个 人 练 习 生1 小时前
数据结构:排序算法详解
c语言·数据结构·经验分享·学习·算法·排序算法
青山木1 小时前
枚举类DP进阶:完全平方数与零钱兑换
java·数据结构·算法·leetcode·动态规划
huang5791472 小时前
从缓存行角度优化链表遍历:批处理与预取策略
数据结构·链表·缓存
吞下星星的少年·-·10 小时前
The 2026 ICPC Asia East Continent Online Contest (II)(构造)
数据结构·算法
Msshu12314 小时前
2~5串锂电快充优选|XSP36 Type‑C升压/升降压快充管理芯片
数据结构·随机森林·贪心算法·排序算法·线性回归·启发式算法
动词ing17 小时前
【学习笔记】数据结构栈与队列(栈先进后出+队列先进先出+括号匹配+消消乐模型)
数据结构·笔记·学习
a1879272183117 小时前
【算法】链表(二):链表上的双指针——变速、异链与定距,和一份路程账本
数据结构·算法·leetcode·链表·go·指针·环形链表
zcmodeltech18 小时前
火电厂模型能演示冷态启动和故障处理吗?——基于STM32与Modbus RTU的集控实训控制系统设计
数据结构·stm32·单片机·嵌入式硬件·能源
Doubbbbbbble云18 小时前
区间合并问题的常见算法模式与优化思路4
java·数据结构·算法