C++ unordered_map/set 深度解析:快在O(1),输在无序,与map/set怎么选

C++ unordered_map/set 深度解析:快在O(1),输在无序,与map/set怎么选

文章目录

  • [C++ unordered_map/set 深度解析:快在O(1),输在无序,与map/set怎么选](#C++ unordered_map/set 深度解析:快在O(1),输在无序,与map/set怎么选)
  • [一、unordered 系列是什么:一句话说清](#一、unordered 系列是什么:一句话说清)
  • 二、先看声明:四个模板参数各管什么
  • 三、三大差异总表
  • [四、差异一:key 的要求,从"小于"变成"转整形 + 等于"](#四、差异一:key 的要求,从"小于"变成"转整形 + 等于")
      • [自定义类型做 key:自己补两个东西](#自定义类型做 key:自己补两个东西)
  • [五、差异二:单向迭代器 + 无序遍历](#五、差异二:单向迭代器 + 无序遍历)
  • 六、差异三:性能,快多少,代价是什么
  • [七、迭代器失效:unordered 特有的坑](#七、迭代器失效:unordered 特有的坑)
  • [八、operator\[\]:用起来一样,语义看 key](#八、operator[]:用起来一样,语义看 key)
  • 九、哈希相关接口:知道有这回事就行
  • [十、multi 版本与全家总表](#十、multi 版本与全家总表)
  • [十一、选型决策:什么时候 map 不可替代](#十一、选型决策:什么时候 map 不可替代)
  • 十二、实战:两数之和
  • 结尾总结

map 和 set 用得再熟,换到 unordered 系列也会连踩三个坑:迭代器突然不支持 -- 了,自定义类型做 key 直接编译报错,遍历出来的顺序完全没法预测。这三个坑指向同一件事------unordered 系列不是"map 换个名字",它是换了一整套查找逻辑。

一句话给结论:只做查找、不关心顺序,unordered 系列就是性能最优解;要有序遍历、范围查找、最大最小值,map 无可替代。这篇不讲底层实现,专讲使用层面必须搞清的三件事:key 的要求变了什么、迭代器差在哪、性能差多少,最后给一张选型决策表。


一、unordered 系列是什么:一句话说清

unordered_set、unordered_map、unordered_multiset、unordered_multimap,这四个容器和 set、map、multiset、multimap 功能高度相似------insert、erase、find、operator\[\],接口几乎一模一样,用法完全通用。唯一的本质区别在底层结构:

set 系列底层是红黑树(二叉搜索树),unordered 系列底层是哈希表。

名字里的 "unordered" 就是在说这件事:明确告诉你"我的遍历不再有序"。底层结构换了,查找逻辑就换了,于是产生三大使用差异------key 的要求、迭代器能力、性能表现。下面逐个拆。


二、先看声明:四个模板参数各管什么

unordered_set 的声明:

cpp 复制代码
template < class Key,                      // 关键字类型
           class Hash = hash<Key>,         // 把 Key 转成整形的仿函数
           class Pred = equal_to<Key>,     // 判断 Key 相等的仿函数
           class Alloc = allocator<Key>    // 空间配置器
           > class unordered_set;

unordered_map 多一个 mapped_type:

cpp 复制代码
template < class Key,                                 // key 类型
           class T,                                   // value 类型
           class Hash = hash<Key>,                    // 哈希仿函数
           class Pred = equal_to<Key>,                // 相等比较仿函数
           class Alloc = allocator<pair<const Key, T>> // 空间配置器
           > class unordered_map;

重点在后两个默认参数。hash<Key> 负责把 key 算成一个 size_t,equal_to<Key> 负责比较两个 key 是否相等------这两个东西就是哈希表查找的全部依赖:先算位置,再确认相等。

平时四个参数只传第一个,后三个全走默认。但"什么情况下默认会失效",正是 unordered 系列和 set 系列最本质的差异,往下看。


三、三大差异总表

先给一张总表收拢全部差异,后面逐个展开:

差异维度 set / map unordered_set / unordered_map
底层结构 红黑树 哈希表
对 key 的要求 支持 <(小于比较) 支持转整形 + 支持 ==
迭代器类型 双向迭代器(++/-- 都行) 单向迭代器(只有 ++)
遍历顺序 中序有序 无序
增删查效率 O(logN) 稳定 平均 O(1),最坏退化 O(N)
反向迭代器 有 rbegin/rend 没有
lower_bound/upper_bound 有 没有

四、差异一:key 的要求,从"小于"变成"转整形 + 等于"

这是最容易被忽略、也最影响自定义类型使用的差异。要理解它,得从两个容器"怎么找数据"说起。

红黑树找数据,靠比较大小导航 :key 比当前节点小走左边,大走右边,相等即命中。整个过程只需要一个运算符:operator<。

哈希表找数据,分两步 :第一步拿 key 算出存储位置(必须先转成整数才能取模定位),第二步在对应位置把候选元素逐个和 key 比较(只需要判断相等)。整个过程需要两个能力:转成整数、operator==。

红黑树(set/map) 哈希表(unordered 系列)
查找依赖 operator< 转整形 + operator==
为什么 大小比较导航左右子树 整数取模定位桶,相等比较确认元素

这个推导直接解释了两个常见疑问:

  1. 为什么 int、string 能直接做 unordered 的 key ------标准库为常用类型提供了 std::hash 的特化。string 的特化会逐字符参与计算(每个字符位置都影响最终哈希值),不是简单取长度或首字符。
  2. 为什么 float/double 能编译但工程上慎用 ------浮点精度问题,两个"看起来相等"的浮点数哈希结果可能不同,查找时 == 也靠不住。

自定义类型做 key:自己补两个东西

std::hash 没有为自定义类型提供特化,pair<int,int> 也没有------这是实战中真实会撞上的坑。想让 pair<int,int> 做 unordered_map 的 key(比如坐标去重),必须自己提供 Hash 和相等比较:

cpp 复制代码
struct PairHash {
    size_t operator()(const pair<int,int>& p) const {
        // 两个 hash 值合并:异或 + 左移错位,让两个分量都参与计算、减少碰撞
        return hash<int>()(p.first) ^ (hash<int>()(p.second) << 1);
    }
};

struct PairEqual {
    bool operator()(const pair<int,int>& a, const pair<int,int>& b) const {
        return a.first == b.first && a.second == b.second;
    }
};

unordered_set<pair<int,int>, PairHash, PairEqual> visited;
visited.insert({3, 5});

对照 set 系列:同样的需求只需要给 pair 重载 operator<(或传比较仿函数),一个运算符搞定。这就是"key 的要求"差异实打实的代价。

另一个小细节:第三个参数 equal_to<Key> 默认就是调 key 的 operator==。所以自定义类型有两条路------要么重载 operator== 让第三个参数走默认,要么显式传比较仿函数 。上面 PairEqual 演示的是后者;如果类型本身重载了 ==,第二个参数给完 Hash 后,第三个参数可以不传:

cpp 复制代码
struct DateHash {
    size_t operator()(const Date& d) const {
        return hash<int>()(d._year) ^ (hash<int>()(d._month) << 4) ^ d._day;
    }
};

unordered_set<Date, DateHash> s;   // Date 重载了 operator==,第三个参数走默认

另一条路是给 std::hash 加特化,效果等价,但仿函数方式不改标准库命名空间、侵入性更低,推荐用仿函数。


五、差异二:单向迭代器 + 无序遍历

先看一个直观的演示:

cpp 复制代码
unordered_set<int> us;
us.insert(8); us.insert(3); us.insert(15);
us.insert(1); us.insert(9);

for (auto e : us) cout << e << " ";
// 输出顺序不可预测,可能是 9 1 15 3 8

输出完全是乱序------每个元素在表中的位置由它的哈希值决定,和插入顺序、大小顺序都无关。对照 set:红黑树中序遍历天然有序,遍历结果就是升序。

补一个容易误解的细节:unordered 的遍历顺序虽然"乱",但在同一环境下是确定的------同一批 key,无论按什么顺序插入,遍历结果完全相同(哈希值是确定性的,落桶位置只由哈希值决定)。乱的只是"相对 key 大小和插入顺序",不是"每次运行都变"。结论:不要让任何业务逻辑依赖遍历顺序,但调试复现不用担心它。

两个直接后果:

  1. 单向迭代器只支持 ++ 。-- 直接编译报错,rbegin/rend 不存在,反向遍历做不了。原因也在底层:哈希节点只存 next 指针不存 prev,回退一个位置在结构上就不支持。
  2. 依赖"有序"的查询全部做不了。unordered 系列没有 lower_bound/upper_bound,"找大于 x 的第一个元素"这种范围查询没有接口;红黑树上 O(logN) 就能拿到的最大最小值,unordered 也只能全遍历。

一句话:unordered 的迭代器只能"从头走到尾",走到哪算哪。


六、差异三:性能,快多少,代价是什么

100 万随机数据下,set 和 unordered_set 的 insert/find 计时对比(课件同款测试,整理成可跑版本):

cpp 复制代码
#include <unordered_set>
#include <set>
#include <iostream>
#include <vector>
#include <cstdlib>
#include <ctime>
using namespace std;

int main()
{
    const size_t N = 1000000;
    unordered_set<int> us;
    set<int> s;
    vector<int> v;
    v.reserve(N);
    srand(time(nullptr));
    for (size_t i = 0; i < N; ++i)
        v.push_back(rand() + i);      // +i 错开随机值,减少重复

    int begin1 = clock();
    for (auto e : v) s.insert(e);     // 红黑树插入 O(logN)
    int end1 = clock();
    cout << "set insert: " << end1 - begin1 << endl;

    us.reserve(N);                    // 提前开好空间,插入途中不扩容
    int begin2 = clock();
    for (auto e : v) us.insert(e);    // 哈希插入平均 O(1)
    int end2 = clock();
    cout << "unordered_set insert: " << end2 - begin2 << endl;

    int begin3 = clock();
    for (auto e : v) s.find(e);
    int end3 = clock();
    cout << "set find: " << end3 - begin3 << endl;

    int begin4 = clock();
    for (auto e : v) us.find(e);
    int end4 = clock();
    cout << "unordered_set find: " << end4 - begin4 << endl;

    return 0;
}

典型结果量级(release 下,具体数值随环境浮动,以实测为准):100 万数据的查找,set 的耗时大约是 unordered_set 的 3~5 倍;数据量越大,logN 和常数的差距越明显。小数据量(几千个元素)两者都跑在毫秒级,差异无感。

三个要点:

  1. 大 O 的真相:哈希的 O(1) 是"平均",全部冲突时退化到 O(N)。标准库靠负载因子控制把最坏情况的概率压到极低,平均意义下 O(1) 成立。
  2. unordered 有 reserve:提前把空间开好,插入全程不触发扩容。set 没有对应操作------红黑树本来就不需要。
  3. 删除同样受益:哈希删除平均 O(1),红黑树删除 O(logN),erase 大批量数据时差距和 find 同级。

把 logN 拆开看更直观:100 万数据约等于 2^20,红黑树一次查找要走约 20 次大小比较;哈希一次查找是 1 次取模定位,加上桶内平均不到 1 次的相等比较。20 次比较对 1 次定位,这就是那几倍差距的来源------而且这个差距随数据量继续拉大。


七、迭代器失效:unordered 特有的坑

这是 unordered 系列独有、面试高频的一个点。

map(红黑树)的失效规则很温和:插入任何元素,已有迭代器全都不失效;删除只使指向被删元素的迭代器失效。因为红黑树插入只是新增节点加变色旋转,已有节点的内存位置从来不动。

unordered 系列(哈希表)多一条 rehash 引发的失效:

cpp 复制代码
unordered_set<int> us;
us.reserve(10);
auto it = us.insert(5).first;   // it 指向元素 5

// 持续插入,一旦负载因子超过上限触发扩容:
// 发生 rehash,所有迭代器全部失效,it 变成悬空
for (int i = 0; i < 100; ++i)
    us.insert(i);

失效规则对照:

操作 红黑树(map/set) 哈希表(unordered 系列)
insert 迭代器全都不失效 可能触发 rehash,全部失效
erase 只失效被删元素的迭代器 只失效被删元素的迭代器
元素的引用/指针 不失效 不失效(即使 rehash)

最反直觉的是最后一行:rehash 之后迭代器全挂了,但元素的引用和指针依然有效。为什么?

哈希桶里存的是节点的指针,rehash 做的事是把节点从旧桶摘下来、按新表长重新取模挂到新桶------节点本体一个都没动,动的只是它挂在哪个桶下。迭代器内部记的是"桶位置 + 节点指针"这类结构信息,桶一重排它就找不到北;引用和指针直接指向节点本体,节点没动它们就还有效。

实用结论:遍历 unordered 容器时不要插入;需要"边查边建索引"时,要么先收集再统一插入,要么提前用 reserve 把空间开足、确保循环内不触发 rehash。

扩容时机可以直接观测:

cpp 复制代码
unordered_set<int> us;
cout << us.bucket_count() << endl;   // 初始桶数(随实现,通常很小)
for (int i = 0; i < 10; ++i)
    us.insert(i);
cout << us.bucket_count() << endl;   // 负载因子触顶后桶数变大------这一刻 rehash 发生

桶数跳变的那一刻,就是 rehash 发生、迭代器全体失效的时刻。上面循环里如果持着旧迭代器,输出就是未定义行为。


八、operator\[\]:用起来一样,语义看 key

unordered_map 支持 operator\[\],行为和 map 一致:key 存在返回 value 的引用,不存在就插入默认值再返回引用。统计词频的经典写法:

cpp 复制代码
unordered_map<string, int> countMap;
string strs[] = {"apple", "banana", "apple", "cherry", "apple", "banana"};
for (auto& e : strs)
    countMap[e]++;   // key 不存在 -> 插入(value默认0) 再 ++

这套写法在 map 和 unordered_map 上完全通用------operator\[\] 不依赖有序性,靠的是"查找失败也返回引用"的插入语义。区别在统计完之后的遍历:map 按字典序输出,unordered_map 乱序输出。要不要按序输出,就是选型问题。


九、哈希相关接口:知道有这回事就行

unordered 系列比 set 系列多一组桶和负载因子接口:

cpp 复制代码
unordered_set<int> us = {8, 3, 15, 1, 9, 22, 7};

cout << us.bucket_count() << endl;     // 桶的总数
cout << us.load_factor() << endl;      // 当前负载因子 = 元素个数 / 桶总数
cout << us.max_load_factor() << endl;  // 负载因子上限,默认 1.0

us.reserve(100);   // 预留:让桶数足够存 100 个元素而不触发扩容

int key = 15;
cout << us.bucket(key) << endl;                  // 15 落在几号桶
cout << us.bucket_size(us.bucket(key)) << endl;  // 该桶里挂了几个元素

这些接口平时用不上,但每一个都和底层结构一一对应:bucket_count 是哈希表的表长,load_factor 是负载因子,reserve 本质是预估扩容。把这组接口和底层结构对上号,哈希表的实现逻辑就打通了一半。


十、multi 版本与全家总表

unordered_multiset / unordered_multimap 支持 key 冗余,和 multiset / multimap 功能对应,差异同样是那三条(key 要求、迭代器、性能)。multimap 不支持 operator\[\] 的结论在 unordered_multimap 上同样成立------key 冗余让"唯一的 value 引用"这个语义不成立。

八个关联容器一张表收拢:

容器 底层 key 要求 遍历顺序 key 冗余
set 红黑树 operator< 有序 否
map 红黑树 operator< key 有序 否
multiset 红黑树 operator< 有序 是
multimap 红黑树 operator< key 有序 是
unordered_set 哈希表 转整形 + == 无序 否
unordered_map 哈希表 转整形 + == 无序 否
unordered_multiset 哈希表 转整形 + == 无序 是
unordered_multimap 哈希表 转整形 + == 无序 是

十一、选型决策:什么时候 map 不可替代

需求场景 用谁 原因
纯查找,不关心顺序,数据量大 unordered 平均 O(1),最快
需要有序遍历 / 按序输出 map unordered 给不了
范围查找(大于 x 的最小元素) map unordered 没有 lower_bound
取最大 / 最小 key map 红黑树两端直接拿
key 无法转整形、又不想写 hash map 只需 operator<
统计 + 有序输出(如词频按字典序) map 一遍统计,遍历即有序

一句话总结:unordered 赢在查找速度,map 赢在结构自带的有序能力。速度差几倍在中小数据量上无感,有序能力却是硬需求------这不是谁取代谁的关系,是按场景选工具。


十二、实战:两数之和

LeetCode 1,两数之和。暴力解法两层循环 O(n²);排序加双指针 O(nlogn) 但下标信息丢了还得额外处理。unordered_map 一遍扫完:

cpp 复制代码
class Solution {
public:
    vector<int> twoSum(vector<int>& nums, int target) {
        unordered_map<int, int> pos;   // 值 -> 下标
        for (int i = 0; i < (int)nums.size(); ++i) {
            int need = target - nums[i];
            auto it = pos.find(need);
            if (it != pos.end())
                return {it->second, i};   // 之前的元素和当前值配对成功
            pos[nums[i]] = i;             // 没找到,把当前值存进索引
        }
        return {};
    }
};

核心就一处:把"找 target - numsi"从 O(n) 的扫描变成 O(1) 的哈希查找,边遍历边建索引,总复杂度 O(n)。"边遍历边建哈希索引"这个模式,是 unordered 容器最经典的用法。

为什么不用 map?功能完全一样,但 key 是 int、需求是纯查找,unordered_map 就是最优选------正好验证了选型表的第一行。

再补一道同模式的题:LeetCode 349,两个数组的交集。暴力双循环 O(mn),unordered_set 降到 O(m+n):

cpp 复制代码
class Solution {
public:
    vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
        unordered_set<int> s1(nums1.begin(), nums1.end());  // 建索引,顺便去重
        unordered_set<int> ret;
        for (int x : nums2)
            if (s1.count(x))        // O(1) 查 x 在不在 nums1
                ret.insert(x);      // 用 set 收集,天然再去一次重
        return vector<int>(ret.begin(), ret.end());
    }
};

"哈希集合查存在性"是和"哈希映射建索引"并列的第二大常用模式:把一个序列建成 set,另一个序列逐个 O(1) 查。两数之和查的是值到下标的映射,这题查的只是值的单纯存在性------前者用 map,后者 set 就够。


结尾总结

四条结论收尾:

  1. unordered 系列和 set 系列接口几乎通用,差异全由底层结构带来:key 要求(转整形 + == 对 operator<)、迭代器(单向无序对双向有序)、性能(平均 O(1) 对 O(logN))。
  2. 自定义类型做 unordered 的 key,必须自己提供 Hash 仿函数和相等比较------pair<int,int> 这种标准类型也不例外,这是实战高频坑。
  3. 单向迭代器、无序遍历、没有 lower_bound------一切依赖"有序"的查询,unordered 都做不了。
  4. 选型一句话:纯查找用 unordered,要有序用 map。
相关推荐
垆边人似月.3 小时前
找出最小的 K 个数
数据结构·c++·算法
C语言小火车3 小时前
C++ 为什么越来越依赖标准库?
java·开发语言·c++
八月的雨季 最後的冰吻3 小时前
C++ 面试要点笔记(续)
c++·笔记·面试
DongQiShanRen4 小时前
六根硬件映射(中):双站校验、生克闭环与驱动接口
数据库·人工智能·算法·rust·哈希算法·集成学习
青少儿编程课堂4 小时前
蓝桥杯·全国青少年信息素养大赛·电子学会C/C++考级:并查集连通块与朋友圈问题解析
c++·python·算法·bfs·信息学竞赛
wyhwust4 小时前
codeblocks程序调试
c++
傲世仙尊5 小时前
运算符重载-operator函数与五个不能重载的符号
c++
傲世仙尊5 小时前
赋值运算符重载-从无到有与从有到有六大函数收官
c++
晴天的雨.9925 小时前
【C++】模板进阶
开发语言·c++·算法