C++哈希表深度剖析:冲突处理、rehash、迭代器与unordered容器封装

面试高频考点:unordered_map / unordered_set底层是开链哈希表,很多人只会调用 API,搞不懂底层冲突、扩容、迭代器逻辑,本文结合原理图把底层讲透。

一、哈希冲突的两种解决思路

哈希函数:hash(key) % M,M 是哈希表数组长度。 不同 key 算出来相同下标,就产生哈希冲突。两大主流方案:开放寻址法、链地址法。

1. 开放寻址法:二次探测

二次探测属于开放寻址,冲突之后,按照偏移公式向后找空位存放元素。

❗核心约束:核心约束:h2(key)必须和 M 互质。 如果 gcd(M,h2(key))=p>1,能够访问到的位置只有 M/p 个,会有大量下标永远访问不到。

举例子:M=12,初始位置 1,偏移量 3,gcd(12,3)=3。 只能访问下标{1,4,7,10},一共 4 个位置,剩下 8 个位置完全无法使用,空间严重浪费。

开放寻址法缺点:

  1. 删除不能直接置空,只能做删除标记,否则探测链断裂,后面元素查找失败;

  2. 负载因子必须小于 1,一般 0.7 就要扩容,否则冲突爆炸;

  3. STL 标准库 unordered 系列没有用开放寻址。

2. 链地址法(开链哈希,STL 真正实现)

数组每一个位置叫bucket 哈希桶,桶里面存链表。哈希值相同的 key,全部挂在同一个桶的链表上。

示例:

复制代码
h(19)=8,h(30)=8,h(96)=8
h(13)=2,h(24)=2
h(12)=1,h(36)=3,h(5)=5
h(20)=9,h(21)=10

桶数组:

  • 桶 1:12

  • 桶 2:24 ->13

  • 桶 3:36

  • 桶 5:5

  • 桶 8:96 ->30 ->19

  • 桶 9:20

  • 桶 10:21

插入:头插法

复制代码
size_t hashi = kv.first % _tables.size();
Node* newNode = new Node(kv);
newNode->_next = _tables[hashi];
_tables[hashi] = newNode;

新节点直接挂到链表头部,效率高。

✅链地址法优势:

  1. 冲突元素直接链表挂载,不占用别的桶位置;

  2. 可以真正删除节点,直接释放内存;

  3. 负载因子允许大于 1。

二、rehash 哈希表扩容

负载因子 = 有效元素数量 / 桶数组大小 C++ 标准库默认阈值为 1,元素数量 >= 桶数量就触发 rehash 扩容。

⚠️重点:扩容不是简单vector.resize()! 哈希下标是key % _tables.size(),桶数组长度改变,所有 key 的哈希下标全部改变,旧节点必须全部重新计算哈希,迁移到新桶。

rehash 代码逻辑:

复制代码
vector<Node*> newtables(_tables.size() * 2);
for (size_t i = 0; i < _tables.size(); i++)
{
    Node* cur = _tables[i];
    while (cur)
    {
        Node* next = cur->_next;
        size_t hashi = cur->_kv.first % newtables.size();
        cur->_next = newtables[hashi];
        newtables[hashi] = cur;
        cur = next;
    }
    _tables[i] = nullptr;
}
_tables.swap(newtables);

执行步骤:

  1. 创建新桶数组,容量一般扩为原来 2 倍;

  2. 遍历旧数组每一个桶,遍历桶内整条链表;

  3. Node* next = cur->_next提前保存后继节点,防止迁移后链表丢失;

  4. 使用新表长度重新计算哈希下标;

  5. 头插把节点挂入新桶;

  6. 旧桶置空;

  7. swap 交换新旧数组,完成扩容。

面试题:rehash 迭代器为什么失效? 旧迭代器保存旧桶数组的指针,rehash 之后旧数组被 swap 替换,迭代器指向已经废弃的旧空间,迭代器失效。节点本身没有释放,只是链表被移动到新桶。

三、哈希表迭代器实现难点

普通单向链表迭代器++it只需要cur = cur->_next。 哈希表是数组套链表,迭代器自增有两种情况:

  1. 当前桶链表还有后续节点:直接cur = cur->_next;

  2. 当前桶链表走到末尾cur == nullptr:向后遍历桶数组,找到下一个不为空的桶,取桶的第一个节点。

复制代码
template<class V, class K, class HF, class ExK, class EqK, class A>
__hashtable_iterator<V,K,HF,ExK,EqK,A>&
__hashtable_iterator<V,K,HF,ExK,EqK,A>::operator++()
{
    const node* old = cur;
    cur = cur->_next;
    if (!cur)
    {
        size_t bucket = ht->_bkt_num(old->_val);
        while (!cur && ++bucket < ht->_buckets.size())
            cur = ht->_buckets[bucket];
    }
    return *this;
}

逻辑拆解:

  1. 先取当前节点的 next;

  2. 如果 cur 为空,代表本桶链表遍历完毕;

  3. 获取当前节点所在桶编号,bucket++ 向后扫描桶数组;

  4. 找到第一个非空桶,cur 赋值为该桶链表头;

  5. 遍历完所有桶,cur=nullptr,就是end()迭代器。

const_iterator:key 不能修改。哈希查找完全依赖 key 计算哈希值,如果 key 被改写,哈希下标错乱,元素再也找不到。所以 unordered_map 的 key 是 const。

四、封装 unordered_set 与 unordered_map

底层是同一个hashtable,只是对外包装不同。

|----------------------|---------------------|---------------------|
| 容器 | 存储内容 | 底层 hashtable 的 V 参数 |
| unordered_set | 只存 key | K |
| unordered_map<K,V> | 存 pair<const K,V> | pair<const K,V> |

手写哈希表整体实现步骤

  1. 实现底层开链 hashtable,实现 insert、erase、find、rehash;

  2. 封装unordered_set:value 就是 key;

  3. 封装unordered_map:value 是 pair<K,V>;

  4. 实现iterator、const_iterator;

  5. 处理 key 不可修改;

  6. 实现operator[](map 专属,set 没有)。

operator[]特性:key 不存在就插入默认构造的 value,返回 value 引用。

五、开放寻址 vs 链地址对比

|------|---------------------|---------------------|
| 对比项 | 开放寻址(二次探测) | 链地址法(STL unordered) |
| 冲突处理 | 数组内找空位 | 桶内链表挂载冲突元素 |
| 删除操作 | 只能标记删除,不能直接清空 | 可以直接删除释放节点 |
| 负载因子 | 必须 < 1,一般 0.7 触发扩容 | 允许大于 1 |
| 缓存性能 | 连续数组,缓存友好 | 节点堆上分散,缓存较差 |
| 扩容频率 | 扩容频繁 | 元素多才扩容 |
| 缺点 | 聚集问题,不能真实删除 | 链表遍历开销 |

六、面试高频问答整理

  1. 二次探测为什么 h_2(key要和 M 互质? 保证探测序列能够遍历整个哈希表全部位置,否则只能访问部分下标,产生访问盲区。

  2. unordered_map 的 key 为什么不能修改? key 参与哈希计算,一旦 key 修改,保存的哈希下标和实际 key 不匹配,查找直接失效。

  3. rehash 过程会释放节点吗? 不会,节点只是从旧桶摘下,重新挂到新桶链表,内存不释放,只是迭代器失效。

  4. 负载因子的意义? 负载因子越大,桶内链表越长,冲突概率越高,查找效率下降,用来控制触发扩容时机。

  5. unordered_map 和 map 区别?

  • map 底层红黑树,有序,O (logN);

  • unordered_map 底层开链哈希,无序,平均 O (1)。

相关推荐
東隅已逝,桑榆非晚1 小时前
算法6. 双指针法-查找总价格为目标的两个商品
c++·学习·算法
qq_406981032 小时前
c++拷贝构造函数
开发语言·c++
H.莓飛4 小时前
【C++】【STL】vector_OJ题
linux·服务器·开发语言·数据结构·c++·visual studio
Rabitebla4 小时前
【Linux操作系统】进程篇(十一):进程程序替换——从底层原理到 exec 函数族
linux·服务器·c++·笔记·学习·算法
沫璃染墨5 小时前
从零入门计算机网络系列(十五):TCP可靠传输机制详解——从序号与确认应答到乱序处理
服务器·网络·c++·tcp/ip·tcp
青少儿编程课堂5 小时前
蓝桥杯·全国青少年信息素养大赛·电子学会Python/C++考级:Bellman-Ford与SPFA含负权最短路及负环检测全解析
c++·python·算法·bfs·信息学竞赛
旖旎夜光5 小时前
【C++ 基础入门】类和对象(下):初始化列表、static 成员、友元与内部类
c++·笔记·学习
j7~5 小时前
【C++ 八股文】(篇四)C++11 新特性
开发语言·c++·面试·c++11·八股文
eggwyw6 小时前
C++中大括号的用法合集
开发语言·c++
xiaoye-duck6 小时前
《C++ 项目:手写大模型接入 SDK》本地 Ollama 部署与适配
开发语言·网络·c++·语言模型