面试高频考点: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,一般 0.7 就要扩容,否则冲突爆炸;
-
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。
二、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);
执行步骤:
-
创建新桶数组,容量一般扩为原来 2 倍;
-
遍历旧数组每一个桶,遍历桶内整条链表;
-
Node* next = cur->_next提前保存后继节点,防止迁移后链表丢失; -
使用新表长度重新计算哈希下标;
-
头插把节点挂入新桶;
-
旧桶置空;
-
swap 交换新旧数组,完成扩容。
面试题:rehash 迭代器为什么失效? 旧迭代器保存旧桶数组的指针,rehash 之后旧数组被 swap 替换,迭代器指向已经废弃的旧空间,迭代器失效。节点本身没有释放,只是链表被移动到新桶。
三、哈希表迭代器实现难点
普通单向链表迭代器++it只需要cur = cur->_next。 哈希表是数组套链表,迭代器自增有两种情况:
-
当前桶链表还有后续节点:直接
cur = cur->_next; -
当前桶链表走到末尾
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;
}
逻辑拆解:
-
先取当前节点的 next;
-
如果 cur 为空,代表本桶链表遍历完毕;
-
获取当前节点所在桶编号,bucket++ 向后扫描桶数组;
-
找到第一个非空桶,cur 赋值为该桶链表头;
-
遍历完所有桶,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> |
手写哈希表整体实现步骤
-
实现底层开链 hashtable,实现 insert、erase、find、rehash;
-
封装
unordered_set:value 就是 key; -
封装
unordered_map:value 是 pair<K,V>; -
实现
iterator、const_iterator; -
处理 key 不可修改;
-
实现
operator[](map 专属,set 没有)。
operator[]特性:key 不存在就插入默认构造的 value,返回 value 引用。
五、开放寻址 vs 链地址对比
|------|---------------------|---------------------|
| 对比项 | 开放寻址(二次探测) | 链地址法(STL unordered) |
| 冲突处理 | 数组内找空位 | 桶内链表挂载冲突元素 |
| 删除操作 | 只能标记删除,不能直接清空 | 可以直接删除释放节点 |
| 负载因子 | 必须 < 1,一般 0.7 触发扩容 | 允许大于 1 |
| 缓存性能 | 连续数组,缓存友好 | 节点堆上分散,缓存较差 |
| 扩容频率 | 扩容频繁 | 元素多才扩容 |
| 缺点 | 聚集问题,不能真实删除 | 链表遍历开销 |
六、面试高频问答整理
-
二次探测为什么 h_2(key要和 M 互质? 保证探测序列能够遍历整个哈希表全部位置,否则只能访问部分下标,产生访问盲区。
-
unordered_map 的 key 为什么不能修改? key 参与哈希计算,一旦 key 修改,保存的哈希下标和实际 key 不匹配,查找直接失效。
-
rehash 过程会释放节点吗? 不会,节点只是从旧桶摘下,重新挂到新桶链表,内存不释放,只是迭代器失效。
-
负载因子的意义? 负载因子越大,桶内链表越长,冲突概率越高,查找效率下降,用来控制触发扩容时机。
-
unordered_map 和 map 区别?
-
map 底层红黑树,有序,O (logN);
-
unordered_map 底层开链哈希,无序,平均 O (1)。