CC++ & Algorithm

unordered_set哈希集合——像超级快速的储物柜

较难7
语言版本:通用
概述:用生活中的储物柜比喻哈希集合,讲解unordered_set如何实现元素的唯一、无序和快速查找,并给出C++和Python的完整代码示例。

unordered_set哈希集合——像超级快速的储物柜

你有没有遇到过这种情况:老师让你检查班级里有没有重复的学号?或者游戏里需要记录你已经访问过的关卡,避免重复进入?这时候,你需要一种能够快速查找、并且自动去重的“神奇盒子”。在计算机里,这个盒子就是 哈希集合(Hash Set)。它像一台超级智能的储物柜:无论你要存放什么东西(数字、字符串、甚至自定义对象),它都能瞬间计算出应该放在哪个柜子,而且如果两样东西一模一样,它会告诉你“这个已经有了”。这种数据结构能让你用极快的速度(平均只需一步)完成插入、删除和查找。

今天我们就来认识两种常见的哈希集合:C++ 的 unordered_set 和 Python 的 set。它们都基于哈希表(hash table)实现,拥有唯一、无序、快速查找的特点。


从生活中的例子引入

想象一下,你有一个超级大的储物柜,每个柜子都有一个号码。但不同的是,这个储物柜有一个神奇的本领:无论你要存放什么东西,它都能瞬间计算出一个号码,然后直接把东西放进对应的柜子里。而且,如果两个一模一样的东西想要放进去,储物柜会告诉你“这个已经存在了,不能再放”。这就像我们去图书馆借书,每本书都有唯一的条码,如果扫到同样的条码,系统会说“这本书已经借出了”。这种只能存一份、查找超级快的数据结构,就是计算机中的“哈希集合”。

在C++中,unordered_set(无序集合)就是一种哈希集合。它就像那个神奇的储物柜,里面的每个元素都是独一无二的(不能重复),而且存储的顺序和插入的顺序无关(无序)。你可以很快地检查某个元素是否存在,插入一个新元素,或者删除一个元素——这些操作平均只需要常数时间(O(1))。

理解STL的unordered_set原理

unordered_set底层使用哈希表(hash table)。哈希表工作的核心是哈希函数:给定一个元素,哈希函数计算出一个整数(哈希值),这个整数决定了元素应该放在哈希表的哪个“桶”(bucket)里。因为哈希函数设计得好的话,不同的元素会被均匀分配到不同的桶,这样查找时只需要到对应的桶里找,不需要遍历整个表,所以非常快。

但是,不同的元素也可能被计算到同一个桶(这叫“哈希冲突”),不过别担心,C++的unordered_set内部会处理冲突(通常用链表法或开放地址法)。你只需要知道:平均情况下插入、删除、查找都是O(1)的时间复杂度,但最坏情况下(比如所有元素都冲突到同一个桶)会退化成O(n),不过实际使用中很少发生。

unordered_set最重要的特点是:元素不可重复。如果你想放一个已经存在的元素,插入操作会失败(不会覆盖原有元素)。另外,元素没有固定的顺序,所以你不能像使用vector那样用下标访问。

哈希函数和冲突解决——深入浅出

哈希函数就像一个“指纹生成器”。对于整数,它可能直接取模;对于字符串,它会根据每个字符计算出一个数字。好的哈希函数会让不同的元素尽量得到不同的桶号,减少冲突。当冲突发生时(两个不同的元素被分到了同一个桶),unordered_set会在该桶内用链表(或红黑树)把所有冲突的元素串起来。这意味着:如果很多元素都挤到同一个桶,查找就会变慢(需要遍历那个链表)。但因为哈希函数设计得够好,这种情况很少出现。

一个小比喻:每个桶好比一个乒乓球筐,哈希函数给每个球分配一个筐号。如果筐号分配得均匀,每个筐里只有一两个球,找球就很快;如果分配得很差,所有球都掉进同一个筐里,那就得在一堆球里翻来翻去。

常用成员函数一览

函数说明示例
insert(val)插入元素val,如果已存在则什么都不做。返回pair<iterator,bool>,bool表示是否成功插入mySet.insert(42);
erase(val)删除值为val的元素,返回删除的元素个数(0或1)mySet.erase(42);
find(val)查找元素,返回迭代器,如果找不到则返回end()auto it = mySet.find(42);
count(val)返回值为val的元素个数(因为集合唯一,所以要么0要么1)if (mySet.count(42)) ...
size()返回集合中元素个数int n = mySet.size();
empty()判断集合是否为空if (mySet.empty()) ...
clear()清空所有元素mySet.clear();
begin() / end()获取开始/结束迭代器,用于遍历for (auto& x : mySet) ...

注意:unordered_set的迭代器是前向迭代器,可以遍历,但不能随机访问(不支持it + n操作)。

C++完整代码实现(带详细注释)

下面是一个完整的C++程序,演示unordered_set的基本用法,包括插入、查找、删除和遍历。

#include <iostream>
#include <unordered_set>  // 使用unordered_set需要包含此头文件
using namespace std;

int main() {
    // 创建一个存储整数的unordered_set
    unordered_set<int> mySet;

    // 插入一些元素
    mySet.insert(10);
    mySet.insert(20);
    mySet.insert(30);
    mySet.insert(20);  // 重复插入20,因为20已存在,这次插入会失败

    // 使用insert的返回值来检查是否真的插入了
    auto result = mySet.insert(20);
    if (result.second == false) {
        cout << "20已经存在,第二次插入失败了" << endl;
    }

    // 打印当前集合的大小
    cout << "集合中元素个数: " << mySet.size() << endl;  // 输出3

    // 查找元素:find返回迭代器,如果等于end()说明没找到
    int key = 20;
    auto it = mySet.find(key);
    if (it != mySet.end()) {
        cout << "找到了元素: " << *it << endl;
    } else {
        cout << "没有找到元素: " << key << endl;
    }

    // 另一种查找方法:count
    if (mySet.count(30) > 0) {
        cout << "30存在于集合中" << endl;
    }

    // 删除元素
    mySet.erase(10);  // 删除值为10的元素
    cout << "删除后大小: " << mySet.size() << endl;  // 输出2

    // 遍历集合(注意顺序是不确定的)
    cout << "当前集合中的元素:";
    for (int x : mySet) {
        cout << " " << x;
    }
    cout << endl;

    // 清空集合
    mySet.clear();
    cout << "清空后是否为空: " << (mySet.empty() ? "是" : "否") << endl;

    return 0;
}

运行结果示例(每次可能不一样,因为顺序随机):

20已经存在,第二次插入失败了
集合中元素个数: 3
找到了元素: 20
30存在于集合中
删除后大小: 2
当前集合中的元素: 30 20
清空后是否为空: 是

存储自定义类型

unordered_set还可以存储自定义类型(比如结构体),但需要提供哈希函数和相等比较。稍后会在第四个知识点中详细讲解。这里简单提一下:如果你存储的是指针,注意指针地址比较。

时间复杂度总结

  • 插入操作:平均O(1),最坏O(n)
  • 删除操作:平均O(1),最坏O(n)
  • 查找操作:平均O(1),最坏O(n)
  • 遍历操作:O(n)

Python等价功能的代码实现(带详细注释)

Python中的set(集合)就是哈希集合,和C++的unordered_set非常相似。下面演示同样的功能:

# Python的set就是哈希集合
my_set = set()  # 创建一个空集合

# 插入元素
my_set.add(10)
my_set.add(20)
my_set.add(30)
result = my_set.add(20)  # 重复添加20,不会报错,但集合仍然只有一个20
# 注意:add没有返回值,但我们可以通过len检查

print("集合中元素个数:", len(my_set))  # 输出3

# 查找元素:用in运算符
key = 20
if key in my_set:
    print("找到了元素:", key)
else:
    print("没有找到元素:", key)

# also可以用count? 实际上可以用in, 没有count方法, 但可以:
print("20是否存在:", key in my_set)

# 删除元素
my_set.discard(10)  # discard不会报错即使不存在,remove会报错
# 或者 my_set.remove(10)  # 如果10不存在会抛出KeyError
print("删除后大小:", len(my_set))  # 输出2

# 遍历集合(顺序不确定)
print("当前集合中的元素:", end=" ")
for x in my_set:
    print(x, end=" ")
print()

# 清空集合
my_set.clear()
print("清空后是否为空:", len(my_set) == 0)  # 输出True

Python的set简单易用,很多内建操作如inlen等,不需要像C++那样处理迭代器。另外,Python set也支持集合运算:并集|、交集&、差集-、对称差^

Python集合运算的实用例子

想象你在整理两本课外书里的生词:

book1 = {"apple", "banana", "cherry", "date"}
book2 = {"banana", "date", "elderberry", "fig"}

# 并集:两本书里出现过的所有生词
all_words = book1 | book2   # 或 book1.union(book2)
print("所有生词:", all_words)

# 交集:两本书都出现的生词
common = book1 & book2   # 或 book1.intersection(book2)
print("共同生词:", common)

# 差集:只在第一本书里出现的生词
only_book1 = book1 - book2   # 或 book1.difference(book2)
print("只在第一本中:", only_book1)

# 对称差:两本书中不重复出现的生词
unique_per_book = book1 ^ book2   # 或 book1.symmetric_difference(book2)
print("各自独有的生词:", unique_per_book)

输出:

所有生词: {'cherry', 'elderberry', 'fig', 'date', 'banana', 'apple'}
共同生词: {'date', 'banana'}
只在第一本中: {'cherry', 'apple'}
各自独有的生词: {'elderberry', 'cherry', 'apple', 'fig'}

这些运算在去重、比较数据时非常有用。

新手容易犯的错误

  1. 误以为元素有序
    unordered_set中的元素没有固定顺序,不能像数组一样用下标访问。如果你需要有序的集合,应该使用set(红黑树实现,插入删除O(log n))。

  2. 插入重复元素但以为会覆盖
    unordered_set中,重复插入同一个值不会覆盖已有元素,而是什么也不做。如果你需要允许重复,应该用unordered_multiset

  3. 在遍历时修改元素
    你不能通过迭代器直接修改集合中的元素(因为修改后可能改变它的哈希值,导致找不到)。如果要修改,必须先删除原元素再插入新元素。

  4. 忘记包含头文件
    C++中使用unordered_set必须包含<unordered_set>头文件,否则编译会报错。

  5. 自定义类型未提供哈希函数
    如果你打算在unordered_set中存储自定义的结构体,必须提供哈希函数和相等比较函数(重载operator==)。否则编译器会报错。例如:

struct Student {
    int id;
    string name;
    // 需要提供相等比较
    bool operator==(const Student& other) const {
        return id == other.id;
    }
};

// 自定义哈希函数
struct StudentHash {
    size_t operator()(const Student& s) const {
        return hash<int>()(s.id);
    }
};

unordered_set<Student, StudentHash> roster;
  1. 误用count来检查元素是否存在(C++)
    count返回0或1,可以判断是否存在。但要注意:有些新手会写成if (mySet.count(key) == 1),这没错,但更推荐用if (mySet.find(key) != mySet.end()),因为count有时会遍历整个桶(在多集合中可能效率低),不过对于unordered_set差别不大。

  2. Python中removediscard混用
    remove如果元素不存在会抛出KeyErrordiscard则静默处理。如果你不确定元素是否存在,建议用discard,或者先用in检查。

完整示例:用哈希集合作班级点名去重

假设老师要统计全班同学的学号,防止重复登记。下面是一个C++示例:

#include <iostream>
#include <unordered_set>
#include <vector>
using namespace std;

int main() {
    // 假设已登记的学号列表(可能有重复)
    vector<int> registered = {101, 102, 103, 101, 104, 102, 105};
    
    // 使用unordered_set去重
    unordered_set<int> unique_ids;
    for (int id : registered) {
        // insert返回pair,second表示是否插入成功
        auto result = unique_ids.insert(id);
        if (!result.second) {
            cout << "学号 " << id << " 已存在,跳过重复" << endl;
        }
    }

    cout << "最终不重复的学号个数: " << unique_ids.size() << endl;
    cout << "学号列表: ";
    for (int id : unique_ids) {
        cout << id << " ";
    }
    cout << endl;

    // 快速查找某个学号是否存在
    int target = 103;
    if (unique_ids.find(target) != unique_ids.end()) {
        cout << "学号 " << target << " 在集合中" << endl;
    }

    return 0;
}

输出:

学号 101 已存在,跳过重复
学号 102 已存在,跳过重复
最终不重复的学号个数: 5
学号列表: 105 104 103 102 101 
学号 103 在集合中

总结要点和注意事项

  1. unordered_set的核心是哈希表,提供平均常数时间的插入、删除、查找,但元素顺序不确定,且不能重复。
  2. 使用场景:当你需要快速判断一个元素是否存在,并且不需要元素有顺序时,unordered_set是理想选择。例如:记录已访问过的节点、去重等。
  3. C++与Python差异
    • C++的insert返回一个pair,可以用来判断是否插入成功;Python的add直接修改集合,不返回状态。
    • C++用findcount查找;Python用in运算符更直观。
    • C++需要包含头文件<unordered_set>;Python的set是内置类型。
  4. 性能注意:虽然平均很快,但哈希函数的选择会影响性能。如果哈希函数很差,会导致大量冲突,退化成链表。C++标准库的实现会自动处理,但自定义类型时需要自己提供好的哈希函数。
  5. 不可存放重复元素:如果同一个值(根据相等比较)多次插入,只有第一次有效。
  6. 迭代器稳定性:插入操作可能会导致哈希表rehash(重新分配桶),从而使已有迭代器失效。删除操作通常不会导致迭代器失效,除非被删除的正是迭代器指向的元素。

相关指引

掌握了unordered_set,你就拥有了一个“超级快速查找”的利器。接下来建议学习:

  • unordered_map(哈希映射):相当于给每个元素关联了一个“值”,就像给储物柜里的每个物品贴上了标签。它和unordered_set类似,但能存储键值对。
  • set(有序集合):如果需要元素按顺序排列(比如从小到大),可以用set,但它的插入和查找速度是O(log n),比哈希集合慢一些。
  • unordered_multiset / multiset:如果你需要允许重复元素的集合,可以用它们(前者哈希,后者有序)。

在Python中,除了set,还可以了解frozenset(不可变集合,可以作为字典的键)以及collections.Counter(用于计数,更像一个允许重复的集合)。

继续探索,你会发现这些容器就像万能工具箱,总有一款适合你的问题!

例题精讲

1单选题

关于C++中的unordered_set容器,以下哪个说法是正确的?

Aunordered_set中的元素按照插入顺序存储
Bunordered_set允许存储重复元素
Cunordered_set的查找操作平均时间复杂度为O(1)
Dunordered_set底层实现是红黑树
2判断题

在Python中,set类型的行为与C++的unordered_set类似,都支持元素的唯一性和快速查找,但不保持元素的顺序。

3填空题
以下C++代码试图统计一个数组中不同整数的个数,请补全空缺部分。<br><code>int countDistinct(vector<int>& nums) {
    ___ st;
    for (int x : nums) {
        st.insert(x);
    }
    return st.size();
}</code>
4单选题

下列关于unordered_set和set的说法中,错误的是?

Aset的元素是有序的,unordered_set的元素是无序的
Bset的底层是红黑树,unordered_set的底层是哈希表
C在数据量较大且不需要有序性的场景下,unordered_set通常比set更快
Dunordered_set允许通过迭代器修改元素的值
5填空题
以下Python代码使用set(类似于unordered_set)去除列表中的重复元素,请补全空缺。<br><code>def remove_duplicates(lst):
    return list(___)</code>