C++ unordered_map/set 深度解析:快在O(1),输在无序,与map/set怎么选
文章目录
- [C++ unordered_map/set 深度解析:快在O(1),输在无序,与map/set怎么选](#C++ unordered_map/set 深度解析:快在O(1),输在无序,与map/set怎么选)
- [一、unordered 系列是什么:一句话说清](#一、unordered 系列是什么:一句话说清)
- 二、先看声明:四个模板参数各管什么
- 三、三大差异总表
- [四、差异一:key 的要求,从"小于"变成"转整形 + 等于"](#四、差异一:key 的要求,从"小于"变成"转整形 + 等于")
-
-
- [自定义类型做 key:自己补两个东西](#自定义类型做 key:自己补两个东西)
-
- [五、差异二:单向迭代器 + 无序遍历](#五、差异二:单向迭代器 + 无序遍历)
- 六、差异三:性能,快多少,代价是什么
- [七、迭代器失效:unordered 特有的坑](#七、迭代器失效:unordered 特有的坑)
- [八、operator\[\]:用起来一样,语义看 key](#八、operator[]:用起来一样,语义看 key)
- 九、哈希相关接口:知道有这回事就行
- [十、multi 版本与全家总表](#十、multi 版本与全家总表)
- [十一、选型决策:什么时候 map 不可替代](#十一、选型决策:什么时候 map 不可替代)
- 十二、实战:两数之和
- 结尾总结
map 和 set 用得再熟,换到 unordered 系列也会连踩三个坑:迭代器突然不支持 -- 了,自定义类型做 key 直接编译报错,遍历出来的顺序完全没法预测。这三个坑指向同一件事------unordered 系列不是"map 换个名字",它是换了一整套查找逻辑。
一句话给结论:只做查找、不关心顺序,unordered 系列就是性能最优解;要有序遍历、范围查找、最大最小值,map 无可替代。这篇不讲底层实现,专讲使用层面必须搞清的三件事:key 的要求变了什么、迭代器差在哪、性能差多少,最后给一张选型决策表。
一、unordered 系列是什么:一句话说清
unordered_set、unordered_map、unordered_multiset、unordered_multimap,这四个容器和 set、map、multiset、multimap 功能高度相似------insert、erase、find、operator\[\],接口几乎一模一样,用法完全通用。唯一的本质区别在底层结构:
set 系列底层是红黑树(二叉搜索树),unordered 系列底层是哈希表。
名字里的 "unordered" 就是在说这件事:明确告诉你"我的遍历不再有序"。底层结构换了,查找逻辑就换了,于是产生三大使用差异------key 的要求、迭代器能力、性能表现。下面逐个拆。
二、先看声明:四个模板参数各管什么
unordered_set 的声明:
cpp
template < class Key, // 关键字类型
class Hash = hash<Key>, // 把 Key 转成整形的仿函数
class Pred = equal_to<Key>, // 判断 Key 相等的仿函数
class Alloc = allocator<Key> // 空间配置器
> class unordered_set;
unordered_map 多一个 mapped_type:
cpp
template < class Key, // key 类型
class T, // value 类型
class Hash = hash<Key>, // 哈希仿函数
class Pred = equal_to<Key>, // 相等比较仿函数
class Alloc = allocator<pair<const Key, T>> // 空间配置器
> class unordered_map;
重点在后两个默认参数。hash<Key> 负责把 key 算成一个 size_t,equal_to<Key> 负责比较两个 key 是否相等------这两个东西就是哈希表查找的全部依赖:先算位置,再确认相等。
平时四个参数只传第一个,后三个全走默认。但"什么情况下默认会失效",正是 unordered 系列和 set 系列最本质的差异,往下看。
三、三大差异总表
先给一张总表收拢全部差异,后面逐个展开:
| 差异维度 | set / map | unordered_set / unordered_map |
|---|---|---|
| 底层结构 | 红黑树 | 哈希表 |
| 对 key 的要求 | 支持 <(小于比较) |
支持转整形 + 支持 == |
| 迭代器类型 | 双向迭代器(++/-- 都行) |
单向迭代器(只有 ++) |
| 遍历顺序 | 中序有序 | 无序 |
| 增删查效率 | O(logN) 稳定 | 平均 O(1),最坏退化 O(N) |
| 反向迭代器 | 有 rbegin/rend | 没有 |
| lower_bound/upper_bound | 有 | 没有 |
四、差异一:key 的要求,从"小于"变成"转整形 + 等于"
这是最容易被忽略、也最影响自定义类型使用的差异。要理解它,得从两个容器"怎么找数据"说起。
红黑树找数据,靠比较大小导航 :key 比当前节点小走左边,大走右边,相等即命中。整个过程只需要一个运算符:operator<。
哈希表找数据,分两步 :第一步拿 key 算出存储位置(必须先转成整数才能取模定位),第二步在对应位置把候选元素逐个和 key 比较(只需要判断相等)。整个过程需要两个能力:转成整数、operator==。
| 红黑树(set/map) | 哈希表(unordered 系列) | |
|---|---|---|
| 查找依赖 | operator< |
转整形 + operator== |
| 为什么 | 大小比较导航左右子树 | 整数取模定位桶,相等比较确认元素 |
这个推导直接解释了两个常见疑问:
- 为什么 int、string 能直接做 unordered 的 key ------标准库为常用类型提供了
std::hash的特化。string 的特化会逐字符参与计算(每个字符位置都影响最终哈希值),不是简单取长度或首字符。 - 为什么 float/double 能编译但工程上慎用 ------浮点精度问题,两个"看起来相等"的浮点数哈希结果可能不同,查找时
==也靠不住。
自定义类型做 key:自己补两个东西
std::hash 没有为自定义类型提供特化,pair<int,int> 也没有------这是实战中真实会撞上的坑。想让 pair<int,int> 做 unordered_map 的 key(比如坐标去重),必须自己提供 Hash 和相等比较:
cpp
struct PairHash {
size_t operator()(const pair<int,int>& p) const {
// 两个 hash 值合并:异或 + 左移错位,让两个分量都参与计算、减少碰撞
return hash<int>()(p.first) ^ (hash<int>()(p.second) << 1);
}
};
struct PairEqual {
bool operator()(const pair<int,int>& a, const pair<int,int>& b) const {
return a.first == b.first && a.second == b.second;
}
};
unordered_set<pair<int,int>, PairHash, PairEqual> visited;
visited.insert({3, 5});
对照 set 系列:同样的需求只需要给 pair 重载 operator<(或传比较仿函数),一个运算符搞定。这就是"key 的要求"差异实打实的代价。
另一个小细节:第三个参数 equal_to<Key> 默认就是调 key 的 operator==。所以自定义类型有两条路------要么重载 operator== 让第三个参数走默认,要么显式传比较仿函数 。上面 PairEqual 演示的是后者;如果类型本身重载了 ==,第二个参数给完 Hash 后,第三个参数可以不传:
cpp
struct DateHash {
size_t operator()(const Date& d) const {
return hash<int>()(d._year) ^ (hash<int>()(d._month) << 4) ^ d._day;
}
};
unordered_set<Date, DateHash> s; // Date 重载了 operator==,第三个参数走默认
另一条路是给 std::hash 加特化,效果等价,但仿函数方式不改标准库命名空间、侵入性更低,推荐用仿函数。
五、差异二:单向迭代器 + 无序遍历
先看一个直观的演示:
cpp
unordered_set<int> us;
us.insert(8); us.insert(3); us.insert(15);
us.insert(1); us.insert(9);
for (auto e : us) cout << e << " ";
// 输出顺序不可预测,可能是 9 1 15 3 8
输出完全是乱序------每个元素在表中的位置由它的哈希值决定,和插入顺序、大小顺序都无关。对照 set:红黑树中序遍历天然有序,遍历结果就是升序。
补一个容易误解的细节:unordered 的遍历顺序虽然"乱",但在同一环境下是确定的------同一批 key,无论按什么顺序插入,遍历结果完全相同(哈希值是确定性的,落桶位置只由哈希值决定)。乱的只是"相对 key 大小和插入顺序",不是"每次运行都变"。结论:不要让任何业务逻辑依赖遍历顺序,但调试复现不用担心它。
两个直接后果:
- 单向迭代器只支持
++。--直接编译报错,rbegin/rend 不存在,反向遍历做不了。原因也在底层:哈希节点只存 next 指针不存 prev,回退一个位置在结构上就不支持。 - 依赖"有序"的查询全部做不了。unordered 系列没有 lower_bound/upper_bound,"找大于 x 的第一个元素"这种范围查询没有接口;红黑树上 O(logN) 就能拿到的最大最小值,unordered 也只能全遍历。
一句话:unordered 的迭代器只能"从头走到尾",走到哪算哪。
六、差异三:性能,快多少,代价是什么
100 万随机数据下,set 和 unordered_set 的 insert/find 计时对比(课件同款测试,整理成可跑版本):
cpp
#include <unordered_set>
#include <set>
#include <iostream>
#include <vector>
#include <cstdlib>
#include <ctime>
using namespace std;
int main()
{
const size_t N = 1000000;
unordered_set<int> us;
set<int> s;
vector<int> v;
v.reserve(N);
srand(time(nullptr));
for (size_t i = 0; i < N; ++i)
v.push_back(rand() + i); // +i 错开随机值,减少重复
int begin1 = clock();
for (auto e : v) s.insert(e); // 红黑树插入 O(logN)
int end1 = clock();
cout << "set insert: " << end1 - begin1 << endl;
us.reserve(N); // 提前开好空间,插入途中不扩容
int begin2 = clock();
for (auto e : v) us.insert(e); // 哈希插入平均 O(1)
int end2 = clock();
cout << "unordered_set insert: " << end2 - begin2 << endl;
int begin3 = clock();
for (auto e : v) s.find(e);
int end3 = clock();
cout << "set find: " << end3 - begin3 << endl;
int begin4 = clock();
for (auto e : v) us.find(e);
int end4 = clock();
cout << "unordered_set find: " << end4 - begin4 << endl;
return 0;
}
典型结果量级(release 下,具体数值随环境浮动,以实测为准):100 万数据的查找,set 的耗时大约是 unordered_set 的 3~5 倍;数据量越大,logN 和常数的差距越明显。小数据量(几千个元素)两者都跑在毫秒级,差异无感。
三个要点:
- 大 O 的真相:哈希的 O(1) 是"平均",全部冲突时退化到 O(N)。标准库靠负载因子控制把最坏情况的概率压到极低,平均意义下 O(1) 成立。
- unordered 有 reserve:提前把空间开好,插入全程不触发扩容。set 没有对应操作------红黑树本来就不需要。
- 删除同样受益:哈希删除平均 O(1),红黑树删除 O(logN),erase 大批量数据时差距和 find 同级。
把 logN 拆开看更直观:100 万数据约等于 2^20,红黑树一次查找要走约 20 次大小比较;哈希一次查找是 1 次取模定位,加上桶内平均不到 1 次的相等比较。20 次比较对 1 次定位,这就是那几倍差距的来源------而且这个差距随数据量继续拉大。
七、迭代器失效:unordered 特有的坑
这是 unordered 系列独有、面试高频的一个点。
map(红黑树)的失效规则很温和:插入任何元素,已有迭代器全都不失效;删除只使指向被删元素的迭代器失效。因为红黑树插入只是新增节点加变色旋转,已有节点的内存位置从来不动。
unordered 系列(哈希表)多一条 rehash 引发的失效:
cpp
unordered_set<int> us;
us.reserve(10);
auto it = us.insert(5).first; // it 指向元素 5
// 持续插入,一旦负载因子超过上限触发扩容:
// 发生 rehash,所有迭代器全部失效,it 变成悬空
for (int i = 0; i < 100; ++i)
us.insert(i);
失效规则对照:
| 操作 | 红黑树(map/set) | 哈希表(unordered 系列) |
|---|---|---|
| insert | 迭代器全都不失效 | 可能触发 rehash,全部失效 |
| erase | 只失效被删元素的迭代器 | 只失效被删元素的迭代器 |
| 元素的引用/指针 | 不失效 | 不失效(即使 rehash) |
最反直觉的是最后一行:rehash 之后迭代器全挂了,但元素的引用和指针依然有效。为什么?
哈希桶里存的是节点的指针,rehash 做的事是把节点从旧桶摘下来、按新表长重新取模挂到新桶------节点本体一个都没动,动的只是它挂在哪个桶下。迭代器内部记的是"桶位置 + 节点指针"这类结构信息,桶一重排它就找不到北;引用和指针直接指向节点本体,节点没动它们就还有效。
实用结论:遍历 unordered 容器时不要插入;需要"边查边建索引"时,要么先收集再统一插入,要么提前用 reserve 把空间开足、确保循环内不触发 rehash。
扩容时机可以直接观测:
cpp
unordered_set<int> us;
cout << us.bucket_count() << endl; // 初始桶数(随实现,通常很小)
for (int i = 0; i < 10; ++i)
us.insert(i);
cout << us.bucket_count() << endl; // 负载因子触顶后桶数变大------这一刻 rehash 发生
桶数跳变的那一刻,就是 rehash 发生、迭代器全体失效的时刻。上面循环里如果持着旧迭代器,输出就是未定义行为。
八、operator\[\]:用起来一样,语义看 key
unordered_map 支持 operator\[\],行为和 map 一致:key 存在返回 value 的引用,不存在就插入默认值再返回引用。统计词频的经典写法:
cpp
unordered_map<string, int> countMap;
string strs[] = {"apple", "banana", "apple", "cherry", "apple", "banana"};
for (auto& e : strs)
countMap[e]++; // key 不存在 -> 插入(value默认0) 再 ++
这套写法在 map 和 unordered_map 上完全通用------operator\[\] 不依赖有序性,靠的是"查找失败也返回引用"的插入语义。区别在统计完之后的遍历:map 按字典序输出,unordered_map 乱序输出。要不要按序输出,就是选型问题。
九、哈希相关接口:知道有这回事就行
unordered 系列比 set 系列多一组桶和负载因子接口:
cpp
unordered_set<int> us = {8, 3, 15, 1, 9, 22, 7};
cout << us.bucket_count() << endl; // 桶的总数
cout << us.load_factor() << endl; // 当前负载因子 = 元素个数 / 桶总数
cout << us.max_load_factor() << endl; // 负载因子上限,默认 1.0
us.reserve(100); // 预留:让桶数足够存 100 个元素而不触发扩容
int key = 15;
cout << us.bucket(key) << endl; // 15 落在几号桶
cout << us.bucket_size(us.bucket(key)) << endl; // 该桶里挂了几个元素
这些接口平时用不上,但每一个都和底层结构一一对应:bucket_count 是哈希表的表长,load_factor 是负载因子,reserve 本质是预估扩容。把这组接口和底层结构对上号,哈希表的实现逻辑就打通了一半。
十、multi 版本与全家总表
unordered_multiset / unordered_multimap 支持 key 冗余,和 multiset / multimap 功能对应,差异同样是那三条(key 要求、迭代器、性能)。multimap 不支持 operator\[\] 的结论在 unordered_multimap 上同样成立------key 冗余让"唯一的 value 引用"这个语义不成立。
八个关联容器一张表收拢:
| 容器 | 底层 | key 要求 | 遍历顺序 | key 冗余 |
|---|---|---|---|---|
| set | 红黑树 | operator< |
有序 | 否 |
| map | 红黑树 | operator< |
key 有序 | 否 |
| multiset | 红黑树 | operator< |
有序 | 是 |
| multimap | 红黑树 | operator< |
key 有序 | 是 |
| unordered_set | 哈希表 | 转整形 + == |
无序 | 否 |
| unordered_map | 哈希表 | 转整形 + == |
无序 | 否 |
| unordered_multiset | 哈希表 | 转整形 + == |
无序 | 是 |
| unordered_multimap | 哈希表 | 转整形 + == |
无序 | 是 |
十一、选型决策:什么时候 map 不可替代
| 需求场景 | 用谁 | 原因 |
|---|---|---|
| 纯查找,不关心顺序,数据量大 | unordered | 平均 O(1),最快 |
| 需要有序遍历 / 按序输出 | map | unordered 给不了 |
| 范围查找(大于 x 的最小元素) | map | unordered 没有 lower_bound |
| 取最大 / 最小 key | map | 红黑树两端直接拿 |
| key 无法转整形、又不想写 hash | map | 只需 operator< |
| 统计 + 有序输出(如词频按字典序) | map | 一遍统计,遍历即有序 |
一句话总结:unordered 赢在查找速度,map 赢在结构自带的有序能力。速度差几倍在中小数据量上无感,有序能力却是硬需求------这不是谁取代谁的关系,是按场景选工具。
十二、实战:两数之和
LeetCode 1,两数之和。暴力解法两层循环 O(n²);排序加双指针 O(nlogn) 但下标信息丢了还得额外处理。unordered_map 一遍扫完:
cpp
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> pos; // 值 -> 下标
for (int i = 0; i < (int)nums.size(); ++i) {
int need = target - nums[i];
auto it = pos.find(need);
if (it != pos.end())
return {it->second, i}; // 之前的元素和当前值配对成功
pos[nums[i]] = i; // 没找到,把当前值存进索引
}
return {};
}
};
核心就一处:把"找 target - numsi"从 O(n) 的扫描变成 O(1) 的哈希查找,边遍历边建索引,总复杂度 O(n)。"边遍历边建哈希索引"这个模式,是 unordered 容器最经典的用法。
为什么不用 map?功能完全一样,但 key 是 int、需求是纯查找,unordered_map 就是最优选------正好验证了选型表的第一行。
再补一道同模式的题:LeetCode 349,两个数组的交集。暴力双循环 O(mn),unordered_set 降到 O(m+n):
cpp
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> s1(nums1.begin(), nums1.end()); // 建索引,顺便去重
unordered_set<int> ret;
for (int x : nums2)
if (s1.count(x)) // O(1) 查 x 在不在 nums1
ret.insert(x); // 用 set 收集,天然再去一次重
return vector<int>(ret.begin(), ret.end());
}
};
"哈希集合查存在性"是和"哈希映射建索引"并列的第二大常用模式:把一个序列建成 set,另一个序列逐个 O(1) 查。两数之和查的是值到下标的映射,这题查的只是值的单纯存在性------前者用 map,后者 set 就够。
结尾总结
四条结论收尾:
- unordered 系列和 set 系列接口几乎通用,差异全由底层结构带来:key 要求(转整形 +
==对operator<)、迭代器(单向无序对双向有序)、性能(平均 O(1) 对 O(logN))。 - 自定义类型做 unordered 的 key,必须自己提供 Hash 仿函数和相等比较------
pair<int,int>这种标准类型也不例外,这是实战高频坑。 - 单向迭代器、无序遍历、没有 lower_bound------一切依赖"有序"的查询,unordered 都做不了。
- 选型一句话:纯查找用 unordered,要有序用 map。