(b)散列:原理

官方信息技术老师·21 页·深入(追求细节与边界)·0 次浏览·2 天前
哈希函数冲突处理时间复杂度数据结构

散列:原理

看完你能讲清散列表为何 O(1) 查找,以及何时会失效

按 空格/→ 演示下一步

1 / 21 页

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

哈希函数冲突处理时间复杂度数据结构

散列:原理

看完你能讲清散列表为何 O(1) 查找,以及何时会失效

1第 1 页 · 散列:原理

电话查号台的启示

上一节我们说散列是一种映射。但映射到底意味着什么、它和翻字典有什么本质不同?让我们从最熟悉的电话查号台说起。

名字→号码的对应
本质就是一个'报名字、出号码'的对应关系
确定性
同一个名字每次都查同一个号码
范围压缩
无穷多个名字被压到有限号码空间
冲突不可避免
输入空间大于输出空间,不同键必撞号
字典查字对应 →查号台查号

字典要排序+二分;查号台靠映射直接定位,不必遍历

h:K[0,m)h: K \to [0, m)
2第 2 页 · 电话查号台的启示

散列的核心思想

左侧多个键汇入散列函数,右侧分散到不同地址再定位取值

图解渲染中…
b1核心变换:把任意键映射到固定范围的地址c1/c2/c3不同键映射到不同槽位(无冲突理想情况)
3第 3 页 · 散列的核心思想

循值访问是什么

上页说散列把关键字变成存储地址。这「变」的过程就是循值访问:不靠位置、不靠顺序,单凭值本身就能算出它该去哪。

值即位置
值的特征直接决定存储地址,无需索引
函数为桥
散列函数把任意关键字映射到固定区间
一次寻址
理想情况下定位只需一次内存访问
代价前置
计算开销和冲突处理写入时就发生
快递柜取件码对应 →循值访问

取件码直接对应柜格,输入即开门

4第 4 页 · 循值访问是什么

数组的随机访问

从下标到值的 O(1) 路径:自左向右走一次完整的随机访问,每步耗时恒定。

图解渲染中…
b2B 是数组首元素在内存中的起始地址b3S 是单个元素占用的字节数
5第 5 页 · 数组的随机访问

循值访问的数组方案

前一页说数组按下标访问是 O(1),一步到位。可我们的需求是反过来的:拿到一个值,直接找到它,别遍历。怎么把「按值」变成「按下标」?答案只有一个动作:把值变成下标。

下标替代遍历
找到下标 = 找到值,省掉 O(n) 的逐个比对
关键动作:值→整数
把任意类型的数据转换为一个整数,作为数组下标
下标必须合法
非负整数,且不能超过数组长度减一
酒店房号对应 →数组下标

把客人名映射为房号,拿到号就能直奔房间,不必逐间敲

6第 6 页 · 循值访问的数组方案

散列函数是什么

前页我们让数组下标直接等于键——但现实中键往往是字符串这样的复杂数据,没法当下标用。需要一个"翻译器"把任意键变成整数下标,这就是散列函数。

输入是"键"
任意类型的数据,如字符串、数字、对象
输出是下标
落在 [0, n) 范围内的整数,n 为数组长度
必须确定性
同一键无论算多少次,永远返回同一地址
键空间远大于槽位数
键的可能性数量远超数组长度,碰撞是数学必然
酒店前台按姓氏分房对应 →散列函数

客人姓名(输入)对应房间号(输出),规则固定但同姓会撞房

h(k)=kmodm,m=数组长度h(k) = k \bmod m, \quad m = \text{数组长度}
7第 7 页 · 散列函数是什么

散列函数的实现

python

两份最小可运行的散列实现:整数取模 + 字符串多项式滚动哈希。

代码高亮加载中…

取模把任意整数压成数组下标;字符串先被多项式编码成整数,每步取模防溢出。'hash' 与 'Hash' 落入不同桶。

8第 8 页 · 散列函数的实现

散列查找的完整流程

一次散列查找分四步走完,每一步都不能少。

1
算哈希
用哈希函数把 key 换算成数组下标
2
定位
按下标直接跳到数组对应槽位
3
比 key
取出槽位里的 key 与目标比较
4
返回/不存在
相等返回值,不等说明 key 不在表里
9第 9 页 · 散列查找的完整流程

什么是冲突

散列函数把键变成数组地址,理想情况下一一对应。但键的取值范围远大于数组长度,总会有不同键算出同一个地址——这就是冲突。

冲突的定义
不同键经散列函数算出同一个数组地址
冲突的必然性
键的取值范围远大于数组长度,重叠不可避免
冲突的后果
后写入的值会覆盖原有数据,造成信息丢失
储物柜分配对应 →冲突

20人只15个柜,必有两人分到同柜,钥匙对不上

K>Lk1k2:hash(k1)=hash(k2)|K| > L \Rightarrow \exists\,k_1\neq k_2:\,\mathrm{hash}(k_1)=\mathrm{hash}(k_2)
10第 10 页 · 什么是冲突

冲突的概率分析

从左到右: 直觉→配对洞察→精确累乘→拐点

图解渲染中…
a1直觉起点: 一群人中可能有同生日b1配对数 C(N,2)=N(N-1)/2 随 N² 增长c1第k人无冲突概率 (365-k+1)/365 累乘e1生日悖论: 23人冲突率约 50.7%
11第 11 页 · 冲突的概率分析

装填因子与性能

上一页用生日悖论说明元素越多越容易撞——但'多'是相对的,撞的概率取决于表有多满。装填因子就是把这种'满'精确量化成一个数字。

装填因子 α
已存元素数 n 除以表容量 m,量化表有多满
α 越大冲突越多
空位越少,新元素的散列值越容易落到已占槽位
性能随 α 恶化
线性探测下成功查找平均探测次数有精确公式
α 越接近 1 越危险
α=0.5→约 1.5 次,α=0.9→约 5.5 次,常设 α≥0.7 触发扩容
停车场使用率对应 →装填因子 α

停车场越满,找空位要绕的圈越多,对应 α 越大平均探测次数越多

Cˉn12(1+11α)\bar{C}_n \approx \frac{1}{2}\left(1+\frac{1}{1-\alpha}\right)
12第 12 页 · 装填因子与性能

两大流派对比

解决冲突的两种思路完全不同——原地探测 vs 链式延伸,这一根本分歧决定了内存布局、缓存行为与负载能力的差异。

开放地址法
  • 冲突时探测数组内下一个空槽
  • 数据连续存放,缓存极友好
  • 装填因子需<0.7,否则性能崩塌
链地址法
  • 冲突时在该槽位挂链表延伸
  • 数组配指针,节点离散分布
  • 可大于1,删除只需摘节点
负载可控、追求极致缓存速度选开放地址法;负载难控、增删频繁则选链地址法。
13第 13 页 · 两大流派对比

开放地址法图解

发生冲突后,三种探测法从h出发,按各自规则生成探查序列。

图解渲染中…
D1线性探测:步长恒为1E3二次探测:步长按1,4,9平方增长F2双重散列:步长=第二个哈希值B菱形=冲突判断分支
14第 14 页 · 开放地址法图解

链地址法图解

上方是桶数组,每个槽位挂一条链表;冲突键串在同一条链。

图解渲染中…
b2桶本身是链表头指针,指向链上第一个节点n1链表末尾的 null 表示此链结束
15第 15 页 · 链地址法图解

链地址法的插入操作

c

C 语言实现链地址法的"头插法"插入,五步即可完成。

代码高亮加载中…

头插法精髓就在这五行—— ④⑤ 两步完成 O(1) 插入:先让新节点指向旧链头,再把桶首指针改成新节点;顺序反了旧链表就丢了。

16第 16 页 · 链地址法的插入操作

再散列

上一页说过,装填因子越大冲突越多、性能越差。怎么办?把表扩大、把元素重新散列一次——这就是「再散列」。

触发条件
装填因子超过阈值(常取 0.7~0.75)
扩容幅度
新容量约为旧表 2 倍,再选下一个质数
重散列过程
遍历旧表所有元素,按新容量重算哈希位置后插入
单次代价
迁移 n 个元素需 O(n) 时间,不可分摊
摊还代价
扩容极少发生,分摊到每次插入仍是 O(1)
搬家换大房子对应 →再散列扩容

旧房住不下→找大一倍新房→所有家具按新房间重新摆位

nO(1)+O(n)n=O(1)\frac{n \cdot O(1) + O(n)}{n} = O(1)
17第 17 页 · 再散列

删除操作的陷阱

开放地址法靠探测链走到底——只要中间出现一个「空」,就认为「后面没有」。删除时如果直接清空槽位,就把探测链切断了。

直接删会断链
把元素所在槽置为空,原本挤在后面的元素永远查不到
墓碑标记
用一个特殊值占住位置,表示「曾有元素、现已删除」
查找穿越墓碑
探查时遇到墓碑继续往后,直到真空位才确认不存在
插入可复用墓碑
插入时碰到墓碑可直接占用,无需继续探测
墓碑堆积
大量删除后墓碑堆积,装填因子虚高,查找性能下降
图书馆书架对应 →开放地址法删除

书被借走不放「已借出」牌子,读者看到空位就以为没这本书

18第 18 页 · 删除操作的陷阱

自测检验

点击作答

散列查找平均可达 O(1) 的最核心原因是什么?

19第 19 页 · 自测检验

本章要点

  • 本质是空间换时间,均摊O(1)有前提
  • 冲突是鸽巢原理的必然,非偶然
  • 装填因子超0.7,性能拐点出现
  • 开放地址vs链地址:看场景权衡
  • 开放地址删除不标记=埋雷
延伸主题:工程级散列函数选型一致性哈希与分布式布隆过滤器
20第 20 页 · 本章要点

课后思考

三道题覆盖核心机制、工业实践、安全边界。建议先合上书自己想 5 分钟,再对照参考答案检验思路。

1为什么开放地址法在装填因子超过 0.7 后性能急剧下降,而链地址法的表现要平稳得多?

参考答案开放地址法每个槽只容一个元素,空槽变少会让探测序列越来越长,并出现连续占用的'聚簇';链地址法每个槽是链表,再满也只是链表变长,查找仍是 O(1+α),扩展代价平滑。

2Java 的 HashMap 为什么把默认装填因子定为 0.75?这个数字背后是经验值还是有数学依据?

参考答案0.75 是空间与时间的折中点。装填因子越小空间浪费越多,越大冲突概率按指数级上升;0.75 时扩容触发概率(泊松分布)约 0.5%,是工业界公认的甜点,也有数学推导支撑。

3当所有键都被散列到同一个桶里,散列表就退化成了一条链表——这个'最坏情况'在真实系统中会出现吗?为什么?

参考答案会出现。攻击者可精心构造大量哈希碰撞的键发起'哈希洪水攻击',让服务端性能骤降。Java 早期 String 哈希因此被攻破,现引入链表转红黑树优化;Python 用随机化哈希种子防御。

21第 21 页 · 课后思考