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. 实现iteratorconst_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)。

相关推荐
qq_246839751 小时前
postgresql xxhash64 哈希函数实现
数据库·postgresql·哈希算法
C++ 老炮儿的技术栈1 小时前
Qt5 使用 QPainter 绘制阿基米德螺线
开发语言·c++·windows·qt·代码化
xxwxx__2 小时前
深入理解 C++ STL:stack、queue 与 deque 从使用到底层实现全解析
开发语言·c++·算法
wuminyu2 小时前
JVM锁膨胀与Futex源码解析
java·linux·c语言·jvm·c++
郝学胜-神的一滴2 小时前
C++11 工程级应用 08:Lambda表达式与Tuple元组
开发语言·jvm·c++·python·程序人生·开源
水饺编程3 小时前
第5章,[Win32 章节] :圆角矩形教学插图绘制程序
c语言·c++·windows·visual studio
Existence_Time3 小时前
CSP 考前复习
c++
zerwave3 小时前
从零实现简化 DDS 完整技术文档(含模块作用与详细注释)
linux·c++·机器人
Dream Cosmos4 小时前
C++ 多态下篇:虚函数表、动态绑定与多态底层原理
开发语言·c++