C++ STL 关联式容器:map、set 与红黑树原理

C++ STL 关联式容器:map、set 与红黑树原理
Lichermione前言
vector、list 和 deque 等容器主要按照元素的位置组织数据,属于序列式容器。map 和 set 则按照关键字组织数据,能够根据关键字快速完成查找、插入和删除,属于关联式容器。
本文将从 pair 开始,依次介绍 set、map、multiset 和 multimap 的使用,并继续分析它们与二叉搜索树、AVL 树和红黑树之间的关系。
本文主要内容如下:
- 序列式容器与关联式容器的区别;
std::pair键值对;set、map、multiset和multimap的接口;map::operator[]的底层逻辑;- 自定义比较器与严格弱序;
- AVL 树的平衡因子与四种旋转;
- 红黑树的性质、插入调整与复杂度;
map和set如何复用同一棵红黑树;- 常见错误与面试问题。
一、关联式容器
1.1 序列式容器
序列式容器使用线性结构组织元素,元素的位置具有明确的先后关系。
常见序列式容器包括:
std::vector;std::list;std::deque;std::forward_list。
例如,vector 中的第 i 个元素由位置决定。查找某个值时,如果数据没有额外的顺序信息,通常需要逐个比较,时间复杂度为 O(n)。
1.2 关联式容器
关联式容器按照关键字组织数据,而不是按照插入位置组织数据。
树形结构的关联式容器包括:
std::set;std::map;std::multiset;std::multimap。
这些容器通常使用红黑树实现,具有以下共同特点:
- 元素始终按照关键字有序排列;
- 查找、插入和删除的时间复杂度通常为
O(log n); - 迭代器按照关键字顺序遍历;
- 不支持像
vector那样通过整数下标随机访问。
1.3 有序容器与无序容器
STL 还提供了哈希结构的关联式容器:
std::unordered_set;std::unordered_map;std::unordered_multiset;std::unordered_multimap。
两类容器的主要区别如下:
| 对比项 | map / set |
unordered_map / unordered_set |
|---|---|---|
| 常见底层结构 | 红黑树 | 哈希表 |
| 元素是否有序 | 有序 | 通常无序 |
| 平均查找复杂度 | O(log n) |
O(1) |
| 最坏查找复杂度 | O(log n) |
O(n) |
| 范围查询 | 支持 | 不擅长 |
| 自定义规则 | 比较器 | 哈希函数与相等判断 |
如果需要有序遍历、范围查找或稳定的最坏复杂度,可以优先考虑 map 和 set。如果只关心平均查找速度,通常可以考虑无序容器。
二、键值对 pair
2.1 pair 的作用
std::pair 用于将两个值组合成一个整体:
1 | template<class T1, class T2> |
其中:
first保存第一个值;second保存第二个值。
例如,可以使用键值对表示英文单词和中文含义:
1 | std::pair<std::string, std::string> word( |
2.2 pair 的构造方式
1 |
|
在 C++17 中还可以使用结构化绑定:
1 | auto [name, count] = p3; |
2.3 map 中的 value_type
map<Key, T> 中保存的元素类型并不是简单的 pair<Key, T>,而是:
1 | std::pair<const Key, T> |
也就是说:
first是只读的键;second是可以修改的映射值。
这样设计是为了防止用户直接修改键,从而破坏红黑树中的排列顺序。
三、set
3.1 set 的特点
set 用于保存互不重复的关键字:
1 |
|
它具有以下特点:
- 元素唯一;
- 元素按照比较规则有序排列;
- 默认使用
std::less<Key>,即升序排列; - 查找、插入和删除通常为
O(log n); - 迭代器指向的元素不能被修改;
- 常见底层实现是红黑树。
从标准接口角度看,set<Key> 的 value_type 就是 Key。某些源码讲解会把 set 抽象成 <value, value>,用于说明其键和值相同,但这不是标准要求的实际元素类型。
3.2 模板参数
set 的模板参数可以简化理解为:
1 | template< |
各参数含义如下:
| 参数 | 含义 |
|---|---|
Key |
元素类型 |
Compare |
排序规则 |
Allocator |
内存分配器 |
大多数情况下只需要指定 Key。
3.3 set 的构造
1 |
|
s2 的最终内容为:
1 | 1 2 3 5 |
重复的 3 只会保留一次,元素也会自动排序。
3.4 set 的遍历
1 | for (int value : s2) |
正向迭代器得到升序序列,反向迭代器得到降序序列。
3.5 为什么 set 中的元素不能修改
假设树中保存:
1 | 1 3 5 |
如果通过迭代器将 3 直接修改为 100,节点仍然停留在原来的树结构中,但它与左右子树之间的大小关系已经被破坏。
因此,下面的代码无法通过编译:
1 | std::set<int> values{1, 3, 5}; |
如果需要修改元素,必须先删除旧值,再插入新值:
1 | values.erase(3); |
3.6 insert 的返回值
set::insert 的常用重载返回:
1 | std::pair<iterator, bool> |
first:指向目标元素;second:表示本次是否真正插入成功。
1 | std::set<int> values; |
第二次插入失败,但返回的迭代器仍然指向已经存在的 10。
3.7 查找与统计
1 | auto it = values.find(10); |
对于 set:
find找到时返回元素迭代器,否则返回end();count只能返回0或1。
如果只想判断是否存在,C++20 可以使用:
1 | if (values.contains(10)) |
3.8 删除操作
1 | std::set<int> values{1, 2, 3, 4, 5}; |
按键删除时,返回被删除元素的数量。对于 set,返回值只能是 0 或 1。
3.9 范围查询
有序关联式容器支持高效范围查询:
| 接口 | 含义 |
|---|---|
lower_bound(key) |
第一个不小于 key 的位置 |
upper_bound(key) |
第一个大于 key 的位置 |
equal_range(key) |
同时返回上述两个边界 |
例如,输出 [3, 7] 范围内的元素:
1 | std::set<int> values{1, 3, 4, 6, 7, 9}; |
输出:
1 | 3 4 6 7 |
用法总结
1 |
|
总结:
1.插入的时候,迭代器的类型。
2.查找的时候,注意和end进行判断
3.set底层是红黑树的
四、map
4.1 map 的特点
map 用于保存互不重复的键值对:
1 |
|
它具有以下特点:
- 元素类型为
pair<const Key, T>; - 键唯一,映射值可以重复;
- 元素按照键排序;
- 键不能修改,映射值可以修改;
- 查找、插入和删除通常为
O(log n); - 支持
operator[]; - 常见底层实现是红黑树。
4.2 模板参数
1 | template< |
| 参数 | 含义 |
|---|---|
Key |
键的类型 |
T |
映射值的类型 |
Compare |
键的排序规则 |
Allocator |
内存分配器 |
红黑树只根据键进行比较,不会根据映射值决定节点位置。
4.3 map 的插入
1 | std::map<std::string, std::string> dictionary; |
插入相同键时,insert 不会覆盖原来的映射值:
1 | auto result = |
4.4 map 的遍历
1 | for (const auto& entry : dictionary) |
遍历顺序由键决定,与元素的插入顺序无关。
在 C++17 中可以使用结构化绑定:
1 | for (const auto& [key, value] : dictionary) |
4.5 operator[] 的行为
map::operator[] 不只是查找操作。
它的逻辑可以近似理解为:
1 | mapped_type& operator[](const key_type& key) |
执行过程如下:
- 使用
key和映射类型的默认值构造键值对; - 尝试将键值对插入
map; - 键存在时,插入失败并获得已有元素的迭代器;
- 键不存在时,插入默认值并获得新元素的迭代器;
- 返回映射值
second的引用。
因此:
1 | std::map<std::string, int> counts; |
虽然只是读取 counts["apple"],但 "apple" 不存在,因此容器中插入了 {"apple", 0}。
4.6 operator[] 的三种用途
插入
1 | dictionary["orange"] = "橙子"; |
查找
1 | std::cout << dictionary["apple"] << '\n'; |
但这种写法可能产生插入副作用。
修改
1 | dictionary["apple"] = "苹果(已更新)"; |
4.7 operator[]、at 和 find 的区别
| 接口 | 键存在 | 键不存在 | 是否可能修改容器 |
|---|---|---|---|
operator[] |
返回映射值引用 | 插入默认值并返回引用 | 是 |
at |
返回映射值引用 | 抛出 std::out_of_range |
否 |
find |
返回迭代器 | 返回 end() |
否 |
如果只是检查数据,不希望容器发生变化,优先使用 find:
1 | auto it = dictionary.find("pear"); |
如果键必须存在,可以使用 at:
1 | try |
4.8 使用 map 统计次数
operator[] 的默认插入行为非常适合计数:
1 | std::vector<std::string> words{ |
第一次遇到单词时,counts[word] 会插入值 0,然后再执行自增。
输出:
1 | apple : 3 |
4.9 如何更新已经存在的值
insert 遇到重复键时不会更新。可以使用以下方式:
1 | dictionary["apple"] = "新含义"; |
C++17 还提供:
1 | dictionary.insert_or_assign("apple", "新含义"); |
两者区别如下:
insert_or_assign:键存在时进行赋值;try_emplace:键存在时不会构造新的映射值,更适合构造代价较高的对象。
4.10 map 中哪些内容可以修改
1 | auto it = dictionary.find("apple"); |
键决定红黑树中的位置,因此不能直接修改;映射值不参与排序,可以安全修改。
用法总结
1 |
|
五、multiset 与 multimap
5.1 multiset
multiset 与 set 的主要区别是:multiset 允许关键字重复。
1 | std::multiset<int> values{ |
输出:
1 | 1 2 2 3 3 3 |
1 | std::cout << values.count(3) << '\n'; // 3 |
对于 multiset,count 不再局限于 0 和 1。
5.2 获取所有相同元素
1 | auto range = values.equal_range(3); |
还可以分别使用:
1 | auto first = values.lower_bound(3); |
5.3 multimap
multimap 允许多个键值对拥有相同的键:
1 | std::multimap<std::string, std::string> contacts; |
查找技术部的所有员工:
1 | auto range = contacts.equal_range("技术部"); |
5.4 multimap 为什么没有 operator[]
假设 multimap 中存在:
1 | <技术部, 张三> |
此时执行:
1 | contacts["技术部"] |
容器无法确定应该返回哪一个映射值的引用,也无法确定用户是想访问已有元素还是继续插入一个同键元素。
因此,multimap 不提供 operator[]。
5.5 四种容器对比
| 容器 | 数据形式 | 键是否唯一 | 是否有 operator[] |
|---|---|---|---|
set |
Key |
是 | 否 |
multiset |
Key |
否 | 否 |
map |
pair<const Key, T> |
是 | 是 |
multimap |
pair<const Key, T> |
否 | 否 |
四种容器都按照键有序排列,常见底层实现都是红黑树。
六、自定义比较器
6.1 默认比较器
下面两个声明等价:
1 | std::set<int> s1; |
std::less<int> 按照升序排列。
如果希望降序排列,可以使用:
1 | std::set<int, std::greater<int>> values{ |
6.2 仿函数比较器
1 | struct Student |
此时学生按照 id 升序排列。
6.3 严格弱序
关联式容器要求比较器满足严格弱序。最基本的要求包括:
comp(x, x)必须为false;- 如果
comp(a, b)为true,则comp(b, a)必须为false; - 比较关系必须具有传递性。
错误示例:
1 | struct BadCompare |
当 left == right 时,left <= right 为 true,违反了严格弱序,容器行为将不再可靠。
正确写法:
1 | return left < right; |
6.4 容器如何判断两个键相等
树形关联式容器通常不要求键提供 operator==。
在比较器 comp 下,如果:
1 | !comp(a, b) && !comp(b, a) |
容器就认为 a 和 b 等价。
这意味着在上面的学生集合中,只要两个学生的 id 相同,容器就认为它们是重复键,即使 name 不同。
七、map 与 set 综合示例
下面的程序集中演示去重、计数、范围查询和一键多值。
1 |
|
运行结果:
1 | 去重并排序:1 2 3 4 5 |
八、经典题目:前 K 个高频单词
对应题目:LeetCode 692,Top K Frequent Words。
题目要求:
- 按照出现次数从高到低排列;
- 次数相同时,按照单词字典序升序排列。
可以先使用 map 统计次数,再使用带自定义比较器的 set 排序。
1 | class Solution |
设输入单词总数为 n,不同单词数量为 m:
- 使用
map统计:O(n log m); - 将
m个键值对插入set:O(m log m); - 总空间复杂度:
O(m)。
这不是该题复杂度最低的解法,但非常适合练习 map、set、键值对和自定义比较器。
九、为什么不能直接使用普通二叉搜索树
普通二叉搜索树的查找、插入和删除复杂度取决于树高 h:
1 | O(h) |
当数据随机且树接近平衡时:
1 | h ≈ log₂n |
但如果按照升序插入:
1 | 1 2 3 4 5 6 7 |
普通二叉搜索树会退化为单支树,树高变成 n,操作复杂度退化为 O(n)。
为了保证稳定的查找效率,必须在插入和删除过程中主动维护树的平衡。
常见平衡搜索树包括:
- AVL 树;
- 红黑树。
十、AVL 树
10.1 AVL 树的概念
AVL 树首先是一棵二叉搜索树,同时还满足:
- 左子树和右子树都是 AVL 树;
- 任意节点左右子树高度之差的绝对值不超过
1。
如果规定平衡因子为:
1 | bf = 右子树高度 - 左子树高度 |
那么 AVL 树中每个节点的平衡因子只能是:
1 | -1、0、1 |
10.2 AVL 节点
1 | template<class T> |
保存父指针可以让插入后的平衡因子更新和旋转连接更加方便。
10.3 AVL 树的插入过程
AVL 树的插入分为两步:
- 按照普通二叉搜索树的规则插入节点;
- 从新节点的父节点开始向上更新平衡因子。
假设新节点为 cur,父节点为 parent:
- 插入到父节点左侧:
parent->bf--; - 插入到父节点右侧:
parent->bf++。
更新后的平衡因子有三种情况:
| 更新结果 | 含义 | 后续处理 |
|---|---|---|
bf == 0 |
子树高度没有增加 | 停止向上更新 |
bf == 1 或 bf == -1 |
子树高度增加但仍平衡 | 继续向上更新 |
bf == 2 或 bf == -2 |
当前子树失衡 | 旋转调整 |
10.4 AVL 树的四种旋转
| 失衡类型 | 插入位置 | 调整方式 |
|---|---|---|
| LL | 较高左子树的左侧 | 右单旋 |
| RR | 较高右子树的右侧 | 左单旋 |
| LR | 较高左子树的右侧 | 先左旋,再右旋 |
| RL | 较高右子树的左侧 | 先右旋,再左旋 |
10.5 LL:右单旋
插入路径为“左、左”时,进行右单旋:
1 | parent subL |
关键步骤如下:
subL的右子树交给parent作为左子树;parent成为subL的右孩子;- 重新连接原来的祖父节点;
- 更新父指针和平衡因子。
10.6 RR:左单旋
RR 与 LL 对称:
1 | parent subR |
10.7 LR:先左旋再右旋
插入发生在左孩子的右子树中:
- 先对左孩子进行左旋,将 LR 转换为 LL;
- 再对失衡节点进行右旋。
10.8 RL:先右旋再左旋
插入发生在右孩子的左子树中:
- 先对右孩子进行右旋,将 RL 转换为 RR;
- 再对失衡节点进行左旋。
10.9 AVL 树的验证
验证一棵树是否为 AVL 树,需要同时检查:
- 中序遍历是否严格有序;
- 每个节点左右子树高度差是否不超过
1; - 保存的平衡因子是否与实际高度差一致。
1 | int Height(Node* root) |
这段验证代码会重复计算子树高度,最坏时间复杂度为 O(n²)。如果在一次后序遍历中同时返回高度和验证结果,可以优化到 O(n)。
10.10 AVL 树的性能
AVL 树的高度始终为 O(log n),因此:
- 查找:
O(log n); - 插入:
O(log n); - 删除:
O(log n)。
AVL 树追求严格平衡,查找效率很高,但插入和删除时可能需要更多的平衡维护。
十一、红黑树
11.1 红黑树的概念
红黑树是一棵近似平衡的二叉搜索树。每个节点额外保存一种颜色:
1 | enum class Color |
红黑树不像 AVL 树那样要求左右子树高度差不超过 1,而是通过颜色约束保证最长路径不会超过最短路径的两倍。
11.2 红黑树的五条性质
- 每个节点不是红色就是黑色;
- 根节点是黑色;
- 红色节点的孩子必须是黑色,即不能出现连续红色节点;
- 从任意节点出发,到所有后代空节点的路径包含相同数量的黑色节点;
- 所有空节点
NIL都视为黑色。
第四条性质中的黑色节点数量通常称为黑高。
11.3 为什么最长路径不超过最短路径的两倍
假设从某个节点到空节点的每条路径都包含 bh 个黑色节点:
- 最短路径可以全部由黑色节点组成,长度约为
bh; - 最长路径可以在每两个黑色节点之间插入一个红色节点;
- 因为不能出现连续红色节点,所以最长路径至多约为
2bh。
因此:
1 | 最长路径长度 <= 2 × 最短路径长度 |
红黑树的高度始终为 O(log n)。
11.4 红黑树节点
1 | template<class T> |
11.5 为什么新节点默认插入为红色
如果新节点插入为黑色,那么经过新节点的所有路径都会增加一个黑色节点,立即破坏“每条路径黑高相同”的性质,调整范围可能很大。
如果新节点插入为红色:
- 不会改变任何路径的黑高;
- 只有父节点也是红色时,才会出现连续红色冲突。
因此,将新节点默认设为红色,通常需要处理的问题更局部。
如果插入的是根节点,最后再将根节点改为黑色。
11.6 红黑树插入的角色
插入调整中常用四个指针:
cur:当前节点;parent:父节点;grandparent:祖父节点;uncle:叔叔节点。
只有当 parent 为红色时才需要调整,因为这违反了不能出现连续红色节点的性质。
11.7 情况一:叔叔节点为红色
已知:
cur为红色;parent为红色;grandparent为黑色;uncle存在且为红色。
处理方式:
- 将
parent和uncle改为黑色; - 将
grandparent改为红色; - 将
grandparent作为新的cur; - 继续向上检查。
如果 grandparent 最终成为根节点,需要把根重新改为黑色。
11.8 情况二:叔叔为黑色,节点在外侧
以父节点是祖父节点左孩子为例:
parent是grandparent的左孩子;cur是parent的左孩子;uncle不存在或为黑色。
这是 LL 结构,处理方式为:
- 将
parent改为黑色; - 将
grandparent改为红色; - 对
grandparent进行右单旋。
右侧 RR 情况与之对称,需要进行左单旋。
11.9 情况三:叔叔为黑色,节点在内侧
以 LR 结构为例:
parent是grandparent的左孩子;cur是parent的右孩子。
处理方式:
- 先对
parent左旋; - 将结构转换为 LL;
- 再按照情况二处理。
RL 情况与之对称。
11.10 红黑树插入调整总结
| 父节点位置 | 当前节点位置 | 叔叔颜色 | 操作 |
|---|---|---|---|
| 左 | 左 | 黑或不存在 | 祖父右旋,父黑祖父红 |
| 左 | 右 | 黑或不存在 | 父左旋,再按 LL 处理 |
| 右 | 右 | 黑或不存在 | 祖父左旋,父黑祖父红 |
| 右 | 左 | 黑或不存在 | 父右旋,再按 RR 处理 |
| 任意 | 任意 | 红 | 父叔变黑,祖父变红,继续向上 |
调整结束后,必须确保根节点为黑色。
11.11 红黑树的验证
验证过程包括:
- 根节点是否为黑色;
- 中序遍历是否有序;
- 是否存在连续红色节点;
- 每条根到空节点的路径黑高是否相同。
下面展示验证红色冲突和黑高的核心逻辑:
1 | bool Check( |
实际验证时,可以先沿一条路径计算基准黑高,再调用递归函数检查所有路径。
十二、AVL 树与红黑树对比
| 对比项 | AVL 树 | 红黑树 |
|---|---|---|
| 平衡程度 | 严格平衡 | 近似平衡 |
| 高度 | 通常更低 | 可能略高 |
| 查找性能 | 通常略好 | 稳定高效 |
| 插入、删除调整 | 可能更频繁 | 通常更少 |
| 约束方式 | 平衡因子 | 颜色和黑高 |
| 典型用途 | 查询密集场景 | 增删查综合场景 |
两者的查找、插入和删除复杂度都是 O(log n)。
STL 中的 map 和 set 需要同时兼顾查找、插入、删除和迭代器稳定性,因此常见实现选择红黑树。
需要注意,C++ 标准规定的是接口语义和复杂度,不强制某个实现必须使用红黑树;只是主流标准库通常采用红黑树。
十三、红黑树如何同时实现 map 和 set
13.1 核心问题
set 保存:
1 | Key |
map 保存:
1 | std::pair<const Key, Value> |
两者的节点值类型不同,但查找和排序时都只需要获得键。
因此,可以将红黑树抽象成:
1 | template< |
三个模板参数分别表示:
Key:键的类型;ValueType:节点实际保存的类型;KeyOfValue:从节点值中提取键的仿函数。
13.2 set 的键提取器
set 的节点值本身就是键:
1 | template<class Key> |
红黑树类型可以表示为:
1 | RBTree<Key, Key, SetKeyOfValue<Key>> |
13.3 map 的键提取器
map 从键值对的 first 中提取键:
1 | template<class Key, class Value> |
红黑树类型可以表示为:
1 | RBTree< |
这样,map 和 set 就可以复用同一套红黑树的插入、删除、查找和迭代器逻辑。
十四、红黑树迭代器原理
14.1 begin 和 end
对红黑树进行中序遍历可以得到有序序列,因此:
begin()指向最左侧节点,也就是最小元素;end()指向一个不保存有效元素的哨兵节点。
不能简单地让 end() 等于 nullptr,因为双向迭代器需要支持:
1 | --container.end() |
结果应当指向容器中的最后一个元素。
常见实现会增加一个头哨兵节点:
- 哨兵的
parent指向根节点; - 哨兵的
left指向最小节点; - 哨兵的
right指向最大节点; end()指向哨兵。
14.2 operator++ 的原理
寻找当前节点在中序遍历中的后继:
- 如果存在右子树,后继是右子树的最左节点;
- 如果不存在右子树,向上查找;
- 找到第一个“当前节点位于其左子树”的祖先;
- 该祖先就是后继。
核心逻辑:
1 | void Increment() |
完整实现还要结合头哨兵处理边界。
14.3 operator– 的原理
寻找当前节点的中序前驱:
- 如果当前是
end(),直接移动到最大节点; - 如果存在左子树,前驱是左子树的最右节点;
- 如果不存在左子树,向上查找;
- 找到第一个“当前节点位于其右子树”的祖先。
operator++ 和 operator-- 互为对称操作。
十五、复杂度与迭代器失效
15.1 常用接口复杂度
| 操作 | map / set |
multimap / multiset |
|---|---|---|
find |
O(log n) |
O(log n) |
insert |
O(log n) |
O(log n) |
按迭代器 erase |
均摊 O(1) |
均摊 O(1) |
按键 erase |
O(log n) |
O(log n + k) |
lower_bound |
O(log n) |
O(log n) |
upper_bound |
O(log n) |
O(log n) |
count |
O(log n) |
O(log n + k) |
其中 k 表示与目标键等价的元素数量。
15.2 迭代器失效规则
对于树形关联式容器:
- 插入元素通常不会使已有迭代器失效;
- 删除某个元素,只会使指向该元素的迭代器失效;
- 其他元素的迭代器通常仍然有效。
安全的遍历删除方式:
1 | for (auto it = values.begin(); it != values.end();) |
不能在删除后继续使用原来的失效迭代器。
十六、常见错误
16.1 使用 operator[] 判断键是否存在
错误写法:
1 | if (counts["apple"] != 0) |
当键不存在时,这段代码会插入 "apple"。
正确做法:
1 | if (counts.find("apple") != counts.end()) |
16.2 认为 insert 会覆盖旧值
1 | std::map<std::string, int> scores; |
最终仍然是 80。
需要覆盖时使用:
1 | scores["Tom"] = 100; |
16.3 直接修改 set 元素或 map 的键
这两种操作都会破坏红黑树排序,因此标准接口禁止修改。
16.4 比较器使用小于等于
<= 不满足严格弱序,必须使用严格比较关系。
16.5 忘记检查 find 的返回值
错误写法:
1 | auto it = scores.find("Alice"); |
当键不存在时,it == scores.end(),解引用会产生未定义行为。
16.6 混淆 map 和 multimap
如果同一个键需要保存多个映射值,应使用 multimap,或者使用:
1 | std::map<Key, std::vector<Value>> |
两者的使用方式和数据语义不同,需要根据业务需求选择。
十七、常见面试问题
17.1 map 和 set 的区别是什么?
set 只保存唯一键,主要用于查找、去重和有序集合;map 保存唯一键与映射值,用于建立映射关系。
17.2 map 和 unordered_map 的区别是什么?
map 通常使用红黑树,元素有序,操作复杂度为 O(log n);unordered_map 使用哈希表,元素通常无序,平均操作复杂度为 O(1),最坏为 O(n)。
17.3 为什么 map 的 key 不能修改?
键决定节点在红黑树中的位置。直接修改键会导致树的结构与排序规则不一致。
17.4 map 的 operator[] 有什么副作用?
当键不存在时,它会插入一个使用默认映射值构造的新元素,因此不能把它当成完全只读的查找接口。
17.5 map 中的元素类型是什么?
1 | std::pair<const Key, T> |
其中键为 const,映射值可以修改。
17.6 为什么 multimap 没有 operator[]?
因为同一个键可以对应多个映射值,无法确定应该返回哪一个值的引用。
17.7 为什么 set 的迭代器不能修改元素?
set 的元素本身就是排序键,修改它会破坏红黑树的有序性。
17.8 红黑树为什么比普通二叉搜索树稳定?
红黑树通过颜色和黑高约束限制树高,保证高度为 O(log n),不会因为有序插入而退化成单支树。
17.9 AVL 树和红黑树有什么区别?
AVL 树更加平衡,查询通常更快;红黑树调整更宽松,插入和删除通常需要更少旋转,更适合综合增删查场景。
17.10 为什么红黑树新节点默认是红色?
红色节点不会增加路径黑高,只可能产生局部的连续红色冲突;如果默认插入黑色节点,会直接改变相关路径的黑高,调整更加困难。
17.11 map 和 set 的底层一定是红黑树吗?
不一定。C++ 标准没有强制具体底层结构,只规定容器行为和复杂度。主流标准库通常使用红黑树实现。
17.12 如何利用同一棵红黑树实现 map 和 set?
让红黑树接收节点值类型和键提取器:
set的节点值就是键;map的节点值是键值对,从first中提取键。
这样两种容器可以复用红黑树的绝大部分实现。
十八、总结
本文需要重点掌握以下内容:
map和set属于有序关联式容器;set保存唯一键,map保存唯一键与映射值;multiset和multimap允许键重复;map的元素类型是pair<const Key, T>;set元素和map的键不能修改;map::operator[]在键不存在时会插入默认值;find和at不会因为键不存在而插入元素;- 有序关联式容器支持
lower_bound、upper_bound和范围查询; - 自定义比较器必须满足严格弱序;
- 普通二叉搜索树可能退化,AVL 树和红黑树通过平衡约束控制树高;
- AVL 树严格平衡,红黑树近似平衡;
- 红黑树插入的核心操作是变色和旋转;
- 红黑树新节点默认设为红色,可以避免直接改变路径黑高;
map和set可以通过节点值类型与键提取器复用同一棵红黑树;- 掌握这些内容有助于深入理解 STL 关联式容器、迭代器和泛型设计。