C++进阶:(九)深度剖析unordered_map 与 unordered_set容器

目录

前言

[一、容器家族新成员: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 的差异同样体现在三个方面:

  1. 键的要求:unordered_multimap/multiset 要求键支持哈希转换和相等比较,而 multimap/multiset 要求键支持小于比较;
  2. 迭代器与遍历:unordered 版本是单向迭代器、无序遍历,multimap/multiset 是双向迭代器、有序遍历;
  3. 性能: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 的使用,能让你在处理大规模数据时如虎添翼,尤其是在算法题、服务器开发、数据分析等场景中,它们将成为你提升程序性能的 "秘密武器"。

相关推荐
七夜zippoe2 小时前
Java并发编程基石:深入理解JMM(Java内存模型)与Happens-Before规则
java·开发语言·spring·jmm·happens-before
Mark Studio2 小时前
QT linux 静态编译问题记录
开发语言·qt
freedom_1024_2 小时前
LRU缓存淘汰算法详解与C++实现
c++·算法·缓存
无敌最俊朗@3 小时前
C++-Qt-音视频-基础问题01
开发语言·c++
折戟不必沉沙3 小时前
C++四种类型转换cast,其在参数传递时的作用
c++
kyle~3 小时前
C++---万能指针 void* (不绑定具体数据类型,能指向任意类型的内存地址)
开发语言·c++
MediaTea3 小时前
Python 第三方库:TensorFlow(深度学习框架)
开发语言·人工智能·python·深度学习·tensorflow
誰能久伴不乏3 小时前
Linux 进程通信与同步机制:共享内存、内存映射、文件锁与信号量的深度解析
linux·服务器·c++
vortex53 小时前
Bash Glob 通配符详细指南:从 POSIX 标准到高级用法
开发语言·bash