从零实现红黑树Map/Set:深入理解STL容器底层设计与平衡原理
1. 从零开始为什么我们要亲手实现Map/Set如果你是一个C的深度使用者或者正在准备面试那么“红黑树”、“Map”、“Set”这几个词对你来说一定不陌生。标准库里的std::map和std::set用起来很方便一键插入、查找、删除性能还很好。但不知道你有没有想过它们为什么这么快面试官总爱问红黑树它到底是怎么和这两个容器联系在一起的仅仅背下“红黑树是平衡二叉搜索树有五个性质”是远远不够的。真正的理解来自于亲手把它搭建起来。我最初决定自己实现一遍是因为在一个性能关键的项目里遇到了标准库容器在某些极端场景下的微小开销问题。为了做针对性优化我必须深入其骨髓了解每一个指针是如何跳转的每一个旋转是如何维持平衡的。这个过程痛苦但极具启发性。它让我彻底明白了所谓的“底层容器”不是一个黑盒魔法而是一套精密的、可以用代码清晰表述的机械结构。今天我们就来一起拆解这个“机械结构”用一棵红黑树同时支撑起Map键值对字典和Set键集合两个容器。你会发现它们的核心原来共享着同一套骨架。2. 设计基石同一棵红黑树如何适配Map与Set直接上结论Map和Set的底层可以使用完全相同的红黑树数据结构差异仅在于节点存储的数据类型不同。这是实现二者代码复用的关键。如果我们为Map和Set各自实现一棵红黑树那将是巨大的重复劳动。优秀的库设计追求的是高内聚、低耦合。2.1 核心模板策略ValueType的魔法我们的目标是设计一个红黑树模板类它不关心自己最终是Map还是Set它只关心自己存储的“值”是什么。对于Set这个“值”就是键Key本身对于Map这个“值”就是一个键值对std::pairconst Key, Value。这里有一个精妙之处为了支持从“值”中提取出用于比较的“键”我们需要一个额外的模板参数——KeyOfValue仿函数。让我们来看看初步的类模板设计// 红黑树节点颜色 enum Colour { RED, BLACK }; // 红黑树节点模板 template class T struct RBTreeNode { T _data; // 存储的数据可能是KeySet或pairconst Key, ValueMap RBTreeNodeT* _left; RBTreeNodeT* _right; RBTreeNodeT* _parent; Colour _col; RBTreeNode(const T data) : _data(data), _left(nullptr), _right(nullptr), _parent(nullptr), _col(RED) // 新节点默认为红色 {} }; // 红黑树模板 template class Key, class T, class KeyOfValue, class Compare std::lessKey class RBTree { typedef RBTreeNodeT Node; private: Node* _root nullptr; // ... 其他成员和方法 };关键点解析T(ValueType)这是树节点实际存储的数据类型。对于SetTT就是Key对于MapK, VT就是std::pairconst K, V。const K确保了键的不可修改性这是Map语义的核心。KeyOfValue这是一个仿函数函数对象它的唯一作用是从T类型的对象中取出用于比较的键。例如// Set的KeyOfValue struct SetKeyOfValue { const Key operator()(const Key key) { return key; } }; // Map的KeyOfValue struct MapKeyOfValue { const Key operator()(const std::pairconst Key, Value kv) { return kv.first; } };通过这个设计红黑树内部的查找、插入等所有比较操作都统一通过KeyOfValue()(node-_data)来获取键从而与具体存储类型解耦。Compare键的比较准则默认为std::lessKey用于定义树的排序规则。这样Set和Map就可以像下面这样“借用”这棵红黑树// Set 的简化定义 template class Key class MySet { private: RBTreeKey, Key, SetKeyOfValue _t; // 第二个Key就是T存储的就是Key本身 public: // ... 封装插入、查找等接口 }; // Map 的简化定义 template class Key, class Value class MyMap { private: RBTreeKey, std::pairconst Key, Value, MapKeyOfValue _t; // 存储pair public: // ... 封装接口 operator[] 等 };2.2 迭代器设计让树“可遍历”容器必须提供迭代器。红黑树的迭代器本质上是节点的指针但需要支持中序后继和--中序前驱操作。中序遍历二叉搜索树会得到有序序列这正是Map/Set迭代有序的基础。迭代器的核心是寻找当前节点的中序后继。规则如下如果当前节点有右子树则后继是其右子树中的最左节点。如果当前节点没有右子树则需要向上回溯。沿着父指针向上走直到找到一个祖先节点且当前节点是该祖先节点的左子节点那么这个祖先节点就是后继。template class T, class Ref, class Ptr struct RBTreeIterator { typedef RBTreeNodeT Node; typedef RBTreeIteratorT, Ref, Ptr Self; Node* _node; // 前置 Self operator() { if (_node-_right) { // 情况1右子树存在找右子树的最左节点 Node* leftMost _node-_right; while (leftMost-_left) { leftMost leftMost-_left; } _node leftMost; } else { // 情况2右子树不存在向上回溯 Node* cur _node; Node* parent cur-_parent; while (parent cur parent-_right) { cur parent; parent parent-_parent; } _node parent; // 注意当走到根节点的父节点nullptr时迭代器结束 } return *this; } // ... 其他操作符重载!, *, -等 };注意operator--的逻辑与operator对称寻找当前节点的中序前驱左子树的最右节点或向上回溯找到第一个作为右子节点的祖先。3. 红黑树引擎插入与平衡的完整实现有了通用的树结构和迭代器接下来就是实现红黑树的核心引擎插入与自平衡。这是整个实现中最复杂但也最体现算法之美的地方。3.1 插入新节点的基本搜索插入的第一步和普通的二叉搜索树BST一样从根节点开始根据键的大小比较找到新节点应该插入的位置一个空的叶子节点位置并记录其父节点。bool Insert(const T data) { if (_root nullptr) { _root new Node(data); _root-_col BLACK; // 根节点必须为黑 return true; } KeyOfValue kov; Node* parent nullptr; Node* cur _root; while (cur) { parent cur; if (kov(data) kov(cur-_data)) { cur cur-_left; } else if (kov(data) kov(cur-_data)) { cur cur-_right; } else { return false; // 键已存在插入失败Map/Set不允许重复键 } } // cur 为 nullptrparent 是待插入位置的父节点 cur new Node(data); cur-_parent parent; if (kov(data) kov(parent-_data)) { parent-_left cur; } else { parent-_right cur; } // ... 接下来进行颜色调整 }3.2 红黑树平衡调整情况拆解与旋转新插入的节点默认为红色。如果其父节点是黑色那么没有违反任何红黑树性质插入完成。但如果父节点是红色就违反了“红色节点的子节点必须为黑色”的性质需要进行调整。调整的核心目标是在局部通过变色和旋转消除连续红色节点同时不破坏红黑树的其他性质尤其是每条路径的黑色节点数相同。设新插入的节点为cur红其父节点为parent红祖父节点为grandfather必为黑因为父节点是红。另一个关键角色是叔叔节点uncleparent的兄弟。根据uncle的颜色分为两大类情况情况一uncle 存在且为红这是最简单的情况。处理策略是将 parent 和 uncle 变为黑色grandfather 变为红色。这样以 grandfather 为根的子树恢复了平衡消除了连续红且黑色高度不变。但 grandfather 变红后可能和它的父节点形成新的连续红因此需要将cur更新为grandfather继续向上调整。黑G 红G (cur上移) / \ / \ 红P 红U -- 黑P 黑U / / 红C 红C情况二uncle 不存在或为黑这种情况需要通过旋转来解决。根据cur、parent、grandfather三者的位置关系又分为两种子情况。旋转的目的是将“红-红”冲突提升到子树根部然后通过一次变色解决。情况二.1直线型 (parent 是 grandfather 的左孩子cur 是 parent 的左孩子)处理对 grandfather 进行一次右单旋然后将 parent 染黑grandfather 染红。黑G 黑P / \ / \ 红P 黑U 右旋(G) 红C 红G / ------- / \红C ? 黑U 情况二.2折线型 (parent 是 grandfather 的左孩子cur 是 parent 的右孩子)处理先对 parent 进行一次左单旋将其转化为情况二.1直线型然后按情况二.1处理。黑G 黑G 黑C / \ / \ / \ 红P 黑U 左旋(P) 红C 黑U 右旋(G) 红P 红G \ ------- / ------- / \ / \ 红C 红P ? ? ? 黑U / ?当 parent 是 grandfather 的右孩子时情况对称使用左单旋和右单旋。旋转代码示例右单旋void RotateR(Node* parent) { Node* subL parent-_left; Node* subLR subL-_right; Node* ppNode parent-_parent; parent-_left subLR; if (subLR) subLR-_parent parent; subL-_right parent; parent-_parent subL; if (parent _root) { _root subL; _root-_parent nullptr; } else { if (ppNode-_left parent) ppNode-_left subL; else ppNode-_right subL; subL-_parent ppNode; } }插入调整的完整代码逻辑就是对这些情况的循环判断和处理直到cur到达根节点或其父节点为黑色为止。最后务必将根节点置为黑色因为调整过程中根可能被染红。4. 封装与优化打造易用的Map/Set接口底层红黑树引擎完工后我们需要为其穿上Map和Set的“外衣”提供符合STL标准的接口。4.1 Set的简单封装Set的封装相对直接因为它的value_type、key_type都是Key。大部分操作只是转发给内部的RBTree。template class Key class MySet { public: typedef Key key_type; typedef Key value_type; // 迭代器类型依赖于红黑树的迭代器 typedef typename RBTreekey_type, value_type, SetKeyOfValue::iterator iterator; iterator begin() { return _t.begin(); } iterator end() { return _t.end(); } std::pairiterator, bool insert(const value_type key) { return _t.Insert(key); // 复用红黑树的Insert } iterator find(const key_type key) { return _t.Find(key); } size_t erase(const key_type key) { return _t.Erase(key); // 红黑树还需实现Erase逻辑更复杂 } private: RBTreekey_type, value_type, SetKeyOfValue _t; };4.2 Map的封装与operator[]的妙用Map的封装更有趣因为它需要处理键值对并提供operator[]这个强大且常用的接口。operator[]的行为是如果键存在返回其对应值的引用如果键不存在则插入一个以该键为键、值初始化的键值对并返回其值的引用。这非常适合用来做计数或初始化访问。template class Key, class Value class MyMap { public: typedef Key key_type; typedef std::pairconst key_type, Value value_type; typedef typename RBTreekey_type, value_type, MapKeyOfValue::iterator iterator; // insert 返回 pairiterator, bool std::pairiterator, bool insert(const value_type kv) { return _t.Insert(kv); } // operator[] 的实现是Map的精华 Value operator[](const key_type key) { // 尝试插入一个键为key值为默认构造的Value的pair std::pairiterator, bool ret insert(std::make_pair(key, Value())); // ret.first 是迭代器指向插入的或已存在的节点 // ret.second 表示是否是新插入的 // 返回该节点存储的键值对中的值的引用 return (ret.first)-second; } // 示例统计单词频率 void wordCount() { MyMapstd::string, int countMap; std::string word; while (std::cin word) { countMap[word]; // 如果word不存在会插入{word, 0}然后变为1 } // 遍历countMap... } private: RBTreekey_type, value_type, MapKeyOfValue _t; };4.3 性能考量与调试技巧自己实现一遍后你对STL容器的性能会有更直观的认识。红黑树的插入、删除、查找时间复杂度都是O(log n)其中n是元素数量。这是因为红黑树通过上述复杂的平衡操作保证了树的高度始终维持在log n级别。在实现和调试过程中有几点心得可视化调试实现一个打印树结构的函数按层序或图形化在每次插入/删除后打印比单步调试指针直观得多。验证性质编写一个检查函数递归验证红黑树的五个性质是否满足在每次操作后调用确保实现正确。理解旋转不要死记硬背旋转代码。画图理解旋转如何改变指针指向并始终保持二叉搜索树的性质不变。可以拿一个小例子如插入序列{5, 3, 7, 1, 4, 6, 8, 2}手动模拟一遍插入和调整过程。内存管理别忘了在析构函数中递归释放所有节点内存避免泄漏。可以使用后序遍历的方式删除。通过这个从底层构建的过程你收获的不仅仅是一个可以运行的Map/Set。你获得的是对数据结构、算法、模板编程、迭代器设计等核心概念的深刻洞察。下次当你再使用std::map时你看到的将不再是一个简单的工具而是一个由精妙平衡的树、高效的迭代器和泛型封装共同构成的工程艺术品。这种理解是阅读任何教科书都无法替代的。

相关新闻

最新新闻

日新闻

周新闻

月新闻