【C++】23. unordered_map和unordered_set的使用

发布于:2025-06-03 ⋅ 阅读:(24) ⋅ 点赞:(0)

1. unordered_set系列的使用

1.1 unordered_set和unordered_multiset参考文档

https://legacy.cplusplus.com/reference/unordered_set/

1.2 unordered_set类的详细介绍

• unordered_set的声明模板如下:

template<class Key,
         class Hash = std::hash<Key>,
         class KeyEqual = std::equal_to<Key>,
         class Allocator = std::allocator<Key>>
class unordered_set;

其中Key就是unordered_set底层存储的关键字类型,其他三个模板参数都有默认值。

• 哈希函数要求:

  • 默认情况下要求Key类型能够转换为整型(std::hash支持基本类型和字符串)
  • 如果Key是自定义类型,可以通过两种方式处理:
    1. 特化std::hash模板
    2. 自定义哈希函数对象传入第二个参数
  • 示例:对于自定义结构体Point的哈希函数
    struct PointHash {
        size_t operator()(const Point& p) const {
            return std::hash<int>()(p.x) ^ std::hash<int>()(p.y);
        }
    };
    

• 键值相等比较:

  • 默认使用std::equal_to进行相等比较
  • 对于自定义类型,可以:
    1. 重载==运算符
    2. 自定义比较函数传入第三个参数
  • 示例:自定义比较函数
    struct PointEqual {
        bool operator()(const Point& p1, const Point& p2) const {
            return p1.x == p2.x && p1.y == p2.y;
        }
    };
    

• 内存分配器:

  • 默认使用std::allocator
  • 可以传入自定义内存池实现(如boost::pool_allocator)
  • 主要用于性能优化和特殊内存管理需求

• 实际使用建议:

  • 90%的使用场景只需要关注Key类型
  • 复杂场景才需要自定义哈希/比较函数
  • 更高级的内存管理需求才需要自定义分配器

• 底层实现特点:

  • 使用哈希表(开链法解决冲突)
  • 平均时间复杂度:
    • 插入:O(1)
    • 删除:O(1)
    • 查找:O(1)
  • 最坏情况下可能退化到O(n)
  • 遍历顺序不确定(取决于哈希函数和插入顺序)

1.3 unordered_set与set的使用区别

• 查阅文档可以发现,unordered_set和set都支持增删查操作,且接口使用方式完全相同,这里不再赘述具体使用方法。

• 两者的首要区别在于对key的要求不同:

  • std::set: 元素类型 Key 必须支持严格的弱序比较。要么定义 < 运算符,要么在构造 set 时提供一个自定义的比较函数对象 (Compare)。

  • std::unordered_set: 元素类型 Key 必须满足两个要求:

    1. 支持计算哈希值:要么有 std::hash 的特化(标准类型如 intstd::string 通常已有),要么在构造 unordered_set 时提供一个自定义的哈希函数对象 (Hash)。

    2. 支持相等比较:要么定义 == 运算符,要么在构造 unordered_set 时提供一个自定义的相等比较函数对象 (Pred)。

• 第二个区别体现在迭代器特性上:

  • 元素顺序:

    • std::set: 元素是有序的。元素总是按照其内部的严格弱序(默认是 < 运算符,或用户提供的比较函数 Compare)进行升序排序。当你遍历一个 set(例如使用迭代器)时,元素会按照这个排序顺序依次出现。

    • std::unordered_set: 元素是无序的。元素的存储顺序取决于它们的哈希值和哈希表的内部状态(桶)。遍历 unordered_set 得到的元素顺序是不确定的(通常看起来是随机的),并且可能随着插入和删除操作而改变。它不保证任何特定的顺序。

  • 内存开销:

    • std::set: 通常每个元素需要额外的指针(用于树结构中的左右子节点和父节点,以及可能的颜色信息),所以内存开销相对较高(每个元素大约需要额外 2-3 个指针的开销)。

    • std::unordered_set: 需要维护哈希表本身(桶数组)以及可能的链表(或其他解决冲突的结构)。为了保持低冲突率和 O(1) 的平均性能,哈希表通常需要比实际元素数量更多的桶(负载因子 < 1),因此也可能有较高的内存开销。具体开销取决于实现和负载因子。

  • 迭代器稳定性:

    • std::set: 迭代器在元素被删除时(除了被删除元素本身的迭代器失效),通常保持稳定。插入操作通常不会使现有迭代器失效(除非容器重组,这在平衡树中不是每次插入都发生)。

    • std::unordered_set: 插入操作可能导致重哈希(当元素数量增加导致负载因子超过阈值时,哈希表会重建一个更大的桶数组并重新映射所有元素)。重哈希会使所有迭代器失效。删除操作只使指向被删除元素的迭代器失效。

• 性能差异是第三个重要区别: 多数情况下,unordered_set的增删查改操作效率更高。这是因为:

  • 查找、插入和删除的平均时间复杂度:

    • std::set: 查找(find)、插入(insert)和删除(erase)操作的平均和最坏时间复杂度都是 O(log n),其中 n 是容器中元素的数量。这是因为平衡树的高度大约是 log n。

    • std::unordered_set: 在理想情况下(好的哈希函数、低冲突):

      • 查找、插入和删除的平均时间复杂度是 O(1)(常数时间)。这是哈希表的主要优势。

      • 然而,最坏情况下的时间复杂度是 O(n)。这发生在哈希函数产生大量冲突(很多元素被映射到同一个哈希桶)时,导致查找退化成在链表(或类似结构)中线性搜索。选择一个好的哈希函数和适当的桶数量可以降低这种风险。

总结对比表:

特性 std::set std::unordered_set
底层结构 平衡二叉搜索树 (红黑树) 哈希表
元素顺序 有序 (按比较函数排序) 无序
查找/插入/删除 O(log n) (平均和最坏) O(1) (平均), O(n) (最坏)
内存开销 较高 (每个元素多个指针) 较高 (桶数组 + 冲突解决结构)
迭代器失效 插入通常不失效 (除重组), 删除只失效被删元素 插入可能因重哈希导致全部失效, 删除只失效被删元素
元素要求 需要 Compare (严格弱序) 需要 Hash (计算哈希) 和 Pred (相等比较)
典型用例 需要元素有序遍历, 需要稳定迭代器, 元素不易哈希或需范围查询 需要极快查找/插入/删除 (平均O(1)), 不关心顺序

如何选择?

  • 如果你需要元素按顺序存储和遍历,或者需要进行范围查询(如 lower_boundupper_bound),或者元素的类型不易定义良好的哈希函数,或者需要迭代器稳定性,那么选择 std::set

  • 如果你只关心元素是否存在(成员资格测试),对顺序没有要求,并且元素类型有良好、高效的哈希函数,追求平均情况下的常数时间查找、插入和删除性能,那么选择 std::unordered_set。这是更常用的集合类型,除非有明确的排序或范围查询需求。

简单来说:要顺序用 set,要速度(平均)用 unordered_set 选择时务必考虑你的具体需求(顺序、性能要求、元素类型特性、迭代器稳定性)。


请参考以下代码演示的对比差异:

#include<unordered_set>
#include<unordered_map>
#include<set>
#include<iostream>
using namespace std;
int test_set2()
{
	const size_t N = 1000000;
	unordered_set<int> us;
	set<int> s;
	vector<int> v;
	v.reserve(N);
	srand(time(0));
	for (size_t i = 0; i < N; ++i)
	{
		v.push_back(rand()); // N比较大时,重复值比较多
		//v.push_back(rand() + i); // 重复值相对少
		//v.push_back(i); // 没有重复,有序
	}
	size_t begin1 = clock();
	for (auto e : v)
	{
		s.insert(e);
	}
	size_t end1 = clock();
	cout << "set insert:" << end1 - begin1 << endl;
	size_t begin2 = clock();
	us.reserve(N);
	for (auto e : v)
	{
		us.insert(e);
	}
	size_t end2 = clock();
	cout << "unordered_set insert:" << end2 - begin2 << endl;
	int m1 = 0;
	size_t begin3 = clock();
	for (auto e : v)
	{
		auto ret = s.find(e);
		if (ret != s.end())
		{
			++m1;
		}
	}
	size_t end3 = clock();
	cout << "set find:" << end3 - begin3 << "->" << m1 << endl;
	int m2 = 0;
	size_t begin4 = clock();
	for (auto e : v)
	{
		auto ret = us.find(e);
		if (ret != us.end())
		{
			++m2;
		}
	}
	size_t end4 = clock();
	cout << "unorered_set find:" << end4 - begin4 << "->" << m2 << endl;
	cout << "插入数据个数:" << s.size() << endl;
	cout << "插入数据个数:" << us.size() << endl << endl;
	size_t begin5 = clock();
	for (auto e : v)
	{
		s.erase(e);
	}
	size_t end5 = clock();
	cout << "set erase:" << end5 - begin5 << endl;
	size_t begin6 = clock();
	for (auto e : v)
	{
		us.erase(e);
	}
	size_t end6 = clock();
	cout << "unordered_set erase:" << end6 - begin6 << endl << endl;
	return 0;
}

1. 插入数据N比较大时,重复值比较多

运行结果:

2. 插入数据重复值相对少

运行结果:

3. 没有重复,有序

运行结果:

运行结果对比可以看出,只有在完全有序时,set性能才占优,其余情况都是unordered_set更具优势,另外以上代码均在release版本下运行


2. unordered_map系列的使用

2.1 unordered_set和unordered_multiset参考文档

https://legacy.cplusplus.com/reference/unordered_map/

2.2 unordered_set与set的使用区别

• 查看STL文档我们会发现unordered_map和map都支持增删查改等基本操作,它们的接口设计几乎相同,包括insert()、erase()、find()、operator[]等常用方法。例如:

std::map<int, string> m;
m.insert({1, "one"});
std::unordered_map<int, string> um;
um.insert({1, "one"});

由于两者的接口高度一致,本节将不再重复演示基本使用方法,而着重讨论它们的关键差异。

• unordered_map和map的第一个主要差异是对key类型的要求不同:

  • std::map: 键类型 Key 必须支持严格的弱序比较。要么定义 < 运算符,要么在构造 map 时提供自定义的比较函数对象 (Compare)。

  • std::unordered_map: 键类型 Key 必须满足两个要求:

    1. 可哈希: 要么有 std::hash 的特化(标准类型如 intstd::string 通常已有),要么在构造 unordered_map 时提供自定义的哈希函数对象 (Hash)。

    2. 可相等比较: 要么定义 == 运算符,要么在构造 unordered_map 时提供自定义的相等比较函数对象 (Pred)。 例如:

    struct MyKey {
      int id;
      bool operator==(const MyKey& other) const { return id == other.id; }
    };
    namespace std {
      template<> struct hash<MyKey> {
        size_t operator()(const MyKey& k) const { return hash<int>()(k.id); }
      };
    }

这种差异源于它们的底层实现机制:map基于红黑树,而unordered_map基于哈希表。

• unordered_map和map的第二个重要差异是迭代器特性:

  • map的迭代器是双向迭代器(bidirectional iterator),支持++和--操作,可以向前和向后遍历。由于底层是红黑树(一种二叉搜索树),采用中序遍历时元素是有序的,因此map迭代器遍历时会得到按键值升序排列的结果,并且自动去重。例如:
    map<int, string> m{{2,"b"}, {1,"a"}, {3,"c"}};
    for(auto it = m.begin(); it != m.end(); ++it) 
      cout << it->first; // 输出顺序为1,2,3
    
  • unordered_map的迭代器是单向迭代器(forward iterator),只支持++操作。由于底层是哈希表,元素存储位置由哈希函数决定,因此迭代器遍历时是无序的(但仍保证去重)。例如:
    unordered_map<int, string> um{{2,"b"}, {1,"a"}, {3,"c"}};
    for(auto it = um.begin(); it != um.end(); ++it)
      cout << it->first; // 输出顺序不确定
    

• unordered_map和map的第三个关键差异是性能特点:

  • 查找、插入和删除的平均时间复杂度:

    • std::map: 查找 (find)、插入 (insert) 和删除 (erase) 操作的平均和最坏时间复杂度都是 O(log n)(n 是元素数量)。平衡树保证了稳定的对数时间复杂度。

    • std::unordered_map: 在理想情况下(好的哈希函数、低冲突):

      • 查找、插入和删除的平均时间复杂度是 O(1)(常数时间)。这是哈希表的核心优势。

      • 然而,最坏情况下的时间复杂度是 O(n)。当哈希函数产生大量冲突(许多键映射到同一个桶)时,性能会线性下降。良好的哈希函数和适当的桶大小管理至关重要。

总结对比表:

特性 std::map std::unordered_map
底层结构 平衡二叉搜索树 (红黑树) 哈希表
键/元素顺序 按键有序 (按比较函数排序) 键无序
查找/插入/删除 O(log n) (平均和最坏) O(1) (平均), O(n) (最坏)
内存开销 较高 (每个元素多个指针) 较高 (桶数组 + 冲突解决结构)
迭代器失效 插入通常不失效 (除重组), 删除只失效被删元素 插入可能因重哈希导致全部失效, 删除只失效被删元素
键要求 需要 Compare (严格弱序) 需要 Hash (计算哈希) 和 Pred (相等比较)
范围查询 支持 (lower_boundupper_bound等) 不支持 (只能精确查找或遍历)
典型用例 需要按键有序遍历/范围查询, 需要稳定迭代器, 键不易哈希 需要极快查找/插入/删除 (平均O(1)), 不关心键顺序, 键有良好哈希

如何选择?

  • 选择 std::map 当:

    • 你需要按键排序遍历元素。

    • 你需要进行范围查询(例如,“找出所有键在 A 和 B 之间的元素”)。

    • 键的类型不易或无法定义良好的哈希函数

    • 你需要迭代器在插入操作后保持相对稳定

    • 你能接受 O(log n) 的查找/插入/删除时间。

  • 选择 std::unordered_map 当:

    • 只关心通过键快速查找、插入和删除值,对键的顺序完全不在意

    • 键的类型有高效、低冲突的哈希函数可用。

    • 平均情况下的常数时间 (O(1)) 性能是你的首要目标。

    • 你不需要范围查询。

    • 迭代器失效(尤其是插入可能导致的重哈希)在你的使用场景中可以接受或容易管理。

简单来说:需要键有序遍历或范围查询用 map;需要最快的平均查找速度且不关心顺序用 unordered_map 根据你的具体应用场景(对顺序、性能、键类型、迭代器稳定性的要求)做出选择。unordered_map 因其平均 O(1) 的访问速度,在不需要顺序的场景下通常是首选。


请参考以下代码演示的对比差异:

int test_set1()
{
	const size_t N = 1000000;
	unordered_map<int, int> um;
	map<int, int> m;
	vector<int> v;
	v.reserve(N);
	srand(time(0));
	for (size_t i = 0; i < N; ++i)
	{
		v.push_back(rand()); // N比较大时,重复值比较多
		//v.push_back(rand() + i); // 重复值相对少
		//v.push_back(i); // 没有重复,有序
	}
	size_t begin1 = clock();
	for (auto e : v)
	{
		m.insert({e, e});
	}
	size_t end1 = clock();
	cout << "map insert:" << end1 - begin1 << endl;
	size_t begin2 = clock();
	um.reserve(N);
	for (auto e : v)
	{
		um.insert({e, e});
	}
	size_t end2 = clock();
	cout << "unordered_map insert:" << end2 - begin2 << endl;
	int m1 = 0;
	size_t begin3 = clock();
	for (auto e : v)
	{
		auto ret = m.find(e);
		if (ret != m.end())
		{
			++m1;
		}
	}
	size_t end3 = clock();
	cout << "map find:" << end3 - begin3 << "->" << m1 << endl;
	int m2 = 0;
	size_t begin4 = clock();
	for (auto e : v)
	{
		auto ret = um.find(e);
		if (ret != um.end())
		{
			++m2;
		}
	}
	size_t end4 = clock();
	cout << "unorered_map find:" << end4 - begin4 << "->" << m2 << endl;
	cout << "插入数据个数:" << m.size() << endl;
	cout << "插入数据个数:" << um.size() << endl << endl;
	size_t begin5 = clock();
	for (auto e : v)
	{
		m.erase(e);
	}
	size_t end5 = clock();
	cout << "map erase:" << end5 - begin5 << endl;
	size_t begin6 = clock();
	for (auto e : v)
	{
		um.erase(e);
	}
	size_t end6 = clock();
	cout << "unordered_map erase:" << end6 - begin6 << endl << endl;
	return 0;
}

 1. 插入数据N比较大时,重复值比较多

运行结果:

2. 插入数据重复值相对少

运行结果:

3. 没有重复,有序

运行结果:

测试结果显示,在大多数场景下unordered_map的操作速度更快。但需要注意:

  1. 哈希表性能受负载因子影响,可能需要rehash
  2. 在最坏情况下(大量哈希冲突),unordered_map性能会退化到O(N)
  3. map的优势在于能保持元素有序,且性能稳定

3. unordered_multimap/unordered_multiset

• 功能特性:与multimap/multiset类似,支持键值冗余存储

• 主要差异体现在以下三个方面:

  • 对键值(Key)的要求不同
  • 迭代器及遍历顺序的差异
  • 性能表现的区别

这里就不过多阐述


4. unordered_xxx的哈希相关接口

unordered_xxx容器提供了两类重要的哈希相关接口:

4.1 Buckets系列接口

这些接口主要用于操作和管理哈希桶(bucket),包括:

  • bucket_count(): 返回当前哈希表中桶的数量
  • bucket_size(n):返回第n个桶中的元素数量
  • bucket(key):返回给定键所在的桶编号
  • begin(n)/end(n):返回指向第n个桶首尾的迭代器

例如:

std::unordered_map<std::string, int> umap;
// 获取哈希表当前使用的桶数量
size_t bucket_num = umap.bucket_count(); 
// 获取键为"apple"的元素所在的桶编号
size_t bucket_idx = umap.bucket("apple");

4.2 Hash policy系列接口

这些接口用于管理哈希策略,特别是负载因子(load factor)相关设置:

  • load_factor(): 返回当前负载因子(元素数量/桶数量)
  • max_load_factor(): 获取或设置最大允许负载因子
  • rehash(n):将容器桶数量设置为至少n
  • reserve(n):预留至少n个元素的存储空间

例如可以通过以下方式调整哈希表性能:

// 设置最大负载因子为0.5
umap.max_load_factor(0.5f);
// 预留100个元素的存储空间
umap.reserve(100); 

从日常使用角度来说,我们不需要过多关注这些底层接口。当后续学习了哈希表底层实现原理后,这些接口的作用将会一目了然。哈希表的高效性很大程度上依赖于合理的桶数量和负载因子设置,但在大多数应用场景中,STL提供的默认值已经足够优化。


网站公告

今日签到

点亮在社区的每一天
去签到