首页
/ 《Hello 算法》哈希碰撞解决方案深度解析:链式地址、开放寻址与工程实践

《Hello 算法》哈希碰撞解决方案深度解析:链式地址、开放寻址与工程实践

2026-09-06 19:21:05作者:尤峻淳Whitney

哈希碰撞是哈希表设计中的核心难题:只要输入空间大于输出空间,碰撞就不可避免,而碰撞处理策略直接决定了哈希表的查询效率、内存占用与扩展性。本文以开源数据结构和算法教程《Hello 算法》的 hash_collision.md 为核心骨架,深入讲解链式地址法与开放寻址两大主流解决方案的基本操作、优缺点与工程取舍,并结合仓库中 Python、C、C++ 等 10 余种语言的真实实现源码(如 hash_map_chaining.pyhash_map_chaining.chash_map_open_addressing.cpp)与 Python、Java、Go 等主流语言的标准库实现策略,帮你从原理到工程彻底理解哈希表内部机制。

为什么哈希碰撞不可避免

回顾 哈希表章节 可知,哈希函数的作用是把“键”映射到“桶索引”。在绝大多数情况下,哈希函数的输入空间远大于输出空间,因此从理论上讲,哈希碰撞是必然的。书中给出的典型例子是:若输入空间为全体整数,而输出空间只是数组容量(桶的数量),那么必然会有多个不同的整数被映射到同一个桶索引上。

哈希碰撞带来的直接危害是查询结果出错,这会严重影响哈希表的可用性。例如在按学号(key)查找姓名的场景中,若两个学号被散列到同一位置,就可能发生“寻到错误的人”或“找不到目标记录”的问题。

针对这一问题,最容易想到的朴素方案是:每当发生碰撞就进行哈希表扩容,直到碰撞消失。这种方案虽然简单直接且有效,但效率极低——扩容过程涉及大量数据迁移与哈希值的重新计算(这一点在源码的 extend() 函数中体现得淋漓尽致)。因此,真正工程化的思路是组合运用以下两条策略:

  1. 改良哈希表的数据结构,使哈希表在发生碰撞时仍能正常工作;
  2. 只在必要时扩容,即仅在碰撞严重(负载因子超阈值)时才触发。

围绕策略一,业界形成了两条主流技术路线——链式地址(Separate Chaining)开放寻址(Open Addressing),下文分别展开。

链式地址法(Separate Chaining)

核心思想与结构

在原始的哈希表中,每个桶只能存储一个键值对;而 链式地址法 将每个桶中的单个元素替换为一条链表,把每个键值对视为一个链表节点,让所有发生碰撞的键值对存储在同一条链表中。

链式地址法哈希表结构示意图:每个桶连接一条链表存储冲突的键值对

基本操作流程

在链式地址实现的哈希表中,增删查三类基本操作的执行路径如下:

  • 查询元素:输入 key,通过哈希函数计算桶索引,定位到对应链表的头节点后顺序遍历,逐个比较键值,直至找到目标键值对;
  • 添加元素:先用哈希函数定位到对应链表,再把键值对节点插入链表中(若键已存在则更新值);
  • 删除元素:先用哈希函数定位链表,再遍历链表找到并删除目标节点。

局限性

链式地址法结构直观、实现简单,但也存在两个不容忽视的代价:

  • 空间占用上升:链表节点需要额外保存指针(指向前驱/后继),比数组存储占用更多内存;
  • 查询效率下降:定位到桶后还需对链表做线性遍历,才能找到目标元素,最坏情况时间复杂度为 O(n)。

源码级实现解读

教材以动态数组(列表)模拟链表以简化代码,因此哈希表(数组)中每个桶都是一个列表。这一实现包含一个关键工程参数——负载因子阈值:当 size / capacity > 2/3 时,将容量扩容为原来的 2 倍。仓库中 Python 版源码 hash_map_chaining.py 完整展示了这套机制:

class HashMapChaining:
    def __init__(self):
        self.size = 0                    # 键值对数量
        self.capacity = 4                # 哈希表容量
        self.load_thres = 2.0 / 3.0      # 触发扩容的负载因子阈值
        self.extend_ratio = 2            # 扩容倍数
        self.buckets = [[] for _ in range(self.capacity)]  # 桶数组,每个桶是一个列表

    def hash_func(self, key: int) -> int:
        return key % self.capacity       # 取模哈希函数

    def load_factor(self) -> float:
        return self.size / self.capacity

    def put(self, key: int, val: str):
        # 当负载因子超过阈值时,执行扩容
        if self.load_factor() > self.load_thres:
            self.extend()
        # ... 遍历桶:键已存在则更新 val,否则追加新键值对

    def extend(self):
        # 暂存原桶数组,容量翻倍后,把每个键值对重新 put 进新表
        self.capacity *= self.extend_ratio
        self.buckets = [[] for _ in range(self.capacity)]
        self.size = 0
        for bucket in buckets:
            for pair in bucket:
                self.put(pair.key, pair.val)

需要说明的是:扩容为何会“消除”碰撞?因为哈希函数 key % capacity 依赖容量大小,容量翻倍后,原本共享同一桶索引的键大概率会被分散到不同桶,从而重新平衡分布。这也正是教科书开篇“扩容直到碰撞消失”思路的技术根基——只是链式地址 + 按阈值扩容的组合避免了频繁、盲目的扩容。

值得补充的是,C 语言版 hash_map_chaining.c 为了贴合教材“链表”的本义,使用的是真正的单链表而非列表:bucketsNode **,插入采用头插法(put 函数),删除需要维护前驱指针(removeItem 函数),扩容时还要逐个释放旧节点内存(extend 函数)。对比 Python 与 C 两个版本,可以直观体会“动态数组简化代码”与“真实链表的内存管理开销”之间的差异。此外 Java、C++、Go、Rust、Swift 等语言也均有等价实现,例如 hash_map_chaining.cppvector<vector<Pair *>> 组织桶,C++ 版还需在析构函数中逐节点释放 Pair 内存。

链表过长时的进阶优化

当某个桶内的链表变得很长时,查询时间复杂度 O(n) 会显著变差。 此时业界通常把链表转换为 AVL 树或红黑树,把查找复杂度降为 O(log n)。这一思路在后面的“编程语言选择”部分有现实对应:Java 的 HashMap 正是这样做的。

开放寻址(Open Addressing)

与链式地址不同,开放寻址法不引入任何额外数据结构,而是依靠反复探测(probing) 在同一张数组上解决碰撞。常见的探测策略有三种:线性探测、平方探测(Quadratic Probing)与多次哈希(Multiple Hashing)。下面以线性探测为主线展开。

线性探测(Linear Probing)

线性探测使用固定步长(通常为 1)顺序向前探测,其操作逻辑与普通哈希表略有不同:

  • 插入元素:用哈希函数算出桶索引;若该桶已被占用,则从冲突位置起按固定步长继续向前探测,直到找到空桶再插入;
  • 查找元素:冲突时按相同步长继续向前探测,找到目标元素即返回其 value;若中途遇到空桶,说明目标元素不在表中,返回 None

下图展示了采用线性探测的开放寻址哈希表中键值对的分布情况。在该哈希函数下,末尾两位数字相同的 key 会映射到同一桶,线性探测则把它们依次安放在该桶及其后续的桶中:

线性探测开放寻址哈希表的键值对分布:冲突键被依次放入后续空桶

聚集现象(Clustering)

然而线性探测有一个致命弱点——容易产生聚集。具体来说,数组中一段连续被占用的区域越长,新碰撞就越容易落在这段区域内,而新元素被安放进去又让聚集区变得更长,形成恶性循环,逐步拖垮插入、删除、查找、更新等全部操作的效率。

不能直接删除元素

开放寻址哈希表还有一个反直觉的约束:不能直接删除元素。若直接删除,会在数组中留下一个空桶 None。由于线性探测在查找时“遇到空桶即停止”,一旦这个空桶出现在某个键的探测序列中途,存储在其后的元素就再也无法被访问到,程序会错误地判断“这些键不存在”。下图展示了这一问题的成因:

开放寻址删除元素引发的查询问题:空桶截断了后续键的探测路径

惰性删除(Lazy Deletion)与墓碑优化

为化解上述问题,工程上普遍采用 惰性删除:不把元素真正移出数组,而是用一个常量 TOMBSTONE(墓碑)标记该桶。在该机制下,NoneTOMBSTONE 都表示“可接受新的键值对”,二者唯一的区别是:线性探测遇到 None 会停止,而遇到 TOMBSTONE 必须继续向前探测,因为目标键值对仍可能存在于探测序列的后方。

但惰性删除并非没有代价——它可能加速哈希表性能退化:每次删除都会遗留一枚标记,随着 TOMBSTONE 数量增多,查找时线性探测可能需要跳过多个墓碑才能抵达目标,搜索时间随之变长。

为此,仓库实现里还有一个精巧的优化:记录线性探测过程中遇到的第一个 TOMBSTONE 的索引,并把找到的目标元素交换到该位置。这样每次查询或插入都能把元素“拉近”到更接近其理想位置(探测起点)的地方,从而提升后续查找效率。该逻辑集中体现在开放寻址的 findBucket 方法中。

源码级实现解读

仓库中的 hash_map_open_addressing.py(C++ 版见 hash_map_open_addressing.cpp)完整实现了“线性探测 + 惰性删除 + 墓碑交换 + 扩容”。核心要点如下:

  • 桶数组元素类型为 Pair | None,并以 TOMBSTONE = Pair(-1, "-1") 作为删除标记;
  • 哈希表被当作 “环形数组” 使用:探测越过数组尾部时取模回到头部继续,即 index = (index + 1) % capacity
  • findBucket 返回两个语义之一的索引:找到键时返回其所在桶索引;未找到时返回可供插入的空位索引(优先复用最早遇到的墓碑);
  • get/put/remove 都复用 findBucket:查到真实键值对才返回/覆盖/删除,其中删除操作只需把桶内容替换为 TOMBSTONE
  • 扩容时跳过 NoneTOMBSTONE,仅把存活键值对重新 put 进翻倍后的新表。

以 Python 版本的核心探测逻辑为例:

class HashMapOpenAddressing:
    def __init__(self):
        self.size = 0
        self.capacity = 4
        self.load_thres = 2.0 / 3.0          # 与链式地址法一致的负载因子阈值
        self.extend_ratio = 2
        self.buckets: list[Pair | None] = [None] * self.capacity
        self.TOMBSTONE = Pair(-1, "-1")      # 删除标记

    def find_bucket(self, key: int) -> int:
        index = self.hash_func(key)
        first_tombstone = -1
        # 线性探测:遇到空桶才停止
        while self.buckets[index] is not None:
            if self.buckets[index].key == key:
                # 把键值对交换到最早遇到的墓碑位置,缩短后续探测距离
                if first_tombstone != -1:
                    self.buckets[first_tombstone] = self.buckets[index]
                    self.buckets[index] = self.TOMBSTONE
                    return first_tombstone
                return index
            if first_tombstone == -1 and self.buckets[index] is self.TOMBSTONE:
                first_tombstone = index      # 记录第一个墓碑
            index = (index + 1) % self.capacity  # 环形数组回绕
        # 键不存在:返回插入位置(优先复用墓碑)
        return index if first_tombstone == -1 else first_tombstone

运行下面两个文件的驱动代码即可观察扩容、查找、删除后的实际哈希表打印结果(含 None/TOMBSTONE 标记):

平方探测(Quadratic Probing)

平方探测与线性探测相似,同为开放寻址的常用策略。区别在于:发生碰撞后,它并不固定跳过 1 步,而是跳过“探测次数 i 的平方”个位置,即依次跳跃 1, 4, 9, ... 步。

其优势在于:

  • 通过“探测次数平方”的跳跃距离,试图缓解线性探测的聚集效应
  • 跳跃距离更大,更容易找到较远的空位,有助于让数据分布更均匀

但平方探测并非完美:

  • 聚集并未完全消除:部分位置的被占用概率仍高于其他位置(即“二次聚集”);
  • 由于平方序列增长过快,平方探测可能无法遍历整张哈希表——即使表中存在空桶,平方探测也不一定能探测到它们。

多次哈希(Multiple Hashing)

顾名思义,多次哈希会同时准备多个哈希函数 f₁(x), f₂(x), f₃(x), ... 用于探测:

  • 插入元素:若 f₁(x) 发生冲突,则尝试 f₂(x),依次类推,直到找到空位并插入;
  • 查找元素:按同样的哈希函数顺序依次探测,找到目标元素即返回;若遇到空位或所有哈希函数都已尝试完毕,则说明元素不在表中,返回 None

与线性探测相比,多次哈希更不易产生聚集,但代价是多个哈希函数带来了额外的计算开销

需要特别提醒的是:基于开放寻址的哈希表——无论线性探测、平方探测还是多次哈希——都存在“无法直接删除元素”的固有问题,必须借助惰性删除等机制来处理。

各主流编程语言的哈希表实现策略

教材还总结了主流编程语言在标准库层面的哈希表实现取向,从中可以看到前述两种方案在真实工程中的取舍:

  • Python:采用开放寻址。内置字典 dict 在发生碰撞时使用伪随机数进行探测,以打散探测序列、缓解聚集;
  • Java:采用链式地址。自 JDK 1.8 起,当 HashMap 中数组长度达到 64、且某条链表长度达到 8 时,会把该链表转换为红黑树以提升搜索性能(这正是链式地址章节“长链表转树”思想的工业落地);
  • Go:采用链式地址。Go 规定每个桶最多存放 8 个键值对,超出容量则链接一个溢出桶(overflow bucket);当溢出桶过多时,会执行一次特殊的等容量扩容以摊平数据、保证性能。

两种方案的对比与选择

综合全文,可对两大方案作如下归纳:

维度 链式地址(Separate Chaining) 开放寻址(Open Addressing)
结构 每个桶挂一条链表/动态数组/树 元素直接存放在桶数组中
处理碰撞 同桶冲突键全部存入同一容器 按探测序列(线性/平方/多次哈希)寻找空桶
删除操作 可直接从链表中摘除节点 不能直接删除,须用墓碑(惰性删除)标记
额外内存 节点需存指针,内存开销更大 无额外结构,空间局部性好、缓存友好
查询性能 桶内需线性遍历(最坏 O(n)),长链表可转树降为 O(log n) 依赖探测步数;易受聚集与墓碑拖累
典型代表 Java HashMap、Go map(带溢出桶) Python dict(伪随机探测)

延伸阅读

本主题在教材章节体系中属于 哈希碰撞;前置章节 哈希表 介绍了哈希函数、负载因子与基本操作,哈希算法 则讨论如何让哈希分布更均匀。想从代码层面动手验证,可进一步阅读仓库中各语言版本的完整实现与驱动测试,例如 Java 版 hash_map_chaining.java、Go 版 hash_map_open_addressing.go,通过修改键值、触发扩容阈值来直观感受碰撞与扩容对哈希表行为的影响。

登录后查看全文
热门项目推荐
相关项目推荐