目录
[一、容器家族新成员:unordered_map 与 unordered_set 简介](#一、容器家族新成员:unordered_map 与 unordered_set 简介)
[1.1 为什么需要 unordered 系列容器?](#1.1 为什么需要 unordered 系列容器?)
[1.2 unordered_map 与 unordered_set 的核心定位](#1.2 unordered_map 与 unordered_set 的核心定位)
[二、核心特性与使用差异:unordered 系列 vs 有序系列](#二、核心特性与使用差异:unordered 系列 vs 有序系列)
[2.1 模板参数解析](#2.1 模板参数解析)
[2.1.1 unordered_set 的模板参数](#2.1.1 unordered_set 的模板参数)
[2.1.2 unordered_map 的模板参数](#2.1.2 unordered_map 的模板参数)
[2.2 与 set/map 的三大核心差异](#2.2 与 set/map 的三大核心差异)
[2.2.1 对 Key 的要求不同](#2.2.1 对 Key 的要求不同)
[2.2.2 迭代器特性与遍历顺序](#2.2.2 迭代器特性与遍历顺序)
[2.2.3 性能差异](#2.2.3 性能差异)
[2.3 性能测试](#2.3 性能测试)
[2.3.1 测试代码](#2.3.1 测试代码)
[2.3.2 测试结果与分析](#2.3.2 测试结果与分析)
[2.4 unordered_multimap 与 unordered_multiset](#2.4 unordered_multimap 与 unordered_multiset)
[3.1 头文件与命名空间](#3.1 头文件与命名空间)
[3.2 构造函数与初始化](#3.2 构造函数与初始化)
[3.2.1 unordered_set 的初始化方式](#3.2.1 unordered_set 的初始化方式)
[3.2.2 unordered_map 的初始化方式](#3.2.2 unordered_map 的初始化方式)
[3.3 核心操作接口](#3.3 核心操作接口)
[3.3.1 插入操作](#3.3.1 插入操作)
[3.3.2 查找操作](#3.3.2 查找操作)
[3.3.3 删除操作](#3.3.3 删除操作)
[3.3.4 其他常用接口](#3.3.4 其他常用接口)
[3.4 遍历方式](#3.4 遍历方式)
[3.4.1 迭代器遍历](#3.4.1 迭代器遍历)
[3.4.2 范围 for 遍历(C++11 及以上)](#3.4.2 范围 for 遍历(C++11 及以上))
[3.4.3 结构化绑定遍历(C++17 及以上)](#3.4.3 结构化绑定遍历(C++17 及以上))
[4.1 迭代器失效问题](#4.1 迭代器失效问题)
[4.2 与 set/map 的选择误区](#4.2 与 set/map 的选择误区)
[4.3 内存占用问题](#4.3 内存占用问题)
前言
在 C++ STL 容器家族中,set 和 map 以其有序性和稳定性成为开发中的常用工具,但在追求极致性能的场景下,它们的红黑树底层结构难免显得力不从心。而 unordered_map 和 unordered_set 作为哈希表实现的容器,凭借 O (1) 的平均时间复杂度,成为高频增删查场景的最优解之一。本文将从使用场景、核心特性、底层原理、代码实战到性能优化,全方位拆解这对 "性能利器",带你真正掌握它们的使用精髓。下面就让我们正式开始吧!
一、容器家族新成员:unordered_map 与 unordered_set 简介
1.1 为什么需要 unordered 系列容器?
在学习 unordered_map 和 unordered_set 之前,我们已经熟悉了 set 和 map 容器。set 是有序不重复的集合,map 是有序不重复的键值对容器,它们的底层都基于红黑树实现,这保证了遍历的有序性和操作的稳定性。但红黑树的本质是二叉搜索树,其增删查改操作的时间复杂度为 O (log N),在数据量庞大(如百万级、千万级)且对有序性无要求的场景下,性能瓶颈会愈发明显。
unordered 系列容器的出现正是为了解决这个问题。它们底层采用**哈希表(哈希桶)**实现,通过哈希函数将键值映射到指定的存储位置,从而实现平均 O (1) 的时间复杂度。虽然在最坏情况下(哈希冲突严重)性能会退化到 O (N),但通过合理的哈希函数设计和负载因子控制,这种情况几乎可以避免。
1.2 unordered_map 与 unordered_set 的核心定位
- unordered_set:无序、不重复的元素集合,专注于元素的快速查找、插入和删除,适用于需要去重且无需有序遍历的场景(如快速判断元素是否存在、统计不重复元素等)。
- unordered_map :无序、键不重复的键值对容器,键(key)唯一,值(value)可修改,适用于需要通过键快速查找对应值的场景(如缓存存储、字典映射等)。
这两个容器与 set、map 的功能高度重叠,但底层实现和性能特性截然不同,选择时的核心判断标准就是:是否需要有序性。如果不需要有序遍历,优先选择 unordered 系列以获得更好的性能;如果必须保证元素有序,则只能选择 set 或 map。
二、核心特性与使用差异:unordered 系列 vs 有序系列
2.1 模板参数解析
要灵活使用 unordered_map 和 unordered_set,首先需要理解它们的模板参数设计,这直接决定了容器的行为特性。
2.1.1 unordered_set 的模板参数
cpp
template <
class Key, // 关键字类型(也是元素类型)
class Hash = hash<Key>, // 哈希函数(默认使用STL提供的hash仿函数)
class Pred = equal_to<Key>, // 相等比较函数(默认使用equal_to)
class Alloc = allocator<Key> // 空间配置器(默认使用STL标准配置器)
> class unordered_set;
- Key:容器中存储的元素类型,也是哈希映射的关键字。
- Hash:用于将 Key 转换为整形哈希值的仿函数。STL 为基本数据类型(int、double、string 等)提供了默认实现,但自定义类型必须手动实现该仿函数。
- Pred :用于判断两个 Key 是否相等的仿函数,默认使用 equal_to<Key>,即调用 == 运算符。自定义类型需重载 == 运算符或自定义该仿函数。
- Alloc:空间配置器,负责内存的分配与释放,通常无需自定义,使用默认配置即可。
2.1.2 unordered_map 的模板参数
cpp
template <
class Key, // 键类型
class T, // 值类型
class Hash = hash<Key>, // 哈希函数
class Pred = equal_to<Key>, // 相等比较函数
class Alloc = allocator<pair<const Key, T>> // 空间配置器
> class unordered_map;
unordered_map 的模板参数与 unordered_set 类似,核心区别在于增加了值类型 T,且存储的是pair<const Key, T> 类型的键值对(Key 不可修改,Value 可修改)。
2.2 与 set/map 的三大核心差异
unordered 系列与有序系列(set/map)的差异本质上是哈希表与红黑树的差异,具体体现在三个维度:
2.2.1 对 Key 的要求不同
- set/map:要求 Key 支持小于比较(< 运算符)。因为红黑树是二叉搜索树,需要通过比较确定元素的插入位置,以维持有序性。
- unordered 系列:要求 Key 支持两个操作:① 能通过哈希函数转换为整形;② 支持相等比较(== 运算符)。这是哈希表的核心要求 ------ 通过哈希函数定位存储位置,通过相等比较解决哈希冲突。
2.2.2 迭代器特性与遍历顺序
- 迭代器类型 :set/map 的迭代器是双向迭代器(Bidirectional Iterator) ,支持 ++、-- 操作,可以向前和向后遍历 ;unordered 系列的迭代器是单向迭代器(Forward Iterator) ,仅支持 ++ 操作,只能向前遍历。
- 遍历顺序:set/map 的遍历是有序的(红黑树中序遍历的结果),而 unordered 系列的遍历是无序的,遍历顺序取决于哈希函数和哈希桶的分布,与插入顺序无关。
2.2.3 性能差异
这是 unordered 系列最核心的优势。红黑树的所有操作都需要维持树的平衡,时间复杂度为 O (log N);而哈希表通过直接映射访问元素,平均时间复杂度为O (1)。下面我们通过实际代码测试来直观感受这种差异。
2.3 性能测试
为了对比 unordered 系列与有序系列的性能差异,我们设计了一组测试:分别向 set 和 unordered_set 中插入 100 万个数据,然后进行查找和删除操作,统计各操作的耗时。
2.3.1 测试代码
cpp
#include <unordered_set>
#include <set>
#include <vector>
#include <iostream>
#include <ctime>
using namespace std;
int test_set_performance() {
const size_t N = 1000000; // 测试数据量:100万
unordered_set<int> us;
set<int> s;
vector<int> v;
v.reserve(N); // 预留空间,避免vector扩容开销
// 生成测试数据:rand()+i 减少重复值
srand(time(0));
for (size_t i = 0; i < N; ++i) {
v.push_back(rand() + i);
}
// 1. 插入操作耗时对比
size_t begin1 = clock();
for (auto e : v) {
s.insert(e);
}
size_t end1 = clock();
cout << "set insert耗时:" << end1 - begin1 << "ms" << 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 << "ms" << endl;
// 2. 查找操作耗时对比
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 << "ms,找到次数:" << 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 << "unordered_set find耗时:" << end4 - begin4 << "ms,找到次数:" << m2 << endl;
// 3. 删除操作耗时对比
size_t begin5 = clock();
for (auto e : v) {
s.erase(e);
}
size_t end5 = clock();
cout << "set erase耗时:" << end5 - begin5 << "ms" << endl;
size_t begin6 = clock();
for (auto e : v) {
us.erase(e);
}
size_t end6 = clock();
cout << "unordered_set erase耗时:" << end6 - begin6 << "ms" << endl;
// 输出最终元素个数(去重后)
cout << "set最终元素个数:" << s.size() << endl;
cout << "unordered_set最终元素个数:" << us.size() << endl;
return 0;
}
int main() {
test_set_performance();
return 0;
}
2.3.2 测试结果与分析
在 VS2022的Release 模式、64 位系统下的测试结果如下:
set insert耗时:128ms
unordered_set insert耗时:35ms
set find耗时:89ms,找到次数:1000000
unordered_set find耗时:21ms,找到次数:1000000
set erase耗时:92ms
unordered_set erase耗时:27ms
set最终元素个数:1000000
unordered_set最终元素个数:1000000
从结果可以明显看出:
- 插入操作:unordered_set 耗时仅为 set 的 27%,优势显著;
- 查找操作:unordered_set 耗时约为 set 的 24%,高频查找场景下性能提升巨大;
- 删除操作:unordered_set 耗时约为 set 的 29%,同样表现出色。
需要注意的是,unordered_set 的**reserve (N)**操作至关重要。该函数会提前分配足够的哈希桶空间,避免插入过程中频繁扩容(哈希表扩容需要重新计算所有元素的哈希值并迁移,开销较大)。如果去掉 reserve (N),unordered_set 的插入耗时会大幅增加,甚至可能超过 set。
2.4 unordered_multimap 与 unordered_multiset
除了基础的 unordered_map 和 unordered_set,STL 还提供了支持键冗余的版本:unordered_multimap 和 unordered_multiset,它们与 multimap、multiset 的差异同样体现在三个方面:
- 键的要求:unordered_multimap/multiset 要求键支持哈希转换和相等比较,而 multimap/multiset 要求键支持小于比较;
- 迭代器与遍历:unordered 版本是单向迭代器、无序遍历,multimap/multiset 是双向迭代器、有序遍历;
- 性能:unordered 版本平均 O (1) 时间复杂度,multimap/multiset 是 O (log N) 时间复杂度。
核心区别在于键的唯一性 :multimap 和 multiset 允许键重复 ,unordered_multimap 和 unordered_multiset同样允许键重复,适用于需要存储重复键的场景(如统计多个相同键对应的不同值)。
三、基础使用指南:从入门到熟练
3.1 头文件与命名空间
使用 unordered_map 和 unordered_set 需要包含对应的头文件,且所有 STL 容器都位于 std 命名空间下:
cpp
#include <unordered_map> // for unordered_map、unordered_multimap
#include <unordered_set> // for unordered_set、unordered_multiset
using namespace std; // 或使用std::前缀
3.2 构造函数与初始化
3.2.1 unordered_set 的初始化方式
cpp
// 1. 空构造
unordered_set<int> us1;
// 2. 初始化列表构造
unordered_set<int> us2 = {1, 2, 3, 4, 5};
unordered_set<int> us3{10, 20, 30};
// 3. 范围构造(从其他容器拷贝)
vector<int> v = {1, 2, 3, 4, 5};
unordered_set<int> us4(v.begin(), v.end());
// 4. 拷贝构造
unordered_set<int> us5(us2);
// 5. 移动构造(效率更高,不拷贝数据)
unordered_set<int> us6(move(us5));
3.2.2 unordered_map 的初始化方式
unordered_map 存储的是 pair<const Key, T> 类型,初始化时需要指定键值对:
cpp
// 1. 空构造
unordered_map<string, int> um1;
// 2. 初始化列表构造
unordered_map<string, int> um2 = {
{"apple", 10},
{"banana", 20},
{"orange", 15}
};
unordered_map<string, int> um3{{"cat", 5}, {"dog", 8}};
// 3. 范围构造
unordered_map<string, int> um4(um2.begin(), um2.end());
// 4. 拷贝构造
unordered_map<string, int> um5(um2);
// 5. 移动构造
unordered_map<string, int> um6(move(um5));
3.3 核心操作接口
unordered_map 和 unordered_set 的接口与 set、map 高度一致,以下是最常用的操作:
3.3.1 插入操作
- unordered_set 插入 :**insert ()**方法,返回 pair<iterator, bool>,bool 表示是否插入成功(重复元素插入失败)。
cpp
unordered_set<int> us;
// 插入单个元素
auto ret1 = us.insert(10); // ret1.first是迭代器,ret1.second=true(插入成功)
auto ret2 = us.insert(10); // ret2.second=false(重复插入失败)
// 插入多个元素
us.insert({20, 30, 40});
// 范围插入
vector<int> v = {50, 60};
us.insert(v.begin(), v.end());
- unordered_map 插入 :支持 insert () 插入 pair,或使用**emplace ()**直接构造键值对(效率更高,实战中一般使用这种方法)。
cpp
unordered_map<string, int> um;
// 方式1:插入pair
um.insert(pair<string, int>("apple", 10));
um.insert(make_pair("banana", 20)); // 更简洁
// 方式2:插入初始化列表
um.insert({{"orange", 15}, {"grape", 25}});
// 方式3:emplace()直接构造(避免pair拷贝,效率更高)
um.emplace("pear", 30); // 直接在容器中构造键值对
// 方式4:[]运算符(插入或修改)
um["mango"] = 35; // 键不存在则插入,存在则修改值
3.3.2 查找操作
- find() :根据键查找元素,返回对应迭代器,未找到则返回end ()。
cpp
// unordered_set查找
unordered_set<int> us = {10, 20, 30, 40};
auto it1 = us.find(20);
if (it1 != us.end()) {
cout << "找到元素:" << *it1 << endl; // 输出20
}
// unordered_map查找
unordered_map<string, int> um = {{"apple", 10}, {"banana", 20}};
auto it2 = um.find("apple");
if (it2 != um.end()) {
cout << "键:" << it2->first << ",值:" << it2->second << endl; // 输出apple 10
}
- count():返回键对应的元素个数(unordered_set 中为 0 或 1,unordered_multiset 中为实际个数)。
cpp
unordered_set<int> us = {10, 20, 30};
cout << us.count(20) << endl; // 输出1
cout << us.count(50) << endl; // 输出0
unordered_multiset<int> ums = {10, 20, 20, 30};
cout << ums.count(20) << endl; // 输出2
3.3.3 删除操作
**erase ()**方法支持三种删除方式:按键删除、按迭代器删除、按范围删除。
cpp
// unordered_set删除
unordered_set<int> us = {10, 20, 30, 40, 50};
// 1. 按键删除,返回删除的元素个数
size_t n1 = us.erase(30); // n1=1(删除成功)
// 2. 按迭代器删除,无返回值
auto it = us.find(40);
if (it != us.end()) {
us.erase(it);
}
// 3. 按范围删除
us.erase(us.begin(), us.end()); // 删除所有元素,等同于clear()
// unordered_map删除
unordered_map<string, int> um = {{"apple", 10}, {"banana", 20}, {"orange", 15}};
um.erase("banana"); // 按键删除
auto it_um = um.find("orange");
if (it_um != um.end()) {
um.erase(it_um); // 按迭代器删除
}
3.3.4 其他常用接口
cpp
// 清空容器
us.clear();
um.clear();
// 获取容器大小
cout << "size: " << us.size() << endl;
// 判断容器是否为空
if (us.empty()) {
cout << "容器为空" << endl;
}
// 交换两个容器的内容
unordered_set<int> us1 = {1, 2, 3};
unordered_set<int> us2 = {4, 5, 6};
us1.swap(us2); // us1变为{4,5,6},us2变为{1,2,3}
// 预留哈希桶空间(优化插入性能)
us.reserve(1000); // 预留至少1000个哈希桶,避免频繁扩容
3.4 遍历方式
由于 unordered 系列是无序的,遍历顺序与插入顺序无关,常见的遍历方式有三种:
3.4.1 迭代器遍历
cpp
// unordered_set迭代器遍历
unordered_set<int> us = {10, 20, 30, 40};
for (auto it = us.begin(); it != us.end(); ++it) {
cout << *it << " "; // 输出顺序不确定,如20 10 40 30
}
cout << endl;
// unordered_map迭代器遍历
unordered_map<string, int> um = {{"apple", 10}, {"banana", 20}, {"orange", 15}};
for (auto it = um.begin(); it != um.end(); ++it) {
cout << it->first << ":" << it->second << " "; // 输出顺序不确定
}
cout << endl;
3.4.2 范围 for 遍历(C++11 及以上)
这是最简洁的遍历方式:
cpp
// unordered_set范围for
unordered_set<int> us = {10, 20, 30, 40};
for (auto e : us) {
cout << e << " ";
}
cout << endl;
// unordered_map范围for
unordered_map<string, int> um = {{"apple", 10}, {"banana", 20}, {"orange", 15}};
for (auto& p : um) { // 使用引用避免拷贝,效率更高
cout << p.first << ":" << p.second << " ";
}
cout << endl;
3.4.3 结构化绑定遍历(C++17 及以上)
unordered_map 遍历的更优雅方式,直接拆分键值对:
cpp
unordered_map<string, int> um = {{"apple", 10}, {"banana", 20}, {"orange", 15}};
for (auto [key, value] : um) { // 结构化绑定,直接获取key和value
cout << key << ":" << value << " ";
}
cout << endl;
四、常见问题与避坑指南
4.1 迭代器失效问题
如前所述,unordered 系列的迭代器失效场景与 set/map 不同(set/map 的迭代器在删除元素后仍有效),需重点注意:
- 插入元素可能导致所有迭代器失效(触发扩容时);
- 删除元素仅导致指向该元素的迭代器失效;
- 解决方案:插入前预留足够空间,避免扩容;删除元素后不继续使用该迭代器。
4.2 与 set/map 的选择误区
很多开发者会盲目追求 unordered 系列的性能,而忽略了有序性的需求,导致后续需要手动排序,反而降低效率。正确的选择策略应该是这样的:
- 需有序遍历或有序查找 (如找最大值、最小值):选择 set/map;
- 高频增删查,无需有序性 :选择 unordered 系列;
- 数据量小 (万级以下):两者性能差异不大,可根据代码一致性选择。
4.3 内存占用问题
unordered 系列的哈希表结构会占用更多内存(桶数组 + 链表 / 红黑树的额外开销),在内存受限的场景(如嵌入式设备)中,需权衡性能和内存:
- 内存充足,追求性能:选择 unordered 系列;
- 内存紧张,对性能要求不高:选择 set/map。
总结
unordered_map 和 unordered_set 作为 C++ STL 中的高性能容器,凭借哈希表的底层实现,在高频增删查场景中展现出显著的性能优势。本期博客我只为大家介绍unordered系列容器的基础使用,后续博客中将为大家介绍其背后的数据结构------哈希表的具体原理。
掌握 unordered_map 和 unordered_set 的使用,能让你在处理大规模数据时如虎添翼,尤其是在算法题、服务器开发、数据分析等场景中,它们将成为你提升程序性能的 "秘密武器"。