从零实现C++红黑树Map/Set:深入理解STL容器底层原理与设计

📅 2026/8/27 23:23:38
从零实现C++红黑树Map/Set:深入理解STL容器底层原理与设计
1. 项目概述从容器到底层为什么我们要自己造轮子在C的世界里std::map和std::set是每个开发者都绕不开的明星容器。它们一个管键值对一个管唯一元素集合靠着底层红黑树的支撑提供了稳定的O(log n)查找、插入和删除性能。用起来确实顺手map[key] value;、set.insert(element);几行代码就能搞定复杂的数据组织。但不知道你有没有过这样的瞬间当程序在某个map.find()操作上卡顿或者想定制一个特殊的内存分配策略时看着STL的黑盒心里会涌起一股“拆开看看”的冲动。这个项目就是一次彻底的“拆解”与“重建”——我们不满足于仅仅使用map和set而是要亲手用红黑树模拟实现它们包括完整的迭代器体系。这绝不只是为了炫技。在面试中手写红黑树是检验数据结构功底的“试金石”在深入理解C标准库设计哲学时剖析其迭代器、分配器、模板特化的精妙之处能极大提升你的内功更实际的是当你需要开发一个对性能、内存布局或遍历顺序有极端要求的专用容器时从零开始的掌控力是无价的。通过这个项目你将不再是一个STL的“用户”而会成为其“设计者”之一透彻理解从一棵树节点到begin()、end()迭代器背后每一行代码的逻辑。接下来我们就从最核心的数据结构选型开始一步步构建我们自己的Map和Set。2. 核心数据结构选型为什么一定是红黑树面对需要有序、高效查找的关联容器我们有好几个候选哈希表、AVL树、B树、跳表当然还有红黑树。STL的map/set选择了红黑树这背后是一系列工程化的权衡。哈希表如unordered_map能提供平均O(1)的查找但它无法保证元素的有序性遍历顺序是未定义的。对于需要范围查询如“找出所有键在A到B之间的元素”或顺序遍历的场景哈希表无能为力。AVL树是严格的平衡二叉树查找效率稳定在O(log n)但为了维持高度平衡插入和删除可能需要更多次的旋转在频繁修改的场景下开销略大。红黑树是一种“近似平衡”的二叉搜索树它通过一组简单的着色规则每个节点非红即黑根节点和叶子节点NIL为黑红色节点的子节点必须为黑从任一节点到其每个叶子节点的所有路径包含相同数目的黑色节点来确保没有一条路径会比其他路径长出两倍。这种设计巧妙地在查找效率和维护成本之间取得了平衡使得插入和删除最多只需要三次旋转在实践中综合性能更优。更重要的是红黑树作为二叉搜索树天然支持中序遍历有序输出这正好契合了map/set要求按键排序的特性。它的稳定性最坏情况下的时间复杂度也有保障和相对简单的实现相比于B树等使其成为标准库实现的首选。因此我们的模拟实现也坚定地选择红黑树作为底层骨架。注意这里说的“简单”是相对的。红黑树的插入和删除尤其是删除后的平衡调整逻辑分支较多是公认的实现难点。但一旦啃下来你对递归、指针操作和平衡树的理解会上一个大台阶。3. 基础架构设计一棵树如何同时服务Map和Set一个优雅的设计是避免写两套几乎相同的红黑树代码。STL采用了“泛型”和“适配器”的思想我们也可以借鉴。核心思路是实现一个通用的红黑树模板类然后让Map和Set作为它的包装器Wrapper或适配器Adapter。这个通用红黑树我们称之为RBTree应该只关心节点的组织、平衡和基本操作插入、删除、查找。至于节点里存什么是单一的Key类型对应Set还是pairconst Key, Value类型对应Map应该由模板参数来决定。我们可以定义一个关键的模板参数T来代表节点存储的数据类型。// 红黑树节点颜色枚举 enum Color { RED, BLACK }; // 红黑树节点模板 template class T struct RBTreeNode { T _data; // 存储的数据对于Set是Key对于Map是pairconst Key, Value RBTreeNodeT* _left; RBTreeNodeT* _right; RBTreeNodeT* _parent; Color _color; RBTreeNode(const T data T()) : _data(data) , _left(nullptr) , _right(nullptr) , _parent(nullptr) , _color(RED) // 新节点默认为红色便于调整 {} };接下来是树本体的设计。RBTree类需要提供基本的插入(Insert)、删除(Erase)、查找(Find)、中序遍历接口。但这里有一个关键点map和set的迭代器行为是不同的。set的迭代器解引用应该得到const Key因为Key是不可修改的而map的迭代器解引用应该得到一个pairconst Key, Value其中Key是const的但Value可以修改。如何让同一棵树支持这两种迭代器解决方案是引入迭代器适配器。我们先实现一个基础的树迭代器__RBTreeIterator它内部持有一个节点指针并重载、--、*、-等操作符。对于操作即中序遍历的下一个节点其逻辑对于map和set是完全相同的找到当前节点的后继节点。区别在于解引用操作符*的返回类型。我们可以通过模板和typedef或using来灵活定义。// 前置声明 template class T, class Ref, class Ptr struct __RBTreeIterator; // 红黑树本体 template class K, class T, class KeyOfT // KeyOfT是一个仿函数用于从T中提取Key class RBTree { typedef RBTreeNodeT Node; public: // 迭代器类型定义 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() { // 返回中序最后一个节点的下一个位置通常用nullptr表示 return iterator(nullptr); } // ... 其他成员函数 private: Node* _root nullptr; };KeyOfT这个模板参数是精髓所在。它是一个仿函数函数对象对于SetT就是KeyKeyOfT直接返回T本身对于MapT是pairconst Key, ValueKeyOfT则返回这个pair的first成员即Key。这样RBTree内部在比较节点大小时就不需要关心T的具体构成统一使用KeyOfT()(node-_data)来获取键值进行比较实现了代码的通用性。// 用于Set的KeyOfT仿函数 template class K struct SetKeyOfT { const K operator()(const K key) { return key; } }; // 用于Map的KeyOfT仿函数 template class K, class V struct MapKeyOfT { const K operator()(const std::pairconst K, V kv) { return kv.first; } };最后Map和Set类就变得非常轻薄它们只是组合了RBTree并暴露了符合STL风格的接口。template class K, class V class Map { private: // 底层红黑树存储pairconst K, V使用MapKeyOfT来提取Key RBTreeK, std::pairconst K, V, MapKeyOfTK, V _t; public: // 类型定义让Map的迭代器解引用得到pair typedef typename RBTreeK, std::pairconst K, V, MapKeyOfTK, V::iterator iterator; iterator begin() { return _t.Begin(); } iterator end() { return _t.End(); } V operator[](const K key) { // 经典的insert返回引用的实现用于支持map[key] value语法 auto ret _t.Insert(std::make_pair(key, V())); return ret.first-second; // ret.first是迭代器指向插入的节点 } // ... 其他接口如insert, find, erase等直接调用_t的对应方法 }; template class K class Set { private: // 底层红黑树存储K使用SetKeyOfT来提取Key其实就是本身 RBTreeK, K, SetKeyOfTK _t; public: // 类型定义Set的迭代器解引用得到const K typedef typename RBTreeK, K, SetKeyOfTK::const_iterator iterator; // 注意这里iterator也是const_iterator iterator begin() const { return _t.Begin(); } iterator end() const { return _t.End(); } // ... 其他接口 };实操心得在实现Set的迭代器时一个常见的坑是iterator和const_iterator的类型。由于Set的元素Key是不可修改的所以即使是非常量迭代器解引用后也应该是const引用。因此Set的iterator类型可以直接定义为底层树的const_iterator。这保证了*it new_value;这样的代码在Set中无法编译符合STL规范。4. 红黑树核心操作实现插入与旋转的艺术有了骨架接下来就是填充血肉——实现红黑树的插入与平衡。这是整个项目最核心也最复杂的部分。我们遵循“先二叉搜索树插入再红黑树平衡”的两步走策略。4.1 二叉搜索树插入首先忽略颜色按照二叉搜索树的规则找到新节点的插入位置。我们需要一个KeyOfT仿函数对象来从节点数据_data中提取出用于比较的键值。template class K, class T, class KeyOfT typename RBTreeK, T, KeyOfT::iterator RBTreeK, T, KeyOfT::Insert(const T data) { KeyOfT kot; // 仿函数对象 if (_root nullptr) { // 树为空新节点为根节点染黑 _root new Node(data); _root-_color BLACK; return iterator(_root); } Node* parent nullptr; Node* cur _root; while (cur) { parent cur; if (kot(data) kot(cur-_data)) { cur cur-_left; } else if (kot(data) kot(cur-_data)) { cur cur-_right; } else { // 键值已存在返回指向已存在节点的迭代器STL map.insert的语义 return iterator(cur); } } // 创建新节点初始为红色 cur new Node(data); cur-_color RED; cur-_parent parent; // 连接到父节点 if (kot(data) kot(parent-_data)) { parent-_left cur; } else { parent-_right cur; } // 至此新节点已作为红色叶子节点插入BST。接下来进行红黑树平衡调整。 // ... 平衡调整代码将在下一小节展开 }4.2 红黑树平衡调整处理双红冲突新插入的红色节点可能会破坏红黑树的性质红色节点的子节点必须为黑。如果其父节点也是红色就产生了“双红冲突”。我们需要根据叔叔节点父节点的兄弟节点的颜色来分情况处理。设新插入节点为cur其父节点为parent祖父节点为grandfather叔叔节点为uncle。情况一叔叔节点存在且为红色。这是最简单的情况。处理策略是“颜色翻转”将父节点和叔叔节点染黑祖父节点染红。然后把祖父节点grandfather当作新的“当前节点”cur继续向上检查调整因为祖父节点变红后可能和它的父节点形成新的双红冲突。while (parent parent-_color RED) { Node* grandfather parent-_parent; // 先确定父节点是祖父的左孩子还是右孩子对称处理 if (parent grandfather-_left) { Node* uncle grandfather-_right; // 情况一叔叔存在且为红 if (uncle uncle-_color RED) { parent-_color BLACK; uncle-_color BLACK; grandfather-_color RED; // 继续向上调整 cur grandfather; parent cur-_parent; } else { // 叔叔不存在或为黑 ... (进入情况二、三) } } else { // parent grandfather-_right对称情况 // ... 对称的代码 } } // 循环结束后确保根节点为黑 _root-_color BLACK;情况二叔叔节点不存在或为黑色且当前节点cur是父节点parent的“内侧”孩子。“内侧”指的是parent是grandfather的左孩子而cur是parent的右孩子或者parent是grandfather的右孩子而cur是parent的左孩子。这种情况无法通过一次旋转解决。处理策略是先对parent进行一次左旋如果cur是右孩子或右旋如果cur是左孩子将结构转化为情况三。旋转后cur和parent的角色互换。// 接上面的else分支 (parent是祖父的左孩子) if (cur parent-_right) { // 情况二cur是parent的右孩子内侧 RotateLeft(parent); // 以parent为轴左旋 // 旋转后cur和parent指针关系发生变化需要交换 std::swap(cur, parent); } // 旋转后cur变为parent的左孩子进入情况三情况三叔叔节点不存在或为黑色且当前节点cur是父节点parent的“外侧”孩子。“外侧”指的是parent是grandfather的左孩子cur也是parent的左孩子或者parent是grandfather的右孩子cur也是parent的右孩子。处理策略是将父节点parent染黑祖父节点grandfather染红然后以祖父节点grandfather为轴进行右旋如果parent是左孩子或左旋如果parent是右孩子。旋转后原来的祖父节点grandfather现在是红色下沉parent现在是黑色上升成为新的局部根完美解决了双红冲突且不会破坏其他性质。// 情况三cur是parent的左孩子外侧 parent-_color BLACK; grandfather-_color RED; RotateRight(grandfather); // 以grandfather为轴右旋 // 调整结束可以退出循环 break;旋转操作左旋/右旋是平衡调整的基石它们能在保持二叉搜索树性质的前提下改变树的局部结构。以左旋为例其核心是让节点parent的右孩子cur“上位”成为新的父节点parent自己则变成cur的左孩子同时处理好原来cur的左子树挂到parent的右孩子上和父指针的更新。void RotateLeft(Node* parent) { Node* cur parent-_right; Node* curleft cur-_left; // 1. cur的左子树成为parent的右子树 parent-_right curleft; if (curleft) { curleft-_parent parent; } // 2. 更新cur与parent的父节点关系 cur-_parent parent-_parent; if (parent _root) { _root cur; } else { if (parent parent-_parent-_left) { parent-_parent-_left cur; } else { parent-_parent-_right cur; } } // 3. parent成为cur的左子树 cur-_left parent; parent-_parent cur; }右旋是左旋的镜像操作。经过这些调整插入操作最终总能恢复红黑树的所有性质。踩坑记录在旋转函数中最容易出错的地方是父指针_parent的更新。一共有三处需要更新1)curleft的父节点指向parent2)cur的父节点指向原parent的父节点3)parent的父节点指向cur。漏掉任何一个都会导致树的结构断裂后续的遍历或查找必然崩溃。务必在纸上画图理清旋转前后各个节点的父子关系。5. 迭代器设计与实现让树也能和--迭代器是连接容器和算法的桥梁它让我们的Map和Set能够像原生数组一样使用范围for循环也能兼容algorithm中的各种算法。对于红黑树我们需要的是一个双向迭代器支持前进和--后退操作对应的是中序遍历的顺序。5.1 迭代器的基本结构迭代器本质上是一个智能指针它封装了一个节点的指针并重载了相关的操作符。template class T, class Ref, class Ptr // Ref是引用类型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; } bool operator(const Self s) const { return _node s._node; } // 最关键的部分前置找到中序后继 Self operator() { // ... 实现见下文 return *this; } // 前置--找到中序前驱 Self operator--() { // ... 实现与对称 return *this; } };5.2 中序后继的实现逻辑中序遍历的顺序是“左-根-右”。对于一个节点它的中序后继是如果该节点有右子树那么后继就是其右子树中最左边的节点即右子树中的最小节点。如果该节点没有右子树则需要沿着父指针向上回溯找到第一个“当前节点是其父节点左孩子”的祖先节点那么这个祖先节点的父节点就是后继。如果回溯到根节点也没找到说明当前节点已经是中序遍历的最后一个节点其后继为空对应end()。Self operator() { if (_node-_right) { // 情况1有右子树后继是右子树的最左节点 Node* left _node-_right; while (left-_left) { left left-_left; } _node left; } else { // 情况2无右子树向上找第一个“当前节点是父节点左孩子”的祖先 Node* cur _node; Node* parent cur-_parent; while (parent cur parent-_right) { cur parent; parent parent-_parent; } _node parent; // parent可能是后继也可能是nullptr表示已是最后一个 } return *this; }operator--找前驱的实现与此对称如果节点有左子树前驱是左子树中最右边的节点如果没有左子树则向上找第一个“当前节点是其父节点右孩子”的祖先节点其父节点即为前驱。5.3 在红黑树中集成迭代器在RBTree类中我们需要提供begin()和end()方法。begin()返回指向中序第一个节点整棵树最左边的节点的迭代器。end()在STL中通常指向“最后一个元素的下一个位置”对于红黑树我们可以用一个空指针nullptr来表示它不指向任何有效节点。iterator Begin() { Node* left _root; while (left left-_left) { left left-_left; } return iterator(left); } iterator End() { return iterator(nullptr); } const_iterator Begin() const { // ... 类似返回const_iterator } const_iterator End() const { return const_iterator(nullptr); }为了让范围for循环工作我们还需要在Map和Set类中提供同名的begin()和end()方法它们直接调用底层RBTree的对应方法。注意事项end()迭代器nullptr不能进行解引用(*)或成员访问(-)操作这是未定义行为。我们的迭代器实现应当与STL保持一致使用者需要自己保证不越界。在实现operator时当_node已经是nullptr时再次也应该有明确的行为通常STL中是未定义的我们可以选择让其保持不变或断言。6. 删除操作与平衡调整最复杂的挑战如果说插入是红黑树的入门考验那么删除就是终极挑战。删除一个节点后可能会破坏红黑树的平衡性质主要是黑高即从根到叶子的黑色节点数量需要进行更为复杂的调整。删除操作同样遵循“先二叉搜索树删除再红黑树平衡”的流程。二叉搜索树的删除有三种情况删除叶子节点。删除只有一个孩子的节点。删除有两个孩子的节点此时通常用其左子树的最大节点或右子树的最小节点来替换被删节点的值然后转为删除那个替换节点该替换节点必是情况1或2。在红黑树中我们更关注最终被物理删除的节点的颜色因为它会带走一个黑色节点可能破坏黑高。设被删节点为del其孩子节点为child可能为空NIL节点我们将其视为黑色。核心思想如果被删节点del是红色直接删除不影响黑高无需调整。如果del是黑色那么删除它会导致经过该节点的路径黑高减1需要调整。调整的焦点集中在接替del位置的child节点上我们视child为“双黑”或“红黑”并通过一系列旋转和变色来消除这个额外的“黑色”。调整过程围绕child的兄弟节点sibling展开情况比插入更多经典教材中常分8种情况。其核心目标是通过旋转和变色将额外的“黑色”向上传递或消除直到根节点或遇到红色节点。这里简述几种典型情况情况Achild的兄弟sibling是红色。此时通过旋转和变色可以将其转化为兄弟为黑色的情况。情况Bchild的兄弟sibling是黑色且sibling的两个孩子都是黑色。这是最简单的情况将sibling染红这样sibling所在分支的黑高也减1与child所在分支平衡了但相当于把“双黑”问题上交给了父节点。将父节点作为新的child继续向上调整。情况Cchild的兄弟sibling是黑色且sibling的远侄子即sibling与child异侧的孩子是红色。这是可以通过一次旋转解决的情况。通过旋转和变色让那个红色的远侄子变成黑色补回失去的黑高调整即可结束。情况Dchild的兄弟sibling是黑色且sibling的远侄子为黑近侄子同侧孩子为红。这种情况需要先通过一次旋转将其转化为情况C然后再按情况C处理。由于删除调整的逻辑分支极其复杂且高度对称左孩子和右孩子的情况镜像代码量通常是插入的好几倍。在实现时强烈建议绘制详细的流程图并为每一种情况编写对应的单元测试。一个微小的指针或颜色赋值错误都可能导致整棵树的性质被破坏且这种错误在简单测试下可能隐藏很深。避坑指南删除操作调试是噩梦。我的经验是1) 先实现一个IsBalance()函数递归检查红黑树的五个性质在每次插入/删除后都调用它进行断言。2) 编写一个随机测试生成大量随机数据进行插入、删除、查找的混合操作并每次验证树是否平衡、中序遍历是否有序、元素数量是否正确。3) 对于删除可以先用小规模确定性数据如1-10的数字手动模拟所有删除情况确保每种调整路径都被覆盖到。7. 常见问题与调试技巧实录在实现这个项目的过程中你几乎一定会遇到下面这些问题。这里记录了我的排查过程和解决方法希望能帮你节省大量时间。7.1 迭代器失效问题问题描述在遍历Map或Set的过程中如果使用类似for(auto it m.begin(); it ! m.end(); it) { if(...) m.erase(it); }的代码删除当前迭代器指向的元素会导致未定义行为因为it迭代器在删除后可能失效了。原因分析红黑树在删除一个节点时会释放该节点的内存。指向该节点的迭代器其内部持有节点指针就变成了“野指针”再对其进行、--或解引用操作是危险的。解决方案STL的map/set的erase方法会返回一个指向被删元素之后元素的迭代器。我们可以模仿这个行为。在RBTree::Erase的实现中在物理删除节点del之前先利用我们实现的operator逻辑找到del节点的后继节点next。删除del后返回指向next的迭代器。这样用户在循环中就可以安全地删除。iterator Erase(iterator pos) { assert(pos ! End()); Node* del pos._node; Node* next del; // 需要先找到后继 pos; // 利用迭代器的操作找到后继节点 if (pos._node) { next pos._node; } else { // 如果pos已经是最后一个则后继为nullptr next nullptr; } // ... 执行红黑树删除逻辑删除节点del // ... return iterator(next); // 返回后继迭代器 }使用方式for(auto it m.begin(); it ! m.end(); ) { if (condition) { it m.erase(it); // erase返回下一个有效迭代器 } else { it; } }7.2 内存泄漏与访问越界问题描述程序运行一段时间后内存占用不断增长或者在随机测试中偶尔出现崩溃错误提示可能是“segmentation fault”或访问了非法内存。排查思路检查new/delete配对确保每一个new Node都有对应的delete尤其是在删除节点和析构函数中。在RBTree的析构函数中需要进行后序遍历来删除所有节点。~RBTree() { _Destroy(_root); _root nullptr; } void _Destroy(Node* root) { if (root nullptr) return; _Destroy(root-_left); _Destroy(root-_right); delete root; }检查指针操作在旋转、插入、删除函数中任何_parent、_left、_right指针的赋值都必须考虑边界条件如nullptr。特别是在处理根节点_root的父指针时它应该始终为nullptr。验证红黑树性质实现一个IsBalance()函数在每次插入/删除后至少在调试阶段调用它。检查以下性质根节点是黑色。不存在连续的红色节点红色节点的子节点必须是黑色。从根节点到所有叶子节点NIL节点的路径上黑色节点的数量相同。一个简单的验证黑高的方法是递归计算左右子树的黑高并检查是否相等同时检查红色节点的子节点是否为黑。7.3 模板编译错误问题描述在编写模板类尤其是嵌套了迭代器模板时编译器会报出一大堆晦涩的错误比如“dependent name is not a type”、“expected initializer before ‘’ token”等。常见原因与解决在模板类内部使用嵌套类型时需要加typename关键字。例如在RBTree类内部iterator是一个依赖于模板参数T的类型当你在函数返回值或参数中使用它时需要明确告诉编译器这是一个类型。// 正确 typename RBTreeK, T, KeyOfT::iterator Insert(const T data);Map和Set中引用底层树的迭代器类型时也需要使用typename因为RBTree...::iterator是一个依赖类型。// 在Map类中 typedef typename RBTreeK, std::pairconst K, V, MapKeyOfTK, V::iterator iterator;分离编译问题模板类的成员函数定义通常需要放在头文件.hpp中而不是单独的源文件.cpp。因为编译器在编译使用模板的代码时需要看到完整的定义才能实例化。7.4 性能对比与优化思考在完成基本实现后我用自己的红黑树Map和std::map进行了一个简单的性能对比测试插入100万个随机整数键值对然后进行100万次随机查找。结果自定义实现的性能大约是std::map的80%-90%。这在意料之中STL的实现经过了极致的优化如使用全局内存池、更精巧的节点结构、编译器特定的优化等。可以优化的方向自定义内存池频繁的new和delete节点是性能瓶颈之一。可以实现一个简单的内存池一次性分配一大块内存节点从中分配和回收能显著减少系统调用的开销。节点结构优化我们的节点存储了三个指针左、右、父和一个颜色。颜色通常只需要1个bit可以尝试将其编码到某个指针的最低有效位中前提是地址对齐保证了这些位为0从而节省内存。但这会大大增加代码的复杂性。使用哨兵节点NIL我们代码中大量使用了nullptr来代表空指针。可以定义一个全局的、黑色的哨兵节点NIL让所有叶子节点都指向它所有新节点的左右孩子初始化为NIL。这样在旋转和调整时可以避免很多nullptr的判断使代码更简洁有时也能提升一点缓存局部性。对于学习目的实现基本功能并保证正确性是首要目标。这些优化可以作为你深入理解后的进阶挑战。当你能够流畅地实现一个带完整迭代器、支持插入删除的红黑树Map/Set时你已经对C数据结构、模板编程和内存管理有了远超普通应用开发者的理解。这份“徒手造轮子”的经历会是你在面对任何复杂系统设计时最坚实的底气。