Lesson 0416 · 哈希表:unordered_map / unordered_set

④ 数据结构与算法 — 第 16 课

上节课:前缀和 / 差分 / 单调栈 —— 三个「一遍扫过去顺手记点东西」的工具。
这节课:前面的题里,查询都是「数组下标 → 值」。可很多题的查询是「值本身 → 位置」「(柜子, 格子) → 物品」「字符串 → 次数」。下标装不下这些东西,于是需要一张能用任意东西当键的表 —— 哈希表。它是 C++ 里最省事的「空间换时间」工具,也是刷题时被用错最多的工具。
三句话速览

1. 什么时候需要它

先看一个对比。同样是「查某个东西在不在」,两种键的差别很大:

键长什么样能用数组吗怎么办
编号 1..n(值 ≤ 105)能,a[编号]普通数组
编号 ≤ 109,但只有 105 个开不下(109 个格子)哈希 / 排序后二分
一对下标 (i, j)要开 n×m 的二维表把对编码成一个整数,或直接哈希
字符串、浮点数、结构体完全不行哈希(字符串要先 #include <string>)
哈希表的本质:它内部用「哈希函数」把键算成一个数组下标,然后在那个位置存值。所以查询是「算一下、跳过去看」——平均 O(1),比排序二分的 O(log n) 还快一档,代价是多占内存、以及最坏情况(大量键撞到同一个位置)会退化成 O(n)。

2. C++ 里的四个工具

写法有序吗单次操作什么时候用
unordered_map<K, V>无序平均 O(1)键值对,只关心「查得到、查得快」
unordered_set<K>无序平均 O(1)只关心「在不在」
map<K, V>按键升序O(log n)需要按顺序遍历,或需要「找前一个/后一个」
set<K>按键升序O(log n)要去重 + 有序

四个工具的常用操作几乎一样,记住这几个就够:

unordered_map<int, int> cnt;

cnt[key] = v;              // 写入(★ 键不存在时会先插入一个默认值 0 再赋值)
cnt[key]++;                // 计数最常用
auto it = cnt.find(key);   // 查找;没找到返回 cnt.end()
if (it != cnt.end()) { /* it->second 是值 */ }
if (cnt.count(key)) { }    // 只判「在不在」
cnt.erase(key);            // 删除

for (auto& [k, v] : cnt) { }   // 遍历(无序!相同代码两次运行顺序可能不同)
第一个大坑:cnt[key] 会插入
哪怕你只是想「读一下」,写 cnt[x] 也会把 x 插进去(值默认 0)。所以判断存在性只能用 find / count,不能用 cnt[x] != 0 —— 后者既慢又改变了容器内容(还可能在遍历时把表撑大)。

3. P3613 寄包柜:把一对下标编码成一个整数

题面要点:n 个柜子(n ≤ 105),每个柜子有 ai 个格子(ai 未知),q 次操作(q ≤ 105):1 i j k 把物品 k 放进柜 i 的格子 j(k = 0 表示清空该格子),2 i j 查询。保证查询的格子存过东西,全图最多 107 个格子。

关键在于:格子总数 107 开不下二维数组(105 × 105 更不可能),而实际用到的格子最多 q 个。这正是哈希表的典型场景:只有 (i, j) 真的被写过,才占内存。

两种存法:

// ① 把 (i, j) 编码成一个整数当键(推荐:一张表,省内存也快)
const long long BASE = 1000005;
unordered_map<long long, int> box;      // key = i * BASE + j

// ② 每个柜子一张表:柜子多、每个柜子只用到少数格子时,空表本身也很占地方
vector<unordered_map<int, int>> box2(n + 1);

主循环(两种存法只是查询那一行不同):

int op; cin >> op;
if (op == 1) {
    int i, j, k; cin >> i >> j >> k;
    long long key = (long long)i * BASE + j;
    if (k == 0) box.erase(key);        // 清空 = 删掉这个键
    else        box[key] = k;
} else {
    int i, j; cin >> i >> j;
    cout << box[(long long)i * BASE + j] << '\n';   // 查过的格子保证有值
}

实测(q = 105 次操作,一半写入一半查询)

存法耗时说明
一张表 + 编码 key5.3 ms推荐
每个柜子一张 unordered_map11.7 ms慢一倍多:105 张空表本身就有开销

另外用 200 组小规模数据与「二维数组」对拍(含 k = 0 的清空操作):0 次不一致。

编码的通用套路:key = i * BASE + j,其中 BASE 取「j 的最大值 + 1」。这样不同的 (i, j) 一定算出不同的 key(因为 j < BASE,不会进位串味)。BASE 要开够大,开小了不同的对会算出同一个 key —— 这是这类题最容易漏的地方。

4. P1102 A-B 数对:一遍扫为什么是错的

题面要点:N ≤ 2×105、正整数 C,统计有多少对 (i, j) 满足 ai − aj = C。不同位置上的相同数字算不同的数对,而且 i 可以等于 j(当 C = 0 时每个位置自己和自己都算一对)。

最自然的想法是「扫一遍,对每个 ai 看看前面有没有 ai − C」:

// ✗ 错写法:只统计了「B 出现在 A 前面」的对
unordered_map<int, int> cnt;
long long ans = 0;
for (int x : a) {
    auto it = cnt.find(x - C);
    if (it != cnt.end()) ans += it->second;
    cnt[x]++;
}

它漏掉了先出现 A、后出现 B 的情况。比如 a = [5, 3]、C = 2:扫到 5 时表里还没有 3,扫到 3 时又去找 3−2=1 —— 正确的那一对从头到尾没被数。

实测:这个写法在 500 组随机小数据上错了 336 组。正确写法是先把整张表建好,再统一查:

// ✓ 两遍:先建全表,再对每个 B 查 B + C 的个数
unordered_map<int, int> cnt;
cnt.reserve(a.size() * 2);          // 先要够空间,省掉反复扩容
for (int x : a) cnt[x]++;
long long ans = 0;                  // ★ 答案最大 4×10^10,必须 long long
for (int x : a) {
    auto it = cnt.find(x + C);
    if (it != cnt.end()) ans += it->second;
}
cout << ans << '\n';

两遍写法在同样的 500 组数据上0 次不一致。

和「排序 + 二分」比一比

数据哈希(两遍)排序 + 二分答案
n = 2×105,随机值37.3 ms22.4 ms39771
n = 2×105,全相同,C = 01.7 ms4.5 ms40000000000

两个结论:

5. P1918 保龄球:值 → 位置

题面要点:n ≤ 105、Q ≤ 105,ai 与查询的 m 都 ≤ 109,且各个位置的瓶子数互不相同(这点很重要:一个值只对应一个位置)。查询某值的位置,没有输出 0。
unordered_map<int, int> pos;    // 值 → 位置
pos.reserve(n * 2);
for (int i = 1; i <= n; i++) {
    int x; cin >> x;
    pos[x] = i;
}

int Q; cin >> Q;
while (Q--) {
    int m; cin >> m;
    auto it = pos.find(m);
    cout << (it == pos.end() ? 0 : it->second) << '\n';
}

同一件事有三种做法,实测(n = Q = 105):

做法建表耗时105 次查询总复杂度
unordered_map8.7 ms0.1 msO(n + Q) 平均
map20.9 ms0.1 msO((n+Q) log n)
存成 pair 数组 + 排序 + 二分6.3 ms0.1 msO(n log n + Q log n)

map 建表慢了一倍多(每个节点都要维护红黑树),但查询一样快 —— 因为 105 次查询的 log 只有 17 步,差别被内存访问吃掉了。只有在「需要按键顺序遍历」时才值得用 map。

6. 性能:reserve 与「被卡哈希」

reserve 是真的有用

插入 106 个键耗时
不 reserve317.1 ms
reserve(n * 2)179.8 ms

差别来自「扩容」:元素多了要重新分配桶、把所有元素搬一遍。知道大概有多少个键时,一行 reserve 能省掉近一半时间。

什么时候哈希表会被卡

出题人可以故意构造数据让 unordered_map 退化。它的哈希函数是固定的(对 int 基本就是它自己),只要造一批「哈希值相同」的键,所有元素就挤在一个桶里,查询退化成 O(n),直接把 O(n log n) 的题卡成 TLE。
应对:① 换成 map(稳定 O(log n));② 手写一个随机化哈希(给键异或一个运行期随机数)作为 unordered_map 的第三个模板参数;③ 改用排序 + 二分。

内存开销

哈希表每个元素除了键和值,还要存指针/节点信息(典型 32–56 字节),是数组的几倍。所以「107 个 int」用数组只要 40 MB,用 unordered_map 可能爆内存 —— 这时还是排序或离散化更稳。

7. 坑点清单

8. 小测

Q1. 只想知道「某个键在不在」,用哪个容器最合适?
Q2. if (mp[x] != 0) 有什么问题?
Q3. P1102 里「扫一遍、查 x−C」为什么错?
Q4. 把柜子和格子编码成一个 key,key = i * BASE + j,BASE 应该取什么?
Q5. 什么情况下应该改用 map 而不是 unordered_map?

9. 动手练习

  1. P3613 寄包柜 —— 主战场。把 (i, j) 编码成一个 long long;记住 k = 0 是清空(用 erase,不是存一个 0)。n、q ≤ 105。
  2. P1102 A-B 数对 —— 别用「扫一遍查 x−C」那个错写法;先建全表再查。答案记得 long long。
  3. P1918 保龄球 —— 值 → 位置;这题也可以排序 + 二分,两种都写一遍对比一下耗时。
卡住时的自查顺序
  1. 有没有该用 find 的地方写成了 mp[x]?
  2. 答案类型够不够(能不能到 n²)?
  3. 编码 key 时 BASE 够大吗?
  4. 一遍扫是不是漏了逆序对?
  5. 还不行就把代码贴过来,我按行给你看 —— 按规矩,我会先让你猜「这组输入应该输出什么」。

本课小结

下一课预告
0417 KMP —— 在字符串里找子串,从 O(nm) 的暴力优化到 O(n+m)。核心是 next 数组(失配时该退到哪),理解它的标志是:能手算出一个模式串的 next 数组,并说清「为什么退到那里不会漏解」。配题 P3375。
这一课的数字都是现场跑出来的:P1102 的错写法和正确写法各 500 组对拍(336 次 vs 0 次不一致)、两版耗时、P1918 三种存法、reserve 对比、P3613 两种存法。编译参数 g++ -O2 -std=c++17 -Wall -Wextra。哪一句看不懂 —— 比如「哈希为什么会退化」「reserve 为什么省时间」—— 直接问我。

← 上一课:前缀和 / 差分 / 单调栈 · 课程对照表 · 算法总结

书籍 《算法 (第4版)》第 3.4 节(散列表)· 《C++ Primer》第 11 章(关联容器)

在线 OI-Wiki · 哈希表 · 洛谷 P3613