在 C++ 标准库中,std::unordered_set 和 std::unordered_map 是使用频率极高的两个关联容器。它们都基于哈希表实现,都提供平均 O(1) 的查找性能,名字也长得很像,因此经常被初学者混用或混淆。本文从底层结构、存储内容、接口差异、性能特征和选型建议几个角度,把两者的区别讲清楚。
一、一句话概括区别
unordered_set 存的是"键本身",unordered_map 存的是"键到值的映射"。
换句话说,unordered_set 只关心"某个元素在不在集合里",而 unordered_map 关心"某个键对应什么值"。前者是集合(Set),后者是字典/映射(Map)。
二、底层结构:同为哈希表,但节点内容不同
两者在标准中都没有规定具体实现,但主流实现(libstdc++、libc++、MSVC STL)都采用**链地址法(separate chaining)**的哈希表:一个桶数组,每个桶挂一条链表(或红黑树,见下文)。
真正的差异在于节点里存什么:
unordered_set<Key>的节点只存一个Key,哈希函数作用于Key,相等比较也作用于Key。unordered_map<Key, T>的节点存一个std::pair<const Key, T>,哈希和相等比较只作用于Key,T只是被"携带"的值。
因此从内存角度看,unordered_map 每个节点比 unordered_set 多出一个 T 的空间(以及可能的对齐填充)。当 T 很大时,这个差距会非常明显。
三、模板参数与类型定义对比
先看两者的声明形式:
cpp
template<
class Key,
class Hash = std::hash<Key>,
class KeyEqual = std::equal_to<Key>,
class Allocator = std::allocator<Key>
> class unordered_set;
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;
可以看到 unordered_map 多了一个 T 参数,分配器分配的元素类型也从 Key 变成了 std::pair<const Key, T>。
对应的 value_type 差异是理解两者接口的关键:
| 项目 | unordered_set<Key> | unordered_map<Key, T> |
|---|---|---|
value_type |
Key |
std::pair<const Key, T> |
key_type |
Key |
Key |
mapped_type |
不存在 | T |
| 迭代器解引用得到 | const Key& |
pair<const Key, T>& |
注意 unordered_set 的迭代器解引用得到的是 const Key&,因为修改键会破坏哈希表结构;而 unordered_map 的迭代器允许修改 second(值),但 first(键)同样是 const 的。
四、核心接口差异
4.1 插入方式
unordered_set 直接插入元素本身:
cpp
std::unordered_set<std::string> words;
words.insert("hello");
words.emplace("world");
unordered_map 插入的是键值对,常用 operator[]、insert、emplace、try_emplace:
cpp
std::unordered_map<std::string, int> counter;
counter["hello"] = 1; // 不存在则默认构造 int(0) 再赋值
counter.insert({"world", 2}); // 已存在则不覆盖
counter.emplace("cpp", 3); // 原地构造
counter.try_emplace("rust", 4); // C++17,键已存在时不构造 value
这里有一个经典陷阱:operator[] 在键不存在时会默认构造 一个 T 并插入,因此它不能用于 const map,也可能意外插入元素。只想查询时应该用 find 或 at。
4.2 查找与访问
两者都有 find、count、contains(C++20):
cpp
if (words.contains("hello")) { /* ... */ }
auto it = counter.find("hello");
if (it != counter.end()) {
std::cout << it->second << '\n'; // map 通过 second 取值
}
区别在于:unordered_set 找到后拿到的就是元素本身;unordered_map 找到后拿到的是 pair,需要通过 it->second 访问值。此外 unordered_map 独有 operator[] 和 at() 两个按键访问值的接口。
4.3 删除
两者都支持按迭代器、按键、按范围删除,接口基本一致:
cpp
words.erase("hello");
counter.erase("world");
五、完整示例对比
下面用同一个需求------统计一段文本中每个单词出现的次数,并判断某个单词是否出现过------来对比两种写法。
cpp
#include <iostream>
#include <sstream>
#include <string>
#include <unordered_map>
#include <unordered_set>
int main() {
std::string text = "the quick brown fox jumps over the lazy dog the fox";
std::istringstream iss(text);
std::string word;
// unordered_map:统计词频
std::unordered_map<std::string, int> freq;
while (iss >> word) {
++freq[word];
}
for (const auto& [w, c] : freq) {
std::cout << w << " : " < c << '\n';
}
// unordered_set:只关心去重后的单词集合
std::unordered_set<std::string> unique_words;
std::istringstream iss2(text);
while (iss2 >> word) {
unique_words.insert(word);
}
std::cout << "unique count = " < unique_words.size() << '\n';
std::cout << "has fox? " << (unique_words.count("fox") ? "yes" : "no") << '\n';
return 0;
}
可以看到:需要"值"的时候用 unordered_map,只需要"存在性"的时候用 unordered_set,后者更省内存、语义也更清晰。
六、性能与内存对比
| 维度 | unordered_set | unordered_map |
|---|---|---|
| 平均查找/插入/删除 | O(1) | O(1) |
| 最坏情况 | O(n) | O(n) |
| 单节点内存 | Key + 指针/控制信息 | Key + T + 指针/控制信息 |
| 迭代顺序 | 无序 | 无序 |
| 迭代器失效 | rehash 时全部失效,删除仅影响被删元素 | 同左 |
几点值得注意:
- 最坏情况都是 O(n) :当所有键哈希到同一个桶时,链表退化为线性查找。C++11 起标准要求
rehash后仍保持平均 O(1),但恶意构造的哈希碰撞仍可能拖慢性能。 - rehash 会失效迭代器 :插入导致负载因子超过
max_load_factor时会触发 rehash,所有迭代器失效,但引用和指针仍然有效(因为节点本身没有移动)。 - 内存开销 :两者每个元素都有额外的指针开销,元素数量很少时反而不如
std::vector或std::map划算。
七、如何选择
- 只需要判断元素是否存在、去重 :用
unordered_set。 - 需要键到值的映射、计数、缓存、索引 :用
unordered_map。 - 需要有序遍历或范围查询 :两者都不合适,应改用
std::set/std::map(基于红黑树,O(log n))。 - 键的类型没有现成的
std::hash特化:需要自己提供哈希函数,两者都要做同样的工作。 - 对内存极度敏感 :优先考虑
unordered_set,或使用开源的扁平哈希表(如absl::flat_hash_map)。
八、常见误区
- 误以为 unordered_map 可以用迭代器修改键 :
it->first是const Key,编译期就会报错。 - 用
operator[]做只读查询 :会意外插入默认值,应改用find/at/contains。 - 认为两者性能完全一样 :
unordered_map节点更大,缓存局部性更差,在元素多、值类型大时差距明显。 - 依赖遍历顺序:两者都是无序容器,遍历顺序取决于哈希函数和桶数量,不同标准库实现、不同版本都可能不同。
九、小结
unordered_set 和 unordered_map 共享同一套哈希表设计思想,核心区别只有一条:是否携带映射值 。理解这一点后,模板参数、value_type、迭代器解引用类型、operator[] 的有无、内存开销的差异,就都能顺理成章地推导出来。实际编码时,先问自己"我只需要知道在不在,还是需要拿到对应的值",答案自然就出来了。