(d1)散列:排解冲突(1)

官方信息技术老师·19 页·深入(追求细节与边界)·0 次浏览·1 天前
哈希冲突鸽巢原理生日悖论装载因子

散列:排解冲突

看完你能用生日悖论解释冲突为何必然,并用装载因子预判其频率

按 空格/→ 演示下一步

1 / 19 页

全部页面点击任意一页,跳回舞台从这页播放

哈希冲突鸽巢原理生日悖论装载因子

散列:排解冲突

看完你能用生日悖论解释冲突为何必然,并用装载因子预判其频率

1第 1 页 · 散列:排解冲突

什么是散列冲突

假设班里 30 个同学,但只有 10 个储物柜。班主任按学号尾数分配——两个尾数相同的同学,必然被指到同一个柜子。谁也挤不进去,这就叫冲突。

冲突的定义
两个或多个不同关键字经哈希函数计算后,落到同一槽位
冲突的来源
哈希函数把无限的关键字压缩到有限的槽位集合
冲突不可避免
抽屉原理:n+1 个物品放入 n 个抽屉,至少一格装 ≥2 件
班级储物柜分配对应 →哈希函数映射

学号尾数 = 哈希函数;尾数相同的两人都被指到同一格,即冲突

2第 2 页 · 什么是散列冲突

一山二虎:冲突场景

26 把钥匙要挂到 10 个挂钩上——挂得下吗?必然挂不下。key 永远比槽位多,冲突不是程序员的失误,是数学的必然。

冲突的本质
两个不同的 key,经散列函数计算后落在同一个槽位
冲突不可避免
key 空间远大于槽位空间,鸽巢原理决定冲突必然发生
冲突 ≠ 设计错误
好散列函数只能降低概率,无法消除冲突
冲突频率
由装载因子 α = n/m 决定,越满越容易撞
一山不容二虎对应 →散列冲突

两虎=两个不同的 key,一山=同一个槽位,都想独占那一个位置

α=nm\alpha = \frac{n}{m}
3第 3 页 · 一山二虎:冲突场景

冲突解决的总体策略

中心向外辐射:先看两大阵营,再看各自内部的具体实现手法。

图解渲染中…
链地址法同槽挂链表,允许多元素共处再散列法换槽重试,每个槽只能住一位双重散列第二个哈希函数算出探测步长可装载溢出n(元素数) 可大于 m(槽数)
4第 4 页 · 冲突解决的总体策略

泾渭分明:开放定址 vs 链地址

上页说冲突有两条路可走。这页走进这两条路,看它们如何一个在原地腾挪、另一个向外延伸。

开放定址
冲突时在数组内向后探测,挨个找下一个空槽
链地址
每个槽位挂一条链表,所有冲突元素都挂到同一槽下
核心差异
原地解决不占额外空间 vs 向外扩展需链表空间
电影院找座位对应 →开放定址

你座位被占了,就挨个往后挪一位找最近的空位坐下

5第 5 页 · 泾渭分明:开放定址 vs 链地址

两种策略的优劣对比

两种策略都能解决冲突,但'哪个更好'没有标准答案——取决于负载因子和操作类型。

开放定址
  • 空间:元素全存数组内,无额外指针开销
  • 时间:缓存友好,负载因子高时急剧退化
  • 删除:必须用墓碑标记,不能直接置空
链地址
  • 空间:每桶挂链表/树,节点需额外指针
  • 时间:负载可>1,平均O(1),最坏O(n)
  • 删除:直接摘除节点,无需特殊标记
负载低、查询密集、数据量稳定时选开放定址;负载波动大、频繁增删时选链地址。
6第 6 页 · 两种策略的优劣对比

开放定址的基本思想

上页做完对比,开放定址和链地址走了完全不同的路。这页专门拆开开放定址——它的招数一句话就能说清:冲突发生后不另起炉灶,就在这张表里按规则继续找空位。

原地探测
冲突时不另开空间,沿表内顺序往后找下一个空位
探测序列
用规则(线性、二次、双散列)决定下一个候选位置
三要素
起点 H(k)、步长规则、终止条件(空位或表满)
图书馆按号找座对应 →开放定址

指定座位被占就往后挪一格,挨个试直到找到空座

Hi(k)=(H(k)+di)modmH_i(k) = \bigl(H(k) + d_i\bigr) \bmod m
7第 7 页 · 开放定址的基本思想

开放定址的插入流程

沿箭头从左侧开始:先定位起始槽,再检查空位、重复键和容量,最终走向处理成功或插入失败。

图解渲染中…
b1散列值给出首次探测的槽位下标。c1记录检查次数,防止全表已满时死循环。d1每次换槽后都重新执行核心检查。k1换槽规则由线性、二次或双散列策略决定。
8第 8 页 · 开放定址的插入流程

线性试探的规则

上一页说开放定址要找新位置。怎么找?最朴素的办法:从原位开始,一位一位往后挪。这就是线性试探。

i 从 0 起
i=0 就是原位,i=1 往后挪一格
顺序 +1
每次冲突,下一个候选在前一个基础上 +1
mod m 绕回
走到表尾就跳回表头,不留死角
遇空即停
探到空槽就插入,搜索终止
停车场找车位对应 →线性试探

指定车位被占就挨个往后挪,直到找到空位

h(k,i)=(h(k)+i)modmh(k,i) = (h(k) + i) \bmod m
9第 9 页 · 线性试探的规则

线性试探插入实例

表长 7,连续插入 hash 值都是 1 的四个键,看线性试探如何逐个占位。

1
插入 8
hash(8)=1,第 1 槽空,直接放入
2
插入 15
hash(15)=1 冲突,往后挪 1 格到第 2 槽
3
插入 22
hash(22)=1 冲突,挪 2 格才到第 3 槽
4
插入 29
hash(29)=1 冲突,挪 3 格才到第 4 槽
10第 10 页 · 线性试探插入实例

线性试探查找实例

以 h(K)=3、3号和4号槽非空为例,查找一直查到5号空槽。

1
计算散列地址
用散列函数计算K的初始槽号,本例为3
2
检查起始槽
检查3号槽;未命中,就按线性规则继续试探
3
检查下一槽
3号槽已占用,接着检查4号槽,仍未命中
4
发现首个空槽
继续检查5号槽;首次出现空位,探测停止
5
判定目标不存在
在无删除墓碑的前提下,K若存在必会占据此空槽
11第 11 页 · 线性试探查找实例

一次聚集与二次聚集

线性试探看起来简洁优雅,但它藏着一个致命缺陷:被占用的格子会像滚雪球一样连成一片,越滚越长。新来的元素只要哈希落在长链覆盖的区域,无论原本想去哪里,都得沿着这条链一步步试探。

一次聚集
线性试探中,连续被占用的槽位连成一条长链,长度只增不减
越长越滚越大
链越长,新哈希落入链内的概率越大,又使链更长——恶性循环
性能急剧恶化
平均探测次数随装填因子非线性增长;超过 70% 后查表代价飙升
二次聚集
初始哈希相同的元素,试探路径完全相同,反复在链中相遇
波及范围不同
一次聚集把无关元素也拖入长链;二次聚集只波及同哈希元素
早高峰地铁闸机前排长队对应 →一次聚集

队伍越长,新乘客不管从哪个口来,都得并进这条长队里挪

成功搜索探测次数12(1+11α)\text{成功搜索探测次数} \approx \tfrac{1}{2}\left(1+\tfrac{1}{1-\alpha}\right)
12第 12 页 · 一次聚集与二次聚集

为什么不能真删除

想想线性试探的链式布局:元素 A 因冲突挪到位置 3,元素 B 又被迫挪到位置 5。若此时「真删除」A,会发生什么?

探测链的依赖
元素能落到槽 N,是因为前 N-1 个槽都不让进(被占或曾被占)
真删除的代价
一旦把槽位真清空,整条探测链就此断裂
查找随之失效
查后继元素时遇到空槽,误以为「不在表里」,直接放弃
问路时沿途的指路人对应 →探测链上的每个槽位

撤走中间任一位,后面的路就断了

13第 13 页 · 为什么不能真删除

懒惰删除的工作原理

从左到右看:删除先把目标槽改成墓碑,再分出查找继续探测与插入复用两条支路。

图解渲染中…
c1删除只改变槽位状态,不搬走其他记录。d1墓碑仍算占位,不能被查找当作真正空槽。e1查找跨过墓碑,直到命中或遇到真正空槽。h1按本页策略,插入可复用遇到的墓碑。
14第 14 页 · 懒惰删除的工作原理

墓碑带来的问题

上一页我们用'墓碑'巧妙解决不能真删的问题,看起来很完美。但跑得越久,墓碑越来越多——数据结构也会'老去'。

墓碑占着槽位
删一个就多一块墓碑,新元素插不进原本的家
查找被迫绕路
每遇到墓碑都要继续探查,相当于走冤枉路
装填因子虚高
墓碑也算占用,实际可用槽位比表显示的少
需要定期重建
只能靠再散列一次性把墓碑清掉
酒店退房立牌对应 →哈希表墓碑

房间空了但牌子占位,新客住不进——空槽有墓碑就落不进新元素

αeff=n+tm\alpha_{\text{eff}} = \dfrac{n + t}{m}
15第 15 页 · 墓碑带来的问题

墓碑的解决思路

上页说到墓碑越积越多,探测链越来越长,查找效率会断崖式下跌。那该怎么办?两个思路:一是让墓碑槽也能「再就业」,二是攒多了就一次性重建。

查找与插入区别对待
查找时墓碑必须当成「有人」继续探;插入时可把它当成「空位」放入新元素
重用墓碑槽
插入时碰到墓碑就直接覆盖,无需额外开销即可逐步消化积压
定期重建哈希表
墓碑占比超阈值时,重新散列所有存活元素,一次性清空所有墓碑
图书馆借阅纸条对应 →墓碑槽复用与重建

纸条=墓碑:可上新书于纸条位,但找旧书不能跳过;纸条太多就重整书架

16第 16 页 · 墓碑的解决思路

知识点回顾

  • 冲突由鸽巢原理注定,不可避免
  • 开放定址与链地址是空间与聚集的权衡
  • 试探序列质量决定开放定址的性能
  • 墓碑是删除的补丁,会污染查找路径
延伸主题:链地址法的实现细节二次与双重散列等试探序列工业级散列表的设计取舍
17第 17 页 · 知识点回顾

自测题

点击作答

在使用线性试探的开放定址法中,为什么不能直接把已删除键所在的槽设为"空"?

18第 18 页 · 自测题

课后思考

先合上答案,自己动笔算一算;实在想不出再点开参考答案看思路。

1为什么开放定址法不能把被删元素所在的槽直接置空,否则后续查找会怎样?

参考答案插入遇到"空"槽就停。若中间被直接置空,查找会在那里提前终止,误以为目标不存在——查找路径被"切断"了。

2设想一张装载因子为 0.7 的线性试探表,一次失败的查找平均要试探多少次?

参考答案提示公式 0.5×(1+1/(1-α)²),代入 α=0.7 数量级在 5~6;成功查找约一半。这就是工业上把装载因子压到 0.5 以下的原因。

3装载因子趋近于 1 时,二次试探是否就能避免一次聚集带来的性能雪崩?为什么?

参考答案不能。二次试探只缓解一次聚集,同一起点的多个键仍会撞车(二次聚集);且高 α 下空槽稀疏,探测序列越来越长,雪崩依旧。

19第 19 页 · 课后思考