【C++进阶】unordered_set 和unordered_map 深度解析

一、基础概念

unordered_set、unordered_map 属于 C++ STL 关联式容器。

  • unordered_set:存储 key,key 唯一;
  • unordered_map:存储pair<const Key,T>键值对,key 唯一;

与之对应的红黑树容器:set、map。 二者对外 API 接口高度趋同,都支持insert、find、erase;但是底层数据结构完全不同,带来有序性、时间复杂度、迭代器行为上巨大差异。

核心区分:

  1. set/map底层:红黑树(平衡二叉搜索树),遍历输出 key 有序;增删查稳定 \(O(logN)\)。
  2. unordered_set/unordered_map底层:开链法哈希桶 ,遍历输出无序;增删查平均 \(O(1)\),最坏 \(O(N)\)。

头文件:

cpp 复制代码
#include <unordered_set>
#include <unordered_map>

表格

容器 底层 是否有序 key 是否唯一 迭代器类别 时间复杂度 key 要求
set 红黑树 ✅有序 唯一 双向迭代器 \(O(logN)\) 支持operator<
unordered_set 开链哈希桶 ❌无序 唯一 单向迭代器 平均\(O(1)\),最坏\(O(N)\) 可哈希 + 支持operator==
map 红黑树 ✅有序 唯一 双向迭代器 \(O(logN)\) 支持operator<
unordered_map 开链哈希桶 ❌无序 唯一 单向迭代器 平均\(O(1)\),最坏\(O(N)\) 可哈希 + 支持operator==
multiset 红黑树 ✅有序 允许重复 双向迭代器 \(O(logN)\) 支持operator<
unordered_multiset 开链哈希桶 ❌无序 允许重复 单向迭代器 平均\(O(1)\),最坏\(O(N)\) 可哈希 + 支持operator==

二、模板参数完整拆解

unordered_set 模板原型

cpp 复制代码
template <class Key,
    class Hash = hash<Key>,        //哈希仿函数
    class Pred = equal_to<Key>,    //相等比较仿函数
    class Alloc = allocator<Key>   //内存分配器
>
class unordered_set;
  1. Key:存储的关键字类型。
  2. Hash = hash<Key> :哈希仿函数,接收 key,返回size_t无符号整数哈希值;内置类型int、std::string标准库已经提供hash特化版本,直接可用。自定义类型作为 key,必须自己提供哈希仿函数。
  3. Pred = equal_to<Key> :相等比较仿函数,用来判断两个 key 是否完全等价,底层调用operator==。自定义类型做 key,需要重载==运算符。
  4. Alloc:空间配置器,管理内存申请释放,业务开发几乎不需要手动传入。

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;
  • 存储元素类型:pair<const Key, T>,key是const,不允许修改,避免哈希定位错乱;value 可以正常读写。
  • 和map一样支持operator[];unordered_multimap 依然不支持 operator \[\]。

对外常用接口,和 map/set 保持一致:

cpp 复制代码
//插入,返回pair<迭代器,bool>;bool标记插入是否成功(key是否重复)
pair<iterator,bool> insert(const value_type& val);
//按key删除
size_type erase(const key_type& k);
//查找key,找不到返回end()
iterator find(const key_type& k);
//unordered_map独有
mapped_type& operator[](const key_type& k);

三、底层:开链哈希桶(拉链法)

unordered 系列底层是数组 + 单向链表 的组合,数组每一个下标位置称为一个bucket哈希桶。

  1. 插入流程 ① 使用哈希仿函数对 key 计算,得到size_t哈希值; ② 哈希值对桶数组的总容量取模,得到桶数组下标; ③ 将结点挂到该下标对应的单向链表尾部。
  2. 查找流程 ① 计算 key 哈希值,取模定位对应桶; ② 遍历这个桶内单向链表,使用==逐个对比 key; ③ 找到匹配 key 返回迭代器;遍历完链表没找到返回end()。
  3. 删除流程 ① 哈希定位桶; ② 遍历桶内链表找到目标结点; ③ 链表删除该结点,释放内存。

哈希冲突

哈希冲突:不同的 key,经过哈希计算取模之后,落到同一个哈希桶。 哈希值不一样,取模之后下标相同,同样会落到同一个桶。 ⚠重点:哈希值相等,不代表 key 相等;哈希值不等,key 一定不相等。 所以定位桶依靠哈希;桶内部区分真正相等 key,必须依靠==相等判断,两套逻辑缺一不可。
开链法解决冲突:冲突的结点全部挂载同一个桶下的单向链表,不会覆盖原有数据。 缺点:如果大量元素集中落在少数桶,链表越来越长,查找就要遍历长链表,性能退化。

负载因子 load_factor

\(load\_factor = \frac{容器有效元素数量size}{哈希桶总数量bucket\_count}\)

  1. max_load_factor:负载因子阈值,STL 默认值为1.0。
  2. 当 load_factor > max_load_factor,触发rehash 重哈希。

rehash 重哈希机制【面试高频】

  1. rehash 会开辟一块更大的哈希桶数组(通常扩大到原来倍数);
  2. 遍历旧哈希表全部结点,每一个 key 重新计算哈希,重新取模,挂载到新桶数组对应链表;
  3. 全部迁移完成后释放旧桶数组内存。

重大后果:rehash 之后,全部旧迭代器全部失效! 因为结点迁移到新内存,旧迭代器指向已经释放的旧数组,解引用属于未定义行为。
优化手段:如果预先知道要插入多少数据,可以调用reserve(n),提前预分配足够桶,减少运行过程中频繁 rehash 带来性能抖动。

cpp 复制代码
unordered_set<int> us;
us.reserve(100000); //预留足够桶,降低rehash触发次数

哈希相关专属接口(业务写代码很少用,底层调试、面试会考)

cpp 复制代码
bucket_count()      // 当前桶总数量
load_factor()       // 当前负载因子
max_load_factor()   // 获取/设置最大负载因子阈值
rehash(n)           // 手动指定至少n个桶,强制重哈希
reserve(n)          // 根据元素数量n,自动计算合适桶数预分配

四、迭代器核心特性

  1. unordered_xxx 是单向迭代器 :仅支持前置 / 后置it++;不支持--it反向迭代 ,没有rbegin()、rend()。

对比 map/set 双向迭代器:++ --都支持,底层红黑树结点保存 parent 父指针,可以向上回溯。 unordered 底层是单向链表,结点没有向前指针,无法后退。

  1. 迭代器失效规则:
    • rehash 重哈希:全部迭代器直接失效。
    • insert 插入:只要不触发 rehash,现有迭代器保持有效;一旦 rehash,全部失效。
    • erase 删除:只有被删除结点迭代器失效,其余迭代器仍然有效。
  2. 遍历无序:

unordered 容器遍历输出顺序,和插入顺序无关;顺序由哈希值、桶下标、链表顺序共同决定。即使同样输入,rehash 之后遍历顺序会改变。 ⚠禁止业务代码依赖 unordered 的遍历顺序,测试偶然有序只是巧合。

五、key 的约束条件(面试高频)

map /set(红黑树)

只需要 key 支持operator<小于比较;内部依靠小于完成排序搜索。

unordered_set /unordered_map(哈希)

两个条件必须同时满足

  1. key 可以被哈希:hash<Key>能够得到 size_t 哈希值;
  2. key 支持相等比较:重载operator==。

面试题:自定义结构体,作为 unordered_set 的 key 需要做什么?

  1. 编写自定义哈希仿函数,把结构体成员混合计算,返回size_t哈希值;
  2. 对结构体重载operator==,判断两个对象 key 语义等价。

对比:自定义结构体作为 set/map 的 key,只需要重载 operator<。

示例伪代码:

cpp 复制代码
struct Student
{
    int id;
    string name;
    bool operator==(const Student& other) const
    {
        return id == other.id;
    }
};

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

//使用:传入自定义哈希
unordered_set<Student, HashStudent> st;

六、unordered_multiset /unordered_multimap

  1. 特性:允许 key 重复,底层依然是开链哈希桶;无序,单向迭代器。
  2. insert:允许插入重复 key;返回的 pair 中 bool 无实际意义。
  3. find(key):返回桶链表中第一个匹配 key 的迭代器。
  4. erase(key)按值删除:会删除容器中全部等于 key 的元素 ;只想删除其中一个,必须传入迭代器erase(it)。
  5. unordered_multimap不支持 operator \[\],和 multimap 保持一致,重复 key 无法确定取哪一个 value。

七、map/set 和 unordered_map/unordered_set 选型策略

✅优先选择 unordered(哈希)

  1. 业务只做单点增、删、查,不需要 key 有序输出;
  2. 数据量大,追求平均性能;

缺点:存在 rehash 性能抖动;最坏退化 O (N);内存开销更大;单向迭代器;无序。

✅优先选择 map /set(红黑树)

  1. 需要 key 有序输出;
  2. 需要区间操作:使用lower_bound、upper_bound做范围查询;哈希表无法高效区间查找;
  3. 业务对最坏时间复杂度有硬性要求,需要稳定\(O(logN)\),不能接受退化到 O (N);
  4. 需要反向遍历,依赖双向迭代器--it。

补充:小数据规模场景,\(O(logN)\)红黑树与哈希\(O(1)\)差距很小;哈希还要计算哈希值、处理 rehash,甚至性能不如红黑树。

八、完整性能测试参考代码

对比 set 与 unordered_set 插入、查找、删除耗时,直观体现性能差异。

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

int main()
{
    const size_t N = 1000000;
    vector<int> v;
    v.reserve(N);
    srand((unsigned int)time(nullptr));
    for(size_t i = 0; i < N; ++i)
    {
        v.push_back(rand() + i);
    }

    set<int> s;
    unordered_set<int> us;

    //插入计时
    size_t begin1 = clock();
    for(auto e : v)
        s.insert(e);
    size_t end1 = clock();
    cout << "set insert time:" << 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 time:" << end2 - begin2 << endl;

    //查找计时
    size_t begin3 = clock();
    for(auto e : v)
        s.find(e);
    size_t end3 = clock();
    cout << "set find time:" << end3 - begin3 << endl;

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

    return 0;
}

现象:大数据量,哈希容器平均速度更快;如果哈希函数设计差,大量冲突,性能会大幅下跌。

九、大厂面试高频问题总结

Q1:map 与 unordered_map 底层,时间复杂度?

map 底层红黑树平衡二叉搜索树;增删查稳定\(O(logN)\),key 有序,双向迭代器,支持lower_bound区间查找。 unordered_map 底层开链哈希桶;平均\(O(1)\),哈希冲突严重最坏退化\(O(N)\);遍历无序,单向迭代器;rehash 会全部迭代器失效。

Q2:unordered 为什么既需要 hash 函数,又需要 == 运算符?

hash 函数只用来计算哈希值定位哈希桶;不同 key 可以算出相同哈希值,发生哈希冲突 ;同一个桶链表内,必须用==来真正判断两个 key 是否等价;哈希值相等不等于 key 相等。

Q3:rehash 重哈希做了什么,会带来什么问题?

负载因子超过阈值 max_load_factor,开辟更大哈希桶数组;把旧容器中全部结点重新计算哈希,迁移挂载到新桶数组,释放旧内存。rehash 发生之后所有旧迭代器全部失效 。可以使用reserve()提前预分配,减少 rehash。

Q4:unordered 的迭代器为什么不支持 -- 反向迭代?

底层桶内是单向链表,结点只有后继指针,没有前驱指针,只能向后遍历,属于单向迭代器;红黑树结点保存 parent 父指针,可以向上回溯,支持双向迭代。

Q5:multimap 和 unordered_multimap 的区别?

multimap 底层红黑树,key 有序,双向迭代器,允许重复 key; unordered_multimap 底层开链哈希桶,无序,单向迭代器,允许重复 key;二者都不支持operator[];按 key 调用 erase,会删除全部匹配 key。

Q6:什么时候用 map,什么时候用 unordered_map?

需要 key 有序、区间查找、要求最坏时间复杂度稳定\(O(logN)\) → map; 大数据量,只做单点增删查,不需要有序,追求平均性能 → unordered_map; 小数据量两者性能差距不大。

Q7:自定义结构体作为 unordered_map 的 key,需要实现什么?

  1. 自定义哈希仿函数,返回size_t哈希值;
  2. 重载operator==,实现 key 相等语义;

如果作为 map 的 key,仅需要重载operator<。

十、开发易错坑清单

  1. ❌不要依赖 unordered 容器的遍历顺序,环境、rehash 都会改变输出顺序;
  2. ❌rehash 之后继续使用旧迭代器,解引用直接未定义行为;
  3. ❌自定义结构体当 key 忘记提供哈希仿函数,直接编译报错;
  4. ❌unordered_multimap使用operator[],编译报错,不支持;
  5. ✅已知插入数据规模,优先调用reserve(n),减少 rehash 带来性能抖动。
相关推荐
chenbingjie_c9 小时前
C++ 进阶核心知识点总结:模板、内存分区、new/delete、内存池
开发语言·c++
Frank_refuel11 小时前
【C++】Json库介绍和使用
c++
重生之小比特11 小时前
【C++进阶】map和set
java·开发语言·c++
知识分享小能手12 小时前
C++ 学习教程,从入门到精通,C++对象和类 — 详细知识点总结(10)
开发语言·c++·学习
顺顺 尼12 小时前
linux 进程信号(上)
linux·c++·算法
海上小飞龙13 小时前
改一个数,右边全得重算,这题怎么扛住两万次查询
java·c++·python
Persistent的粽子!13 小时前
双指针算法:最大盛水容器
c++·算法·leetcode
小小龙学IT14 小时前
三菱 PLC MC 协议(SLMP / QnA 兼容 3E 帧)深度解析:从帧结构到 C++/Go 双语言采集实战
c语言·c++·golang
程序猿编码14 小时前
C++/CUDA 手写 LLM 推理引擎:拆解 vLLM 核心 PagedAttention 与连续批处理
开发语言·c++·深度学习·神经网络·推理·vllm