手写一版 set/map,听起来像面试题,但其实是一堂特别划算的 C++ 进阶课。用熟了 std::map 的人很多,可真被问到底层是什么结构、迭代器为什么 insert 之后不会失效、set 里的元素为什么改不了,不少人会卡壳。这篇文章就做一件事:不借助任何现成树形容器,从零模拟实现一棵红黑树,再把它封装成 set 和 map。你跟着代码走一遍,前面那些"背过但没真懂"的问题,基本都能落地。
文章会完整给出四块内容:整体架构上如何让一棵树同时服务 set 和 map,红黑树插入时最关键的三类调整逻辑,迭代器的 ++ / -- 实现细节,以及封装阶段的 const 正确性问题。最后我还会把实际写代码时最容易踩的坑整理一遍。适合已经掌握类、模板、基础二叉树,想往 C++ 进阶迈一步的人。
1. 先想清楚:set/map 模拟实现到底在训练什么能力
1.1 为什么跟红黑树绑得这么死
std::set 和 std::map 在底层通常都是红黑树。为什么偏偏是红黑树?因为这两个容器本质上是"有序关联容器",要求三个能力:插入删除后还能保持有序、查找/插入/删除都是 O(log n)、迭代器遍历时能按 key 从小到大走完所有元素。红黑树恰好能满足这三条,而且工程实现上比 AVL 树更省旋转成本。
手写一遍 set/map,你不是在重复造轮子,而是在补两门课。第一门是"数据结构怎么跟模板技术结合":你要处理不同数据类型(set 存 key,map 存 key-value 对)、提取 key 的规则不同、比较规则不同,这些都要靠模板参数和萃取手法解决。第二门是"接口是怎么设计出来的":insert 为什么返回 pair<iterator, bool>、迭代器为什么分 const 和非 const、map 的 operator[] 为什么可以缺省插入。这些问题只看理论永远隔着一层,自己写一遍就全通了。
还有一个现实原因:这是经典 C++ 进阶阶段的高频动手项目。面试聊到 STL 时,"红黑树插入调整有几种情况""map 迭代器失效规则"基本是必问点。与其背标准答案,不如亲手实现一次,哪怕只完成了 insert 和迭代器,你对这些问题的理解深度也会完全不一样。
1.2 选择红黑树而不是 AVL 树的原因
先做个对比,这个选择背后有明确工程逻辑:
| 对比维度 | AVL 树 | 红黑树 |
|---|---|---|
| 平衡标准 | 左右子树高度差不超过 1 | 最长路径不超过最短路径的 2 倍 |
| 查找消耗 | 平衡更严格,查找略快 | 略宽容,但仍是 O(log n) |
| 插入旋转 | 平均可能需要多次旋转 | 最多两次旋转 |
| 删除旋转 | 可能一路螺旋旋转 | 最多三次旋转 |
| 写操作成本 | 较高 | 较低 |
| 使用场景 | 读多写少、对查找极苛刻 | 通用容器、需要频繁增删 |
AVL 树是"强平衡",红黑树是"弱平衡"。"最长路径不超过最短路径的 2 倍"这个约束听起来不如 AVL 严谨,但它保证了最坏情况下的复杂度仍然是对数。关键是,红黑树把旋转次数压到了很低:插入最多两次旋转,删除最多三次旋转。对于需要频繁写入的通用容器来说,这个收益非常实际。STL 的 set/map 选它,本质上是照顾了读写混合场景的整体吞吐。
对模拟实现来说,还有个教学上的好处:红黑树插入的调整规则比 AVL 更"情况化",学完它,你对旋转、变色、向上回溯这些动作的理解会更深刻。
1.3 这篇文章的实现范围和验证目标
要在一篇文章里手写完整红黑树,包括 erase、迭代器适配、分配器等,篇幅会失控。所以这里我把范围定得务实一点:实现红黑树节点的构建、插入及颜色调整、析构销毁,实现迭代器的 ++ / -- 和 begin / end,再基于它封装 set 和 map,map 额外提供 operator[]。erase 不会展开实现,但我会在最后讲清楚它为什么比 insert 难、以及你应该从哪些方向去扩展。
验证目标也很明确:插入随机数据后,中序遍历结果严格有序;树满足红黑树四个性质;set/map 的接口行为与标准容器对齐。这三个目标都达到,你的模拟实现就可以认为是"能用"的状态了。
2. 整体架构设计:一棵树怎么同时服务 set 和 map
2.1 关键设计一:KeyOfValue 值提取器
set 和 map 的差别,一个是"值本身就是 key",另一个是"值是一个键值对,key 是其中第一个字段"。如果为这两个容器各写一棵红黑树,代码会大量重复。标准库的做法是让底层树泛化:树不关心你存的是什么,只关心"从这个数据里怎么拿到 key"。
这个职责用一个叫 KeyOfValue 的小函数对象承担:
// set 的提取器:key 就是 K 本身 template<class K> struct SetKeyOfValue { const K& operator()(const K& k) const { return k; } }; // map 的提取器:key 是 pair 的 first template<class K, class V> struct MapKeyOfValue { const K& operator()(const std::pair<const K, V>& kv) const { return kv.first; } };底层红黑树在需要比较数据大小时,不直接比较 T,而是比较KeyOfValue()(data)。set 传 SetKeyOfValue,map 传 MapKeyOfValue,一棵树的插入、查找、迭代逻辑就能完全复用。这就是"把变化的部分提取出来,把不变的部分沉淀下去"的典型手法。
这里的 const 引用返回值很重要。比如 map 的提取器返回kv.first,但 kv 本身是常引用,如果返回值类型写成 K 就会拷贝;如果省掉 const,又会丢掉 const 限定,让底层树在比较时能够修改 key,这会造成致命错误。所以我建议你写的时候把 const 限定完整保留。
2.2 关键设计二:模板参数和节点结构
红黑树的模板长这样:
template<class T, class KeyOfValue> class RBTree { typedef RBTreeNode<T> Node; ... };T 是容器存的数据类型。set 实例化时 T=K,map 实例化时 T=std::pair<const K, V>。KeyOfValue 负责从 T 里提取 key。为了让代码简洁聚焦,比较直接用 key 的 operator<,不额外接自定义比较器;自定义比较器的扩展方式我放到后面专门讲。
节点结构是基础中的基础:
enum Colour { RED, BLACK }; template<class T> struct RBTreeNode { T _data; RBTreeNode<T>* _left; RBTreeNode<T>* _right; RBTreeNode<T>* _parent; Colour _col; RBTreeNode(const T& data) : _data(data) , _left(nullptr) , _right(nullptr) , _parent(nullptr) , _col(RED) {} };注意三个细节。
第一,节点里保存了_parent指针。红黑树调整时经常要访问父节点和祖父节点,没有父指针就得每次重新遍历查找,代价很高。红黑树这种带父指针的设计,本质上是牺牲一点空间,换取调整代码的简洁和效率。
第二,新节点默认涂成红色。这是红黑树插入策略的核心:插入红色节点不会改变路径上的黑节点数量,因此性质"任何路径的黑节点数相同"不会立即被破坏;唯一可能被破坏的是"不能出现连续两个红色节点"。换句话说,默认红色把插入问题收敛到只处理一个矛盾上。
第三,为什么 map 的 T 用std::pair<const K, V>而不是std::pair<K, V>。因为 map 的 key 语义上不可修改。如果 T 用的是普通 pair,迭代器返回pair<K, V>&,用户就能通过迭代器改 key,排序结构会被悄无声息地破坏。标准库里这么设计,就是要从类型系统上堵死这条路。
2.3 insert 为什么返回 pair<iterator, bool>
理解这个设计,得先想清楚"关联容器插入的语义"。向 set 插入一个已存在的 key,标准规定不是错误,而是"什么都没发生"。但调用者怎么知道到底插没插进去?所以返回值必须是pair<iterator, bool>:iterator 指向已存在元素或新插入元素,bool 表示这次插入是否真的生效。
这样的设计对 map 还有一个额外红利:它可以自然地支撑 operator[]。operator[] 的实现本质就是"按 key 插入一个默认值,并返回其 value 的引用"。如果 key 已存在,insert 返回已有节点,operator[] 相当于一次访问;如果不存在,insert 会帮我们创建默认节点,operator[] 就扩展了 map。一个接口解决两件事,这是标准库接口设计很聪明的地方。
3. 红黑树插入的核心代码与调整过程
3.1 先按二叉搜索树规则插入
红黑树首先是二叉搜索树,所以插入第一步和普通 BST 完全一样:从根节点开始,比当前节点 key 小就向左,大就向右,遇到空位挂上去。
std::pair<iterator, bool> Insert(const T& data) { KeyOfValue kov; if (_root == nullptr) { _root = new Node(data); _root->_col = BLACK; return std::make_pair(iterator(_root), true); } Node* parent = nullptr; Node* cur = _root; while (cur) { if (kov(cur->_data) < kov(data)) { parent = cur; cur = cur->_right; } else if (kov(data) < kov(cur->_data)) { parent = cur; cur = cur->_left; } else { return std::make_pair(iterator(cur), false); } } cur = new Node(data); Node* newNode = cur; // 记住真正的新节点 if (kov(parent->_data) < kov(data)) parent->_right = cur; else parent->_left = cur; cur->_parent = parent; // 之后进入调整阶段 ... }空树插入时,新节点直接作根并涂黑,因为红黑树要求根必须黑。非空插入时,新节点是红色。查找过程中遇到相同 key 就返回 false,这是 set/map 去重语义的体现。
有个容易踩的细节:比较顺序要写成"当前节点 key < 目标 key"和"目标 key < 当前节点 key"两次判断。如果两次都不成立,说明两者等价。不要只写if (cur->_data < data)和else,因为 else 里混着"大于"和"等于"两种情况,会导致重复元素被错误插入或错误跳过。
3.2 左旋和右旋:旋转的底盘功夫
调整的主体动作是旋转。旋转不改变中序顺序,只改变树的局部结构,这是红黑树能通过旋转平衡的关键前提。先看左旋,右旋是它的镜像。
void RotateL(Node* parent) { Node* subR = parent->_right; Node* subRL = subR->_left; // 1. subR 的左子树过继给 parent parent->_right = subRL; if (subRL) subRL->_parent = parent; // 2. parent 成为 subR 的左孩子 Node* ppNode = parent->_parent; subR->_left = parent; parent->_parent = subR; // 3. 处理 subR 与祖父节点的关系 if (ppNode == nullptr) { _root = subR; subR->_parent = nullptr; } else { if (ppNode->_left == parent) ppNode->_left = subR; else ppNode->_right = subR; subR->_parent = ppNode; } }文字叙述旋转容易晕,配个示意图:
p subR / \ / \ A subR => p C / \ / \ B C A B左旋做的事情可以概括为三步:subR 的左子树 B 过继给 p 当右孩子;p 变成 subR 的左孩子;subR 顶替 p 接上父节点。关键点在于:B 是整棵子树中大小介于 p 和 subR 之间的部分,把它过继给 p 并不会破坏二叉搜索树的有序性。这个性质同样适用于右旋。
写旋转函数最容易出错的是第一步里忘记处理 B 的父指针,以及第三步里忘记判断 ppNode 为空。B 的父指针不更新,后续调整时一旦访问 parent 就会指向错误节点;ppNode 为空时不更新 _root,树根就丢了。
右旋代码完全镜像:
void RotateR(Node* parent) { Node* subL = parent->_left; Node* subLR = subL->_right; parent->_left = subLR; if (subLR) subLR->_parent = parent; Node* ppNode = parent->_parent; subL->_right = parent; parent->_parent = subL; if (ppNode == nullptr) { _root = subL; subL->_parent = nullptr; } else { if (ppNode->_left == parent) ppNode->_left = subL; else ppNode->_right = subL; subL->_parent = ppNode; } }我建议你把两个旋转函数写完就立刻做单测:往不同结构的树上调用旋转,然后打印中序序列,确认中序顺序在旋转前后完全一致。这个测试能过滤掉至少一半的旋转 bug。
3.3 插入后的三类调整情况
插入红色节点后,唯一的隐患是"连续红色"——如果父节点是黑色,什么都不用做;如果父节点是红色,就要进入调整。调整按"叔叔节点(父节点的兄弟)的颜色和形状"分成三类情况。下面分父节点是祖父左孩子这条主线来写,另一侧完全对称。
KeyOfValue kov; // 上面插入流程中已有 Node* parent = cur->_parent; while (parent && parent->_col == RED) { Node* grand = parent->_parent; if (parent == grand->_left) { Node* uncle = grand->_right; // 情况一:叔叔存在且为红 if (uncle && uncle->_col == RED) { parent->_col = BLACK; uncle->_col = BLACK; grand->_col = RED; cur = grand; parent = cur->_parent; } else { // 情况二:叔叔是黑的/不存在,cur 是折线形态 if (cur == parent->_right) { RotateL(parent); std::swap(parent, cur); } // 情况三:直线形态,右旋 + 变色 RotateR(grand); parent->_col = BLACK; grand->_col = RED; break; } } else { Node* uncle = grand->_left; if (uncle && uncle->_col == RED) { parent->_col = BLACK; uncle->_col = BLACK; grand->_col = RED; cur = grand; parent = cur->_parent; } else { if (cur == parent->_left) { RotateR(parent); std::swap(parent, cur); } RotateL(grand); parent->_col = BLACK; grand->_col = RED; break; } } } _root->_col = BLACK;情况一,叔叔是红节点。这时直接把父节点和叔叔变黑、祖父变红,等于"把红色上移了两层"。红色上移后,祖父的父节点可能是红色,所以把祖父当作新的 cur 继续向上检查。循环会一直向上跑,最坏情况一路改到根。
情况二,叔叔是黑的或不存在,且 cur 是"折线"(cur 是父节点的右孩子,父节点是祖父的左孩子)。这时候先对父节点左旋,把折线掰成直线,配合交换 parent 和 cur 的指向,把"中间节点"重新包装成 parent,然后进入情况三统一处理。交换指针会让代码结构统一,避免情况二和情况三各写一遍旋转逻辑。
情况三,叔叔是黑的或不存在,且 cur 是"直线"(cur 是父节点的左孩子)。这时对祖父右旋,旋转后原父节点顶到子树的根,原祖父成为其右孩子,然后把新根变黑、原祖父变红。这里可以直接 break,因为以新根为根的子树已经全部满足红黑树性质。
为什么情况二要先旋转再进入情况三?因为直线形态旋转一次就能把红色节点"扶正";折线形态直接旋祖父,会旋出一个"中间节点悬空"的尴尬结构。先转一次把形状理顺,是红黑树处理这类问题的经典手法。
循环结束后统一把根涂黑,这一步即使根已经在循环里被改成红色也能兜底修正,保证性质"根必须是黑色"始终成立。
3.4 插入的完整示意与复杂度说明
我建议你亲自动手模拟一组插入过程。比如依次插入 1、2、3、4、5,你会看到:插入 2 触发一次左旋加变色;插入 3 触发叔叔为红的纯变色;插入 4 会经历折线转直线再右旋。这组数据基本把三个情况都覆盖了一遍,跑通它,你对红黑树的信心会立刻上来。
红黑树插入的复杂度保证很漂亮:查找插入位置 O(log n),调整阶段变色可能沿树向上传播 O(log n) 次,但真正的旋转最多两次。这也是红黑树相对于 AVL 树的核心优势之一——旋转是物理结构调整,代价比改颜色高得多,把旋转次数压到常数级,意味着大部分插入操作的实际成本都集中在变色上。
4. 迭代器实现:中序增量的经典写法
4.1 迭代器模板与基本运算符
迭代器是容器接口的门面。红黑树的迭代器要支持*、->、++、--、相等比较。因为需要区分普通迭代器和 const 迭代器,这里有两个模板参数 Ref 和 Ptr,分别控制*和->的返回类型。
template<class T, class Ref, class Ptr> struct RBTreeIterator { typedef RBTreeNode<T> Node; typedef RBTreeIterator<T, Ref, Ptr> Self; Node* _node; RBTreeIterator(Node* node = nullptr) : _node(node) {} // 允许普通迭代器转换为 const 迭代器 RBTreeIterator(const RBTreeIterator<T, T&, T*>& it) : _node(it._node) {} Ref operator*() const { return _node->_data; } Ptr operator->() const { return &_node->_data; } bool operator==(const Self& s) const { return _node == s._node; } bool operator!=(const Self& s) const { return _node != s._node; } ... };Ref 和 Ptr 在普通迭代器里是 T& 和 T*,在 const 迭代器里是 const T& 和 const T*。这样一套模板可以生成两种迭代器,不用写两份几乎相同的代码。映射到 set 时还隐藏着一个设计点:set 的 iterator 和 const_iterator 本质上都是底层 const_iterator,后面细说。
那个特殊的转换构造函数值得单独讲。C++ 里普通迭代器要能隐式转换为 const 迭代器,否则下面的代码编译不过:
set<int>::iterator it = ...; set<int>::const_iterator cit = it; // 必须允许转换构造函数的参数固定写RBTreeIterator<T, T&, T*>,当迭代器模板被实例化为 const 版本时,它恰好接收一个普通迭代器完成转换;当模板被实例化为普通迭代器时,它退化为普通的拷贝构造函数,不会造成歧义。这个小细节是很多初学者卡住的地方。
4.2 operator++ 和 operator-- 的算法
中序遍历的顺序是"左子树、根、右子树"。找到当前节点的下一个节点,逻辑非常简单:
- 如果当前节点有右子树,下一个节点是右子树最左边的节点。
- 如果没有右子树,就一直向上走,直到当前节点不再是"父节点的右孩子"。停下时,父节点就是下一个节点。如果走到空,说明已经遍历完,也就是 end()。
对应代码:
Self& operator++() { if (_node->_right) { Node* left = _node->_right; while (left->_left) left = left->_left; _node = left; } else { Node* cur = _node; Node* parent = cur->_parent; while (parent && cur == parent->_right) { cur = parent; parent = cur->_parent; } _node = parent; } return *this; }向上回溯的条件"cur == parent->_right"是整段代码的灵魂。它表达的意思是:当前节点是父节点的右子树,说明父节点及其左子树都已经遍历完了,所以继续向上走;一旦发现当前节点是父节点的左孩子,说明父节点还没访问过,于是下一个访问的就是父节点。这是中序遍历迭代化的标准解法。
operator-- 完全镜像:如果当前节点有左子树,前驱是左子树最右边的节点;否则向上走到"当前节点不再是父节点的左孩子"为止,停下时的父节点就是前驱。
Self& operator--() { if (_node->_left) { Node* right = _node->_left; while (right->_right) right = right->_right; _node = right; } else { Node* cur = _node; Node* parent = cur->_parent; while (parent && cur == parent->_left) { cur = parent; parent = cur->_parent; } _node = parent; } return *this; }++ 和 -- 的代码框架几乎一样,区别只在"左孩子/右孩子"和"右孩子/左孩子"的互换。写完分别测试递增和递减两种遍历,各打印一遍中序序列,比对结果是否互为逆序。
4.3 begin/end 与 set 的 const 迭代器
begin() 是整棵树最左边的节点,end() 在简化版本里用空指针表示。对空树来说 begin() 和 end() 都是空指针,比较相等,符合"空容器 begin == end"的语义。
iterator Begin() { Node* left = _root; while (left && left->_left) left = left->_left; return iterator(left); } const_iterator Begin() const { Node* left = _root; while (left && left->_left) left = left->_left; return const_iterator(left); } iterator End() { return iterator(nullptr); }封装到 set 里有一个关键设计:set 的元素不能被修改。因为改了 key 就等于破坏排序结构。所以标准库里 set 的 iterator 和 const_iterator 实际都是"只能读不能写"的迭代器。对应到代码里:
template<class K> class set { ... public: typedef typename RBTree<K, SetKeyOfValue>::const_iterator iterator; typedef typename RBTree<K, SetKeyOfValue>::const_iterator const_iterator; ... };set 的 iterator 直接 typedef 成底层 const_iterator。这样用户拿到 set 的迭代器后,*it返回 const K&,编译期就禁止修改,从根本杜绝了"改坏排序树"的可能。
4.4 end() 的边界问题
你必须知道一个简化版实现的限制:使用 nullptr 作为 end() 标记后,--end()是不合法的。因为它会试图对空指针解引用。标准库的 end() 其实是一个特殊的哨兵节点(header),它的 parent 指向根节点,left 指向最左节点,right 指向最右节点。有了哨兵后,--end()才能优雅地返回最右节点,--begin()才能安全地停留在哨兵上。
我这里的简化版本为了保持教学代码清晰,牺牲了这部分边界能力。实际工程里如果需要完整的反向遍历能力,建议你按哨兵方案的思路扩展。这个点也是面试官常追问的地方——能答出"标准库里有 header 哨兵"和"简化版用 nullptr 的局限性",说明你是真的写过代码,而不是背过答案。
5. 封装 set 和 map:把一颗树变成两个容器
5.1 set 的封装细节
有了底层红黑树,set 的封装其实非常薄。主要工作是定义 KeyOfValue 提取器,并把迭代器类型映射到 const 版本。
template<class K> class set { struct SetKeyOfValue { const K& operator()(const K& k) const { return k; } }; public: typedef typename RBTree<K, SetKeyOfValue>::const_iterator iterator; typedef typename RBTree<K, SetKeyOfValue>::const_iterator const_iterator; std::pair<iterator, bool> insert(const K& key) { std::pair<typename RBTree<K, SetKeyOfValue>::iterator, bool> ret = _t.Insert(key); return std::make_pair(iterator(ret.first), ret.second); } iterator begin() const { return _t.Begin(); } iterator end() const { return _t.End(); } private: RBTree<K, SetKeyOfValue> _t; };insert 这里要留意一个类型转换:底层树的 Insert 返回的是pair<typename RBTree<K, SetKeyOfValue>::iterator, bool>,而 set 对外暴露的 iterator 是底层 const_iterator。二者底层指针类型相同,所以可以直接用 iterator(ret.first) 构造。libstdc++ 的实现里是通过 pair 的转换构造函数隐式完成的,但显式写出来更直观,也能避免部分编译器对 pair 转换的歧义报错。
begin() 是 const 成员函数,所以要调用_t.Begin()的 const 重载,返回的正是底层 const_iterator,与 set::iterator 类型完全一致。整个 set 的接口就这么多:insert、begin、end、加上我们后面补的 find,一个容器就立起来了。
5.2 map 的封装与 operator[]
map 的封装比 set 多一个键值对的处理。值类型是 pair<const K, V>,提取器返回 kv.first。
template<class K, class V> class map { struct MapKeyOfValue { const K& operator()(const std::pair<const K, V>& kv) const { return kv.first; } }; public: typedef typename RBTree<std::pair<const K, V>, MapKeyOfValue>::iterator iterator; typedef typename RBTree<std::pair<const K, V>, MapKeyOfValue>::const_iterator const_iterator; iterator begin() { return _t.Begin(); } iterator end() { return _t.End(); } std::pair<iterator, bool> insert(const std::pair<const K, V>& kv) { return _t.Insert(kv); } V& operator[](const K& key) { std::pair<iterator, bool> ret = insert(std::make_pair(key, V())); return ret.first->second; } private: RBTree<std::pair<const K, V>, MapKeyOfValue> _t; };operator[] 的实现只有三行,但它把 insert 的返回值设计价值彻底发挥出来了。先用 key 构造一个默认值 V(),插入到底层树:如果 key 已存在,返回已有节点引用,V() 会被丢弃,不改变原值;如果 key 不存在,新的键值对就被插入进去。然后统一返回ret.first->second。所以 map 的 [] 操作天生具备"访问或创建"的语义。
这里还能顺带理解一个常识:为什么 const map 不能调用 operator[]?因为 operator[] 可能修改 map 本身,所以 const 版本只提供 at() 或者 find()。这也是为什么标准库的 operator[] 只对非 const map 开放。
5.3 find 接口与自定义比较器扩展
给底层树加一个 find 很简单,复用和 insert 相同的查找逻辑:
iterator Find(const T& data) { KeyOfValue kov; Node* cur = _root; while (cur) { if (kov(cur->_data) < kov(data)) cur = cur->_right; else if (kov(data) < kov(cur->_data)) cur = cur->_left; else return iterator(cur); } return iterator(nullptr); }find 返回空指针表示没找到,正好对应 end()。set/map 各加一层包装即可。
前面我提过,为了聚焦,底层树直接用 operator< 比较 key。如果你想支持自定义比较器,改动也不大。RBTree 加一个模板参数 Compare,成员里定义一个比较对象,把所有a < b的地方替换成comp(a, b);set/map 也各自把 Compare 透传下去:
template<class K, class Compare = std::less<K>> class set { ... }; template<class K, class V, class Compare = std::less<K>> class map { ... };注意默认参数 std::less<K> 只做一件事:调用 operator<。所以即使不传自定义比较器,行为也和现在的版本一致。这个扩展既不影响现有代码理解,又让接口贴近标准库形态,适合作为你写完基本功之后的第一道升级题。
6. 常见问题与调试实录
6.1 调整死循环、返回错迭代器、内存泄漏
我实际调试这段代码时,遇到最多的三个问题分别是:调整阶段死循环、返回迭代器指向错误、内存泄漏。
死循环的典型原因有两个。一是情况三里忘了写 break,循环把刚调整好的子树当成新问题继续向上处理,结果祖先链上的颜色反复横跳,永远退出不了;二是情况一里 cur = grand 后,没有同步更新 parent = cur->_parent,导致循环条件里 parent 一直是旧值,逻辑错乱。排查建议:在 while 循环入口打出 cur 的值,观察是否重复出现同一个节点;一旦出现,先检查 break 和 parent 更新。
返回错迭代器的问题非常隐蔽。很多人在 Insert 里复用 cur 做搜索变量,调整过程中 cur 会被改成 grand,最后直接return std::make_pair(iterator(cur), true),返回的成了祖父节点。正确做法是我代码里已经做的:新节点创建后立刻保存到 newNode,返回时用 newNode 构造迭代器。这个 bug 不会让程序立刻崩,但会表现为:insert 返回的迭代器指向的内容和预期不符,整个遍历结果乱掉。
内存泄漏则是每个人都会遇到的第一课。红黑树节点全部是 new 出来的,如果不写析构,跑一次几万次插入的测试,内存占用会肉眼可见地涨。销毁应该用后序遍历递归释放:
void Destroy(Node* root) { if (root == nullptr) return; Destroy(root->_left); Destroy(root->_right); delete root; } ~RBTree() { Destroy(_root); _root = nullptr; }6.2 迭代器失效规则:和 vector 完全相反
很多人第一次听到"insert 使迭代器失效"时,脑子里装的都是 vector 的经验。vector 是连续存储,扩容会搬走所有元素,所以任何插入都可能让所有迭代器失效。但 set/map 是节点式容器,插入操作只是改了几根指针,已经拿到的迭代器还指向原来的节点,数据没搬家,迭代器自然继续有效。erase 也只会让被删节点对应的迭代器失效,其他迭代器不受影响。
这个差异很实际。比如你写一个循环往 set 里插数据,同时拿着旧迭代器做判断,这在 set 上是安全的,但同样的写法放到 vector 上就是未定义行为。很多人记不住这套规则,其实根源就是没理解底层存储形态。自己实现一遍后你会彻底记住:节点式容器的迭代器,本质上就是"包了一层节点指针"。
6.3 set 的迭代器为何必须是 const 的
接手这个项目的人最容易怀疑人生的地方就是 set 的 iterator 定义。直觉上迭代器分普通和 const 两种,set 为什么两个都 typedef 成 const?因为 set 的元素就是 key,而 key 是排序的依据。如果允许*it = newKey,红黑树的结构就会立刻变成非法状态:节点还在原位置,但 key 已经变了,搜索和遍历都会出错。
类型系统是防止这个错误的最强屏障。把 set 的 iterator 定义成 const_iterator,用户在编译期就无法修改元素,连"代码写得看起来正确但运行崩溃"的机会都没有。map 里则不同:map 迭代器的 key 被 pair<const K, V> 锁死,但 value 允许修改。修改 value 不影响排序结构,所以 map 的普通迭代器可以返回 pair 的非常引用。这两种容器对迭代器 const 性的不同处理,背后全是同一个原则:不允许破坏排序不变量的操作。
6.4 如何验证自己写的红黑树是合法的
写完插入和迭代器,第一件事不是继续加功能,而是写个验证函数,检查红黑树的四条性质是否成立:节点非红即黑、根为黑、无连续红节点、所有路径黑节点数相同。
bool _IsValidRBTree(Node* root, int blackNum, int& refBlackNum) { if (root == nullptr) { if (refBlackNum == 0) refBlackNum = blackNum; // 用第一条路径的黑高作基准 return blackNum == refBlackNum; } if (root->_col == RED) { if (root->_parent && root->_parent->_col == RED) { std::cout << "存在连续红色节点" << std::endl; return false; } } if (root->_col == BLACK) ++blackNum; return _IsValidRBTree(root->_left, blackNum, refBlackNum) && _IsValidRBTree(root->_right, blackNum, refBlackNum); }验证思路是从根往左右递归,每经过一个黑节点 blackNum 加一:如果某条路径到空节点的黑节点数和第一条不一样,红黑树就被破坏了。同时在递归过程中检查有没有连续的红色节点。最后再单独检查根的颜色和中序序列是否有序。这一套验证代码应该作为项目的"常驻测试",每次改完代码都跑一遍随机插入:
int prev = *s.begin(); bool ok = true; for (auto it = s.begin(); it != s.end(); ++it) { if (*it < prev) { ok = false; break; } prev = *it; }插入一万个随机数后,中序有序、无连续红、黑高一致,这个模拟实现就可以放心往下扩展了。
6.5 继续加餐的方向
如果你把 insert 和迭代器都跑通了,接下来值得挑战的方向有三个。
第一是实现 erase。它比 insert 难的地方在于:删除节点可能会造成路径黑节点数减少,形成"双黑节点",修复它要处理四类情况。这四类情况和插入的三个情况不一样,核心思路是看兄弟节点的颜色和侄子节点的形状。建议你先实现普通二叉搜索树的删除,再逐步叠加红黑树的修复逻辑。
第二是给底层树换上 header 哨兵节点。这个改动能修复--end()的边界问题,也会让你真正理解标准库迭代器的设计闭环。
第三是补齐 const 接口。比如 const 对象调用 begin()/end() 返回 const_iterator、operator[] 的 const 版本用 at() 语义替代等。这些都是把"能跑"变成"像标准库"的重要一步。
我个人的体会是:手写一遍 set/map 之后,很多以前靠死记的知识才真正变成自己的。比如红黑树插入最多两次旋转、set 迭代器天然是 const、map 的 key 用 const 限定锁死——这些结论现在都能在代码里找到直接依据。后续你读任何源码中关于树形容器的设计,都会多一层"哦,原来这里是这样处理的"的共鸣。所以别把这个项目当应试任务,把它当成送给自己的 C++ 进阶礼物,绝对不亏。