红黑树(Red-Black Tree)深度笔记 —— 从插入原理到代码调优

发布时间:2026/7/23 4:45:25
红黑树(Red-Black Tree)深度笔记 —— 从插入原理到代码调优 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 的路径黑色节点数固定记为bhblack height。最短路径就是极端情况下全是黑色节点组成的路径长度正好是bh。由规则 2、3红色节点不能连续出现所以最长路径的极端情况就是一黑一红交替排列黑色节点数不变仍是bh但总节点数翻倍长度是2×bh。所以对任意路径长度h都有bh ≤ h ≤ 2×bh也就是最长路径最多是最短路径的 2 倍。这一步的关键洞察是规则4锁死了黑色节点数这个不变量规则23锁死了红色节点不能扎堆两者一结合路径长度的波动范围就被焊死在一个可控区间内。这也是为什么后面插入调整时所有操作都是奔着不破坏这两个不变量去做的。由此可推出效率设 N 为节点数h 为最短路径长度则2^h - 1 ≤ N 2^(2h) - 1反解出h ≈ logN最坏路径2×logN所以增删查改都是O(logN)。3.3 插入的整体思路先按 BST 规则插入。新插入的节点必须是红色。这是个很反直觉但很关键的设计选择如果插入黑色节点必然破坏规则4这条路径突然多了一个黑色节点其他路径没有而规则4是全局性的修复代价极高插入红色节点只有父节点也是红色这一种情况才违规违反规则3这是局部性问题修复代价低得多。这是一种典型的把全局约束问题转化成局部约束问题的设计思路工程上很常见比如很多分布式系统宁可牺牲局部一致性去保全局可用性本质是同一种权衡逻辑。如果父节点是黑色什么都不用做直接结束。如果父节点是红色违反规则3才需要真正处理这时候祖父节点必然是黑色因为规则3保证红色节点的孩子都是黑色父亲是红的父亲的父亲只能是黑的关键看叔叔节点的颜色分三种情况。设新节点为ccur父亲为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 }; templateclass K, class V struct RBTreeNode { pairK, V _kv; RBTreeNodeK, V* _left; RBTreeNodeK, V* _right; RBTreeNodeK, V* _parent; Colour _col; RBTreeNode(const pairK, V kv) : _kv(kv) , _left(nullptr) , _right(nullptr) , _parent(nullptr) , _col(RED) // 新增节点默认红色构造时就定死不依赖外部再赋值 {} }; templateclass K, class V class RBTree { typedef RBTreeNodeK, V Node; public: bool Insert(const pairK, 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)向上修复的循环每次要么直接breakO(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 dir0或1左右对称的逻辑可以合并成一份用dir和1-dir互相替代不需要写两遍。思路B只保留一份以某个方向为准的旋转逻辑调用时传方向参数比如把LeftRotate/RightRotate抽象成RotateAt(Node* x, int dir)dir决定旋转方向情况2/3的处理函数也只写一份传入同侧/异侧作为参数。为什么我不建议你现在就重构成这样情况1叔叔为红那部分逻辑虽然重复了两遍但它是纯颜色赋值没有指针操作本身没有出错风险重复的是读起来啰嗦而不是容易出bug。反而是思路A/B这种抽象化会引入方向参数数组索引这类新的心智负担对于你现在这个阶段第一次手写红黑树、要发博客讲清楚原理代码的可读性、可对照图理解的程度比 DRY不重复自己更重要。工程上一般的建议是红黑树的插入/删除实现多数成熟项目包括 Linux 内核也保留了左右对称的重复代码而不是强行抽象因为这类代码写一次、极少改动重复带来的维护成本其实很低抽象反而增加了理解门槛。