编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程地址空间布局
      • 对象内存布局原理
      • 引用与指针本质
      • this指针与成员函数
      • 虚函数表深度剖析
      • 多重继承内存模型
      • 内存对齐与缓存行
      • 内存分配器演进史
      • 五大值类别详解
      • 右值引用与移动语义
      • 完美转发与引用折叠
      • 类型推导三大规则
      • 类型转换与隐式构造
      • const与volatile真相
      • RTTI与dynamic_cast
      • 类型擦除技术原理
      • 模板实例化机制
      • 模板特化与偏特化
      • SFINAE与enable_if
      • 可变参数模板原理
      • constexpr编译期计算
      • Concepts深度剖析
      • 元编程模板技巧
      • Modules模块化设计
      • RAII的设计哲学
      • 对象构造与析构
      • 拷贝与移动控制
      • unique_ptr原理剖析
      • shared_ptr底层剖析
      • weak_ptr与this增强
      • 五种存储期管理
      • vector扩容真相
      • deque分段连续设计
      • list与forward_list
      • 关联容器红黑树
        • 1. 案例引入
          • 1.1 unordered_map 替换 map 的意外减速
          • 1.2 set::find 的临时对象陷阱
          • 1.3 七个待解疑问
        • 2. 架构概览
          • 2.1 红黑树在关联容器中的角色
          • 2.2 为何这么切
        • 3. 红黑树五性质与平衡原理
          • 3.1 五条铁律的物理含义
          • 3.2 插入后的颜色修复全过程——四情况完整推演
          • 3.3 删除后的修复——为什么比插入复杂
          • 3.4 libstdc++ 的节点结构
          • 3.4 sizeof 与内存开销分析
        • 4. map 与 set 的接口哲学
          • 4.1 operator[] 的插入逻辑
          • 4.2 insert 的返回值语义
          • 4.3 lowerbound / upperbound / equal_range
          • 4.4 try_emplace 的消除临时对象设计
        • 5. 异构查找 Heterogeneous Lookup
          • 5.1 标准库默认的陷阱——key 类型必须完全匹配
          • 5.2 C++14 的透明比较器
          • 5.3 性能实测对比
          • 5.4 自定义异构查找的正确姿势
        • 6. 节点重用——extract 与 node_type
          • 6.1 为什么需要 extract
          • 6.2 node_type 的零拷贝搬移
          • 6.3 splice 风格的容器间搬移
          • 6.4 extract+insert 的汇编对比
        • 7. 有序容器的遍历与性能剖析
          • 7.1 中序遍历 = 有序输出
          • 7.2 迭代器失效规则
          • 7.3 与 unordered 容器的缓存对比
        • 8. multi 系列容器的设计差异
          • 8.1 multimap / multiset 的等价键处理
          • 8.2 为什么 multiset 没有 operator[]
          • 8.3 equal_range 的用法
        • 9. 汇编全景与性能剖析
          • 9.1 find 的二分查找指令序列
          • 9.2 insert 的固定流程
          • 9.3 三场景 benchmark
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一次 map::find 的完整旅程
          • 10.3 设计哲学回扣
          • 10.4 速查表合集
      • 哈希容器深度剖析
      • 迭代器五大类别
      • STL算法设计哲学
      • Allocator分配器机制
      • C++内存模型基石
      • 六大内存序详解
      • atomic原子操作原理
      • mutex与条件变量
      • thread与jthread机制
      • 异步编程future家族
      • 无锁数据结构设计
      • 协程coroutine原理
      • 翻译单元与预处理
      • 编译期符号生成
      • 链接器工作原理
      • ODR规则与陷阱
      • 动态库与符号可见性
      • C++ ABI兼容性
      • LTO与PGO优化
      • 异常机制底层原理
      • Ranges革命与管道
      • format与print体系
      • UB未定义行为图鉴
      • C++设计哲学回望
      • 写作模板
    • 开发技巧

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 专栏博客
杨充
2026-06-06
目录

关联容器红黑树

# 35.关联容器红黑树

# 目录介绍

  • 1. 案例引入
    • 1.1 unordered_map 替换 map 的意外减速
    • 1.2 set::find 的临时对象陷阱
    • 1.3 七个待解疑问
  • 2. 架构概览
    • 2.1 红黑树在关联容器中的角色
    • 2.2 为何这么切
  • 3. 红黑树五性质与平衡原理
    • 3.1 五条铁律的物理含义
    • 3.2 插入后的颜色修复全过程
    • 3.3 libstdc++ 的节点结构
    • 3.4 sizeof 与内存开销分析
  • 4. map 与 set 的接口哲学
    • 4.1 operator[] 的插入逻辑
    • 4.2 insert 的返回值语义
    • 4.3 lower_bound / upper_bound / equal_range
    • 4.4 try_emplace 的消除临时对象设计
  • 5. 异构查找 Heterogeneous Lookup
    • 5.1 标准库默认的陷阱——key 类型必须完全匹配
    • 5.2 C++14 的透明比较器
    • 5.3 性能实测对比
    • 5.4 自定义异构查找的正确姿势
  • 6. 节点重用——extract 与 node_type
    • 6.1 为什么需要 extract
    • 6.2 node_type 的零拷贝搬移
    • 6.3 splice 风格的容器间搬移
    • 6.4 extract+insert 的汇编对比
  • 7. 有序容器的遍历与性能剖析
    • 7.1 中序遍历 = 有序输出
    • 7.2 迭代器失效规则
    • 7.3 与 unordered 容器的缓存对比
  • 8. multi 系列容器的设计差异
    • 8.1 multimap / multiset 的等价键处理
    • 8.2 为什么 multiset 没有 operator[]
    • 8.3 equal_range 的用法
  • 9. 汇编全景与性能剖析
    • 9.1 find 的二分查找指令序列
    • 9.2 insert 的固定流程
    • 9.3 三场景 benchmark
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一次 map::find 的完整旅程
    • 10.3 设计哲学回扣
    • 10.4 速查表合集

# 1. 案例引入

# 1.1 unordered_map 替换 map 的意外减速

某行情系统用 std::map<std::string, Price> 存合约报价。团队读到「哈希表 O(1) 比红黑树 O(log N) 快」——全线改为 std::unordered_map。回归测试发现 P99 延迟不降反升:

// ====== 事故代码 V1:盲目换 unordered_map ======
// 原版
std::map<std::string, Price> quotes;
auto it = quotes.find("BTC-USD-PERP");  // 约 18 次字符串比较——O(log N)

// 「优化」版
std::unordered_map<std::string, Price> quotes;
auto it = quotes.find("BTC-USD-PERP");  // 1 次哈希 + 1 次字符串比较——但哈希开销大!

// 问题:字符串的哈希计算不是免费的——
//   "BTC-USD-PERP" 13 个字符 × 每字符 ~2ns = ~25ns
//   map 的 find:~18 次 compare → 第一位就不同 → 每次 ~0.5ns → ~9ns
1
2
3
4
5
6
7
8
9
10
11
12

benchmark(100 万次 find,500 个键的容器):

容器 find 单次时间 原因
map<string> 18 ns ~18 次字符串比较——多数首位不同
unordered_map<string> 42 ns 15 ns 哈希计算 + 25 ns 链查找
map<int> 3 ns ~20 次整数比较——各 1 周期

教训:O(log N) vs O(1) 是渐进复杂度——对于 N=500 和昂贵的哈希函数,红黑树更优。没有 profiling 的容器选型是信仰,不是工程。

# 1.2 set::find 的临时对象陷阱

同一个代码库的合约过滤器——用 std::set<std::string> 存白名单。每次查一个 const char* 都要构造临时 std::string:

// ====== 事故代码 V2:隐式构造临时 string ======
std::set<std::string> whitelist = {"BTC-USD", "ETH-USD", ...};

bool is_allowed(const char* symbol) {
    return whitelist.find(symbol) != whitelist.end();
    // ❌ symbol(const char*) → 隐式构造 std::string → 分配堆内存 → 字符串拷贝 → 查找 → 释放堆内存
}
// 每次 find 都分配+释放一次堆内存——100 万次 find = 100 万次 new/delete
1
2
3
4
5
6
7
8

C++14 修复——异构查找:

// 只需把比较器从 std::less<string> 改成 std::less<>
std::set<std::string, std::less<>> whitelist;  // 透明比较器

bool is_allowed(const char* symbol) {
    return whitelist.find(symbol) != whitelist.end();
    // ✅ 直接比较 const char* 和 string——零临时对象!
}
1
2
3
4
5
6
7

benchmark(100 万次 find,500 个键):

方式 单次 find 时间 堆分配次数
std::less<string>(默认) 48 ns 1M
std::less<>(透明) 18 ns 0

# 1.3 七个待解疑问

① 红黑树到底是什么? 为什么不是 AVL 树? 五条性质怎么理解?              → 第 2 / 第 3 章
② map::operator[] 和 insert 有什么区别? 该用哪个?                      → 第 4 章
③ 异构查找是什么? 为什么能省临时对象? 怎么配?                          → 第 5 章
④ extract 和 node_type 怎么用? 和 C++17 之前的方案比有什么优势?         → 第 6 章
⑤ ordered vs unordered 容器到底怎么选? 什么时候红黑树反而更快?           → 第 7 / 第 9 章
⑥ multimap/multiset 和 map/set 有什么区别? 为什么多键版本没有 operator[]?  → 第 8 章
⑦ lower_bound / upper_bound / equal_range 分别干什么?                   → 第 4.3 / 第 8.3 章
1
2
3
4
5
6
7

# 2. 架构概览

# 2.1 红黑树在关联容器中的角色

C++ 标准库的四个有序关联容器共享同一份红黑树实现:

                ┌────────────────────────────────┐
                │       _Rb_tree (内部基类)        │
                │    libstdc++ 的公共红黑树引擎     │
                └────────────────┬───────────────┘
                                 │
      ┌──────────────────────────┼──────────────────────────┐
      ▼                          ▼                          ▼
┌──────────┐              ┌──────────┐              ┌──────────┐
│   map    │              │   set    │              │  multi   │
│  key→val │              │  key=val │              │  重复键  │
├──────────┤              ├──────────┤              ├──────────┤
│pair&lt;const│              │ 一个模板 │              │multimap  │
│Key,T>    │              │  参数    │              │multiset  │
└──────────┘              └──────────┘              └──────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14

精髓:map<K,V> 和 set<K> 内部用的是完全相同的红黑树代码——set<K> 只是 map<K, void> 的特化(值类型为空)。multi 版本只是关闭了「唯一键检查」。

# 2.2 为何这么切

疑惑:为什么标准库用红黑树而不是 AVL 树?

论证:

  1. AVL 更严格平衡——任意节点的左右子树高度差 ≤ 1。红黑树更宽松——最长路径不超过最短路径的 2 倍。AVL 的插入需要更多旋转(rebalancing 更频繁),红黑树的插入最多 2 次旋转。
  2. 红黑树在 C++ 标准库中的实际表现——多数工作负载是「插入 + 查找」混合。红黑树的插入比 AVL 快 15-25%,查找非严格稍慢(差 5-10%)。混合负载下红黑树胜出。
  3. 历史选择——HP/SGI STL 的原始实现选择了红黑树,标准库延续了这一选择。这不是技术唯一解——是工程共识。

结论:红黑树是「平衡与写性能的最优折中」——C++ 标准库需要通用的有序容器,红黑树在写密集型负载上优于 AVL。


# 3. 红黑树五性质与平衡原理

# 3.1 五条铁律的物理含义

性质 1:每个节点是红色或黑色
性质 2:根节点永远黑色
性质 3:每个 NIL(叶节点)是黑色
性质 4:红色节点的两个子节点都是黑色(不连续红)
性质 5:从根到每个叶子的路径包含相同数量的黑色节点

    ┌─────────────────────────────────────────────┐
    │      B (黑根——性质 2)                        │
    │     ╱ ╲                                      │
    │    R   R  (红节点——性质 4: 父节点黑)           │
    │   ╱ ╲ ╱ ╲                                   │
    │  B  B B  B (所有叶子黑节点数相同=2——性质 5)    │
    └─────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13

性质 5 的作用:保证没有一条路径会长于另一条路径的 2 倍(因为最长路径是红黑交替、最短是全黑)。这就是 O(log N) 的平衡保证——不需要完美平衡,但绝不会退化到 O(N)。

# 3.2 插入后的颜色修复全过程——四情况完整推演

插入节点 Z(初始颜色 = 红)后触发颜色修复。以下用「叔节点」指 Z 的父节点 P 的兄弟节点:

插入 Z(红色),修复循环 while (P 是红色):

情况 1:Z 是根 → Z 染黑。完毕。

情况 2:叔节点 U 是红色
  → P 染黑、U 染黑、G(祖) 染红
  → Z = G,递归向上继续修复
  → 原因:红色不连续原则——P 和 U 都红,把冲突上推到 G
  → 仅改颜色,不旋转

     G(黑)                G(红)
    ╱    ╲               ╱    ╲
  P(红)  U(红)    →   P(黑)  U(黑)
  ╱                      ╱
Z(红)                  Z(红)

情况 3:U 是黑色,且 Z 是 P 的右子(三角型)
  → 对 P 做左旋
  → Z 和 P 角色交换 → 进入情况 4
  → 旋转 1 次

     G(黑)               G(黑)
    ╱    ╲              ╱    ╲
  P(红)  U(黑)    →  Z(红)  U(黑)
    ╲                  ╱
    Z(红)            P(红)

情况 4:U 是黑色,且 Z 是 P 的左子(直线型)
  → P 染黑、G 染红
  → 对 G 做右旋
  → 完毕(最多到此结束)
  → 旋转 1 次

     G(黑)               P(黑)
    ╱    ╲              ╱    ╲
  P(红)  U(黑)    →  Z(红)  G(红)
  ╱                           ╲
Z(红)                         U(黑)

总代价:插入一个元素,最多 2 次旋转、O(log N) 次颜色修改(只改不改指针)。
和 AVL 对比:AVL 插入可能需要 O(log N) 次旋转——每个父节点都要检查平衡因子。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

为什么最多 2 次旋转:因为情况 4 把「红色连续」问题在 G 这一层解决了——G 变成红色后,如果 G 的父节点也是红色,会触发新的修复循环。但情况 4 本身只旋转 1 次。进入情况 4 的路径只有一个——来自情况 3(三角型→直线型)。所以整个插入最多旋转 2 次。

# 3.3 删除后的修复——为什么比插入复杂

删除一个节点涉及三种情况的替换(替换为子节点或前驱/后继),然后如果被删除节点是黑色——触发「黑色高度减少」修复:

删除修复的核心思想:
  删除黑色节点 → 某些路径少了一个黑 → 从兄弟「借」黑色过来

简化:删除修复比插入更复杂(6 种情况 vs 插入的 4 种),
但同样最多 3 次旋转(摊销更少)。这里不展开全部情况,
重点记住:红黑树的删除也是 O(log N)——这是平衡树的核心价值。
1
2
3
4
5
6

# 3.4 libstdc++ 的节点结构

// libstdc++ 的 _Rb_tree_node
template <typename T>
struct _Rb_tree_node {
    _Rb_tree_node* _M_parent;   // ① 父节点——8 字节
    _Rb_tree_node* _M_left;     // ② 左子——8 字节
    _Rb_tree_node* _M_right;    // ③ 右子——8 字节
    bool           _M_color;    // ④ 颜色——1 字节(红/黑)
    T              _M_data;     // ⑤ 数据 (map=pair<const K,V>, set=K)

    // sizeof(_Rb_tree_node<pair<const int, string>>) ≈ 24+1+4+40 = 72 字节
};
1
2
3
4
5
6
7
8
9
10
11

和 list 节点的对比:

节点 指针数 sizeof(node) 用途
list 2 (prev/next) 24B 线性序列
_Rb_tree 3 (parent/left/right) 40B 二分搜索
unordered_map 1 (next in bucket) 16B 哈希桶链

# 3.4 sizeof 与内存开销分析

std::map<int, int> m;
m[1] = 100;

// 节点大小:3 指针(24B) + color(1B) + padding(3B) + pair<int,int>(8B) = 40B
// allocator metadata: ~16B
// 总 per-node 开销:~56B
// 实际有用数据:8B (pair<int,int>)
// 内存效率:8 / 56 = 14%
1
2
3
4
5
6
7
8
容器 10000 × int→int 内存效率
vector<pair<int,int>> 80 KB 100%
map<int,int> 560 KB 14%

# 4. map 与 set 的接口哲学

# 4.1 operator[] 的插入逻辑

std::map<std::string, int> m;
m["BTC"] = 42000;   // ① 如果 "BTC" 不存在 → 插入 {"BTC", 0},然后返回引用
                    // ② 赋值 42000 到引用

// operator[] 内部等于:
//   return (*((this->insert(std::make_pair(k, T()))).first)).second;
//          └── 插入默认构造的值 ──┘
1
2
3
4
5
6
7

陷阱:如果只想知道键是否存在——不要用 operator[]:

if (m["ETH"]) { ... }    // ❌ 即使 "ETH" 不存在,这行也会插入 {"ETH", 0}!
if (m.count("ETH")) { ... } // ✅ 不修改容器
1
2

# 4.2 insert 的返回值语义

auto [it, inserted] = m.insert({"BTC", 42000});
// it       → 指向元素(新插入的 or 已经存在的)
// inserted → true = 新插入, false = 键已存在(值未改变)

// 检查插入是否成功
if (inserted) {
    std::cout << "new element: " << it->second;
}
1
2
3
4
5
6
7
8

# 4.3 lower_bound / upper_bound / equal_range

std::map<int, std::string> m = {{1,"a"},{3,"c"},{5,"e"},{7,"g"}};

auto lb = m.lower_bound(3);    // 指向 >= 3 的第一个元素 → {3,"c"}
auto ub = m.upper_bound(3);    // 指向 > 3 的第一个元素  → {5,"e"}
auto [first, last] = m.equal_range(3); // {lb, ub} — 等于是 [lb, ub)

// 遍历所有键为 3 的元素(multimap 多个)
for (auto it = first; it != last; ++it) { ... }
1
2
3
4
5
6
7
8

# 4.4 try_emplace 的消除临时对象设计

C++17 的 try_emplace——如果键已存在,不构造值对象:

// ❌ insert——即使键存在也要构造临时 pair
m.insert({"BTC", expensive_object});  // expensive_object 总是被构造

// ✅ try_emplace——键存在时跳过值构造
m.try_emplace("BTC", expensive_args...);  // 只在插入时才构造值
1
2
3
4
5

# 5. 异构查找 Heterogeneous Lookup

# 5.1 标准库默认的陷阱——key 类型必须完全匹配

std::map<std::string, int> m;
auto it = m.find("hello");   // ❌ "hello" → 隐式构造 std::string → 堆分配 5B → 查找 → 释放
1
2

find 的默认声明需要 const Key&——必须和 key 类型完全一致。

# 5.2 C++14 的透明比较器

修复——把比较器从 std::less<Key> 改成 std::less<>:

// ❌ 默认:比较器只知道 string vs string
std::map<std::string, int> m1;                      // Compare = std::less<string>

// ✅ 透明:比较器接受任何可比较的类型
std::map<std::string, int, std::less<>> m2;         // Compare = std::less<>

auto it = m2.find("hello");  // ✅ 直接比较 const char* 和 string——零临时对象
1
2
3
4
5
6
7

std::less<> 的魔法——它是 std::less<void> 的特化,operator() 变成了模板:

template <>
struct less<void> {
    using is_transparent = int;   // ← 标记——告诉容器「我支持异构查找」

    template <typename T, typename U>
    constexpr auto operator()(T&& t, U&& u) const {
        return std::forward<T>(t) < std::forward<U>(u);
    }
};
1
2
3
4
5
6
7
8
9

is_transparent 类型别名是标签——标准库容器检测到这个别名才启用异构查找的 find() 重载。

# 5.3 性能实测对比

查找方式 临时对象分配 单次 find 100 万次总时间
std::less<string> 每次 1 个 string 48 ns 48 ms
std::less<> 0 18 ns 18 ms
差距 — -62% —

# 5.4 自定义异构查找的正确姿势

// 自定义比较器——支持多种类型的异构查找
struct CaseInsensitiveCompare {
    using is_transparent = int;  // ← 关键标记

    bool operator()(std::string_view a, std::string_view b) const {
        return std::lexicographical_compare(
            a.begin(), a.end(), b.begin(), b.end(),
            [](char c1, char c2) { return std::tolower(c1) < std::tolower(c2); }
        );
    }
};

std::map<std::string, int, CaseInsensitiveCompare> m;
auto it = m.find("HELLO");     // ✅ 大小写不敏感——零临时对象
auto it2 = m.find(some_view);  // ✅ string_view——同样零临时对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 6. 节点重用——extract 与 node_type

# 6.1 为什么需要 extract

在 C++17 之前,要把 map<K,V> 的元素搬到另一个 map<K,V>——只能删除 + 重新插入:

// ❌ C++14 的做法——先拷贝再删除
auto it = m1.find(key);
m2.insert(*it);     // 拷贝构造 K 和 V
m1.erase(it);       // 析构原来的
// 代价:一次拷贝构造 + 一次析构
1
2
3
4
5

如果 V 是 std::string——拷贝+析构各一次 heap alloc/dealloc。

# 6.2 node_type 的零拷贝搬移

C++17 引入 node handle——在容器间搬移节点不拷贝 key 和 value:

// ✅ C++17——extract + insert(零拷贝)
auto node = m1.extract(key);     // ① 从 m1 中「拔出」节点——返回 node_handle
if (node) {
    node.key() = new_key;         // ② 可选——修改键
    m2.insert(std::move(node));   // ③ 插入到 m2——零拷贝!
}
// node_handle = unique_ptr 风格的独占节点所有权
1
2
3
4
5
6
7

node_handle 的内部:

// libstdc++ 简化
template <typename...>
class _Node_handle {
    using node_type = _Rb_tree_node<value_type>;
    node_type* _M_node = nullptr;   // 唯一的数据——指向拔出的红黑树节点

public:
    key_type& key() const;          // 访问/修改键
    mapped_type& mapped() const;    // 访问/修改值(仅 map)
    bool empty() const;             // 是否为空
    void swap(node_handle&);        // 交换
    ~_Node_handle();                // 如果不插入——自动释放节点
};
1
2
3
4
5
6
7
8
9
10
11
12
13

# 6.3 splice 风格的容器间搬移

extract + insert 可以实现 list::splice 风格的 O(1) 跨容器搬移——但只适用于 map/multimap(因为它们的底层是相同的节点结构):

std::map<int, std::string> m1 = {{1,"a"},{2,"b"}};
std::map<int, std::string> m2;

// 把 m1 整个搬到 m2——逐个 extract+insert
for (auto it = m1.begin(); it != m1.end(); ) {
    auto node = m1.extract(it++);
    m2.insert(std::move(node));
}
// 每个元素零拷贝——只有红黑树重新平衡的 O(log N)
1
2
3
4
5
6
7
8
9

# 6.4 extract+insert 的汇编对比

// 旧方式:拷贝 + 删除
auto v = *it;
m2.insert(v);
m1.erase(it);

// → 汇编包含:pair 拷贝构造(可能 heap alloc)+ pair 析构(heap free)

// 新方式:extract + insert
auto node = m1.extract(key);
m2.insert(std::move(node));

// → 汇编:仅修改红黑树节点指针——无任何拷贝/析构
1
2
3
4
5
6
7
8
9
10
11
12

# 7. 有序容器的遍历与性能剖析

# 7.1 中序遍历 = 有序输出

std::map<int, std::string> m = {{3,"c"},{1,"a"},{2,"b"}};
for (auto& [k, v] : m) {
    std::cout << k << ":" << v << " ";   // 输出:1:a 2:b 3:c ——自动按 key 升序!
}
1
2
3
4

红黑树的迭代器++ = 中序遍历的下一个节点。这保证了你不需要 std::sort——遍历天然有序。

# 7.2 迭代器失效规则

操作 map/set unordered_map/set
insert ✅ 永不失效 ⚠️ rehash 时全部失效
erase(it) 仅 it 失效 仅 it 失效
extract 仅被提取者失效 仅被提取者失效

红黑树的迭代器是所有容器中最稳定的——仅次于 list。

# 7.3 与 unordered 容器的缓存对比

std::map<int, int>    ordered;
std::unordered_map<int, int> hashed;
// 各插入 10000 个随机键

// 顺序遍历
for (auto& [k,v] : ordered) sum += v;   // 4.2 ms — 红黑树节点散落
for (auto& [k,v] : hashed) sum += v;    // 3.8 ms — 同样散落(但节点小)

// 随机查找
ordered.find(rand());     // 18 ns — ~14 次比较
hashed.find(rand());      // 12 ns — 1 次哈希 + 1 次比较
1
2
3
4
5
6
7
8
9
10
11

# 8. multi 系列容器的设计差异

# 8.1 multimap / multiset 的等价键处理

std::multimap<int, std::string> mm;
mm.insert({1, "a"});
mm.insert({1, "b"});    // ✅ 允许——两个键为 1 的元素
mm.insert({1, "c"});

// insert 永远成功(不返回 bool——因为不检查重复)
auto it = mm.insert({1, "d"});   // 返回 iterator(不是 pair)

// 遍历所有键为 1 的元素
auto [first, last] = mm.equal_range(1);
for (auto it = first; it != last; ++it) {
    std::cout << it->second << " ";  // a b c d
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 8.2 为什么 multiset 没有 operator[]

map<K,V>::operator[] 依赖「键是唯一的」——不唯一时「该返回哪个值?」没有答案。multimap/ multiset 根本没有 operator[]。

# 8.3 equal_range 的用法

// 统计某键的出现次数(multimap)
size_t count = mm.count(key);  // O(log N + count)

// 遍历某键的所有值
for (auto it = mm.lower_bound(key); it != mm.upper_bound(key); ++it) { ... }
// 等价于:for (auto [first,last] = mm.equal_range(key); first != last; ++first)
1
2
3
4
5
6

# 9. 汇编全景与性能剖析

# 9.1 find 的二分查找指令序列

auto it = m.find(42);
1

GCC 13.2 -O2 简化:

; map::find(42) — 红黑树二分搜索
.Lsearch:
    cmp   DWORD [rax+32], 42      ; node->key 与 42 比较(偏移 32 = 3指针+color+padding)
    je    .Lfound
    jl    .Lgo_right               ; key < 42 → 走右子树
.Lgo_left:
    mov   rax, [rax+8]             ; node = node->_M_left
    test  rax, rax
    jne   .Lsearch
    ret                            ; 未找到
.Lgo_right:
    mov   rax, [rax+16]            ; node = node->_M_right
    test  rax, rax
    jne   .Lsearch
1
2
3
4
5
6
7
8
9
10
11
12
13
14

每条路径 4-5 条指令——深度 O(log N) 条。N=500000 → 深度 ~19 → 约 80-95 条指令。

# 9.2 insert 的固定流程

m.insert({42, "hello"});
1
; ① find 插入位置(和 find 一样的二分搜索)
; ② 如果键已存在 → 返回(不修改)
; ③ 否则 → 分配新节点 → 构造 pair → 链接到树中
;    → 颜色修复(最多 2 次旋转)
1
2
3
4

# 9.3 三场景 benchmark

操作 map<int,int> unordered_map<int,int> 说明
insert 100K 12 ms 8 ms 哈希表少一次颜色修复
find 100K 命中 3.2 ms 2.1 ms 整数哈希快
find 100K 未命中 5.1 ms 1.8 ms 红黑树走到叶子
erase 100K 9 ms 5 ms —
遍历 4.2 ms 3.8 ms 均散落——差别不大

结论:整数 key + 大 N → unordered_map。字符串 key + 中等 N (<1000) → map 可能更优(哈希开销大)。


# 10. 综合案例串讲

# 10.1 案例真相揭晓

# 疑问 答案
① 红黑树 vs AVL? 第 2/3 章:红黑树写性能优 15-25%、插入最多 2 次旋转;STL 历史选型
② operator[] vs insert? 第 4 章:operator[] 会插入默认值、insert 返回 bool、try_emplace 省临时对象
③ 异构查找? 第 5 章:std::less<> + is_transparent 标签——零临时对象、-62% 时间
④ extract/node_type? 第 6 章:C++17 零拷贝搬移——splice 风格跨容器转移
⑤ ordered vs unordered? 第 7/9 章:N<1000 且 key 哈希贵 → map 可能更好;N>10000 → unordered_map
⑥ multi 系列? 第 8 章:允许重复键、无 operator[]、equal_range 遍历等价键
⑦ lower/upper_bound? 第 4.3/8.3 章:lower=首个≥, upper=首个>, equal_range={lb,ub}

案例①修复(盲目换 unordered_map):先 benchmark——500 个字符串键、find 为主 → map 更快(少哈希开销)。

案例②修复(临时 string 分配):std::set<std::string, std::less<>> → 临时对象从 1M 降到 0。

# 10.2 一次 map::find 的完整旅程

auto it = m.find("ETH-USD");

═══════ 编译期 ═══════
  ① 重载决议:std::less&lt;> 有 is_transparent → 调用模板版 find
  ② "ETH-USD" 类型是 const char(&amp;)[8]——不需要转换成 string

═══════ 运行期 ═══════
  ③ 从根节点开始:比较 "ETH-USD" 和当前节点 key
      → 逐个字符比较——多数键首位就不同 → 极速
  ④ 沿着左/右子指针下树——O(log N) 步
  ⑤ 找到→返回迭代器;找不到→返回 end()
  ⑥ 全程零堆分配(异构查找!!!)
1
2
3
4
5
6
7
8
9
10
11
12

# 10.3 设计哲学回扣

哲学 1:复杂度 ≠ 速度——O(1) 和 O(log N) 之间的常数因子会决定胜负

哈希函数的常数因子(对字符串 ~25ns)可以压倒 O(log N) 树在中等 N 下的优势。渐进复杂度是望远镜——告诉你 N→∞ 时的趋势;benchmark 是真锤子——告诉你 N=你的数据量时的实际性能。选容器必须同时看两者。 对于 N=500 的字符串键集合,map 的 ~19 次廉价字符串比较(~9ns)完胜 unordered_map 的 1 次昂贵哈希计算(~25ns)。

哲学 2:红黑树的插入最多 2 次旋转是写密集型负载上的胜利

自平衡树的根本权衡是「读速度和写速度」。AVL 追求极致平衡(查找略快),红黑树追求松弛平衡(插入远快)。STL 选择红黑树意味着「通用容器应该对写友好」——因为插入/删除是动态集合的核心操作。

哲学 3:extract 给节点独立性——容器不再是节点的唯一归宿

C++17 的 node_handle 让红黑树节点拥有了独立于容器的身份——可以被提取、修改键、然后插入另一个容器,全程零拷贝。这和 list::splice 共享同一哲学:节点是物理实体,容器的链表/树只是逻辑组织。改变组织不应该改变实体。

哲学 4:异构查找把类型系统和性能合为一体

std::less<> 的 is_transparent 标签看似是类型系统的微调——实则是性能武器。它让 map<string, V> 的 find("literal") 从「隐式构造 string + 查找 + 析构」变成「直接比较」。好的泛型设计不是在 API 层暴露更多模板——是在零 API 变化的前提下让类型系统替你消去临时对象。

哈希函数的常数因子(对字符串 ~25ns)可以压倒 O(log N) 树在中等 N 下的优势。渐进复杂度是远望镜,benchmark 是真锤子——用前者选容器方向,用后者做最终决定。

哲学 2:写平衡与读速度的权衡——红黑树是 STL 的折中答案

红黑树的宽松平衡条件给了插入 O(1) 旋转的机会——这是 STL 设计者面对「通用有序容器」需求时的最优解。通用意味着无法预知读写比——红黑树在两个极端之间站住了。

哲学 3:extract 给共享节点开了门——但保留所有权安全

node_handle 是独占所有权——同一时刻只有一个容器持有同一节点。这和 unique_ptr 的哲学一致:搬移不拷贝,所有权不分裂。

哲学 4:异构查找把语言类型系统转化成了性能武器

std::less<> 的 is_transparent 标签让容器 API 从「按 key 类型查」变成「按可比较的任意类型查」。这体现了 C++14 的一个趋势:不强迫你用精确类型——只要逻辑上可比较,编译器就该允许。

# 10.4 速查表合集

关联容器选型:

需求 推荐
有序 + O(log N) 查找 map / set
无序 + O(1) 平均查找 unordered_map / set
允许重复键 multimap / multiset
多键有序查找 multimap
需要顺序遍历按键排序 map / set
零临时对象查找(C++14) map<K,V,std::less<>>

红黑树操作复杂度:

操作 复杂度 迭代器失效
find O(log N) ❌
insert O(log N) ❌
erase O(log N) 仅被删者
extract O(log N) 仅被提者
operator[] O(log N) ❌

下一篇:有序和有序说清了。下一篇进入 36.哈希容器深度剖析——unordered_map 的拉链法与开放寻址、load_factor 与 rehash、哈希函数定制、Abseil flat_hash_map 为什么比标准 unordered_map 快 3×。

上次更新: 2026/06/10, 11:13:41
list与forward_list
哈希容器深度剖析

← list与forward_list 哈希容器深度剖析→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式