std::unordered_set 是一个非常实用且高效的容器。它与 std::unordered_map 有着相似的底层实现,但在使用场景和特性上有自己的特点。让我为你深入解析。
1. 为什么引入?解决的痛点 (The "Why")
在C++11之前,我们主要使用 std::set 作为集合容器,它基于红黑树实现,提供了有序的存储。
std::set 的局限性:
- O(log n) 的时间复杂度:查找、插入、删除都是对数时间复杂度。
- 需要元素的可比较性:元素类型必须定义
<操作符。 - 内存开销相对较大:树结构需要存储额外的指针信息。
std::unordered_set 的引入,是为了提供接近常数时间 O(1) 复杂度的集合操作,特别适用于需要快速判断元素是否存在但不需要排序的场景。
典型应用场景:
- 去重操作:快速从序列中去除重复元素。
- 存在性检查:检查某个元素是否在集合中(如单词拼写检查、用户ID验证)。
- 集合运算:交集、并集、差集等操作。
- 缓存实现:实现布隆过滤器或其他需要快速查找的数据结构。
- 图算法:记录已访问的节点。
2. 是什么? (The "What")
std::unordered_set 是 C++11 引入的基于哈希表实现的集合容器。
- 它存储唯一的元素:每个元素在集合中只出现一次。
- 元素不按任何特定顺序存储:与
std::set的有序性相反,unordered_set中的元素顺序是不确定的。 - 提供平均 O(1) 的访问性能:在理想情况下,查找、插入、删除操作都是常数时间复杂度。
- 最坏情况 O(n):当哈希冲突严重时,性能会退化。
- 它是
std::unordered_map的简化版:可以看作是只有键没有值的unordered_map。
简单来说,std::unordered_set 是一个"使用哈希表快速查找的唯一元素集合",专注于元素的存在性检查。
3. 内部的实现原理 (The "How-it-works")
std::unordered_set 的底层实现与 std::unordered_map 几乎完全相同,都是基于哈希表和链地址法。
核心组件:
-
哈希函数(Hash Function)
- 作用:将元素映射到哈希值。
- 使用
std::hash模板特化。
-
桶数组(Bucket Array)
- 存储多个"桶",每个桶是一个链表的头指针。
-
链表节点
- 存储实际的元素值(而不是键值对)。
- 哈希冲突的元素被放入同一桶的链表中。
工作流程(以插入为例):
std::unordered_set<int> set;
set.insert(42);
- 计算哈希:对元素
42调用哈希函数,得到哈希值h。 - 确定桶索引:计算
h % bucket_count,得到桶索引。 - 处理冲突:
- 如果桶为空,直接创建新节点插入。
- 如果桶不为空,遍历链表,用
key_eq()比较函数检查是否已存在相同的元素。- 如果找到相同元素,不进行插入(保持唯一性)。
- 如果没找到,在链表末尾添加新节点。
关键机制:
1. 负载因子与重哈希
与 unordered_map 完全相同:
- 负载因子 =
size() / bucket_count() - 当负载因子超过
max_load_factor()时,自动进行重哈希操作。
2. 迭代器失效规则
也与 unordered_map 相同:
- 插入操作:如果导致重哈希,所有迭代器失效;否则只有指向被修改桶的迭代器可能失效。
- 删除操作:只有指向被删除元素的迭代器失效。
4. 怎么正确使用 (The "How-to-use")
1. 基本操作
#include <unordered_set>
#include <iostream>
std::unordered_set<int> numbers;
// 插入元素
numbers.insert(1);
numbers.insert(2);
numbers.insert(3);
numbers.insert(1); // 重复插入,会被忽略
// 查找元素(主要用途)
if (numbers.find(2) != numbers.end()) {
std::cout << "2 exists in the set" << std::endl;
}
// 检查元素是否存在(C++20 引入的便捷方法)
if (numbers.contains(3)) { // 更直观的语法
std::cout << "3 exists in the set" << std::endl;
}
// 删除元素
numbers.erase(2);
// 获取大小
std::cout << "Size: " << numbers.size() << std::endl;
// 遍历所有元素
for (const auto& num : numbers) {
std::cout << num << " ";
}
std::cout << std::endl;
// 使用初始化列表构造
std::unordered_set<std::string> words = {"hello", "world", "hello"};
// 结果只包含 "hello" 和 "world"
2. 性能优化技巧
预分配桶数量:提前预留空间避免多次重哈希。
std::unordered_set<int> big_set;
big_set.reserve(10000); // 预分配空间
for (int i = 0; i < 10000; ++i) {
big_set.insert(i);
}
设置最大负载因子:对于性能敏感的场景。
std::unordered_set<int> sensitive_set;
sensitive_set.max_load_factor(0.75); // 设置更严格的重哈希条件
3. 自定义元素类型
如果要用自定义类型作为元素,需要提供哈希函数和相等比较函数:
struct Person {
std::string name;
int age;
// 相等比较运算符(必需)
bool operator==(const Person& other) const {
return name == other.name && age == other.age;
}
};
// 方法1:为 std::hash 提供特化
namespace std {
template<>
struct hash<Person> {
size_t operator()(const Person& p) const {
// 组合哈希:基于name和age
return hash<string>()(p.name) ^ (hash<int>()(p.age) << 1);
}
};
}
// 使用
std::unordered_set<Person> people;
people.insert({"Alice", 30});
people.insert({"Bob", 25});
// 方法2:将哈希函数作为模板参数传递
struct PersonHash {
size_t operator()(const Person& p) const {
return hash<string>()(p.name) ^ (hash<int>()(p.age) << 1);
}
};
std::unordered_set<Person, PersonHash> people2;
4. 重要注意事项和陷阱
元素不可修改:
std::unordered_set<int> set = {1, 2, 3};
for (auto it = set.begin(); it != set.end(); ++it) {
// *it = 10; // 错误!元素是 const 的,不能修改
}
这是因为修改元素可能会改变其哈希值,破坏哈希表的结构。如果需要"修改"元素,正确的做法是先删除旧元素,再插入新元素。
引用和指针作为元素的危险性:
std::unordered_set<const char*> string_set;
string_set.insert("hello");
string_set.insert("hello"); // 可能插入两次!取决于字符串字面量的地址
// 正确的做法是使用 std::string
std::unordered_set<std::string> safe_string_set;
safe_string_set.insert("hello");
safe_string_set.insert("hello"); // 只会有一个元素
5. 实用技巧和模式
快速去重:
std::vector<int> numbers = {1, 2, 2, 3, 3, 3, 4, 4, 4, 4};
std::unordered_set<int> unique_numbers(numbers.begin(), numbers.end());
std::vector<int> result(unique_numbers.begin(), unique_numbers.end());
// result 现在包含 [1, 2, 3, 4](顺序可能不同)
集合运算:
std::unordered_set<int> set1 = {1, 2, 3, 4};
std::unordered_set<int> set2 = {3, 4, 5, 6};
// 并集
std::unordered_set<int> union_set = set1;
union_set.insert(set2.begin(), set2.end());
// 交集
std::unordered_set<int> intersection_set;
for (int elem : set1) {
if (set2.find(elem) != set2.end()) {
intersection_set.insert(elem);
}
}
// 差集 (set1 - set2)
std::unordered_set<int> difference_set;
for (int elem : set1) {
if (set2.find(elem) == set2.end()) {
difference_set.insert(elem);
}
}
5. std::unordered_set vs std::set
| 特性 | std::unordered_set | std::set |
|---|---|---|
| 底层实现 | 哈希表 | 红黑树 |
| 时间复杂度 | 平均 O(1),最坏 O(n) | 稳定 O(log n) |
| 元素顺序 | 无序 | 按比较函数排序 |
| 内存开销 | 较低(但需要桶数组) | 较高(每个节点需要多个指针) |
| 元素要求 | 需要哈希函数和相等比较 | 需要严格弱序(< 比较) |
| 迭代器稳定性 | 插入可能导致全部失效(重哈希时) | 除了被删除元素,其他稳定 |
| 使用场景 | 需要快速存在性检查,不关心顺序 | 需要元素有序遍历,或需要稳定迭代器 |
总结
| 方面 | 说明与最佳实践 |
|---|---|
| 核心价值 | 提供接近常数时间的元素存在性检查,用于快速去重和集合操作。 |
| 实现机制 | 哈希表 + 链地址法,与 unordered_map 几乎相同,但只存储键。 |
| 关键接口 | insert()、find()、contains()(C++20)、erase()。 |
| 性能优化 | 使用 reserve() 预分配,合理设置 max_load_factor()。 |
| 自定义元素 | 必须提供哈希函数和相等比较函数。 |
| 重要限制 | 元素是 const 的,不能直接修改。 |
| 选择时机 | 要极速查找且不关心顺序时选 unordered_set;要有序遍历或稳定性能时选 set。 |
std::unordered_set 是处理唯一元素集合的强大工具,特别适合需要快速判断元素是否存在的场景。理解其哈希表的实现原理、正确使用API以及避免常见的陷阱(如修改元素、错误的使用指针等),能让你在合适的场景下充分发挥其性能优势。
记住它的核心价值:当你只需要回答"这个元素在不在集合中"并且需要极快的回答速度时,unordered_set 是你的最佳选择。
C++底层机制推荐阅读
【C++基础知识】深入剖析C和C++在内存分配上的区别
【底层机制】【C++】vector 为什么等到满了才扩容而不是提前扩容?
【底层机制】malloc 在实现时为什么要对大小内存采取不同策略?
【底层机制】剖析 brk 和 sbrk的底层原理
【底层机制】为什么栈的内存分配比堆快?
【底层机制】右值引用是什么?为什么要引入右值引用?
【底层机制】auto 关键字的底层实现机制
【底层机制】std::unordered_map 扩容机制
【底层机制】稀疏文件--是什么、为什么、好在哪、实现机制
【底层机制】【编译器优化】RVO--返回值优化
【基础知识】仿函数与匿名函数对比
【底层机制】【C++】std::move 为什么引入?是什么?怎么实现的?怎么正确用?
【底层机制】emplace_back 为什么引入?是什么?怎么实现的?怎么正确用?
【底层机制】【编译器优化】循环优化--为什么引入?怎么实现的?流程啥样?
【底层机制】std::string 解决的痛点?是什么?怎么实现的?怎么正确用?
【底层机制】std::unique_ptr 解决的痛点?是什么?如何实现?怎么正确使用?
【底层机制】std::shared_ptr解决的痛点?是什么?如何实现?如何正确用?
【底层机制】std::weak_ptr解决的痛点?是什么?如何实现?如何正确用?
【底层机制】std::move 解决的痛点?是什么?如何实现?如何正确用?
【底层机制】std:: forward 解决的痛点?是什么?如何实现?如何正确用?
【计算机通识】【面向对象】当谈到OOP时我们总是说封装继承多态,为什么不是多态继承封装?
【底层机制】std::unordered_map 为什么引入?是什么?怎么实现的?怎么正确用?
【计算机通识】IoT 是什么、如何工作、关键技术、应用场景、挑战与趋势
【Android】【底层机制】为什么Android要使用Binder而不是传统的Socket?
【底层机制】Android标准C库为什么选择 bionic 而不是 musl【一】
【底层机制】鸿蒙系统为什么使用 musl【二】
【计算机通识】主流标准C库演进、差异和设计哲学【三】
关注公众号,获取更多底层机制/ 算法通俗讲解干货!