首页 / 资讯中心 / 文章详情

红黑树与哈希表深度对比:从C++ map/unordered_map底层原理到工程实践

红黑树与哈希表深度对比:从C++ map/unordered_map底层原理到工程实践 ★ FEATURED ARTICLE
1. 容器选型背后的设计逻辑先从使用感受说起很多人在初学阶段就背过一个结论map底层是红黑树unordered_map底层是哈希表。但真正被问到“为什么set和map可以共用同一套红黑树实现”“为什么unordered_map的元素不是有序的”这类问题时往往只能答个大概。我先从日常使用感受切入把两类容器放在同一张表里做个对比这比空谈时间复杂度直观得多。维度map / setunordered_map / unordered_set底层结构红黑树近似平衡的二叉搜索树哈希表桶数组 冲突链元素顺序按键值从小到大有序无顺序由哈希函数决定存放位置查询时间复杂度O(log n)稳定平均 O(1)最坏 O(n)冲突严重时迭代器稳定性插入删除操作不影响其他元素迭代器扩容rehash会使所有迭代器失效适用场景需要有序遍历、频繁插入删除大量等值查询、对顺序无要求自定义类型支持只需重载operator需要自定义哈希函数和相等判断一个很有意思的点是set和map的底层红黑树其实是同一套数据结构只是节点里存的东西不同——set只存键map存键值对。标准库通过泛型参数把这两者统一了起来这一点我在后面的实现章节中会拆开细讲。从上面的对比可以看到选map还是unordered_map核心考量是“你是否需要有序性”而不是“谁更快”。很多人一上来就觉得哈希表什么都快结果做区间查询时发现还得把整个表遍历一遍这就本末倒置了。2. 红黑树实现 map 和 set难点不在于“平衡”而在“复用”如果让你自己写一棵红黑树核心工作是旋转和变色但标准库在实现map和set时真正棘手的却另有其事如何让同一棵树既能存单个 key又能存 pairconst Key, T。2.1 节点类型的巧妙抽象红黑树节点只关心两件事颜色和指针。至于节点里装的是什么数据标准库通过模板参数Value来抽象。map的节点存的是pairconst Key, Tset的节点存的是const Key。这样做的直接好处是红黑树的旋转、插入、删除逻辑完全不需要区分自己是在服务map还是set对用户而言无论通过哪种容器拿到的是迭代器指向的值都不能随便改动键的部分树的内部操作只依赖“键的比较”不依赖“值的类型”。用伪代码来表达这个抽象// 树节点只关心颜色和指针数据通过模板参数传入 template class Value struct RBTreeNode { RBTreeNode* left; RBTreeNode* right; RBTreeNode* parent; Color color; // 红/黑 Value data; // map: pairconst K, Vset: const K };而负责“取键”的职责则交给一个萃取类key extraction不同容器传入不同的萃取器树就能复用同一套比较逻辑// 从 set 的节点中取键 struct Identity { const Key operator()(const Key key) const { return key; } }; // 从 map 的节点中取键 struct SelectFirst { const Key operator()(const pairconst Key, T kv) const { return kv.first; } };这层抽象是理解整个实现的关键。初学者容易陷入“map 的树和 set 的树是两套代码”的误解实际上标准库在底层把它们统一成了一个_Rb_tree类模板只通过模板参数区分语义。2.2 迭代器的设计与 const 正确性先看一个最常见的误区map的迭代器解引用得到的是pairconst Key, T也就是说你无法通过迭代器修改 key但可以修改 value。这是红黑树实现map时的基础约束。标准库为红黑树实现了双向迭代器重点在于迭代器的自增自减操作。因为红黑树不是按物理内存排布的所以迭代器必须能够从当前节点推导出中序遍历顺序中的下一个节点void increment() { // 如果右子树存在找右子树的最左节点 if (node-right ! nullptr) { node leftmost(node-right); return; } // 否则向上回溯找到第一个“左侧祖先” while (node-parent ! nullptr node node-parent-right) { node node-parent; } node node-parent; }自减操作就是对称的“找前驱”过程。这里有个工程实现上的细节标准库在树的顶端加了一个 header 节点它的 left 指向最左节点right 指向最右节点这样end()迭代器即 header在自减时才能正确落到最大元素上避免了边界判断的特殊逻辑。我开始以为这只是一个优化后来自己写了一个不带 header 的版本发现到处都是对nullptr的特判代码丑得不行。后来才明白 header 节点的意义不只是效率更是为了统一边界行为。2.3 插入删除后如何维持平衡红黑树的平衡规则是五条性质最关键的三条是根节点是黑色红色节点的子节点必须是黑色即不允许连续的红色节点从任一节点到其所有叶子节点的路径上黑色节点数量相同。当插入新节点时默认把它染成红色。为什么因为这样最多只破坏第二条性质不会破坏“黑色高度相同”的第五条性质修复起来更简单。这种“默认红色”的设计是红黑树区别于 AVL 树的典型策略之一。插入后的修复有几种情况叔叔节点是红色将父节点和叔叔节点改为黑色祖父改为红色然后继续向上处理叔叔节点是黑色且当前节点呈“折线”关系即父是左孩子自己是右孩子先对父节点做一次左旋或右旋变成直线关系叔叔节点是黑色且呈“直线”关系对祖父节点旋转变色。实际编码时最让人头晕的是“折线变直线”的那一步——它本身不改变颜色只是调整结构让下一步的统一处理能够成立。我自己的记忆口诀是先旋成直线再处理颜色。如果一上来就纠结颜色很容易把自己绕晕。删除修复比插入棘手得多。核心难点在于如果删掉的是一个黑色节点那么从它的父节点到叶子路径上的黑色高度就少了一必须通过旋转和变色来“借”一个黑色过来。具体可以分为兄弟节点是红色先把兄弟变黑父变红旋转转化成兄弟为黑的情况兄弟节点是黑色且兄弟的两个孩子都是黑色把兄弟染红问题向上传递兄弟节点是黑色且至少有一个红孩子通过旋转和重染色完成修复。删除实现的代码量通常是插入的两倍以上。标准库的做法是把修复逻辑封装成几个内部函数通过 while 循环逐级向上处理。实际工程中除非你真的是在做库开发否则不建议每一次都手动实现完整的删除逻辑理解规则并能在纸上模拟几遍比背代码重要得多。2.4 为什么说 map 的插入删除不会让迭代器失效红黑树的插入和删除本质上只是指针的重新指向和节点的染色。insert只改动了树内部连接的指针erase也只释放目标节点本身。因此持有其他节点的迭代器其指向的地址没有变化所以依然有效但被删除节点的迭代器当然失效这是物理意义上的失效对map而言即便发生旋转也不会影响未被旋转的节点地址。这一点和vector形成鲜明对比。vector插入元素导致扩容时所有迭代器直接作废即使不扩容insert也可能让插入位置之后的所有迭代器失效。所以在写“边遍历边删除”的代码时map和set是安全的只要删除的是当前迭代器指向的元素而unordered_map则需要额外小心——特别是在插入可能触发扩容的场景下。3. unordered_map 和 unordered_set哈希容器的独特优势与隐蔽问题如果说红黑树容器的优势是有序和稳定那么哈希容器的优势就是“普通场景下的快速访问”。但哈希表有一个永远绕不开的话题哈希冲突。3.1 哈希函数与桶的分配机制先理清一个概念。unordered_map并不是“直接存了一个大数组”而是维护了一个“桶数组”。每个桶本质上是一条链表的头指针。当你插入一个键时实际过程是调用哈希函数计算键的哈希值将哈希值对桶数量取模实际上是按位与当桶数量是 2 的幂时找到对应的桶在链表中查找是否已存在相同键不存在则插入链表尾部C11 之后是尾部插入。其中第二步有一个关键细节桶数量通常被控制为 2 的幂这样hash % bucket_count可以直接用位运算替代速度更快。但代价是只有哈希值的最低几位参与选桶如果哈希函数的低几位区分度不高冲突就会明显增加。这就是为什么标准库的哈希函数在返回之前会做一次“位散列”比如hash_combine之类的操作以提升低位的随机性。自定义结构体做键时最常见的错误是只把成员变量的哈希值相加这样会导致{a, b}和{b, a}的哈希相同——这不是错误但是会显著增加冲突概率。更好的做法是给每个成员乘以一个不同的质数再相加struct Person { string name; int age; bool operator(const Person other) const { return name other.name age other.age; } }; struct PersonHash { size_t operator()(const Person p) const { size_t h1 hashstring{}(p.name); size_t h2 hashint{}(p.age); return h1 ^ (h2 1); // 简单混合但不是最优 } }; unordered_setPerson, PersonHash people;注意unordered_set要求同时提供哈希函数和相等判断。相等判断是必要的因为哈希冲突时链表里可能有多个元素落在同一个桶中必须通过operator精确比对。3.2 负载因子与 rehash为什么扩容会让迭代器失效负载因子是桶中平均元素数量的衡量指标定义为size / bucket_count。标准库默认在负载因子超过 1.0 时触发 rehash默认max_load_factor 1.0。rehash 的过程是重新分配一个更大的桶数组通常翻倍遍历旧桶中的每一个元素重新计算哈希值对应的新桶位置把元素挂到新桶中。这一步听起来简单但意味着所有节点的物理位置虽然没有改变但迭代器的“关联关系”被打乱了。标准库规定rehash 会导致迭代器失效虽然元素的地址没变但迭代器通过桶定位元素的逻辑已经不可依赖。所以安全写法是在循环中插入元素时不要把插入和遍历放在同一轮循环里或者先预留好足够的桶数量unordered_mapint, int mp; mp.reserve(10000); // 一次性预留足够空间避免中途 rehash我开始读标准时总觉得“节点地址没变它凭什么说迭代器失效”后来才理解失效规则是以标准约定为准而不是以物理内存为准。所以这里根本没得商量老老实实遵守标准不要赌实现细节。3.3 哈希容器的遍历顺序为什么是“玄学”我经常看到有人追问“为什么 unordered_map 遍历出来的顺序和插入顺序不一样”这背后的原因就是哈希值取模后得到的桶位置决定了存储顺序而桶位置和插入顺序无关。同一个程序里从小到大遍历桶和链表的组合得到的就是哈希表里的物理顺序。更隐蔽的是哈希值相同的一组键虽然逻辑上会挂在同一条链表上但如果它们还触发了扩容换桶之后可能全部重新分布。所以不要依赖unordered_map的任何顺序如果需要按插入顺序访问应该额外维护一个vector或链表来记录插入顺序切勿把哈希容器的迭代顺序用于日志输出的稳定性测试时很容易出现“A 机器打印顺序和 B 机器不一样”的诡异情况。3.4 一个容易忽略的工程问题桶数组与内存占用哈希表在负载因子较低时会消耗大量不必要的内存。比如一个unordered_map只存了 10 个元素但桶数组可能已经开了 64 个桶每个桶是一个指针大小再加上链表节点本身的分配开销其内存占用往往显著高于map的红黑树节点。所以在内存敏感的场景下别急着说哈希表省内存。红黑树节点虽然多存了颜色和父子指针三指针 1 字节颜色但没有桶数组的额外开销。真实项目中如果元素数量少且键值占用大有时候map反而更省内存。这类问题没有标准答案必须结合具体数据量测算。4. 实际手写一版简化红黑树实现 map拆开黑盒子很多教程把红黑树讲得神乎其神但真正上手写一次会发现在理解规则的前提下代码量也没有夸张到写不完。这里我给出一版只实现插入功能的简化结构重点展示前面提到的“节点存储与比较抽象”。注意下面代码只是为了演示核心逻辑并不用于生产环境。生产环境请直接用标准库容器。#include iostream enum class Color { Red, Black }; // 红黑树节点的数据域通过模板参数 Value 决定 template typename Value struct RBNode { Value data; Color color; RBNode* left; RBNode* right; RBNode* parent; RBNode(const Value data) : data(data), color(Color::Red), left(nullptr), right(nullptr), parent(nullptr) {} }; // KeyOfValue 的作用是从 Value 中取出比较用的键 template typename Value, typename KeyOfValue class SimpleRBTree { public: using Node RBNodeValue; SimpleRBTree() : root_(nullptr) {} // 插入返回值用于区分成功或已有相同键 bool insert(const Value data) { KeyOfValue ko; // 通俗理解成一个函数对象 if (root_ nullptr) { root_ new Node(data); root_-color Color::Black; return true; } Node *cur root_, *parent nullptr; while (cur ! nullptr) { parent cur; if (ko(data) ko(cur-data)) { cur cur-left; } else if (ko(cur-data) ko(data)) { cur cur-right; } else { return false; // 已存在不插入 } } Node* node new Node(data); node-parent parent; if (ko(data) ko(parent-data)) { parent-left node; } else { parent-right node; } // 插入后修复红黑树性质 insertFixup(node); return true; } void inorder() { inorderInternal(root_); std::cout std::endl; } private: Node* root_; void rotateLeft(Node* x) { Node* y x-right; x-right y-left; if (y-left) 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; } void rotateRight(Node* x) { Node* y x-left; x-left y-right; if (y-right) y-right-parent x; y-parent x-parent; if (x-parent nullptr) { root_ y; } else if (x x-parent-right) { x-parent-right y; } else { x-parent-left y; } y-right x; x-parent y; } void insertFixup(Node* z) { while (z-parent z-parent-color Color::Red) { if (z-parent z-parent-parent-left) { Node* uncle z-parent-parent-right; if (uncle uncle-color Color::Red) { // case 1叔叔为红只变色不旋转 z-parent-color Color::Black; uncle-color Color::Black; z-parent-parent-color Color::Red; z z-parent-parent; } else { // case 2当前节点在右侧先左旋变成直线 if (z z-parent-right) { z z-parent; rotateLeft(z); } // case 3当前节点在左侧旋转并变色 z-parent-color Color::Black; z-parent-parent-color Color::Red; rotateRight(z-parent-parent); } } else { // 对称处理 Node* uncle z-parent-parent-left; if (uncle uncle-color Color::Red) { z-parent-color Color::Black; uncle-color Color::Black; z-parent-parent-color Color::Red; z z-parent-parent; } else { if (z z-parent-left) { z z-parent; rotateRight(z); } z-parent-color Color::Black; z-parent-parent-color Color::Red; rotateLeft(z-parent-parent); } } } root_-color Color::Black; } void inorderInternal(Node* node) { if (node nullptr) return; inorderInternal(node-left); std::cout node-data.first : node-data.second ; inorderInternal(node-right); } }; // 从 pair 中提取键 struct SelectFirst { const int operator()(const std::pairconst int, std::string kv) const { return kv.first; } };下面是这个简化树配合 map 语义使用的测试代码int main() { SimpleRBTreestd::pairconst int, std::string, SelectFirst tree; tree.insert({3, c}); tree.insert({1, a}); tree.insert({2, b}); tree.insert({5, e}); tree.insert({4, d}); tree.inorder(); // 输出 1:a 2:b 3:c 4:d 5:e证明中序有序 return 0; }可以看到这个SimpleRBTree根本不知道自己在服务一个 map 还是一个 set它只知道“比大小”。SelectFirst告诉它如何从pair中提取比较用的 key。这正好呼应了前面第 2 节说的节点抽象。这段代码隐藏的一个隐患旋转时没有更新 root 的 parent如果树很高又碰上了删除操作很容易在回溯时出错。真实标准库还额外维护了一个 header 节点专门解决根节点的 parent 指向问题我的写法是简化版的偷懒方式仅供参考。5. 常见问题与排查技巧实录使用中的典型坑点我自己在实际编码和给团队做培训时遇到的高频问题集中在下面几个区域。这里整理成一张速查表配合必要的展开说明。问题现象根因解决办法自定义类型无法作为map的键编译报错没有提供operator重载operator或者提供比较器仿函数自定义类型无法作为unordered_map的键没有自定义哈希和相等判断提供operator和自定义哈希仿函数遍历unordered_map时插入新元素程序行为异常插入可能触发 rehash导致迭代器失效提前reserve或遍历结束再插入map迭代器不能修改 key键被声明为const Key需要修改就删除后重新插入哈希容器内存占用比预期高桶数组远大于元素数量调低max_load_factor或及时rehash两个哈希容器遍历顺序不同哈希值取模规则与桶数量密切相关不依赖顺序按需另行记录插入顺序大量元素插入map很慢每次插入 O(log n)n 大且单个键比较开销高如果不需要有序性改用unordered_map下面挑几个代表性的坑展开说。5.1 自定义结构体的哈希陷阱低三位冲突很多人自定义哈希函数时习惯按下面这样写struct BadHash { size_t operator()(const pairint, int p) const { return hashint{}(p.first) ^ hashint{}(p.second); } };问题在于如果p.first和p.second的哈希值二进制高位相同异或之后低位信息可能丢失。而底层选桶时恰恰是低几位参与计算。所以两个pair在语义上完全不同却可能落到同一个桶。等键数量上来退化到 O(n) 也不是不可能。更稳的写法是使用位移混合struct GoodHash { size_t operator()(const pairint, int p) const { size_t h1 hashint{}(p.first); size_t h2 hashint{}(p.second); return h1 ^ (h2 1); // 或者 h1 0x9e3779b9 (h2 6) (h2 2) } };这段位运算看着像玄学其实是用左移把第二个成员的哈希分散到更高的位上减少低位重叠。5.2 erase 返回值的妙用边遍历边删除map和unordered_map在 C11 之后erase返回指向下一个元素的迭代器。这个特性让“边遍历边删除”变得非常安全// 错误写法erase 后当前迭代器已失效 for (auto it mp.begin(); it ! mp.end(); it) { if (it-second 0) { mp.erase(it); // 未定义行为 } } // 正确写法C11 起 for (auto it mp.begin(); it ! mp.end();) { if (it-second 0) { it mp.erase(it); } else { it; } }注意上面的正确写法对map和unordered_map都有效。但对unordered_map来说如果循环体里还做了insert即便插入不会触发 rehash 也可能因为桶冲突链变化而导致遍历重复或遗漏所以尽量避免在同一循环里插入。5.3 用户自定义比较器容易踩的标准库闭坑点写自定义比较器时很多人会犯一个隐蔽的错误定义了一个 “Equivalence” 关系却没有保证严格弱序。比如struct WrongCompare { bool operator()(const string a, const string b) const { return a.size() b.size(); } };这个比较器的问题是ab和cd大小相等但a b和b a都为 false。标准库红黑树判定两个元素相等时用的是!comp(a, b) !comp(b, a)也就是说两者会被视为“相等”后插入的会被忽略。这不是 bug而是标准库的既定行为。但很多开发者并不理解为什么自己的map会神秘“吞掉”某些键。所以自定义比较器必须满足严格弱序这意味着对于不相等的两个元素必须能分出绝对的先后。当比较逻辑像a.size() b.size()这样天然不满足时设计上就应该改为比较(size, content)组合才能得到符合直觉的结果。5.4 reserve 与 rehash 的实测感受我实际测试过两组数据一组是向空的unordered_map连续插入 10 万个整数键不做任何预留另一组先调用reserve(100000)再插入。前者的耗时往往是后者的两到三倍因为中途会发生多次 rehash每次 rehash 都要重新计算每个键的桶位置并把所有节点重新挂链。这个开销积少成多之后非常可观。这里有个点值得记住reserve(n)的本质是让桶数量能够容纳 n 个元素且不超过max_load_factor。它不是预留节点空间而是预留桶数组。因此调用之后迭代器不会失效但如果你在 reserve 之前已经有迭代器reserve 本身是安全的。6. 最后还是想再多说两句我在多年前第一次手写红黑树删除修复时整整折腾了一个周末最后在纸上把每一种 case 画出来才彻底理顺。这种熟练度并不是为了“重造轮子”而是为了在使用容器时真正能理解那些看似奇怪的性能表现和迭代器失效规则背后的原因。一个比较有价值的后续练习是试着在SimpleRBTree中加入erase逻辑然后对比你的实现和标准库在随机插入删除场景下的性能差异。这能让你直观体会到“旋转 变色”的实际代价以及为什么标准库可以做到常数空间额外开销。另外如果你需要在极低延迟场景下使用哈希容器建议关注一些开源的高性能哈希表实现比如absl::flat_hash_map或robin_hood系列。它们的核心思路和unordered_map一致但在内存布局、冲突处理、查找短路优化上的差异会直接影响真实业务场景的吞吐量。作为对比flat_hash_map把数据连续存储在 slot 数组中而不是链表节点加桶头的组合在 cache 命中率上有明显优势。用惯了标准库再去看这些实现会有一种“原来哈希表还能这么玩”的开窍感。回到标题本身红黑树和哈希表并不是“二选一”的对立关系而是两种适合不同语义的底层工具。理解了它们的实现差异你在面对“为什么这里慢”“为什么这里迭代器失效”“为什么顺序不对”这类问题时就能一眼看穿本质。
阅读完成 · 觉得有帮助?
咨询建站