
1. 项目概述为什么我们要亲手实现STL的map和set如果你写过一段时间的C肯定对std::map和std::set这两个容器不陌生。它们一个提供键值对映射一个提供唯一元素集合是处理有序关联数据的利器。但你是否好奇过为什么它们能保证对数级的查找、插入和删除效率为什么迭代器遍历出来的元素总是有序的答案就藏在它们的底层数据结构——红黑树里。很多C开发者包括一些有经验的对STL容器的使用可能很熟练但对底层实现却是一知半解停留在“知道是红黑树”的层面。这就像开车很溜却不知道发动机怎么工作。当遇到一些边界情况比如自定义类型的比较、迭代器失效的诡异问题或者需要做深度性能优化时这种“黑盒”认知就会成为瓶颈。亲手用红黑树模拟实现一遍map和set是打破这个瓶颈最有效的方法。这不仅仅是一个学习项目更是一次对数据结构、模板编程、迭代器设计、内存管理和STL设计哲学的深度探索。通过这个项目你能真正理解“平衡”二字的代价与收益明白STL接口设计背后的精妙权衡最终让你从STL的“使用者”蜕变为“理解者”甚至“定制者”。2. 红黑树核心原理与设计抉择在动手写代码之前我们必须把红黑树的规则吃透。红黑树不是一种普通的二叉搜索树而是一种“近似平衡”的二叉搜索树。它通过一套简单的着色规则和旋转操作确保从根到叶子的最长路径不会超过最短路径的两倍从而保证了最坏情况下的操作效率。2.1 红黑树的五项基本规则红黑树的平衡性由以下五条规则共同维护任何时刻都必须满足每个节点非红即黑。根节点是黑色的。所有叶子节点NIL节点即空节点都是黑色的。红色节点的两个子节点必须是黑色的。即不能有连续的红色节点从任一节点到其每个叶子节点的所有简单路径上包含相同数量的黑色节点。规则4和规则5是保证平衡的关键。规则4限制了红色节点的连续出现规则5则保证了黑色节点的路径长度一致。正是这些约束使得红黑树在插入和删除时能通过有限的调整重新着色和旋转恢复平衡其调整次数在常数级别这是它比AVL树在某些场景下更受青睐的原因——AVL树是严格平衡的维护成本更高。2.2 节点结构设计模板化的艺术一个红黑树节点的设计直接决定了后续所有操作的复杂度。我们需要仔细考虑它要存储什么数据以及如何与上层的map和set兼容。基础节点结构enum Colour { RED, BLACK }; templateclass T struct RBTreeNode { RBTreeNodeT* _left; RBTreeNodeT* _right; RBTreeNodeT* _parent; // 父指针用于向上回溯简化调整逻辑 T _data; // 存储的数据 Colour _col; // 节点颜色 RBTreeNode(const T data T()) : _left(nullptr) , _right(nullptr) , _parent(nullptr) , _data(data) , _col(RED) // 新节点默认为红色有利于减少破坏规则5的情况 {} };这里有一个关键设计点新插入的节点默认设为红色。如果设为黑色会立刻违反规则5所有路径黑高增加调整起来非常麻烦。设为红色可能违反规则4出现连续红节点但可以通过局部调整重新着色和旋转来解决影响面更小。为map和set设计不同的T这是实现map和set共用同一棵红黑树的核心技巧。对于setT就是Key类型。set只存储值。对于mapT是std::pairconst Key, Value类型。注意这里的Key是const因为map的键是不可修改的这保证了树结构的有序性不被破坏。那么树如何比较两个T类型对象的大小呢我们引入一个模板参数KeyOfT它是一个仿函数负责从T中提取出用于比较的Key。// 对于set struct SetKeyOfT { const K operator()(const K key) { return key; } }; // 对于map struct MapKeyOfT { const K operator()(const std::pairconst K, V kv) { return kv.first; } }; templateclass K, class T, class KeyOfT class RBTree { ... }; // 红黑树类模板这样在红黑树的Find、Insert内部我们通过KeyOfT()仿函数来获取Key进行比较实现了代码的复用。2.3 旋转操作平衡的物理实现当插入或删除节点导致红黑树规则被破坏时我们需要通过旋转来调整树的结构。旋转分为左旋和右旋它们是互逆操作。左旋 (RotateLeft)围绕某个节点p进行左旋。假设p的右孩子subR存在。让subR的左子树subRL成为p的右子树。让p成为subR的左孩子。更新subRL和p的父节点的指向。最后如果p原来是整棵树的根需要将根更新为subR。右旋 (RotateRight)围绕某个节点p进行右旋。假设p的左孩子subL存在。让subL的右子树subLR成为p的左子树。让p成为subR的右孩子。更新subLR和p的父节点的指向。最后如果p原来是整棵树的根需要将根更新为subL。旋转操作只改变了节点的相对位置并没有改变二叉搜索树“左小右大”的性质。它的时间复杂度是O(1)。在后续的插入调整中旋转通常与重新着色配合使用以最小的代价恢复红黑树的性质。实操心得画图画图画图理解旋转最有效的方法不是死记硬背代码而是在纸上画图。准备红、黑两种颜色的笔画出旋转前后的树结构手动更新每个节点的left、right、parent指针。模拟几次之后代码逻辑会变得非常直观。我强烈建议你在实现RotateLeft和RotateRight函数时旁边一定要有一张纸边画边写。3. 红黑树的关键操作实现理解了基本原理和设计后我们进入最核心的实现环节插入与删除。这两个操作是红黑树实现中最复杂、最考验对规则理解的部分。3.1 插入操作与平衡调整插入分为两步1) 按照二叉搜索树的规则找到插入位置并插入新节点红色2) 检查并调整以恢复红黑树性质。第一步二叉搜索树插入这部分和普通BST没有区别。从根开始比较待插入值data的Key与当前节点Key的大小一路向下找到空位创建新节点并链接到其父节点上。第二步调整因新节点为红色可能违反规则4设新插入节点为cur红色其父节点为parent红色才需要调整。祖父节点为grandfather必为黑因为父节点是红。关键看叔叔节点uncle的颜色。情况一uncle存在且为红这是最简单的情况。将parent和uncle变为黑色grandfather变为红色。此时以grandfather为根的子树黑高不变但grandfather变红可能使其与它的父节点形成双红。因此将cur指向grandfather继续向上调整。// 伪代码逻辑 parent-_col uncle-_col BLACK; grandfather-_col RED; cur grandfather; // 继续向上检查 parent cur-_parent;情况二uncle不存在或为黑且cur、parent、grandfather在一条直线上例如parent是grandfather的左孩子cur也是parent的左孩子左左。或者对称的右右情况。 处理方式对grandfather进行一次右旋左左或左旋右右。然后将parent染黑grandfather染红。 旋转后parent成为了这棵子树的根黑色完美解决了双红问题且整棵树的黑高保持不变。调整结束。// 以左左为例 if (parent grandfather-_left cur parent-_left) { RotateR(grandfather); parent-_col BLACK; grandfather-_col RED; }情况三uncle不存在或为黑且cur、parent、grandfather不在一条直线上例如parent是grandfather的左孩子cur是parent的右孩子左右。或者对称的右左情况。 处理方式先对parent进行一次左旋左右或右旋右左将其转化为情况二。此时cur和parent的角色互换。然后再按照情况二处理新的“祖父-父-当前”节点关系。// 以左右为例 if (parent grandfather-_left cur parent-_right) { RotateL(parent); // 先左旋转化为左左 std::swap(parent, cur); // 交换指针现在parent是原来的cur红色cur是原来的parent红色 } // 然后按照情况二左左处理 RotateR(grandfather); cur-_col BLACK; // 注意此时cur指向的是原来的parent节点 grandfather-_col RED;无论哪种情况最后都要记得将根节点重新染为黑色规则2因为调整过程中根可能被染红。3.2 删除操作与平衡调整删除操作比插入更复杂因为删除一个节点后可能会破坏黑高规则5。我们采用“替代删除法”找到被删节点target的后继节点replace通常是右子树的最左节点用replace的值覆盖target的值然后问题转化为删除replace节点。replace节点最多有一个右孩子或没有孩子这简化了删除逻辑。删除节点del后调整的触发条件是被删除的节点是黑色。因为删除红色节点不影响任何规则。设del的替代节点即实际被删除或移动的节点为curcur的兄弟节点为brother。调整的核心思想是cur所在的路径少了一个黑节点我们试图通过旋转和变色让兄弟子树“贡献”一个黑节点过来或者通过重新着色将黑高缺失向上传递。调整是一个循环过程cur从被删位置开始可能一直向上回溯到根。情况极其繁多根据cur、brother、brother子节点的颜色和位置有近十种情况但可以归纳为几大类。这里简述最经典的分类假设cur是父节点的左孩子兄弟为红通过旋转将情况转化为兄弟为黑。兄弟为黑且兄弟的两个孩子都为黑将兄弟染红。此时以父节点为根的子树黑高统一减1将cur上移至父节点将问题向上传递。兄弟为黑且兄弟的远侄子即与cur异侧的侄子为红通过对父节点进行旋转cur在左则左旋并交换父与兄弟的颜色再将远侄子染黑。这样可以完美补全cur路径缺失的黑高调整结束。兄弟为黑且兄弟的近侄子为红远侄子为黑先通过旋转和变色将情况转化为情况3。注意事项删除调整是红黑树实现的难点删除调整的代码冗长且容易出错。强烈建议你先理解再编码务必在纸上画出每一种情况调整前和调整后的树结构理解每一步操作是如何恢复黑高平衡的。编写辅助函数将“获取兄弟节点”、“判断节点颜色”等操作封装成函数提高代码可读性。充分测试编写大量测试用例覆盖删除根节点、删除叶子节点、删除有一个孩子的节点、删除有两个孩子的节点等各种情况并验证中序遍历是否有序、红黑树规则是否满足。3.3 迭代器设计让树可遍历STL容器的灵魂之一就是迭代器。对于红黑树我们需要实现双向迭代器支持和--。迭代器结构templateclass T, class Ref, class Ptr struct __RBTreeIterator { typedef RBTreeNodeT Node; typedef __RBTreeIteratorT, Ref, Ptr Self; Node* _node; __RBTreeIterator(Node* node) : _node(node) {} Ref operator*() { return _node-_data; } Ptr operator-() { return (_node-_data); } bool operator!(const Self s) const { return _node ! s._node; } // ... 其他运算符重载 };关键是如何实现operator中序遍历的下一个节点如果当前节点有右子树则下一个节点是右子树的最左节点。如果当前节点没有右子树则需要向上回溯找到第一个“当前节点是其父节点左孩子”的祖先节点该祖先节点的父节点即为下一个节点。如果回溯到根节点的父节点nullptr则遍历结束。operator--的逻辑正好相反寻找中序遍历的前一个节点。在红黑树类中提供迭代器接口typedef __RBTreeIteratorT, T, T* iterator; typedef __RBTreeIteratorT, const T, const T* const_iterator; iterator begin() { Node* left _root; while (left left-_left) left left-_left; return iterator(left); } iterator end() { return iterator(nullptr); } // 通常用空指针表示end这样我们的红黑树就支持了范围for循环for (const auto e : tree)。4. 封装map与set有了功能完整的红黑树模板类RBTreeK, T, KeyOfT封装map和set就变得水到渠成。这里主要工作是提供符合STL标准的接口。4.1 set的封装set的Key和Value是同一个类型。它的大部分功能直接调用底层红黑树的方法即可。templateclass K class set { public: struct KeyOfT { const K operator()(const K k) { return k; } }; typedef typename RBTreeK, K, KeyOfT::const_iterator iterator; // set的迭代器是const的防止修改Key typedef typename RBTreeK, K, KeyOfT::const_iterator const_iterator; iterator begin() const { return _t.begin(); } iterator end() const { return _t.end(); } std::pairiterator, bool insert(const K key) { return _t.Insert(key); // 直接复用红黑树的Insert } // ... 其他接口find, erase, size, empty等 private: RBTreeK, K, KeyOfT _t; };注意set的迭代器被定义为底层红黑树的const_iterator这是因为set的元素即Key是不可修改的修改会破坏树的有序性。4.2 map的封装map需要存储键值对pairconst K, V。它的operator[]是一个特色功能其行为是如果key存在返回对应value的引用如果key不存在则插入一个{key, V()}的键值对并返回新插入value的引用。这为map提供了非常方便的插入和修改语法。templateclass K, class V class map { public: struct KeyOfT { const K operator()(const std::pairconst K, V kv) { return kv.first; } }; typedef typename RBTreeK, std::pairconst K, V, KeyOfT::iterator iterator; typedef typename RBTreeK, std::pairconst K, V, KeyOfT::const_iterator const_iterator; V operator[](const K key) { std::pairiterator, bool ret insert(std::make_pair(key, V())); return ret.first-second; // ret.first是迭代器指向插入的pair取其secondvalue的引用 } // ... 其他接口 private: RBTreeK, std::pairconst K, V, KeyOfT _t; };map的迭代器是普通迭代器因为我们可以修改pair中的valuekey是const的不可修改。4.3 测试与验证实现完成后必须进行 rigorous 的测试。功能测试大量插入、查找、删除随机数据验证结果正确性。顺序测试用中序遍历或迭代器遍历验证元素是否严格有序。红黑树规则测试编写一个IsBalance()函数递归检查上述5条规则是否全部满足特别是规则4和5。这是验证实现正确性的金标准。性能对比与std::map/std::set进行插入、查找性能的简单对比虽然我们的实现可能不如标准库高度优化但应具备相同的对数级时间复杂度趋势。边界测试插入重复键对于map和set应失败、删除不存在的键、空容器的操作等。5. 常见问题与深度思考在实现过程中你肯定会遇到各种“坑”。这里记录一些典型问题和我的思考。5.1 迭代器失效问题对于红黑树以及map/set插入操作通常不会导致迭代器失效除非发生重哈希但红黑树没有。删除操作只会使指向被删除节点的迭代器失效其他迭代器仍然有效。这一点与vector的迭代器失效机制完全不同因为红黑树是节点式容器删除一个节点不影响其他节点的内存地址。理解不同容器的迭代器失效规则是写出健壮C代码的关键。5.2 模板与编译错误模板编程的编译错误信息往往又长又晦涩。一个常见的错误是在类模板内部使用嵌套类型如iterator。你必须使用typename关键字来告诉编译器这是一个类型而不是静态成员。typedef typename RBTreeK, V, KeyOfT::iterator iterator; // 正确 typedef RBTreeK, V, KeyOfT::iterator iterator; // 错误可能导致编译失败。5.3 红黑树 vs 其他数据结构vs AVL树AVL树更平衡左右子树高度差不超过1因此查找效率略优于红黑树。但红黑树在插入和删除时需要的旋转操作更少整体性能更均衡所以STL、Linux内核等广泛使用红黑树。vs 哈希表 (unordered_map/set)哈希表提供平均O(1)的查找但无序。红黑树提供有序数据和对数级稳定性能。选择取决于是否需要元素有序以及是否在乎哈希表最坏情况O(n)的性能抖动。5.4 实现中的优化点内存池频繁的new/delete节点会影响性能。可以预先分配一块内存内存池来管理节点但这会增加实现复杂度。标准库的实现通常考虑了这一点。父指针存储我们的节点存储了_parent指针这增加了内存开销但极大简化了插入、删除和迭代器的实现。这是一种典型的“空间换时间”的权衡。递归 vs 迭代红黑树的查找、插入、删除都可以用递归或迭代实现。迭代实现通常效率更高且避免栈溢出风险但代码稍复杂。我们的示例为了清晰可能用了递归但在生产代码中应优先考虑迭代实现。亲手实现一遍红黑树和STL的map/set是一个痛并快乐着的过程。它强迫你去思考每一个指针的指向每一次颜色的变化每一次旋转的意图。当你最终看到自己实现的容器能通过所有测试并能与标准库容器在简单的性能测试中表现出相似的趋势时那种对底层原理的透彻理解所带来的自信是任何书本知识都无法替代的。这不仅仅是掌握了一个数据结构更是获得了一种剖析复杂系统、理解其设计哲学的能力。下次当你再使用std::map时你看到的将不再是一个黑盒而是一棵在你脑海中清晰旋转、着色的红黑树。