【底层机制】std:: unordered_set 为什么引入?是什么?怎么实现的?怎么正确用?

116 阅读9分钟

std::unordered_set 是一个非常实用且高效的容器。它与 std::unordered_map 有着相似的底层实现,但在使用场景和特性上有自己的特点。让我为你深入解析。


1. 为什么引入?解决的痛点 (The "Why")

在C++11之前,我们主要使用 std::set 作为集合容器,它基于红黑树实现,提供了有序的存储。

std::set 的局限性

  1. O(log n) 的时间复杂度:查找、插入、删除都是对数时间复杂度。
  2. 需要元素的可比较性:元素类型必须定义 < 操作符。
  3. 内存开销相对较大:树结构需要存储额外的指针信息。

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 几乎完全相同,都是基于哈希表链地址法

核心组件:

  1. 哈希函数(Hash Function)

    • 作用:将元素映射到哈希值。
    • 使用 std::hash 模板特化。
  2. 桶数组(Bucket Array)

    • 存储多个"桶",每个桶是一个链表的头指针。
  3. 链表节点

    • 存储实际的元素值(而不是键值对)。
    • 哈希冲突的元素被放入同一桶的链表中。

工作流程(以插入为例):

std::unordered_set<int> set;
set.insert(42);
  1. 计算哈希:对元素 42 调用哈希函数,得到哈希值 h
  2. 确定桶索引:计算 h % bucket_count,得到桶索引。
  3. 处理冲突
    • 如果桶为空,直接创建新节点插入。
    • 如果桶不为空,遍历链表,用 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_setstd::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库演进、差异和设计哲学【三】


关注公众号,获取更多底层机制/ 算法通俗讲解干货!