④ 数据结构与算法 — 第 16 课
unordered_map / unordered_set(哈希)、map / set(有序树)。mp[k] 会静默插入默认值;答案要用 long long。先看一个对比。同样是「查某个东西在不在」,两种键的差别很大:
| 键长什么样 | 能用数组吗 | 怎么办 |
|---|---|---|
| 编号 1..n(值 ≤ 105) | 能,a[编号] | 普通数组 |
| 编号 ≤ 109,但只有 105 个 | 开不下(109 个格子) | 哈希 / 排序后二分 |
一对下标 (i, j) | 要开 n×m 的二维表 | 把对编码成一个整数,或直接哈希 |
| 字符串、浮点数、结构体 | 完全不行 | 哈希(字符串要先 #include <string>) |
O(1),比排序二分的 O(log n) 还快一档,代价是多占内存、以及最坏情况(大量键撞到同一个位置)会退化成 O(n)。
| 写法 | 有序吗 | 单次操作 | 什么时候用 |
|---|---|---|---|
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 —— 后者既慢又改变了容器内容(还可能在遍历时把表撑大)。
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'; // 查过的格子保证有值
}
| 存法 | 耗时 | 说明 |
|---|---|---|
| 一张表 + 编码 key | 5.3 ms | 推荐 |
| 每个柜子一张 unordered_map | 11.7 ms | 慢一倍多:105 张空表本身就有开销 |
另外用 200 组小规模数据与「二维数组」对拍(含 k = 0 的清空操作):0 次不一致。
key = i * BASE + j,其中 BASE 取「j 的最大值 + 1」。这样不同的 (i, j) 一定算出不同的 key(因为 j < BASE,不会进位串味)。BASE 要开够大,开小了不同的对会算出同一个 key —— 这是这类题最容易漏的地方。
(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 ms | 22.4 ms | 39771 |
| n = 2×105,全相同,C = 0 | 1.7 ms | 4.5 ms | 40000000000 |
两个结论:
unordered_map 每次操作要算哈希、跳内存,缓存不友好;而排序后二分是连续内存访问。数据规模不大时(105 级)两者差别完全可以忽略,选你写得顺手的。n² = 4×1010,int 直接溢出。这题的正确打开方式是先算上界再定类型。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_map | 8.7 ms | 0.1 ms | O(n + Q) 平均 |
map | 20.9 ms | 0.1 ms | O((n+Q) log n) |
| 存成 pair 数组 + 排序 + 二分 | 6.3 ms | 0.1 ms | O(n log n + Q log n) |
map 建表慢了一倍多(每个节点都要维护红黑树),但查询一样快 —— 因为 105 次查询的 log 只有 17 步,差别被内存访问吃掉了。只有在「需要按键顺序遍历」时才值得用 map。
| 插入 106 个键 | 耗时 |
|---|---|
不 reserve | 317.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 可能爆内存 —— 这时还是排序或离散化更稳。
mp[k] 会插入:判断存在性只能用 find / countlong long:数对的答案能到 n²(P1102 的 4×1010 就是典型)i * BASE + j 里 BASE 必须 > j 的最大值unordered_map 两次运行可能打印顺序不同;要顺序输出就换 map 或自己排序for (auto& [k,v] : mp) mp[newKey] = 1; 会导致迭代器失效(rehash),要改就另建一张表pair / 自定义结构体不能直接当键(要自己写 hash),用编码整数代替reserve,能省近一半时间if (mp[x] != 0) 有什么问题?key = i * BASE + j,BASE 应该取什么?map 而不是 unordered_map?(i, j) 编码成一个 long long;记住 k = 0 是清空(用 erase,不是存一个 0)。n、q ≤ 105。long long。find 的地方写成了 mp[x]?n²)?unordered_map / unordered_set(哈希,无序)、map / set(有序树,O(log n))mp[k] 会插入,判存在用 find / counti * BASE + j,BASE 取 j 的上界 + 1(P3613 实测:一张表 5.3 ms vs 每柜一张 11.7 ms)4×1010,必须 long longreserve 能省一半(106 次插入:317 → 180 ms)map 或排序二分O(nm) 的暴力优化到 O(n+m)。核心是 next 数组(失配时该退到哪),理解它的标志是:能手算出一个模式串的 next 数组,并说清「为什么退到那里不会漏解」。配题 P3375。
reserve 对比、P3613 两种存法。编译参数 g++ -O2 -std=c++17 -Wall -Wextra。哪一句看不懂 —— 比如「哈希为什么会退化」「reserve 为什么省时间」—— 直接问我。