☰
C++ unordered_map和unordered_set的使用示例详解
2026/10/8 14:38:15 网站建设 项目流程

前言

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时:


  1. 计算h = hash(k)

  2. 用h % bucket_count()得到桶下标

  3. 把节点挂到该桶里


因为下标由哈希值决定,桶内遍历顺序与插入顺序无关,所以整个容器遍历出来是"乱"的。这正是"无序"的含义:它不保证任何顺序,也不承诺两次运行顺序一致。

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。当插入导致负载因子超过它,容器会:


  1. 申请更大的桶数组(通常是下一个质数或约 2 倍)

  2. 重新计算每个元素的桶位置(rehash)

  3. 释放旧桶数组


这一步是O(n)的,且会让所有迭代器失效。这就是"循环里反复插入会慢"的根源。

1.3 unordered_map 与 unordered_set 的关系

对比项unordered_mapunordered_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的键,需要两样东西:


  1. 哈希函数:能被std::hash<Key>或自定义Hash调用

  2. 相等比较: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_setmap/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,不是 0

operator[]的语义是"不存在就默认构造并插入",它永远不是只读的。而且如果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)"的容器。想清楚你是否真的不需要顺序,再决定用哪一个。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询