C++STL之unordered_map详解

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

一个元素存储的大致过程:

  1. 对 key 调用哈希函数,得到一个 size_t
  2. 用这个值对桶数量取模:index = hash(key) % bucket_count
  3. 把键值对挂到第 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

  1. 分配一个更大的桶数组(通常是原来的 2 倍)
  2. 把所有元素重新哈希,挂到新桶数组上
  3. 释放旧桶数组

整个过程是 O(N) 的------因为你必须重新哈希每一个元素。这就是为什么 unordered_map 的插入在绝大多数时候是 O(1),但偶尔会触发一次 O(N) 的 rehash。

这个 rehash 会带来一个严重的副作用:所有迭代器失效。 因为元素节点虽然没变,但桶数组换了一块内存,指向旧桶的迭代器全部变成野指针。

所以如果你需要边遍历边插入unordered_map 做不到------或者说,做的话行为是未定义的。map 没有这个问题,因为红黑树插入新节点不影响已有节点。

2.3 reserve 和 rehash:你知道它们有区别吗?

很多人以为 reserverehash 是一样的,其实不是:

  • 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_maprobin_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> 提供默认的哈希函数。它不像 intstring 那样有内置的特化。所以我们需要自己写哈希。

但是,即便你写了哈希,如果你对 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 带来的迭代器失效、内存碎片、遍历无序------这三个坑才是理解它的关键。把 mapunordered_map 放在一起理解,别孤立地学。