【数据结构】考研408 | 冲突解决精讲: 拉链法——链式存储的艺术与优化

拉链法

导读

大家好,很高兴又和大家见面啦!!!

在前面的内容中,我们共同确立了散列查找的核心思想:

  • 通过哈希函数建立关键字与存储地址的直接映射,以期实现近乎 O ( 1 ) O(1) O(1)的查找效率。

我们认识到,这一高效策略的基石在于一个设计优良的哈希函数 ,但它也带来了一个不可回避的挑战------哈希冲突

哈希函数 的构造我们介绍了4种构造方法:

  • 直接定址法
  • 除留余数法
  • 数字分析法
  • 平方取中法

但是无论 哈希函数 如何精巧,冲突 在理论上终究无法完全避免。那么,当冲突不可避免地发生时,我们应当采取何种策略来妥善处理,从而保证哈希表的高效运作

这正是本篇所要解答的核心问题。我们将深入探讨一种极为经典且实用的冲突解决策略 ------拉链法。该方法以其直观的思想和灵活的应变能力,在实践中得到了广泛应用。

现在,让我们一同揭开 拉链法 的奥秘。

一、基本思想

拉链法 是解决 哈希表键冲突 的一种经典策略,其核心思想是将散列到同一位置的元素通过链表串联起来
2 head key1 key2 ... 1 head key1 key2 ... ...

如上图所示,当发生 冲突 时,拉链法 通过在 冲突地址 中创建一个 链表 ,通过该链表将所有的 同义词 连接起来;

不难发现,拉链法 就是 哈希表链表 的一种组合应用:

  • 哈希表 中通过 哈希函数 可以快速找到 关键字哈希地址
  • 链表 中通过 查找 可以找到该地址下的所有 关键字

接下来我们就来一起了解一下该方法的 插入查找删除等基本操作;

二、插入

哈希表 的插入操作比较简单:

  • 通过 哈希函数 确定 关键字哈希地址
  • 通过 哈希地址关键字 插入到 链表

这里我们以关键字序列 [19, 14, 23, 01, 68, 20, 84, 27, 55, 11, 10, 79] 为例来说明其具体的插入过程;

2.1 哈希函数

当前我们需要进行操作的 关键字序列 中存在 12 12 12 个元素,因此我们可以创建一个表长为 13 13 13 的 哈希表
哈希表 0 1 2 3 4 5 6 7 8 9 10 11 12

这里我们可以通过 除留余数法 来构造 哈希函数 ,表长为 13 13 13 的 哈希表 ,不大于 13 13 13 的最大质数为 p = 13 p = 13 p=13 ,因此这里我们选择 13 13 13 作为 模数 ,对应的 哈希函数 为:

H a s h ( k e y ) = k e y   m o d   13 Hash(key) = key \bmod 13 Hash(key)=keymod13

2.2 哈希地址

为了简单一点,这里我们直接通过 哈希函数 获取该 关键字序列 中所有 关键字 对应的 哈希地址

  • H a s h ( 19 ) = 19   m o d   13 = 6 Hash(19) = 19 \bmod 13 = 6 Hash(19)=19mod13=6
  • H a s h ( 14 ) = 14   m o d   13 = 1 Hash(14) = 14 \bmod 13 = 1 Hash(14)=14mod13=1
  • H a s h ( 23 ) = 23   m o d   13 = 10 Hash(23) = 23 \bmod 13 = 10 Hash(23)=23mod13=10
  • H a s h ( 01 ) = 01   m o d   13 = 1 Hash(01) = 01 \bmod 13 = 1 Hash(01)=01mod13=1
  • H a s h ( 68 ) = 68   m o d   13 = 3 Hash(68) = 68 \bmod 13 = 3 Hash(68)=68mod13=3
  • H a s h ( 20 ) = 20   m o d   13 = 7 Hash(20) = 20 \bmod 13 = 7 Hash(20)=20mod13=7
  • H a s h ( 84 ) = 84   m o d   13 = 6 Hash(84) = 84 \bmod 13 = 6 Hash(84)=84mod13=6
  • H a s h ( 27 ) = 27   m o d   13 = 1 Hash(27) = 27 \bmod 13 = 1 Hash(27)=27mod13=1
  • H a s h ( 55 ) = 55   m o d   13 = 3 Hash(55) = 55 \bmod 13 = 3 Hash(55)=55mod13=3
  • H a s h ( 11 ) = 11   m o d   13 = 11 Hash(11) = 11 \bmod 13 = 11 Hash(11)=11mod13=11
  • H a s h ( 10 ) = 10   m o d   13 = 10 Hash(10) = 10 \bmod 13 = 10 Hash(10)=10mod13=10
  • H a s h ( 79 ) = 79   m o d   13 = 1 Hash(79) = 79 \bmod 13 = 1 Hash(79)=79mod13=1

2.3 插入

通过 哈希函数 得到了 关键字哈希地址 后,我们就需要将 关键字 插入到该地址对应的 链表 中;

链表 的插入有 头插法尾插法 ,这里我们直接采取 头插法 依次插入 关键字
哈希表 0 1 2 3 4 5 6 7 8 9 10 11 12 79 27 01 14 55 68 84 19 20 10 23 11

三、查找

拉链法 中,哈希表 的查找分为三步:

  • 通过 哈希函数 确认 哈希地址
  • 通过 哈希地址 找到 链表
  • 通过 顺序查找目标关键字
    • 找到 目标关键字 则查找成功
    • 未找到,则查找失败

就比如我们需要查找 k e y 1 = 14 、 k e y 2 = 25 key1 = 14、key2 = 25 key1=14、key2=25:

  • 通过 哈希函数 确认 哈希地址
    • H a s h ( k e y 1 ) = 14   m o d   13 = 1 Hash(key1) = 14 \bmod 13 = 1 Hash(key1)=14mod13=1
    • H a s h ( k e y 2 ) = 25   m o d   13 = 12 Hash(key2) = 25 \bmod 13 = 12 Hash(key2)=25mod13=12
  • 在链表中通过 顺序查找 找到 目标关键字
    • k e y 1 key1 key1 在地址 H a s h ( k e y 1 ) = 1 Hash(key1) = 1 Hash(key1)=1 的链表中成功找到,查找成功
    • k e y 2 key2 key2 在地址 H a s h ( k e y 2 ) = 12 Hash(key2) = 12 Hash(key2)=12 的链表中未找到,查找失败

哈希表 0 1 2 3 4 5 6 7 8 9 10 11 12 79 27 01 14 55 68 84 19 20 10 23 11

四、删除

拉链法删除 操作实际上就是 链表 的删除操作。其具体步骤如下:

  • 通过 哈希函数 确认 哈希地址
  • 通过 哈希地址 找到对应 链表
  • 通过 顺序查找 找到 目标关键字
    • 找到 目标关键字删除 该结点
    • 未找到,则不执行任何操作

比如这里我们需要删除 k e y 1 = 01 , k e y 2 = 12 key1 = 01, key2 = 12 key1=01,key2=12 ,通过 哈希函数 我们可以确定 目标关键字哈希地址

  • H a s h ( k e y 1 ) = 01   m o d   13 = 1 Hash(key1) = 01 \bmod 13 = 1 Hash(key1)=01mod13=1
  • H a s h ( k e y 2 ) = 12   m o d   1312 Hash(key2) = 12 \bmod 13 12 Hash(key2)=12mod1312

确认了 哈希地址 后,我们可以找到 目标关键字 所在链表:
哈希表 1 12 79 27 01 14

通过 顺序查找 查找,我们可以确定 目标关键字 是否存在链表中:
哈希表 1 12 79 27 01 14 NULL

从图中可以看到, k e y 1 key1 key1 查找成功, k e y 2 key2 key2 查找失败;

接下来,我们只需要对 k e y 1 key1 key1 执行删除操作即可:
哈希表 1 12 79 27 14 NULL 01 释放结点空间内存

五、优缺点

拉链法 作为 哈希表 的一种非常实用的 哈希冲突 解决策略,它具有哪些优势和局限性呢?接下来,我们分别来探讨一下其优势与局限性;

5.1 优势

拉链法 通过 链表 来解决 哈希冲突 ,因此 链表 为其带来了以下优势:

  • 动态适应性强

由于链表节点是动态申请的,拉链法特别适合在建表之初无法确定记录总数的场景。数据可以随时增删,哈希表只需管理链表的增长,扩容的迫切性相对开放地址法要低。

这一点在介绍 链表顺序表 时就有提到过,我就不再继续展开;

  • 空间利用率高

拉链法 允许 装填因子 大于1,这意味着数组空间可以比实际存储的记录数小,这在处理大规模数据时能节省可观的内存。特别是当记录本身尺寸很大时,指针带来的额外空间开销相对而言就变得微不足道了。

我们知道 装填因子 是由 已经存储的元素个数 以及 哈希表的长度 共同决定:

α = 已存储元素个数 哈希表长度 \alpha = \frac{已存储元素个数}{哈希表长度} α=哈希表长度已存储元素个数

那么当我们选择用一个表长为 3 3 3 的哈希表来存储 [19, 14, 23, 01, 68, 20, 84, 27, 55, 11, 10, 79] 这组 关键字序列 时,我们通过 哈希函数

H a s h ( k e y ) = k e y   m o d   3 Hash(key) = key \bmod 3 Hash(key)=keymod3

就得到了下图的哈希表:
哈希表 0 1 2 84 27 19 01 55 10 79 14 23 68 20 11

此时的 装填因子 : α = 12 3 = 4 > 1 \alpha = \frac{12}{3} = 4 > 1 α=312=4>1 。因此,拉链法 是可以通过 较小的数组空间 存储 更多的数据 ,以此来充分利用 内存空间 ,减少 空间的浪费

  • 避免堆积现象

拉链法 只会将真正的 同义词 链接在一起,非同义词 绝不会发生冲突,这使得其平均查找长度通常较短。

堆积 简单的理解就是在 哈希表 中,当发生冲突时,若通过 线性探测法 来处理冲突,那么就会导致 同义词 聚集在某一块区域。

就比如这里我通过 哈希函数 H a s h ( k e y ) = k e y   m o d   10 Hash(key) = key \bmod 10 Hash(key)=keymod10 来存储 [1, 11, 21, 31, 41, 51, 61, 71, 2, 4] 这个 关键字序列 ,并且采用 线性探测法 来处理冲突,那么我们就会得到下面这个 哈希表

0 1 2 3 4 5 6 7 8 9
4 1 11 21 31 41 51 61 71 2

可以看到,[11, 21, 31, 41, 51, 61, 71] 这些 关键字 通过 哈希函数 得到的 哈希地址 均为 H a s h ( k e y ) = k e y   m o d   10 = 1 Hash(key) = key \bmod 10 = 1 Hash(key)=keymod10=1 ,因此他们都会在 k e y = 1 key = 1 key=1 插入到 哈希表 后发生冲突;

但是当我们使用的是 线性探测法 来处理冲突的话,就会导致这些 关键字 发生 聚集 ,这就使得在计算 [2, 4] 这两个 关键字哈希地址 时,发生 二次冲突

拉链法 则完全避免了这个问题,同义词 通过 链式存储 实现了全部存放在同一个 哈希地址 ,因此 非同义词 之间是完全不会发生任何 冲突

PS: 线性探测法 会在后面的内容中详细介绍,这里大家简单了解即可

5.2 局限性与应对

拉链法也并非完美,其局限性主要体现在:

  • 额外空间开销

每个节点都需要额外的指针空间。当存储的节点规模本身很小时,这部分开销就显得比较突出。

这也是为什么在一些极致追求空间效率、且数据量小的场景下,开发者可能会考虑开放地址法。

  • 缓存不友好与性能波动

链表节点在内存中是随机分布的,这不如开放地址法将数据连续存储在数组中的局部性好,跳转访问会带来额外的时间开销。

同时,虽然 拉链法 平均性能优异,但若 哈希函数 设计不当,导致大量数据聚集在少数几个桶中,造成链表过长,性能会急剧下降。

拉链法 中我们将 哈希地址 称为 ,通过 链表 链接起来的 同义词 就像是放入 中的带有数字的乒乓球;

哈希函数 设计不当,导致某个 中的乒乓球过多时,我们想要从 中找到 目标乒乓球 的时间复杂度就会从 O ( 1 ) O(1) O(1) 退化到 O ( N ) O(N) O(N) ,这就相当于,我们使用的 数组 + 链表哈希表 就直接退化为了 链表

正是因为这种情况的存在,这就使得 拉链法 的性能不太稳定;

六、优化

正是因为 拉链法 存在 性能波动 的问题,因此我们可以对其进行进一步的 优化。其具体的优化思路可以分为两类:

  • 优化 哈希函数 :选择更合适的 哈希函数 从而保证 哈希表 的高效性
  • 优化 链表 :通过对 链表 进行优化,保证在 最坏情况 下的查找效率

这里我们主要介绍第二类------优化 链表

6.1 优化思路

优化 链表 的目的就是为了提高 链表 的查找效率,因此我们不能仅仅局限于 链表 ,而是要将 链表 视为 链式存储 的其中一种实现方式;

那也就是说,我们对 链表 进行的优化,实际上是对 链式存储 进行的优化。因此我们有以下几种优化方案:

  • 初步优化:将 无序链表 优化为 有序链表
  • 进阶优化:将 链表 优化为 二叉查找树
  • 深度优化:将 二叉查找树 优化为 AVLRBT

6.2 实例说明

这里我们还是以前面的例子为例,简单介绍一下 初步优化 的过程;

插入 操作中,将 同义词 有序插入到 链表 中,使其成为 有序链表
哈希表 0 1 2 3 4 5 6 7 8 9 10 11 12 01 14 27 79 55 68 19 84 20 10 23 11

链表 有序后,我们在查找的过程中,就可以通过判断 目标 关键字的范围,来初步提升查找效率;

当然在实际的应用中,我们更偏向于 深度优化 ,如 Java 中的HashMap 在链表长度超过一定阈值(如 8 8 8)时,会将其转换为 红黑树 ,以确保即使在最坏情况下,查找效率也能维持在 O ( log ⁡ N ) O(\log N) O(logN)。

结语

在今天的内容中我们介绍了第一种解决冲突的方法------拉链法。下面我们就来一起回顾一下今天的内容:

基本思想

拉链法 作为解决 哈希冲突 的一种经典策略,其 核心思想 是:

  • 将同一哈希地址的同义词通过链表链接

基本操作

采取 拉链法 解决冲突的哈希表,其基本操作实际上是对 链表 的操作:

  • 插入:将 同义词 插入到 哈希地址 对应的 链表
  • 查找:通过 哈希函数 找到 哈希地址 ,再通过 哈希地址 找到 链表 ,最后对 链表 进行 查找
  • 删除:通过 查找 操作找到 目标元素 ,再将 目标元素链表删除

优缺点

正是因为 拉链法 采用的是 数组 + 链表 的组合,其优势与局限性均体现再 链表 上:

  • 优势:
    • 动态适应性强
    • 空间利用率高
    • 避免堆积现象
  • 局限性:
    • 额外空间开销
    • 缓存不友好与性能波动

优化思路

由于 拉链法数组 + 链表 的组合,在 哈希函数 不合理的情况下会导致 哈希表 退化成 链表 ,因此我们的整体优化思路是 优化链式存储

  • 初步优化:将 无序链表 优化为 有序链表
  • 进阶优化:将 链表 优化为 二叉查找树
  • 深度优化:将 BST 优化为 AVLRBT

拉链法 作为一项经典技术,其设计思想在 JavaHashMap 等现代数据结构中得到了实际应用和优化,展现了强大的生命力。

当然,冲突 的处理不仅仅可以使用 拉链法 ,在下一篇内容中,我们将会学习第二种 冲突 处理策略------开放定址法。具体内容我们会在下一篇内容中揭晓。

互动与分享

  • 点赞👍 - 您的认可是我持续创作的最大动力

  • 收藏⭐ - 方便随时回顾这些重要的基础概念

  • 转发↗️ - 分享给更多可能需要的朋友

  • 评论💬 - 欢迎留下您的宝贵意见或想讨论的话题

感谢您的耐心阅读! 关注博主,不错过更多技术干货。我们下一篇再见!

相关推荐
一直都在5722 小时前
数据结构入门:二叉排序树的构建与相关算法
数据结构·算法
_Minato_2 小时前
数据结构知识整理——复杂度的计算
数据结构·经验分享·笔记·算法·软考
x_lrong2 小时前
交叉验证笔记
笔记
月明长歌2 小时前
【码道初阶】【LeetCode 102】二叉树层序遍历:如何利用队列实现“一层一层切蛋糕”?
java·数据结构·算法·leetcode·职场和发展·队列
لا معنى له3 小时前
学习笔记:注意力机制(Attention)、自注意力(Self-Attention)和多头注意力(Multi-Head Attention)
笔记·学习
走在路上的菜鸟3 小时前
Android学Dart学习笔记第十六节 类-构造方法
android·笔记·学习·flutter
代码游侠3 小时前
学习笔记——线程控制 - 互斥与同步
linux·运维·笔记·学习·算法
聆风吟º3 小时前
【数据结构手札】顺序表实战指南(一):线性表定义 | 顺序表定义
数据结构·顺序表·线性表
四谎真好看3 小时前
MySQL 学习笔记(进阶篇1)
笔记·学习·mysql·学习笔记