
去年做一个排序容器项目时我一开始老老实实写了两个底层类一个给set用一个给map用。写完之后一看两棵树长得几乎一样只有比较的字段和节点里存的东西不同。后来痛定思痛把两份代码合并成一份泛型红黑树再通过薄薄一层封装分别暴露成set和map的接口。这篇笔记就是把我当时的实现思路、封装技巧和调试过程中踩过的坑整理出来给也想动手造轮子的同学一个参考。这套封装的核心思路不复杂红黑树只管平衡不管上层是集合还是映射。节点里存什么、按什么字段排序全部通过模板参数交给上层决定。树的代码只依赖一个类似std::function的键提取器set传自己就是键map传取pair.first。一旦树本身稳定下来set和map就变成了两棵树的穿衣问题难度低很多而且调试范围被压缩得很小。1. 设计取舍为什么set和map能共用一棵红黑树1.1 核心矛盾键值一体与键值分离set存储时每个元素既是键也是值排序比较直接用元素本身map存储的是一个键值对排序比较只看键值只是挂在键旁边跟着走。这两者天然不一样但底层平衡逻辑根本不关心元素长什么样。解决方式是用一个T作为树节点的存储类型再用一个KeyOfValue函数对象从T中取出键。对set而言T KeyKeyOfValue返回参数本身对map而言T pairconst Key, T或简化版本的pairKey, TKeyOfValue返回it-first。树在比较时不直接操作T而是先通过KeyOfValue拿到键再比这样树的所有逻辑都能在不知道T具体含义的情况下工作。1.2 泛型红黑树的模板参数设计我把树的模板参数设计成下面这样template class T, class KeyOfValue, class Compare std::less class rb_tree;T节点存储的数据类型。KeyOfValue从T中提取键的函数对象set和map各传各的。Compare键的比较逻辑默认升序。树内部的所有查找、插入位置判断、删除替换都只调用KeyOfValue()(nodeValue)和Compare绝不直接比较整个T。这样带来的最大好处是树本身不用修改就能同时服务set和map。1.3 仿函数提取Key差异点收敛为比较规则对于set可以写一个空壳仿函数struct Identity { const Key operator()(const Key x) const { return x; } };对于map则从pair中取出firststruct Select1st { const Key operator()(const std::pairconst Key, Value p) const { return p.first; } };这样set和map的差异就被压缩到了很小的一行。今后如果要做一个multiset只需要把插入语义从相等则返回false改成相等也继续往右走树的平衡逻辑完全不用动。这算是封装带来最直接的好处。2. 底层数据结构与节点设计哨兵、颜色与旋转2.1 节点定义与哨兵节点为何重要红黑树的节点我按经典方案设计每个节点包含左右孩子、父亲、颜色、数据五部分enum Color { RED, BLACK }; template class T struct rb_node { rb_node* left; rb_node* right; rb_node* parent; Color color; T value; };除了真实节点外我额外保留了一个永远存在的null_node哨兵节点。这个设计一开始觉得多余后来发现少了它迭代器无法实现。因为树的end()返回的是最后一个节点之后的位置真实树里根本没有这个物理节点必须用一个哨兵来表示。哨兵节点我把它设在根节点的父指针位置同时让树的header结构管理它。初始状态下header-parent指向真正的根节点header-left指向最小节点header-right指向最大节点。这样begin()可以直接返回最左节点end()直接返回哨兵rbegin()通过header-right拿到最右节点所有操作都变成O(1)。2.2 左旋右旋的实现细节与指针更新旋转是红黑树平衡的基础操作。左旋把当前节点的右孩子提升为父节点右旋则相反。实现时最容易出错的地方不是节点移动而是三个方向的parent指针必须同步更新少一条链整棵树就裂了。我的左旋实现长这样void rotate_left(rb_node* x) { rb_node* y x-right; x-right y-left; if (y-left ! nullptr) 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; }旋转之后需要额外注意两点一是如果x之前有父节点必须判断x是父节点的左孩子还是右孩子并把y接到正确的位置二是如果x是根旋转后根要指向y同时header-parent也要跟着更新。我最初遗漏了根节点更新导致插入几次后整棵树脱管排查了很久。2.3 红黑树五条性质在实现中的落地检查开始动手前把红黑树五条性质重新梳理一遍每个节点非红即黑根节点是黑色NIL叶子视为黑色一般用nullptr代替红色节点的两个子节点必须都是黑色简称不红红从任意节点到其每个叶子节点的路径上黑色节点数量相同简称黑高相等。性质4和性质5是插入删除修复循环的终止条件。我的调试函数里直接写了一个check_violations()函数每次插入删除结束后调一遍一旦发现违反就打印节点值和颜色。调试阶段几乎全靠它快速定位。3. 插入与删除的平衡修复从叔叔颜色到删除替换3.1 插入修复的两种分支插入新节点默认涂成红色这样可以不破坏黑高性质但可能引发性质4。插入修复的核心是看叔叔节点的颜色修复循环从当前节点向上走只要父节点存在且是红色就说明性质4被破坏取叔叔节点如果叔叔是红色就把父和叔叔都涂黑、祖父涂红然后把当前节点上移到祖父继续循环如果叔叔是黑色则根据当前节点在父节点的内侧还是外侧决定先旋转再变色还是直接旋转变色。旋转方向可以用镜像处理。左侧还是右侧本质都是把三个节点的链条掰直再让中间节点上移变黑。代码实现时关键要处理好当前节点在父节点内侧的折线情况先对父节点旋转一次把折线变成直线再对祖父旋转一次。3.2 删除的前置处理如何找真正被删的节点删除是红黑树里最容易写崩的部分。直接想删除目标节点会碰到两个孩子都存在的麻烦所以标准做法是找后继节点替换如果目标节点有两个孩子就找到右子树中的最小节点把它和当前节点的值交换然后转而删除这个后继节点后继节点最多只有一个右孩子不存在两个孩子的问题删除它就简单很多真正的物理删除是把后继节点的子节点接到它的父亲上再释放内存。我一开始没做值交换而是尝试移动节点指针结果遇到各种parent悬挂。后来改成值交换逻辑一下就清晰了唯一的代价是迭代器指向的节点可能不是最初那个节点但迭代器存储的是指针值交换不改变指针所以迭代器的指向位置在删除后依然有效。3.3 删除修复的四种情况与循环终止条件删除后如果被删节点是黑色某条路径就少了一个黑节点黑高性质被破坏。修复循环以当前节点是黑色且非根为条件分四种情况兄弟是红色把兄弟涂黑、父涂红旋转一次把情况转成兄弟是黑色兄弟是黑色且兄弟的两个孩子都是黑色把兄弟涂红当前节点上移到父节点兄弟是黑色兄弟的左孩子是红色右孩子是黑色先右旋兄弟把红色转到外侧进入情况四兄弟是黑色兄弟的右孩子是红色让兄弟变父的颜色、父变黑、兄弟的右孩子变黑旋转然后直接结束。这里最需要理解的是修复循环不是做完一次就完事红色上移后新的当前节点可能依然是黑色因此循环要继续直到当前节点变成红色最后把它涂黑收尾。我自己的调试经验是把这四种情况写成日志输出用缩进级别表示循环深度然后针对删除根删除唯一黑色节点删除红色叶子几类极端输入做单测。没有日志一旦循环走在情况二和情况三之间跳不出来看代码很难发现。3.4 自己调试平衡逻辑的实战手段除了性质验证我还会做序列化打印。每次插入或删除后输出树的括号表示例如50(30(20,40),70(65,80))配合颜色标记可以直接看到旋转前后树的形态变化。插入修复的旋转肉眼一看就知道方向对不对删除修复则是看黑高是否一致。这套方法帮我发现过一个经典bug删除修复里我判断当前节点是父节点的左孩子还是右孩子用的是x parent(x)-left但在情况三旋转兄弟之后忘记重新取兄弟指针导致后续操作拿着旧兄弟节点树立刻乱掉。这个坑的根源是对兄弟节点的引用没有跟随结构变化而更新。4. set封装把树收紧成不可改值的有序集合4.1 迭代器退化为什么set的iterator也要返回const引用set的特点是元素不可修改因为一旦修改就会破坏排序。很多初学者以为不可修改只需要提供const迭代器就够了但标准库set的iterator类型在解引用时返回的是const Key而不是Key。我起初觉得这有点多余直到自己写了一个解除引用返回可变引用的set版本然后做了一件操作auto it myset.begin(); *it 42;编译可以通过运行时树直接乱掉因为排序依据变了。这就是为什么set的iterator和const_iterator本质都是const语义。实现上我在给树编写迭代器时对set直接让iterator类型别名指向const_iterator或者让*it返回const引用彻底堵死修改路径。4.2 接口适配insert/find/erase返回值的统一处理set的insert需要返回pairiterator, bool用布尔值表示本次插入是否成功。红黑树的插入函数返回的是一个指向插入位置的迭代器 是否成功的pair。封装层几乎只是透传std::pairiterator, bool insert(const Key k) { auto res tree_.insert_unique(k); // 树层已经比较了键 return {iterator(res.first), res.second}; }find和erase的实现更简单树层的查找函数直接接受一个键值封装层把键传给树内部的find_impl返回节点指针再包装成迭代器。这样树层的代码只需实现一套逻辑set和map的接口就都齐了。4.3 一个细节set不需要改写任何树内部代码这是封装依赖设计力量的地方。树的模板参数接的是T和KeyOfValueset传入TKey、KeyOfValueIdentity树内部所有取键再比较的动作对set来说都是比较自己多一层函数调用但逻辑等价。我不反对读者一开始用两个具体红黑树分别实现set和map比如set树直接比较节点值map树比较first。但一旦未来要维护、扩展两套树的代码很容易在某个修复里改了一处漏了一处。共用一棵树以后修改平衡逻辑只影响一处这个收益在写代码的第二天就能体会到。5. map封装从KVPair到operator[]的一层薄壳5.1 存储形状选择pairconst Key, T与pairKey, Tmap的节点存储的是一个键值对。C中标准map的key是const的如果选pairconst Key, T那么键不可改值可以改。这个设计有它的合理性因为修改key同样会破坏树的排序。但对于一棵手写的树来说pairconst Key, T会带来一个问题节点的赋值操作和值交换无法进行。因为我们删除替换时用node-value other-value而const Key不允许赋值。为了减少封装层的阻碍我实现的这个教学版map使用了pairKey, T键虽然是非const的但封装层不提供任何修改键的接口迭代器解引用时只允许修改.second。如果你想严格模拟标准库语义可以使用pairconst Key, T但相应地树层需要把值交换改成先析构再构造或者采用节点指针移动方案。两者都能用教程版用非const键是因为实现代码更直观。5.2 用仿函数遮蔽比较map的Key提取方式map的KeyOfValue写为Select1st树在比较时拿到的是pairKey, T但比较时只看firststruct Select1st { const Key operator()(const std::pairKey, T p) const { return p.first; } };树层compare调用代码大概是这样bool go_left comp(KeyOfValue()(value), key); bool go_right comp(key, KeyOfValue()(value));两个方向都比一遍如果都为false就认为键相等。这样做的好处是map的查找接口可以只传一个Key进来树内部通过KeyOfValue把节点的值转换成键再与查找键比较。调用方不需要知道树里存的是pair。5.3 operator[]的下标逻辑与insert返回值利用map的operator[]是实现时最有意思的部分它要解决找不到就插入默认值找到了就直接返回值的引用T operator[](const Key k) { auto res tree_.insert_unique(std::make_pair(k, T())); return res.first-second; }下沉到红黑树内部insert_unique如果发现键已经存在就返回已有节点的迭代器和false如果不存在就插入新节点并返回新节点的迭代器和true。上层拿到迭代器后统一通过second拿到值的引用读写都靠这个引用完成。这个设计非常巧妙的一点是operator[]和insert用的底层逻辑是同一份区别只在于调用方是否使用返回的bool值。对operator[]而言bool被丢掉只留下引用对insert而言调用方把bool通过pairiterator, bool返回出去。封装层只做一层薄薄的转发没有重复编写查找、插入逻辑。5.4 map迭代器与set迭代器的差异map的迭代器解引用返回的是pairconst Key, T或本文实现的pairKey, T这样调用方可以通过迭代器修改pair里的second也就是值部分。而set的迭代器解引用返回const引用二者差异完全由T的类型决定树和迭代器本身不需要做任何条件编译。迭代器实现时我让树的迭代器模板持有node*指针解引用时返回node-value的引用。set把value定义为Keymap把value定义为pair返回类型就自然不同了。6. 测试与边界如何证明封装没有破坏红黑树6.1 性质校验中序遍历有序、红黑约束、黑高一致封装完成后第一件事不是测接口而是验证红黑树本身仍然有效。我写了一个验证函数递归检查树中每个节点中序遍历结果必须严格升序没有连续红色节点根节点为黑任意节点到所有叶子节点的黑色节点数相同。严格升序这点对set和map同样适用只不过map要比较的是pair里的first字段。如果这些规则全部通过树层就算稳定。建议把这个验证函数留在测试代码中不要删掉后续改任何代码都能随时调一遍。6.2 迭代器边界begin/end、到end、--回退迭代器最容易出问题的是边界尤其是从end()往回走。由于end()是哨兵所以it--必须把原来的end()转换成树中最大节点。实现方式可以在迭代器的自减运算符里判断如果当前指针等于哨兵就让指针指向header-right否则按当前节点是否有左孩子的规则走中序前驱。同样begin()对应的最小节点也不一定是根节点。红黑树虽然有序但最小节点需要沿着根的left一路走到最左叶子。如果忘了维护header-leftbegin()就会指向错误位置。我的测试用例专门覆盖了空树begin()end()、只有根节点时和--都能回到原位、从end()--一次得到最大节点、从begin()--一次得到哨兵这四类。实测最容易挂的是空树和单节点树因为这类情况下指针为nullptr需要double-check每个解引用点。6.3 随机压力测试与性能观察为了让测试更可靠我写了一个随机测试脚本式循环来生成大量插入、删除、查找操作。具体做法是维护一个外部的std::set作为标准答案每一步操作后都把自封装容器和标准答案的内容做对比for (int i 0; i N; i) { int key rand() % RANGE; if (rand() % 2) { auto res1 myset.insert(key); auto res2 stdset.insert(key); assert(res1.second res2.second); } else { myset.erase(key); stdset.erase(key); } assert(std::equal(myset.begin(), myset.end(), stdset.begin())); }这个测试跑了上百万次随机操作帮我找出了树迭代器在删除交替执行后的一个指针野引用bug删除时直接释放节点内存但迭代器还停留在被删节点上下一轮访问就崩了。虽然标准库容器也不保证被删迭代器有效但至少不能让随机测试直接崩溃所以我额外加了删除后让迭代器失效但不访问的思路避免测试脚本踩出未定义行为。从性能上看自封装红黑树在插入和查找上比标准库慢一个常数级别但量级一致。对于造轮子学习项目来说这完全在预期范围内。真要追性能差距可以看一下标准库使用的内存分配方式、node的布局方式、内联展开程度这些优化细节并不影响学习封装的整体思路。6.4 我自己的一个习惯封装层绝不掺入平衡逻辑最后分享一个个人习惯封装层绝不掺入任何与平衡有关的代码。树的旋转、变色、插入修复、删除修复、迭代器次序运算全部在树层完成set和map只是把自己的数据类型和比较方式告诉树然后做少量接口转发。这个习惯的价值在出bug时特别明显。如果封装层出现问题代码量小排查范围一眼就能看清楚如果树层有问题验证函数和随机对比测试立刻能抓到。两者职责分明互不干扰长期维护起来非常舒服。如果你也想动手写一遍我建议按树层 - set - map - 测试的顺序走。先把树的插入和查找做好用中序遍历验证有序再做删除和修复用性质校验最后上封装。封装本身不痛苦真正花时间的是把红黑树本身写稳定。