前言
std::map/std::set是有序关联容器(ordered associative container),底层红黑树,操作复杂度稳定在O(log n)。而std::unordered_map/std::unordered_set是 C++11 引入的无序关联容器(unordered associative container),底层哈希表(hash table),平均复杂度O(1)。
很多人会背"哈希表快",但真正用起来问题不断:
- 为什么自定义类型编译不过?—— 缺少哈希函数或
operator== - 为什么
unordered_map的迭代顺序每次运行都不一样? - 为什么
um[1000000]在循环里用会慢?—— 哈希表扩容(rehash) - 为什么
unordered_set里元素顺序和插入顺序不同?
本文把这两个容器的原理、接口、实战套路和常见坑一次讲清。
一、底层原理:为什么它是无序的
1.1 桶(bucket)与哈希
unordered_map内部维护一个桶数组(bucket array),每个桶挂一条链表(或开放寻址)。插入一个键k时:
- 计算
h = hash(k) - 用
h % bucket_count()得到桶下标 - 把节点挂到该桶里
因为下标由哈希值决定,桶内遍历顺序与插入顺序无关,所以整个容器遍历出来是"乱"的。这正是"无序"的含义:它不保证任何顺序,也不承诺两次运行顺序一致。
bucket[0] -> (k="apple", v=1) bucket[1] -> (k="cat", v=3) -> (k="banana", v=2) bucket[2] -> nullptr bucket[3] -> (k="dog", v=4)1.2 负载因子与扩容
负载因子(load factor)= 元素个数 / 桶个数。默认最大负载因子是1.0。当插入导致负载因子超过它,容器会:
- 申请更大的桶数组(通常是下一个质数或约 2 倍)
- 重新计算每个元素的桶位置(rehash)
- 释放旧桶数组
这一步是O(n)的,且会让所有迭代器失效。这就是"循环里反复插入会慢"的根源。
1.3 unordered_map 与 unordered_set 的关系
| 对比项 | unordered_map | unordered_set |
|---|---|---|
| 存储内容 | 键值对pair<const Key, T> | 仅键Key |
| 元素类型 | value_type=pair<const Key, T> | value_type=Key |
| 典型用途 | 字典、计数、缓存 | 去重、存在性判断 |
operator[] | 有(键不存在则插入) | 无 |
| 底层结构 | 完全相同 | 完全相同 |
unordered_set可以理解成unordered_map只保留键的特化版本。
二、基本用法实战
2.1 头文件与声明
#include <unordered_map> #include <unordered_set> #include <string> #include <iostream> int main() { std::unordered_map<std::string, int> score; std::unordered_set<int> seen; return 0; }模板参数其实有 5 个,只是后面几个有默认值:
template< class Key, class T, class Hash = std::hash<Key>, class KeyEqual = std::equal_to<Key>, class Allocator = std::allocator<std::pair<const Key, T>> > class unordered_map;理解这一点,后面自定义类型的解法就自然出来了。
2.2 插入与访问
#include <unordered_map> #include <string> #include <iostream> int main() { std::unordered_map<std::string, int> age; // 方式 1:operator[] —— 不存在则默认构造并插入 age["Tom"] = 18; // 方式 2:insert —— 已存在则不覆盖 auto r = age.insert({"Jerry", 20}); std::cout << "插入成功? " << r.second << '\n'; // 1 r = age.insert({"Tom", 99}); std::cout << "插入成功? " << r.second << '\n'; // 0,Tom 仍是 18 // 方式 3:insert_or_assign (C++17) —— 存在则覆盖 age.insert_or_assign("Tom", 99); std::cout << "Tom = " << age["Tom"] << '\n'; // 99 // 方式 4:emplace —— 原地构造,避免临时对象 age.emplace("Spike", 5); // 方式 5:try_emplace (C++17) —— 只有键不存在才构造值 age.try_emplace("Spike", 100); // 不生效,Spike 已存在 for (const auto& [name, a] : age) std::cout << name << " -> " << a << '\n'; }insert和emplace的区别值得说清楚:emplace直接把参数转发给节点的构造函数,理论上省掉一次临时pair的构造。但注意 ——即使插入失败,emplace也可能已经构造了对象(C++17 起的try_emplace才彻底解决这个问题)。
2.3 查找
#include <unordered_map> #include <iostream> int main() { std::unordered_map<int, std::string> m{{1, "one"}, {2, "two"}}; // find —— 返回迭代器,找不到返回 end() if (auto it = m.find(1); it != m.end()) std::cout << it->first << " = " << it->second << '\n'; // count —— 返回 0 或 1(unordered 容器不允许重复键) std::cout << m.count(2) << '\n'; // 1 // contains (C++20) #if __cplusplus >= 202002L std::cout << std::boolalpha << m.contains(3) << '\n'; // false #endif }重要规律:unordered_map的键唯一,所以count()只会返回 0 或 1。要判断存在性,优先用find/contains,不要用operator[],原因见"常见坑点"。
2.4 unordered_set 去重
#include <unordered_set> #include <vector> #include <iostream> int main() { std::vector<int> v{1, 3, 3, 5, 5, 7, 1}; std::unordered_set<int> s(v.begin(), v.end()); std::cout << "去重后个数: " << s.size() << '\n'; // 4 for (int x : s) std::cout << x << ' '; std::cout << '\n'; // 插入的返回值:second 表示是否真的插进去了 auto [it, ok] = s.insert(3); std::cout << std::boolalpha << ok << '\n'; // false,已存在 }三、进阶用法
3.1 自定义类型作键
这是最高频的编译错误来源。要作为unordered_map的键,需要两样东西:
- 哈希函数:能被
std::hash<Key>或自定义Hash调用 - 相等比较:
operator==或自定义KeyEqual
#include <unordered_map> #include <iostream> #include <string> struct Point { int x, y; bool operator==(const Point& o) const { return x == o.x && y == o.y; } }; // 方式 A:特化 std::hash namespace std { template <> struct hash<Point> { size_t operator()(const Point& p) const noexcept { // 组合两个整数的经典写法 size_t h1 = std::hash<int>{}(p.x); size_t h2 = std::hash<int>{}(p.y); return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2)); } }; } // namespace std int main() { std::unordered_map<Point, std::string> m; m[Point{1, 2}] = "A"; m[Point{3, 4}] = "B"; std::cout << m[Point{1, 2}] << '\n'; // A }方式 B 是传自定义仿函数,不污染std命名空间,更推荐:
struct PointHash { size_t operator()(const Point& p) const noexcept { return std::hash<int>{}(p.x) * 31 + std::hash<int>{}(p.y); } }; std::unordered_map<Point, std::string, PointHash> m;3.2 预留空间避免 rehash
#include <unordered_map> #include <vector> int main() { std::unordered_map<int, int> m; // 已知要存 100000 个元素,提前预留 m.reserve(100000); // 直接保证能装下 n 个元素而不 rehash // m.rehash(200000); // rehash 是"保证至少 n 个桶" std::vector<int> data(100000, 1); for (int i = 0; i < 100000; ++i) m[i] = data[i]; }reserve(n)是最省心的写法:它内部会按n / max_load_factor()计算需要的桶数。
3.3 遍历时删除
#include <unordered_map> #include <iostream> int main() { std::unordered_map<int, int> m{{1, 1}, {2, 2}, {3, 3}, {4, 4}}; // C++11 起 erase 返回下一个迭代器 for (auto it = m.begin(); it != m.end(); ) { if (it->first % 2 == 0) it = m.erase(it); // 正确:接收返回值 else ++it; } for (const auto& [k, v] : m) std::cout << k << ' '; // 1 3 }3.4 自定义桶迭代(可选)
#include <unordered_map> #include <iostream> int main() { std::unordered_map<int, int> m; for (int i = 0; i < 10; ++i) m[i] = i * i; std::cout << "桶数: " << m.bucket_count() << '\n'; std::cout << "负载因子: " << m.load_factor() << '\n'; std::cout << "最大负载因子: " << m.max_load_factor() << '\n'; // 遍历 3 号桶里的元素 for (auto it = m.begin(3); it != m.end(3); ++it) std::cout << it->first << ' '; }四、unordered 与 ordered 容器选型
| 维度 | unordered_map/unordered_set | map/set |
|---|---|---|
| 底层结构 | 哈希表 | 红黑树 |
| 查找/插入/删除 | 平均O(1),最坏O(n) | 稳定O(log n) |
| 元素顺序 | 无序(不保证) | 按键升序 |
| 迭代器失效 | rehash 时全部失效 | 仅被删元素失效 |
| 需要的能力 | hash+== | <(严格弱序) |
| 范围查询 | 不支持 | 支持lower_bound等 |
| 内存开销 | 桶数组 + 节点指针 | 节点指针(3 个) |
| 抗哈希攻击 | 可能退化 | 不会 |
选择建议:
- 只做"键 → 值"的单点查找,且键可哈希 → 用
unordered_* - 需要有序遍历、范围查询(
[a, b))→ 用map/set - 键类型没有天然哈希(比如自定义结构体),且不想写哈希函数 → 用
map - 键是
int/string且数据量小(几十个)→ 两者差别不大,map反而更省内存
常见坑点
坑 1:用operator[]做"只读查找",意外插入
❌ 错误写法:
std::unordered_map<std::string, int> m; if (m["missing"] == 0) { // 偷偷插入了一个 {missing, 0}! // ... } std::cout << m.size(); // 1,不是 0operator[]的语义是"不存在就默认构造并插入",它永远不是只读的。而且如果T没有默认构造函数,直接编译报错。
✅ 正确写法:
if (auto it = m.find("missing"); it != m.end() && it->second == 0) { // ... } std::cout << m.size(); // 0,干净坑 2:rehash 导致迭代器全部失效
❌ 错误写法:
std::unordered_map<int, int> m; auto it = m.begin(); for (int i = 0; i < 1000000; ++i) { m[i] = i; // 中途 rehash,it 变成野指针 } std::cout << it->first; // 未定义行为✅ 正确写法:先reserve,或者不保存迭代器,或者用返回值重新获取。
m.reserve(1000000);注意规则差异:unordered_map的reserve/rehash会让所有迭代器失效;而std::vector的reserve只在扩容时失效。两者别记混。
坑 3:保存operator[]返回的引用后又插入
❌ 错误写法:
std::unordered_map<int, std::string> m; m[1] = "a"; std::string& ref = m[1]; for (int i = 2; i < 100; ++i) m[i] = "x"; // 可能 rehash ref += "b"; // ref 可能已悬空关键点:unordered_map的rehash 只让迭代器失效,不会让指向元素的引用/指针失效(节点本身不移动,只是桶指针重排)。所以严格来说这个例子在标准下是安全的 —— 但删除元素会让该元素的引用失效:
std::string& ref = m[1]; m.erase(1); ref += "b"; // 真的悬空了,未定义行为结论:引用只在"不删除"的前提下有效,reserve之后更不能想当然。
坑 4:erase(key)与erase(iterator)混用
❌ 错误写法:
for (auto it = m.begin(); it != m.end(); ++it) { if (it->second == 0) m.erase(it->first); // 用 key 删,it 立即失效,再 ++it 是 UB }✅ 正确写法(二选一):
// 写法 1:用迭代器版本并接住返回值 for (auto it = m.begin(); it != m.end(); ) { if (it->second == 0) it = m.erase(it); else ++it; } // 写法 2:先收集 key,再统一删除 std::vector<int> dead; for (const auto& [k, v] : m) if (v == 0) dead.push_back(k); for (int k : dead) m.erase(k);C++20 还有std::erase_if(m, pred),一步到位:
std::erase_if(m, [](const auto& kv) { return kv.second == 0; });坑 5:哈希函数写得差,退化成链表
❌ 错误写法:
struct BadHash { size_t operator()(const Point& p) const { return p.x; } // 忽略 y };如果所有点的x都相同,全部落进同一个桶,查找复杂度退化为O(n)。
✅ 正确写法:让哈希值充分混合所有字段。
struct GoodHash { size_t operator()(const Point& p) const noexcept { size_t h1 = std::hash<int>{}(p.x); size_t h2 = std::hash<int>{}(p.y); return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2)); } };0x9e3779b9是黄金比例常数,用于打散位模式,这是boost::hash_combine的经典做法。
坑 6:哈希函数与operator==不一致
这是最隐蔽的 bug:两个"相等"的对象哈希值不同,或者哈希值相同但不相等。
struct Key { int id; std::string name; // 只比较 id bool operator==(const Key& o) const { return id == o.id; } }; struct KeyHash { // 却把 name 也混进哈希 —— 违反契约! size_t operator()(const Key& k) const { return std::hash<int>{}(k.id) ^ std::hash<std::string>{}(k.name); } };契约:a == b必须推出hash(a) == hash(b)。反过来(哈希相同但不等)是允许的,叫哈希冲突。上面代码Key{1,"a"}和Key{1,"b"}相等却哈希不同,会导致"插入进去了却查不到"的诡异现象。
✅ 正确写法:哈希只使用参与operator==的字段。
struct KeyHash { size_t operator()(const Key& k) const { return std::hash<int>{}(k.id); // 只哈希 id } };坑 7:std::hash对pair/ 自定义类型没有特化
std::unordered_map<std::pair<int,int>, int> m; // 编译错误标准库不为pair、vector、自定义结构体提供std::hash(只有基本类型、string、智能指针等有)。必须自己写:
struct PairHash { size_t operator()(const std::pair<int,int>& p) const noexcept { size_t h1 = std::hash<int>{}(p.first); size_t h2 = std::hash<int>{}(p.second); return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2)); } }; std::unordered_map<std::pair<int,int>, int, PairHash> m; // OK坑 8:认为迭代顺序等于插入顺序
std::unordered_set<int> s; for (int i = 0; i < 5; ++i) s.insert(i); for (int x : s) std::cout << x << ' '; // 可能是 4 3 2 1 0,也可能别的不要依赖任何顺序。更要命的是,不同标准库实现(libstdc++ / MSVC STL / libc++)结果不同,同一实现在不同版本也可能变。需要有序输出就拷进vector排序。
std::vector<int> v(s.begin(), s.end()); std::sort(v.begin(), v.end());总结
| 主题 | 要点 |
|---|---|
| 底层 | 桶数组 + 链表,hash % bucket_count定位 |
| 复杂度 | 平均O(1),最坏O(n)(哈希退化) |
| 顺序 | 无序,不可依赖;需要顺序请用map/set |
| 插入 | 只读查找用find/contains,别用operator[] |
| 扩容 | 已知规模先reserve,否则会不断 rehash |
| 失效 | rehash 让迭代器全部失效,引用仍有效(除非删除) |
| 删除 | it = m.erase(it),或用 C++20 的erase_if |
| 自定义键 | 需hash+operator==,且两者必须一致 |
一句话记住它:unordered_*是用"放弃顺序"换"平均 O(1)"的容器。想清楚你是否真的不需要顺序,再决定用哪一个。