C++ STL之unordered_map详解:从使用到底层,再到面试八股
本文面向面试和日常开发,先讲调用,再讲原理,最后给口语化面试答案。阅读前建议先看完 map详解,本文大量对比两者的差异。
一、用法速查
1.1 基本操作
cpp
#include <unordered_map>
unordered_map<string, int> mp;
// 插入
mp["apple"] = 1; // 下标
mp.insert({"banana", 2}); // 花括号
mp.emplace("cherry", 3); // 原地构造
// 查找
auto it = mp.find("apple"); // 返回迭代器,未找到返回 end()
if (mp.count("apple")) { } // 存在返回1
if (mp.contains("apple")) { } // C++20
// 删除
mp.erase("apple"); // 按键删除
mp.erase(it); // 按迭代器删除
// 遍历(无序!顺序与插入无关)
for (auto &[k, v] : mp)
cout << k << " -> " << v << "\n";
1.2 常用函数速查
| 方法 | 含义 | 复杂度 |
|---|---|---|
mp[key] |
访问/插入,不存在则创建默认值 | O(1) 平均 |
mp.insert({k,v}) |
插入 | O(1) 平均 |
mp.emplace(k,v) |
原地构造插入 | O(1) 平均 |
mp.find(key) |
查找 | O(1) 平均 |
mp.count/contains(key) |
判断存在 | O(1) 平均 |
mp.erase(key) |
删除 | O(1) 平均 |
mp.bucket_count() |
桶的数量 | O(1) |
mp.load_factor() |
负载因子 = size/bucket_count | O(1) |
mp.max_load_factor() |
获取/设置最大负载因子 | O(1) |
mp.rehash(n) |
设置桶数量至少为 n | O(N) |
mp.reserve(n) |
预留至少能存 n 个元素的空间 | O(N) |
1.3 自定义哈希函数
cpp
struct Point { int x, y; };
// 方法1:自定义哈希仿函数
struct PointHash {
size_t operator()(const Point &p) const {
return hash<int>()(p.x) ^ (hash<int>()(p.y) << 1);
}
};
// 别忘了还要定义相等比较
struct PointEqual {
bool operator()(const Point &a, const Point &b) const {
return a.x == b.x && a.y == b.y;
}
};
unordered_map<Point, string, PointHash, PointEqual> mp;
// 方法2:特化 std::hash(全局生效)
namespace std {
template<> struct hash<Point> {
size_t operator()(const Point &p) const {
return hash<int>()(p.x) ^ (hash<int>()(p.y) << 1);
}
};
}
// 还得定义 operator==
bool operator==(const Point &a, const Point &b) {
return a.x == b.x && a.y == b.y;
}
// 现在可以直接用 unordered_map<Point, string>
方法 1 更安全------只影响当前容器。方法 2 全局生效,但你不需要每次都传哈希函数。
二、底层原理
2.1 哈希表:unordered_map 的底层数据结构
unordered_map 的底层是一个哈希表。具体来说,三大主流编译器都采用**链地址法(Separate Chaining)**来解决哈希冲突。
先看哈希表的整体结构:
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ ← 桶数组(bucket array)
└─┬─┴─┬─┴───┴─┬─┴───┴───┴───┴───┘
│ │ │
▼ ▼ ▼
[node] [node] [node] → [node] ← 每个桶挂着一条链表(或单节点)
K,V K,V K,V K,V
一个元素存储的大致过程:
- 对 key 调用哈希函数,得到一个
size_t值 - 用这个值对桶数量取模:
index = hash(key) % bucket_count - 把键值对挂到第
index个桶的链表上
查找时同理:哈希 → 算桶号 → 在桶的那条链表里找。如果链表很短(理想情况),查找就是 O(1)。
2.2 负载因子与 rehash
上面说的"理想情况"有一个前提:链表不能太长。当元素越来越多、桶数量不变时,每个桶上挂的元素就会越来越多,链表越来越长,查找逐渐退化成 O(N)。
为了避免这种情况,unordered_map 维护了一个负载因子(load factor):
load_factor = size / bucket_count
当 load_factor > max_load_factor() 时(默认 max_load_factor 是 1.0),触发 rehash:
- 分配一个更大的桶数组(通常是原来的 2 倍)
- 把所有元素重新哈希,挂到新桶数组上
- 释放旧桶数组
整个过程是 O(N) 的------因为你必须重新哈希每一个元素。这就是为什么 unordered_map 的插入在绝大多数时候是 O(1),但偶尔会触发一次 O(N) 的 rehash。
这个 rehash 会带来一个严重的副作用:所有迭代器失效。 因为元素节点虽然没变,但桶数组换了一块内存,指向旧桶的迭代器全部变成野指针。
所以如果你需要边遍历边插入 ,unordered_map 做不到------或者说,做的话行为是未定义的。map 没有这个问题,因为红黑树插入新节点不影响已有节点。
2.3 reserve 和 rehash:你知道它们有区别吗?
很多人以为 reserve 和 rehash 是一样的,其实不是:
rehash(n):把桶数量(bucket_count)设置为 ≥ n。如果 n 小于当前桶数,可能缩小也可能不变,具体看实现。reserve(n):保证至少能存储 n 个元素而不触发 rehash。内部会调用rehash(ceil(n / max_load_factor()))。
如果你预知要插入多少元素,用 reserve 一次性预分配,避免多次 rehash:
cpp
unordered_map<int, string> mp;
mp.reserve(10000); // 保证插入 10000 个以内不 rehash
for (int i = 0; i < 10000; i++)
mp[i] = "value"; // 全部 O(1),无 rehash 抖动
如果不做 reserve,随着元素增长会触发多次 rehash(16→32→64→128...→8192→16384),每次 rehash 都是 O(N),累积起来性能就垮了。
2.4 哈希冲突的两种解决方案
主流编译器都选择了链地址法(Separate Chaining),但另一种方案**开放寻址法(Open Addressing)**也值得你了解,因为面试可能问到。
链地址法(gcc/clang/MSVC 使用):每个桶是一个链表的头指针。冲突时把新节点挂到链表末尾。优点是实现简单、删除方便、元素数量可以超过桶数量。缺点是链表节点分散在堆上,缓存局部性差------遍历时指针跳来跳去,CPU 缓存命中率低。
开放寻址法 (如 absl::flat_hash_map、robin_hood 等高性能第三方库使用):所有元素存在桶数组内部,冲突时用探测序列(线性探测/二次探测/双重哈希)找到下一个空桶。优点是缓存友好------所有数据在连续内存上,遍历时 CPU 预取效率极高。缺点是删除复杂(不能直接清空,得设墓碑标记)、负载因子不能太高(通常设 0.5-0.7)。
面试时如果你能说出"STL 用链地址法,工业级高性能场景用开放寻址法、比如 Google 的 SwissTable",面试官会觉得你对这个领域有实际了解。
2.5 memcmp 比较的隐患(std::pair 作 key)
当我们用 unordered_map<pair<int,int>, string> 时,C++ 标准库并没有为 pair<int,int> 提供默认的哈希函数。它不像 int 或 string 那样有内置的特化。所以我们需要自己写哈希。
但是,即便你写了哈希,如果你对 pair 的相等比较不提供正确的 operator==,哈希表也会出错。因为哈希表通过哈希值来确定桶号,但同一桶内如果链表长度大于 1,最终确定是不是同一个 key,靠的是相等比较。
正确的做法是同时提供哈希和相等:
cpp
auto hash_pair = [](const pair<int,int> &p) {
return hash<int>()(p.first) ^ (hash<int>()(p.second) << 1);
};
auto equal_pair = [](const pair<int,int> &a, const pair<int,int> &b) {
return a.first == b.first && a.second == b.second;
};
unordered_map<pair<int,int>, string, decltype(hash_pair), decltype(equal_pair)> mp(8, hash_pair, equal_pair);
如果你忘了传相等比较函数,而 pair 本身默认有 operator==(C++ 标准库提供的),这种情况下不会出错------但并非所有自定义类型都有合理的 operator==。这是一个常见的疏忽点。
2.6 为什么遍历顺序跟插入顺序不一样?
这是 unordered_map 最容易让新手困惑的点。你按顺序插入 "apple"→"banana"→"cherry",遍历出来的顺序是乱的无规律的,而且每次运行可能还不一样。
原因很简单:遍历的不是插入顺序,而是桶数组的顺序。遍历大概长这样:
for 桶号 from 0 to bucket_count - 1:
for 该桶上的链表:
输出节点
而一个元素落到哪个桶,完全由 hash(key) % bucket_count 决定------哈希值是伪随机的,桶号就是伪随机的。所以遍历顺序也是随机的。
如果你需要保持插入顺序,C++ 没有内置的 LinkedHashMap(Java 有)。需要自己组合 list + unordered_map,或者用第三方库(如 boost::multi_index)。
三、map vs unordered_map:一张表讲清楚
| 维度 | map(红黑树) | unordered_map(哈希表) |
|---|---|---|
| 底层结构 | 红黑树 | 哈希表(链地址法) |
| 有序性 | 按键升序 | 无序(顺序不可预期) |
| 查找 | O(logN),稳定 | O(1) 平均,O(N) 最坏(哈希冲突严重时) |
| 插入 | O(logN),稳定 | O(1) 平均,偶尔 O(N) rehash |
| 删除 | O(logN) | O(1) 平均 |
| 内存 | 每节点 ~40B(3指针+颜色+KV) | 桶数组 + 链表节点(≈每元素更大的常数) |
| 迭代器失效(插入时) | 不失效 | rehash 时全部失效 |
| 范围查询 | 支持(lower_bound/upper_bound) | 不支持 |
| 遍历顺序 | 升序 | 桶序 |
| 自定义 key 门槛 | 需 operator< |
需哈希函数 + operator== |
| 最小数据量 | 100 以内可能比哈希表快 | 哈希建表有小额开销 |
选择口诀:
- 需要有序遍历或范围查询 → map
- 纯查找,key 是 int/string 等内置类型 → unordered_map
- 不知道存多少元素且要边遍历边插入 → map(迭代器不失效)
- 数据量巨大且预知大小 → unordered_map + reserve(n)
2.7 负载因子:为什么默认是 1.0?
要理解为什么默认值是 1.0,先理解负载因子高了和低了分别意味着什么。
负载因子低(如 0.5):桶数组很大,每个桶平均只挂 0.5 个元素------大部分桶是空的,链表很短。查找很快(几乎每次都是 O(1)),但内存浪费严重。适合查找密集型场景。
负载因子高(如 2.0):桶数组较小,每个桶平均挂 2 个元素------链表稍长。内存利用率高,但查找需要遍历链表,性能略降。适合内存受限场景。
默认 1.0 是一个平衡点:内存开销和查找性能在大多数场景下都能接受。
你可以调整:
cpp
mp.max_load_factor(0.5); // 降低负载因子,用内存换速度
mp.max_load_factor(2.0); // 提高负载因子,用速度换内存
2.8 内存碎片:unordered_map 的隐藏成本
很多教程只看时间复杂度,但不看空间开销。unordered_map 除了桶数组(一块连续内存),每个节点的链表节点是分散在堆上的。如果你频繁插入删除,内存分配器会产生大量碎片。
相比之下,map 虽然每节点空间更大,但很多 map 的节点只涉及少数几种大小------红黑树节点的类型是固定的,分配器可以高效地管理这些固定大小的内存块。这也是 map 在某些测试场景下实际性能反超 unordered_map 的原因之一。
一个更优的选择是 absl::flat_hash_map(Google Abseil 库)------它用开放寻址法把所有元素存在连续内存里,避免了链表节点的碎片问题,缓存局部性也极好。如果你的项目对性能有极致要求,可以绕过 STL 直接用这些工业级实现。
四、面试题 + 口语化答案
Q1:unordered_map 底层是什么?怎么处理哈希冲突?
"哈希表,用链地址法解决冲突------每个桶是一条链表。冲突了就挂到链表上。STL 三大实现都是链地址法。工业级高性能哈希表(如 Google SwissTable)用开放寻址法。"
Q2:什么时候会 rehash?迭代器会怎样?
"当 size / bucket_count > max_load_factor()(默认 1.0)时触发 rehash。rehash 会分配更大的桶数组,重新哈希所有元素------所有迭代器在 rehash 后全部失效。因为桶数组换了一块内存。"
Q3:reserve 和 rehash 有什么区别?
"rehash(n) 设置桶数量 ≥n,reserve(n) 保证存 n 个元素不 rehash。reserve 更实用------你知道要插 10000 个元素,就 reserve(10000),避免多次 rehash。"
Q4:map 和 unordered_map 什么时候用哪个?
"需要有序遍历或范围查询用 map。纯查找用 unordered_map。但数据量小时(<100)map 可能更快------哈希建表有小额开销。还有,map 迭代器插入不失效,unordered_map rehash 时全失效。"
Q5:为什么 unordered_map 遍历顺序是乱的?
"因为遍历是按桶数组顺序走的,不是按插入顺序。元素落到哪个桶取决于 hash(key) % bucket_count------哈希值是伪随机的,所以遍历顺序也是随机的。"
Q6:怎么给自定义类型做 unordered_map 的 key?
"两步:写哈希函数(仿函数或特化 std::hash),写相等比较(operator== 或仿函数)。两者缺一不可。哈希决定桶号,相等比较决定桶内是不是同一个 key。"
Q7:负载因子设多少合适?
"默认 1.0 是个平衡点。需要更快查找到话设低一点(如 0.5),用内存换速度。内存紧张的话可以设高(如 2.0),用速度换内存。一般不需要调,除非你对场景很了解。"
Q8:unordered_map 能边遍历边删除吗?
"可以------用 it = mp.erase(it) 返回下一个有效迭代器的模式。但是不能边遍历边插入------插入可能触发 rehash,所有迭代器失效。这点和 map 不一样------map 边遍历边插入没问题。"
一句话总结 :unordered_map 看起来只是 map 的"更快的版本",但 rehash 带来的迭代器失效、内存碎片、遍历无序------这三个坑才是理解它的关键。把 map 和 unordered_map 放在一起理解,别孤立地学。