namespace Jianyi 本文用于系统复盘红黑树插入的实现,覆盖动机、原理、代码、易错点、复杂度与面试延伸。附带对自己实现代码的 Debug 记录(真实踩坑,不是编的)。
目录
1. 为什么会有红黑树?(解决什么问题)
2. 核心思想(一句话)
3. 工作原理
3.1 四条规则
3.2 为什么这四条规则能推出"最长路径 ≤ 2×最短路径"
3.3 插入的整体思路
4. 代码实现
5. 每一句关键代码为什么这么写
6. 易错点
6.1 你自己代码里的真实bug(不是泛泛而谈,是你写的)
6.2 通用易错点
7. 时间复杂度
8. 我的理解
9. 思想总结
拓展 / 面试角度
关于"叔叔为红"分支重复代码怎么化简
1. 为什么会有红黑树?(解决什么问题)
先回到更早的问题:为什么需要"平衡树"?
普通二叉搜索树(BST)在最坏情况下会退化成一条链(比如顺序插入 1,2,3,4,5),此时查找、插入、删除的复杂度从理想的 O(logN) 退化成 O(N)。为了避免退化,我们需要某种机制在插入/删除时主动纠正树形,把高度控制在 O(logN) 量级。
AVL 树是第一个方案:用严格的高度差(平衡因子 ∈ {-1,0,1})来控制平衡。它的问题是控制太严格——每次插入/删除后,为了维持这个严格条件,可能引发多次旋转(删除场景下最坏是 O(logN) 次旋转),维护平衡因子本身也有成本。
红黑树是对这个问题的"工程学妥协":放弃绝对平衡,换取更低的调整成本。它不追求"最长路径 = 最短路径",只追求"最长路径 ≤ 2 × 最短路径",用四条颜色规则间接实现这个近似平衡。插入最多只需要颜色调整 + 常数次旋转(≤2次),这也是为什么 STL 的map/set、Linux 内核的调度器、Java 的TreeMap都选红黑树而不是 AVL 树——读多写少用 AVL,写多读多用红黑树,红黑树在增删的综合成本上更划算。
一句话动机:AVL 树平衡得太"用力",红黑树用颜色规则换取更便宜的平衡代价。
2. 核心思想(一句话)
用节点颜色(红/黑)的四条约束规则,间接限制树中任意路径的黑色节点数量相等,从而保证最长路径不超过最短路径的 2 倍,达到近似平衡。
四条规则本身不难背,难的是理解它们**为什么恰好能推出"最长路径 ≤ 2×最短路径"**这个结论——这是第 3 节要讲的。
3. 工作原理
3.1 四条规则
- 节点非红即黑
- 根节点是黑色
- 红色节点的两个孩子必须是黑色(即不能有连续的红色节点)
- 任意节点到其所有 NULL 路径上,黑色节点数量相同(黑高 black-height 一致)
3.2 为什么这四条规则能推出"最长路径 ≤ 2×最短路径"
这是我认为整篇笔记里最值得内化的一步推导,很多人背了规则却说不出这层因果关系:
- 由规则 4,从根到任意 NULL 的路径黑色节点数固定,记为
bh(black height)。最短路径就是极端情况下全是黑色节点组成的路径,长度正好是bh。- 由规则 2、3,红色节点不能连续出现,所以最长路径的极端情况就是"一黑一红"交替排列,黑色节点数不变仍是
bh,但总节点数翻倍,长度是2×bh。- 所以对任意路径长度
h,都有bh ≤ h ≤ 2×bh,也就是最长路径最多是最短路径的 2 倍。
这一步的关键洞察是:规则4锁死了"黑色节点数"这个不变量,规则2+3锁死了"红色节点不能扎堆",两者一结合,路径长度的波动范围就被"焊死"在一个可控区间内。这也是为什么后面插入调整时,所有操作都是奔着"不破坏这两个不变量"去做的。
由此可推出效率:设 N 为节点数,h 为最短路径长度,则2^h - 1 ≤ N < 2^(2h) - 1,反解出h ≈ logN,最坏路径2×logN,所以增删查改都是O(logN)。
3.3 插入的整体思路
- 先按 BST 规则插入。
- 新插入的节点必须是红色。这是个很反直觉但很关键的设计选择:如果插入黑色节点,必然破坏规则4(这条路径突然多了一个黑色节点,其他路径没有),而规则4是"全局性"的,修复代价极高;插入红色节点,只有"父节点也是红色"这一种情况才违规(违反规则3),这是"局部性"问题,修复代价低得多。这是一种典型的"把全局约束问题转化成局部约束问题"的设计思路,工程上很常见(比如很多分布式系统宁可牺牲局部一致性去保全局可用性,本质是同一种权衡逻辑)。
- 如果父节点是黑色,什么都不用做,直接结束。
- 如果父节点是红色(违反规则3),才需要真正处理,这时候祖父节点必然是黑色(因为规则3保证红色节点的孩子都是黑色,父亲是红的,父亲的父亲只能是黑的),关键看"叔叔"节点的颜色,分三种情况。
设新节点为c(cur),父亲为p,祖父为g,叔叔为u。
情况1:叔叔存在且为红 —— 变色,不旋转
把p和u都变黑,g变红。直觉:p和u所在的两条子树路径各多了一个黑色节点(因为红变黑),而g由黑变红少了一个黑色节点,两边抵消,这条子树对外的黑色节点总数不变,同时解决了c、p连续红色的问题。但g变红了,如果g的父亲也是红色,问题又出现在更上一层——所以要把g当作新的c,向上继续处理,直到根节点(根节点若变红要强制拉回黑色)。
情况2:叔叔不存在或为黑,且c、p、g呈"同侧"(比如都往左)—— 单旋 + 变色
以g为旋转点做一次单旋(p是左孩子就右单旋),再把p变黑、g变红。子树黑色节点数不变,没有连续红色节点,且不需要继续向上处理——因为新的子树根p已经是黑色,它对上层而言是"安全"的。
情况3:叔叔不存在或为黑,且c、p、g呈"异侧"(比如p是左孩子但c是p的右孩子,也就是"之字形")—— 双旋 + 变色
先对p做一次旋转把结构掰直(变成情况2的同侧形态),再对g做一次旋转,最后把新的子树根(原来的c)变黑、g变红。同样不需要向上传播。
三种情况的本质区别:情况1只调整颜色,因为它能靠"红转黑"消化多出来的高度,不需要动结构;情况2、3因为叔叔那边"没有红色余量可用",纯变色会打破黑高一致性,所以必须靠旋转来重新分配子树高度。能不能仅靠变色解决,取决于叔叔这条路径上有没有富余的红色节点可以牺牲,这是理解这三种情况分野的关键。
4. 代码实现
下面是修正过编译错误和逻辑bug之后的版本,基于你原有的代码结构:
#pragma once #include <utility> namespace Jianyi { enum Colour { RED, BLACK }; template<class K, class V> struct RBTreeNode { 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) , _col(RED) // 新增节点默认红色,构造时就定死,不依赖外部再赋值 {} }; template<class K, class V> class RBTree { typedef RBTreeNode<K, V> Node; public: 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; // 已存在,不允许重复key } cur = new Node(kv); // 构造函数里已经是RED if (parent->_kv.first < kv.first) parent->_right = cur; else parent->_left = cur; cur->_parent = parent; // 向上修复:只要父亲是红色就说明规则3被破坏了 while (parent && parent->_col == RED) { Node* grandfather = parent->_parent; if (parent == grandfather->_left) { Node* uncle = grandfather->_right; if (uncle && uncle->_col == RED) { // 情况1:变色,向上继续 parent->_col = uncle->_col = BLACK; grandfather->_col = RED; cur = grandfather; parent = cur->_parent; } else { // 情况2/3:注意这里必须是 == 不是 = if (cur == parent->_left) { RightRotate(grandfather); parent->_col = BLACK; grandfather->_col = RED; } else { LeftRotate(parent); RightRotate(grandfather); cur->_col = BLACK; grandfather->_col = RED; } break; // 旋转分支处理完就不需要向上传播了 } } else // parent 是 grandfather 的右孩子,逻辑镜像对称 { Node* uncle = grandfather->_left; if (uncle && uncle->_col == RED) { parent->_col = uncle->_col = BLACK; grandfather->_col = RED; cur = grandfather; parent = cur->_parent; } else { if (cur == parent->_right) { LeftRotate(grandfather); parent->_col = BLACK; grandfather->_col = RED; } else { RightRotate(parent); LeftRotate(grandfather); cur->_col = BLACK; grandfather->_col = RED; } break; } } } _root->_col = BLACK; // 兜底:万一根被情况1染红了,强制拉回黑色 return true; } void LeftRotate(Node* x) { Node* y = x->_right; x->_right = y->_left; if (y->_left) y->_left->_parent = x; y->_parent = x->_parent; if (x->_parent == nullptr) _root = y; else if (x == x->_parent->_left) x->_parent->_left = y; else x->_parent->_right = y; y->_left = x; x->_parent = y; } void RightRotate(Node* y) { Node* x = y->_left; y->_left = x->_right; if (x->_right) x->_right->_parent = y; x->_parent = y->_parent; if (y->_parent == nullptr) _root = x; else if (y == y->_parent->_left) y->_parent->_left = x; else y->_parent->_right = x; x->_right = y; y->_parent = x; } private: Node* _root = nullptr; }; } // namespace Jianyi5. 每一句关键代码为什么这么写
while (parent && parent->_col == RED)
这行是整段插入调整逻辑的"发动机"。为什么用while而不是递归?本质上这是一个自底向上的传播过程——情况1处理完之后问题可能会"冒泡"到祖父那一层,效果等价于递归调用"再检查一次祖父"。但这里没有用递归,而是把cur重新指向grandfather、parent重新指向新cur的父亲,用循环模拟了尾递归。这是很值得记住的思维模式:任何"处理完一层,问题可能传导到上一层"的场景,都可以写成"更新指针 + while 循环",而不必真的用函数递归(省栈)。
parent && ...这个判断顺序也有讲究:短路求值,先判断parent非空再取parent->_col,避免空指针解引用——如果cur已经变成根节点,parent就是nullptr,此时必须先终止循环。
if (parent == grandfather->_left)/else
这里在判断"父亲是祖父的左孩子还是右孩子",决定了叔叔是grandfather->_right还是grandfather->_left。这一整块 if/else 是完全镜像对称的逻辑(这也是"能不能化简"的地方,第6节详细讲)。
if (cur == parent->_left)
这里在判断新节点是"同侧"还是"异侧"插入,决定走单旋还是双旋。
break;
情况2、3处理完之后直接跳出循环,因为旋转之后新的子树根颜色是黑色(parent或cur被强制设为BLACK),对上层来说,"我这条路径多了一个节点但黑高没变、也没有连续红色",是安全状态,不需要再向上传播。这个break的正确性依赖于"旋转+变色之后不变量一定恢复"这个数学事实,不是随便加的。
为什么插入的节点是RED而不是BLACK(构造函数里写死)
已经在第3.3节讲了本质原因:插入黑色会破坏"全局性"的规则4,插入红色只破坏"局部性"的规则3。这里补充一句实现层面的考虑:把默认颜色写进构造函数比在外部每次手动赋值更安全,你原来的写法是构造完之后再手动cur->_col = RED,一旦哪天多了一条插入路径忘记写这一句,就会用到未初始化的_col,是典型的"防御性编程"该出手的地方。
6. 易错点
6.1 你自己代码里的真实bug(不是泛泛而谈,是你写的)
插入后调用IsBalance()断言)才能捞出来的那种问题。这也是为什么第 2.5 节的Check/IsBalance校验函数不是可有可无的——对于红黑树这种"正确性不是靠肉眼能看出来的数据结构",验证函数本身就是开发流程的一部分,不是事后补充。
bool Check(Node* root, int blackNum, int refNum) { if (root == nullptr) { // 走到空节点,说明一条路径走完了,比较黑色节点数是否等于参考值 if (blackNum != refNum) { cout << "存在黑色节点数量不相等的路径" << endl; return false; } return true; } // 检查规则3:红色节点不能有红色父亲(反过来查父亲,比查孩子方便, // 因为孩子可能有0/1/2个,父亲只有一个) if (root->_col == RED && root->_parent && 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; // 规则2:根必须是黑色 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); }6.2 通用易错点
- 忘记叔叔可能不存在:判断
uncle && uncle->_col == RED里uncle &&不能省,叔叔为nullptr时按"黑色"处理(NIL 节点视为黑色),如果漏了空指针判断直接取uncle->_col就是野指针访问。 - 同侧/异侧判断反了:情况2/3的判断容易在左右对称的代码里复制粘贴时改漏,比如右边分支忘记把
parent->_left改成parent->_right。 - 旋转函数里
_parent指针更新遗漏:旋转不仅要改_left/_right,还要同步更新被移动节点的_parent,以及原来x->_parent对y的引用(x->_parent->_left/_right = y),这一步漏掉最隐蔽,因为短期内查找操作可能还正常(查找不依赖_parent),但后续插入/删除会因为_parent错乱而崩溃。 - 忘记根节点强制染黑:情况1有可能把根节点变红(当根的两个孩子和插入路径连续变色传播到根),必须在
Insert结尾强制_root->_col = BLACK。
7. 时间复杂度
- 插入:O(logN)。BST 定位插入点是 O(logN),向上修复的循环每次要么直接
break(O(1)),要么继续向上(最多传播到根,O(logN)次),且旋转最多发生 2 次(情况2、3各触发一次后必然break)。 - 查找:严格 BST 逻辑,O(logN)。
- 空间复杂度:O(1) 额外空间(除去递归版本的 Check 函数用到的调用栈,插入本身是迭代实现,不占用额外栈空间)。
对比 AVL:同为 O(logN) 量级,但红黑树对平衡的容忍度更宽("黑高相同"比"高度差≤1"宽松),所以插入相同数量的节点,红黑树的旋转次数更少,这是它在工程上(STL、内核)被更广泛采用的直接原因。
8. 我的理解
红黑树最反直觉但也最巧妙的地方是:它不追求"看起来平衡",而是追求"用一个可以被高效维护的不变量,间接约束住失衡的上限"。AVL 树维护的是"高度差",这个量每次插入都可能变化,需要频繁重算;红黑树维护的是"颜色规则",这个量的维护成本被"局部化"了——大多数情况下(情况1)只需要变色,不需要碰结构,只有在"没有富余可消化"的时候(情况2、3)才需要旋转,而且旋转次数被数学证明限制在 2 次以内。
这其实是一种很通用的系统设计思路:当你没法直接维护"全局最优"的不变量时,退而求其次,找一个"局部可维护、且能推出全局有界"的替代不变量。红黑树用局部的颜色规则换取全局的高度有界,本质和很多分布式系统用局部的租约/心跳机制换取全局的一致性保证是同一类权衡。
9. 思想总结
- 红黑树是"用可控的不平衡换取更低的维护成本",AVL 是"零容忍失衡换取更快的查询"——没有绝对更优,是读写比例决定的权衡。
- 插入节点默认红色,是把"全局规则(黑高)"的破坏转化成"局部规则(无连续红色)"的破坏,降低修复成本,这是一种把全局约束问题局部化的通用技巧。
- 三种插入修复情况的分野,取决于"叔叔那条路径上有没有红色余量可以消化":有就变色(情况1),没有就必须旋转重新分配高度(情况2、3)。
- 用
while+ 指针重新指向上层节点,模拟自底向上的传播过程,是比递归更省栈的常见写法,可以迁移到很多"修复会向上传导"的场景(比如并查集路径压缩的迭代写法、堆的上滤/下滤)。 - 正确性不直观的数据结构,验证函数(
IsBalance/Check)应该被当作实现的一部分,而不是事后的可选项——=vs==这种bug只有跑验证函数或针对性构造用例才能发现。
拓展 / 面试角度
- "红黑树和AVL树怎么选?"—— 读多写少(比如很少变动的索引结构)选 AVL,查询更快;写操作频繁(比如 Linux 进程调度器 CFS、STL 容器)选红黑树,插入删除的旋转开销更低、更稳定。
- "为什么 STL 的 map/set 用红黑树不用哈希表?"—— 红黑树中序遍历有序,支持范围查询、
lower_bound/upper_bound,哈希表做不到;这是有序性 vs 常数级查找的权衡。 - "删除比插入复杂在哪?"—— 插入只需处理"多了一个红色节点"这一种失衡模式,最多传播到根;删除可能"少了一个黑色节点",失衡模式更多(兄弟节点红/黑、兄弟的孩子红/黑的组合),且删除的节点本身可能有两个孩子需要转化为删除前驱/后继的问题,情况数远多于插入,这也是很多教材
- 手写代码时最容易被面试官抓到的点:叔叔为空的判断有没有漏、旋转后
_parent有没有正确更新、根节点最后有没有强制染黑、==有没有写成=。这几乎是白板手撕红黑树的标准踩坑清单。
关于"叔叔为红"分支重复代码怎么化简
情况1(叔叔为红)的处理逻辑在parent == grandfather->_left和parent == grandfather->_right两个分支里,代码逻辑完全一样,只是左右换了个位置,情况2/3也是同样的镜像重复。常见的化简思路有两种:
思路A:用_child[2]数组代替_left/_right,把方向变成可计算的索引
很多生产级实现(比如 Linux 内核的 rbtree、一些教科书的进阶写法)不用_left/_right两个具名指针,而是用Node* _child[2],约定_child[0]是左,_child[1]是右。这样"父亲是祖父的左孩子还是右孩子"就变成一个int dir(0或1),左右对称的逻辑可以合并成一份,用dir和1-dir互相替代,不需要写两遍。
思路B:只保留一份"以某个方向为准"的旋转逻辑,调用时传方向参数
比如把LeftRotate/RightRotate抽象成RotateAt(Node* x, int dir),dir决定旋转方向,情况2/3的处理函数也只写一份,传入"同侧/异侧"作为参数。
为什么我不建议你现在就重构成这样:
情况1(叔叔为红)那部分逻辑虽然重复了两遍,但它是纯颜色赋值,没有指针操作,本身没有出错风险,重复的是"读起来啰嗦"而不是"容易出bug"。反而是思路A/B这种抽象化,会引入"方向参数""数组索引"这类新的心智负担,对于你现在这个阶段(第一次手写红黑树、要发博客讲清楚原理),代码的可读性、可对照图理解的程度,比 DRY(不重复自己)更重要。工程上一般的建议是:红黑树的插入/删除实现,多数成熟项目(包括 Linux 内核)也保留了左右对称的重复代码,而不是强行抽象,因为这类代码"写一次、极少改动",重复带来的维护成本其实很低,抽象反而增加了理解门槛。