news 2026/9/9 13:52:24

《Hello 算法》哈希章节总结:哈希表 O(1) 查找原理、碰撞处理与哈希算法设计要点

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
《Hello 算法》哈希章节总结:哈希表 O(1) 查找原理、碰撞处理与哈希算法设计要点

《Hello 算法》哈希章节总结:哈希表 O(1) 查找原理、碰撞处理与哈希算法设计要点

【免费下载链接】hello-algo《Hello 算法》:动画图解、一键运行的数据结构与算法教程。支持简中、繁中、English、日本語,提供 Python, Java, C++, C, C#, JS, Go, Swift, Rust, Ruby, Kotlin, TS, Dart 等代码实现项目地址: https://gitcode.com/GitHub_Trending/he/hello-algo

哈希表是本仓库《Hello 算法》数据结构与算法课程中“哈希”一章的核心主题。本文基于俄文版章节总结 ru/docs/chapter_hashing/summary.md 整理成一篇可独立阅读的技术总览,并结合 hash_map.md、hash_collision.md、hash_algorithm.md 三篇正文以及仓库内可运行的示例代码,系统梳理哈希表为何能做到 $O(1)$ 查找、哈希碰撞为何不可避免、如何用链式地址与开放寻址化解碰撞,以及工程实践中哈希算法需要满足哪些性质。读完本文,你将能够完整复述“哈希表→哈希函数→哈希碰撞→扩容与负载因子→哈希算法设计”这一条知识主线,并用仓库提供的多语言示例代码(如 Python 的hash_map.pyhash_map_chaining.py)验证每一个结论。

一、核心结论速览:哈希表为什么是 $O(1)$

哈希表(hash table)也叫散列表,其本质是建立起“键key”与“值value”之间的映射关系:只要把key传入哈希表,就能在 $O(1)$ 时间内取回对应的value

要理解这一结论,需要先接受三个事实:

  1. 复杂度对照:普通数组与链表在“查找”“删除”上都是 $O(n)$(必须遍历),只有“尾部追加”是 $O(1)$;而哈希表的查找、插入、删除均为 $O(1)$。下表直接摘自正文 hash_map.md:

    操作数组链表哈希表
    查找元素$O(n)$$O(n)$$O(1)$
    添加元素$O(1)$$O(1)$$O(1)$
    删除元素$O(n)$$O(n)$$O(1)$
  2. 典型操作集合:哈希表的常见操作包括——查询value、添加键值对、删除键值对,以及遍历哈希表(遍历键值对、只遍历键、只遍历值三种方式)。这些代码在仓库中都有可运行的多语言示例,例如 Python 版 hash_map.py 演示了hmap[key] = value添加、hmap[key]查询、hmap.pop(key)删除,以及.items()/.keys()/.values()三种遍历。

  3. 一句口诀:哈希表以“空间换时间”——它通常比数组、链表更快,但代价是大量桶(bucket)处于空闲状态、内存利用率偏低(详见后文 Q&A 中“为何比数组、链表快”的讨论)。

二、哈希函数:key 到桶的映射

1. 工作流程

在哈希表中,数组的每个空位称为一个桶(bucket),每个桶存放一对键值对。给定key后如何找到它的桶?答案是哈希函数。哈希函数的作用是把“很大的输入空间”映射到“较小的输出空间”,其计算过程分为两步:

  1. 用某个哈希算法hash()计算key的哈希值;
  2. 将哈希值对桶的数量(即数组长度capacity)取模,得到桶下标:
index = hash(key) % capacity

正文用“学生证号 → 姓名”的例子做了直观说明:设capacity = 100hash(key) = key,则哈希函数退化为key % 100

2. 哈希碰撞不可避免

由于输入空间(所有可能的key)远大于输出空间(数组下标),“多个输入对应同一个输出”在理论上必然存在,即哈希碰撞(hash collision)无法从原理上根除。还是以key % 100为例:

12836 % 100 = 36 20336 % 100 = 36

两个不同的学生证号映射到了同一个桶,就会产生错误查询结果。

3. 极简实现的源码佐证

仓库中的 array_hash_map.py 用 100 个桶的数组实现了一个最朴素的哈希表:hash_func()返回key % 100get()/put()/remove()分别对应查询、插入(覆盖)、删除,并将键值对封装为Pair类。它没有任何碰撞处理逻辑,恰好可以用来观察“碰撞产生错误结果”的最坏形态。

三、缓解碰撞的两条路线:扩容与负载因子

面对碰撞,有两类手段:

  1. 改进哈希表内部结构,让它在发生碰撞时仍能正确工作(见第四节);
  2. 仅当碰撞严重时才扩容,控制碰撞发生的概率。

为什么扩容能减少碰撞?因为哈希函数的最后一步通常是“对数组长度取模”。扩容使长度n改变,同一key的桶下标随之改变,原先挤在同一桶里的多个key可能被分散到不同桶中,碰撞自然被削弱(正文 hash_map.md 中用键值对(136, A)(236, D)在扩容前后从冲突变不冲突的示意图说明这一点)。

但扩容的成本很高:和数组扩容一样需要把全部键值对迁移到新表,而且由于capacity变了,每个键值对都要用哈希函数重新计算存储位置,计算开销随之叠加。因此编程语言通常预先分配足够大的容量以避免频繁扩容。

**负载因子(load factor)**是这里的关键指标,定义为“哈希表中元素个数 ÷ 桶的数量”,用于评估碰撞的严重程度,也常被用作扩容的触发条件。以文档中给出的 Java 为例:当负载因子超过 $0.75$ 时,系统会把哈希表扩容为原来的 $2$ 倍。

四、碰撞处理的两大流派

1. 链式地址(separate chaining)

链式地址把“单个元素”升级为“链表”:发生冲突的键值对按链表节点形式挂在同一个桶下。

  • 查找:经哈希函数定位到桶后,遍历该链表、逐个比较key,找到目标键值对;
  • 添加:定位到链表头后把新节点追加进链表;
  • 删除:遍历链表、定位目标节点后摘除。

其代价一是链表指针带来的额外内存,二是查找需要线性遍历链表。当链表过长时,查找退化为 $O(n)$——此时可以把链表进一步改造成 AVL 树或红黑树,把查找复杂度优化回 $O(\log n)$(Java 的HashMap正是这一思路的工业实现)。

仓库中的 hash_map_chaining.py 给出了完整实现:初始容量capacity = 4,负载因子阈值load_thres = 2/3,扩容倍率extend_ratio = 2put()每次先检查load_factor() > load_thres再决定是否调用extend()extend()中新建容量翻倍的桶数组并逐对重插所有键值对。这份代码是“扩容 + 链式地址”思想最直接的落地范本。

2. 开放寻址(open addressing)

开放寻址不引入额外数据结构,而是通过“反复探测(probing)”寻找空桶。常见三种变体:

  • 线性探测:以固定步长(通常为 1)顺序向后探测。插入时遇到被占用的桶就向后走,直到找到空桶;查找时若发生碰撞同样按步长前进,遇到空桶则说明元素不存在。
  • 二次探测:探测距离取“尝试次数的平方”,即 $1, 4, 9, \dots$。它比线性探测更能缓解“聚集”,但因为平方增长过快,可能无法覆盖整个哈希表——即使存在空桶也不一定探测得到。
  • 再哈希(双散列):准备多个哈希函数 $f_1(x), f_2(x), f_3(x), \dots$,插入时依次尝试,找到空位为止;查找按同样顺序进行。它比线性探测更不易聚集,但多个函数的计算带来额外开销。

开放寻址有一个通病:不能直接删除元素。直接置空会在数组中留下空位,而线性探测查找遇到空位就会提前终止,导致其后本应存在的元素被误判为“不存在”。业界解决方案是惰性删除(lazy deletion):不真正删除,而是把该桶标记为特殊常量TOMBSTONENoneTOMBSTONE都可用于放置新键值对,但区别在于:线性探测遇到TOMBSTONE时必须继续向后探测(其后可能还有键值对)。

惰性删除的副作用是性能退化——每删除一次就多一个墓碑,探测链条越来越长。优化的做法是:在查找过程中记住第一个TOMBSTONE的位置,找到目标元素后把目标与其交换,让元素尽可能回到“理想探测起点”附近;同时把整个数组当作环形结构处理,越界后回到开头继续探测。仓库中 hash_map_open_addressing.py 正是这样一套“线性探测 + 惰性删除 + 环形数组”的完整实现。

3. 不同语言的不同选择

编程语言对哈希表的实现策略并不一致,正文 hash_collision.md 举例说明了三种代表性选择:

  • Pythondict使用开放寻址,探测时结合伪随机数;
  • JavaHashMap使用链式地址;从 JDK 1.8 起,当内部数组长度达到 64 且链表长度达到 8 时,链表会转换为红黑树以维持查找性能;
  • Go:同样使用链式地址,但规定每个桶最多存放 8 对键值对,溢出时挂接 overflow 桶;当溢出桶过多时执行同规模的专门扩容。

五、哈希算法:决定碰撞概率的上游因素

1. 三个基本目标

链式地址和开放寻址都只能让哈希表“在碰撞发生后仍能工作”,并不能降低碰撞发生的概率。真正决定键值对分布的是哈希函数——在容量capacity固定的情况下,index = hash(key) % capacity中的分布特性完全由hash()决定。因此,哈希算法应满足:

  • 确定性:相同输入永远得到相同输出,否则哈希表不可靠;
  • 高效率:哈希值计算要足够快,计算成本越低哈希表的实用价值越高;
  • 均匀分布:尽量把键值对均匀摊到各桶,分布越均匀碰撞概率越低。

2. 加密场景的额外要求

当哈希用于密码存储、数据完整性校验等安全场景时,还必须满足更严格的性质:

  • 单向性:无法从哈希值反推输入的任何信息;
  • 抗碰撞性:极难找到两个不同输入共享同一哈希值;
  • 雪崩效应:输入微小变化应引起输出显著且不可预测的变化。

需要注意,“均匀分布”与“抗碰撞”是两个独立概念key % 100对随机输入可以分布得很均匀,但它太简单,容易被反向构造出碰撞输入,无法承担安全职责。

3. 几种朴素哈希算法

正文与源码 simple_hash.py 给出四种入门级字符串哈希:

  • 加法哈希:累加所有字符的 ASCII 码;
  • 乘法哈希:每次把当前值乘以常数(如 31)再加下一个字符的 ASCII 码;
  • XOR 哈希:用异或运算把各字符累积进哈希值;
  • 旋转哈希:每次累积前先对哈希值做循环移位,如hash = (hash << 4) ^ (hash >> 28) ^ ord(c)

这四种算法都软弱可欺:加法和 XOR 满足交换律,因而加法哈希与 XOR 哈希无法区分“相同字符、不同排列”的字符串,容易诱发碰撞甚至安全问题,只能用于对安全性要求不高的场景。

4. 为什么取模要用大素数

细心观察会发现,上述每个算法的最后一步都是对大素数 $1000000007$取模,目的是把哈希值控制在合理范围内。为什么要强调素数?结论是:用大素数作模数能最大限度保证哈希值分布均匀,因为素数与其他数没有公因子,能削弱取余运算带来的周期性规律、降低碰撞。

正文用算例对比了这一点。取合数 $9$ 作模数时,它含有因子 $3$,所有能被 $3$ 整除的key只会落到 $0, 3, 6$ 三个哈希值上,周期性输入下必然聚集;换成素数 $13$ 后,key序列 ${0,3,6,9,12,15,\dots}$ 的哈希值变成 ${0,3,6,9,12,2,5,8,11,1,4,7,\dots}$,分布立刻均匀起来。当然,如果输入本身是均匀随机的,合数模数影响不大;可一旦输入存在周期性,合数模数就很容易导致聚集。因此实践中习惯取较大的素数作为模数。

5. 主流哈希算法一览

工程上真正使用的是 MD5、SHA-1、SHA-2、SHA-3 这类把任意长度输入映射为定长哈希值的标准算法。正文 hash_algorithm.md 给出的对照如下:

MD5SHA-1SHA-2SHA-3
诞生年份1992199520022008
输出长度128 bit160 bit256/512 bit224/256/384/512 bit
碰撞情况频繁频繁罕见罕见
安全等级低,已被成功攻破低,已被成功攻破
典型用途已过时,仍偶用于数据完整性校验已过时加密货币交易校验、数字签名等可作 SHA-2 的替代

一个容易被误解的表述:哈希算法常在“取模”之外,另有以自身结果直接参与运算的朴素场景,但标准哈希算法并不局限于取模结构——它们通过内部轮函数把定长状态反复混淆扩散,实现雪崩效应。

6. 数据类型的哈希与“只可哈希不可变对象”

语言的key可以是整数、浮点数、字符串等多种类型,运行时通常为这些类型内置了哈希算法用于计算桶下标。以 Python 为例,其内建hash()的行为(示例代码见 built_in_hash.py):

  • 整数与布尔值的哈希值等于其自身(True为 1);
  • 浮点数与字符串的哈希计算较复杂;
  • 元组按元素逐个哈希后再合并成最终值;
  • 对象默认按内存地址构造哈希值;若重写哈希方法,则可改为按内容计算。

这里还有一个关键限制:哈希表只允许把不可变对象用作key。若用列表这种可变对象当key,一旦内容改变,其哈希值也随之改变,原本存入的value将永远无法再被找到。自定义对象(如链表节点)虽然字段可变,却仍然可哈希——因为它的哈希基于内存地址,地址不变哈希值就不变。此外,Python 解释器每次启动都会为字符串哈希函数加入随机盐(salt),所以同一程序在不同控制台输出的哈希值不同,这正是为了防御 HashDoS 类攻击。

六、原章节 Q&A 精讲:7 个高频疑问逐一拆解

Q1:哈希表何时退化到 $O(n)$?

当碰撞严重到一定程度时,哈希表的时间复杂度就会退化到 $O(n)$。只要哈希函数设计良好、容量选择合理、冲突分布足够均匀,通常可视为 $O(1)$。使用语言内置哈希表时,一般直接按 $O(1)$ 对待。

Q2:为什么不直接用 $f(x) = x$ 这种“零碰撞”哈希函数?

若 $f(x) = x$,每个元素对应唯一桶下标,结构就退化成数组了。但输入空间通常远大于输出空间(数组长度),所以哈希函数最后一步必须对数组长度取模。也就是说,哈希表的本质正是“把较大的状态空间映射进较小的空间,并保持 $O(1)$ 查询”,碰撞是这种压缩映射的必然代价。

Q3:哈希表底层是数组、链表、二叉树,为什么反而比它们快?

有三个原因。其一,哈希表是“以空间换时间”,大量内存处于闲置,时空效率此消彼长。其二,它只在特定场景更快——若同一问题用数组或链表也能达到相同渐进复杂度,通常这种直白的实现反而更快,因为计算哈希本身要耗费时间,相当于把常数项抬高了。其三,哈希表的复杂度同样可能劣化,比如链式地址下仍需在链表或红黑树中检索,$O(n)$ 退化的风险始终存在。

Q4:再哈希是否有“不能直接删除”的缺点?被标记删除的空间还能复用吗?

再哈希属于开放寻址,所有开放寻址方法都有“不能直接删除、只能打删除标记”的通病。被标记为已删除的空间可以复用:插入新元素做探测时,若撞上这类标记位,新元素可以直接占用它。这既保持了探测序列的连续性,又维持了可接受的空间利用率。

Q5:线性探测在查找时为什么也会“碰到”碰撞?

查找时先用哈希函数定位桶与键值对,若发现该位置的key与目标不一致——这就意味着发生了哈希碰撞。此时线性探测按预设步长继续向后找,直到命中正确的键值对,或确认查找失败为止。

Q6:为什么扩容能缓解碰撞?

哈希函数最后一步通常是对数组长度 $n$ 取模。扩容后 $n$ 变了,同一key的下标可能随之改变:原先挤在同一桶里的多个key,扩容后可能被分摊到多个桶,碰撞自然减弱。

Q7:既然要快速访问,为何不直接用数组?

key是小范围连续整数时,直接用数组最简单高效。但若key是字符串等其他类型,就需要哈希算法把key映射成数组下标、再用桶数组存储元素——这种结构才叫哈希表。

七、如何动手验证与继续深入学习

  • 想观察“朴素数组哈希表 + 无碰撞处理”的最简形态,运行 array_hash_map.py;
  • 想看到链式地址与“负载因子超过 $2/3$ 自动扩容为 2 倍”的完整逻辑,运行 hash_map_chaining.py;
  • 想验证线性探测 + 惰性删除 + 环形数组的实现细节,阅读 hash_map_open_addressing.py;
  • 想亲手计算四种朴素哈希并对“大素数取模”获得直观感受,运行 simple_hash.py。

在《Hello 算法》仓库中,俄文版哈希章节由四篇文档构成:概念与基础操作见 hash_map.md,两种碰撞处理方案详见 hash_collision.md,哈希算法的目标、设计与常用算法分析见 hash_algorithm.md,本章配套练习题位于 exercises.md,正文多处配有可视化动画图。这些资源与本文配合阅读,即可把“哈希表为何高效、碰撞为何存在、如何化解、算法如何设计”这条主线彻底打通。

【免费下载链接】hello-algo《Hello 算法》:动画图解、一键运行的数据结构与算法教程。支持简中、繁中、English、日本語,提供 Python, Java, C++, C, C#, JS, Go, Swift, Rust, Ruby, Kotlin, TS, Dart 等代码实现项目地址: https://gitcode.com/GitHub_Trending/he/hello-algo

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 13:52:03

模型生态集成实战:从config.toml配置到API错误排查

这一周&#xff0c;模型生态里是真的热闹。不说别的&#xff0c;光是新模型的名字&#xff0c;我就记了满满一屏&#xff1a;对话模型、图像生成模型、机器人控制模型、自动驾驶世界模型、医疗影像分析基础模型……每一家都在喊“我们带来了新的突破”&#xff0c;但对真正干活…

作者头像 李华
网站建设 2026/9/9 13:50:17

ECC不是缩写游戏:硬件纠错、工具校验与应用误报三层解析

1. ECC不是缩写游戏&#xff0c;而是工程级纠错的底层逻辑ECC这个词最近在开发者圈子里反复刷屏&#xff0c;但很多人点开搜索结果后反而更迷糊了——有人在问“SAP ECC年结怎么搞”&#xff0c;有人贴出npx ecc-universal的报错截图&#xff0c;还有人纠结“TypeScript里怎么输…

作者头像 李华
网站建设 2026/9/9 13:50:13

ECC纠错码全解析:从内存翻位到SAP年结,一次讲透uncorr. ECC

内存里的数据翻位&#xff0c;后台日志里蹦出“uncorr. ECC 显示2”&#xff0c;这时候值班群里的第一反应往往是&#xff1a;又一条内存要挂了&#xff1f;还是SSD主控在瞎报&#xff1f;如果你只用过消费级电脑&#xff0c;可能一辈子都碰不到这个提示&#xff1b;可在服务器…

作者头像 李华
网站建设 2026/9/9 13:48:36

Spring事务治理:从@Transactional到TransactionTemplate的工程实践

我第一次被问到“为什么大厂一般不推荐使用 Transactional”时&#xff0c;愣了一下。后来在新东家翻了核心业务系统的代码&#xff0c;发现一个耐人寻味的现象&#xff1a;真正跑在高并发、资金相关、订单核心链路上的方法&#xff0c;绝大多数没有直接在上面对 Transactional…

作者头像 李华
网站建设 2026/9/9 13:47:57

化工CAD基础:PFD与PID绘制顺序、图层设置及检查清单

化工CAD新班基础操作&#xff08;二&#xff09;&#xff0c;我们把它聚焦在一个具体目标上&#xff1a;从空白绘图区出发&#xff0c;完成PFD&#xff08;工艺流程图&#xff09;和P&ID&#xff08;管道仪表流程图&#xff09;的基础图面。很多初学者在这类图纸上卡住&…

作者头像 李华
网站建设 2026/9/9 13:47:20

HC-SR04超声波测距模块详解:原理、接线、代码与实战

简介&#xff1a;HC-SR04超声波测距模块资料包&#xff0c;面向电子爱好者、大学生及嵌入式入门开发者&#xff0c;可系统解决测距原理不清、引脚接线错误、编程显示无从下手等常见问题。包内共有61个文件&#xff0c;压缩包仅1.8MB&#xff0c;以C语言工程源码、可烧录hex固件…

作者头像 李华