如何实现一个优秀的 HashTable 散列表?

news2025/8/3 2:26:38

本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 提问。

前言

大家好,我是小彭。

在前几篇文章里,我们聊到了 Java 中的几种线性表结构,包括 ArrayList、LinkedList、ArrayDeque 等。今天,我们来讨论另一种常用的基础数据结构,同时也是 “面试八股文” 的标准题库之一 —— 散列表(Hash Table)。

同时,在后续的文章里,我们将以 Java 语言为例,分析标准库中实现的散列表实现,包括 HashMap、ThreadLocalMap、LinkedHashMap 和 ConcurrentHashMap。请关注。


小彭的 Android 交流群 02 群已经建立啦,扫描文末二维码进入~


思维导图:


1. 什么是散列表?

散列表是基于散列思想实现的 Map 数据结构,散列思想是散列表的核心特性,也就做哈希算法或 Hash 算法。散列算法是一种将 “任意长度的输入数据” 映射为 “固定长度的特征值” 的算法,输出的特征值就是散列值。

用一个表格总结散列算法的主要性质:

性质描述
1、单向性(基本性质)支持从输入生成散列值,不支持从散列值反推输入
2、高效性(基本性质)单次散列运算计算量低
3、一致性(基本性质)相同输入重复计算,总是得到相同散列值
4、随机性(高效性质)散列值在输出值域的分布尽量随机
5、输入敏感性(高效性质)相似的数据,计算后的散列值差别很大

将散列思想应用到散列表数据结构上时,就是通过 hash 函数提取键(Key)的特征值(散列值),再将键值对映射到固定的数组下标中,利用数组支持随机访问的特性,实现 O(1) 时间的存储和查询操作。

事实上,一般不会直接使用 hash 函数计算后的散列值作为数组下标。例如 Java Object#hashCode() 散列值是 int 类型,值域足足有 2^32 的容量,我们不可能创建这么大的数组。

最简单的做法是将散列值对数组长度取余后再取绝对值:|hash % length|。如果数组长度 length 是 2 的整数幂,还可以等价替换成位运算:hash & (length - 1) ,不管被除数是正负结果都是正数。 不仅将取余运算替换为位运算,而且减少了一次取绝对值运算,提高了索引的计算效率。

10  % 4 = 2
-10 % 4 = -2      // 负数
10  & (4 - 1) = 2
-10 & (4 - 1) = 2 // 正数

散列表示意图

提示: 虽然我们将取余运算优化为位运算,但是为了便于理解,我们在后文中依然描述为逻辑上的 “取余” 运算。


2. 散列表无法避免的冲突问题

因为 Hash 算法会将非常大甚至无穷大的输入值域映射到 “固定长度的特征值”,所以 Hash 算法一定是压缩映射。例如,MD5 的输出散列值为 128 位,SHA256 的输出散列值为 256 位,这就存在 2 个不同的输入产生相同输出的可能性。这就是散列冲突或哈希冲突(Hash Collision)问题。

事实上,在散列表的设计中存在 2 次散列冲突:

  • 第 1 次 - hash 函数的散列冲突: 这是一般意义上的散列冲突;

  • 第 2 次 - 散列值取余转数组下标: 本质上,将散列值转数组下标也是一次 Hash 算法,也会存在散列冲突。同时,这也说明 HashMap 中同一个桶中节点的散列值不一定是相同的。

其实,散列冲突只要用鸽巢原理(又称:抽屉原理)就很好理解了,假设有 10 个鸽巢,现有 11 只鸽子,无论分配多么平均,也肯定有一个鸽巢里有两只甚至多只鸽子。举一个直接的例子,Java 中的字符串 "Aa""BB" 的就存在散列冲突。

散列冲突举例

String str1 = "Aa";
String str2 = "BB";
System.out.println(str1.hashCode());  // 2112
System.out.println(str2.hashCode());  // 2112 散列冲突

由于我们无法避免散列冲突,所以只能保证散列表不会因为散列冲突而失去正确性。常用的散列冲突解决方法有 2 类:

  • 开放寻址法: 例如 ThreadLocalMap;
  • 分离链表法: 例如 HashMap。

3. 开放寻址法

开放寻址(Open Addressing)的核心思想是: 在出现散列冲突时,在数组上重新探测出一个空闲位置。 经典的探测方法有线性探测(Linear Probing)、平方探测(Quadratic Probing)和双散列探测(Double Hashing Probing)。

3.1 线性探测

线性探测是最基本的探测方法,在 Java 实现线程局部存储的 ThreadLocal 类中的散列表,就是基于线性探测的散列表。ThreadLocal 我们会在后续专栏文章会讨论,请关注。

  • 添加键值对: 先将散列值取余映射到数组下标,然后从数组下标位置开始探测与目标 Key 相等的节点。如果找到,则将旧 Value 替换为新 Value,否则沿着数组顺序线性探测。直到线性探测遇到空闲位置,则说明节点不存在,需要添加新节点。如果在添加键值对后数组没有空闲位置,就触发扩容;

  • 查找键值对: 查找类似。也是先将散列值映射到数组下标,然后从数组下标位置开始线性探测。直到线性探测遇到空闲位置,则说明节点不存在;

  • 删除键值对: 删除类似。由于查找操作在遇到空闲位置时,会认为键值对不存在于散列表中,如果删除操作时 “真删除”,就会使得一组连续段产生断层,导致查找操作失效。因此,删除操作要做 “假删除”,删除操作只是将节点标记为 “Deleted”,查找操作在遇到 “Deleted” 标记的节点时会继续向下探测。

开放寻址法示意图

线性探测的缺点是 “一次聚集” 问题: 不仅会让散列冲突的键值对聚集,还会让原本没有散列冲突但位置被占用的节点被迫聚集在一起,降低了添加和查找效率。最坏情况下,有可能需要线性探测整张散列表才能找到目标位置。

3.2 平方探测法

平方探测与线性探测类似,区别在于: 线性探测的探测指针是一个线性序列,而平方探测的探测指针是一个平方序列。使用平方探测并不能完全解决 “聚集” 问题,但相比于线性探测聚集现象有所减弱。

需要特别注意, 平方探测法必须要求数组的长度必须是 4k+3 型素数, 才能保证能够探测完整个数组空间,否则会出现数组有空闲位置,但平方探测找不到的情况,此时扩容显得没有必要。

3.3 双散列探测

双散列探测的核心思想是: 提供一组散列函数,在遇到计算得到的数组下标位置被占用,则使用下一个散列函数重新计算,直到找到空闲位置。

对比下 3 种方法的探测步骤:

  • 线性探测: hash(key) + 0,hash(key) + 1,hash(key) + 2,hash(key) + 3…

  • 平方探测: hash(key) + 0^2,hash(key) + 1^2,hash(key) + 2^2,hash(key) + 3^2…

  • 双散列探测: hash(key),hash1(key),hash2(key),hash3(key)…


4. 分离链表法

分离链表法(Separate Chaining)的核心思想是: 在出现散列冲突时,将冲突的元素添加到同一个桶(Bucket / Slot)中,桶中的元素会组成一个链表,或者跳表、红黑树等动态数据结构。

相较之下,链表法是更常用且更稳定的冲突解决方法,我们熟悉的 Java HashMap 就是基于分离链表法的实现。HashMap 我们会在后续专栏文章会讨论,请关注。

  • 添加键值对: 先通过散列函数将散列值映射到数组下标,然后沿着链表寻找节点的 Key 和添加的 key 相等的节点。如果找到,则将旧 Value 替换为新 Value,如果找不到,则创建在链表上新建节点;

  • 查找键值对: 查找与添加的步骤类似,也是先将散列值映射到数组下标,然后沿着链表寻找节点的 Key 和添加的 key 相等的节点。如果找不到,则说明键值对不存在于散列表中;

  • 删除键值对: 删除键值对不需要 “假删除”,与添加和查找类似,也是先将散列值映射到数组下标,然后沿着链表寻找节点的 Key 和添加的 key 相等的节点。如果找到,则将节点从链表上移除。

分离链表法示意图


5. 影响散列表性能的因素

从上面的内容我们逐渐明白, 散列表操作的时间复杂度并不是绝对的 O(1)。 它与地址堆积的个数 K 或链表的长度 K 有关,也就是 O(K)。虽然 O(K) 也是常数时间复杂度,但并不是固定的常数。在极端情况下,当所有的数据都堆积在一起,或者所有数据都映射到相同的链表中时,时间复杂度就会从 O(1) 退化到 O(n)。

换句话说,影响散列表性能的关键在于 “散列冲突的发生概率”,冲突概率越低,时间复杂度越接近于 O(1)。 那么,哪些因素会影响冲突概率呢?主要有 3 个:装载因子、冲突解决方法、散列函数。

5.1 因素 1 - 装载因子和扩容

理解了开放地址法和分离链表法两种冲突解决方法后,我们会发现: 无论使用哪种方法,随着散列表中元素越来越多,空闲位置越来越少,就会导致散列冲突的发生概率越来越大,使得散列表操作的平均时间会越来越大。为了描述散列表的装满程度,我们定义 装载因子 (Load Factor) = 散列表中键值对数目 / 散列表的长度。

  • 在基于开放寻址法的散列表中: 装载因子的最大值是 1(数组装满),装载因子为 1 时无法添加新元素,必须扩容;

  • 在基于分离链表法的散列表中: 允许装载因子超过 1(拉长出很长的链表),装载因子为 1 时,扩容并不是必须的。

装载因子 = 散列表中键值对数目 / 散列表的长度

扩容本质上是扩大了散列算法的输出值域,扩大输出值域可以直接降低冲突概率。事实上,一般不会等到装载因子接近 1 时再扩容,而是设置一个处于 (0, 1) 之间的 装载因子上限(扩容阈值)。 例如,在 HashMap 中设置的默认装载因子上限是 0.75。

当散列表的装载因子大于扩容阈值时,就会触发扩容操作,并将原有的数据搬运到新的数组上。与普通数组相比,散列表的动态扩容不再是简单的数据搬运,因为数组的长度变化了,公式 hash & (length - 1) 的计算的下标位置也变了,所以这一扩容过程也叫 “再散列”(不要和双散列探测混淆)。

散列表的扩容过程

当添加操作触发扩容时,需要花费 O(n) 时间再散列和搬运数据,那么散列表的时间复杂度还是 O(1) 常数时间吗?对于这种大部分操作时间复杂度很低,只有个别情况下时间复杂度会退化,而且这些操作之间存在很强烈的顺序关系的情况,就很适合用 “均摊时间复杂度分析” 了。我们将花费 O(n) 时间的那一次插入操作的时间均摊到随后的多次 O(1) 时间插入操作上,那我们从整体看,添加数据的均摊时间复杂度就是 O(1)。

以上是从算法分析的角度,从工程分析的角度看,事情还没这么简单。 在大数据场景下,如果旧散列表中有 1 GB 数据,那么扩容操作就是对 1 GB 的数据量做再散列。无论算法分析把时间复杂度摊还到多低,对 1 GB 数据量的再散列就是实打实的耗时操作,也是无法忍受的。此时,为避免一次性扩容过多数据的情况,有一种 “懒扩容” 方案:在创新一个新散列表的同时,保留旧的散列表。每次插入新的数据都插入到新散列表中,并从旧散列表中取一个数据再散列到新的散列表中。经过多次操作后,旧散列表中的数据就逐渐搬运到新散列表中。

5.2 因素 2 - 采用的冲突解决方法

开放寻址法和分离链表法的优缺点和适用场景不同:

  • 1、访问效率不同: 开放寻址法中数据都存储在数组中,是一个连续的内存区域,基于局部性原理,开放寻址法能够更好地命中 CPU 缓存行。而分离链表法中的数据主要位于链表中,是离散的内存区域,对 CPU 缓存行不优友好;

  • 2、冲突概率不同: 开放寻址法的冲突概率天然比分离链表法高,这是因为开放寻址法在发生冲突后,会在临近的位置寻找空闲位置填充数据,这使得原本并没有 “冲突” 的键值对也会因为没有空闲位置而被迫堆积。而分离链表法只有确实发生冲突的键值对才会堆积到同一个桶中;

  • 3、内存利用率不同: 由于开放寻址法的冲突概率更高,所以装载因子上限不能设置很高,存储相同的数据量,开放寻址法也需要预先申请更大的数组空间,内存利用率不会高。当然,分离链表法在链表指针上也有额外内存消耗,如果存储的元素的内存量远远大于一个指针的内存量,则可以忽略不及。

综上所述,它们各自的适用场景是什么呢?

  • 开放寻址法 - 对装载因子敏感,适合于小数据量且装载因子较小的场景: 例如 Java 的 ThreadlLocalMap,因为项目中不会大量使用 ThreadLocal 线程局部存储,所以它是一个小规模数据场景,这里使用开发地址法是没问题的;

  • 分离链表法 - 对装载因子的容忍度更高,适合于大数据量且大对象(相对于一个指针)的场景: 例如,Java 中更通用的 HashMap 散列表就是采用分离链表法。而且,分离链表法还能够使用更多灵活的优化策略,例如将链表树化为红黑树,避免极端情况下时间复杂度退化为 O(n)。

5.3 因素 3 - 散列函数设计

散列算法随机性和高效性也会影响散列表的性能。如果散列值不够随机,即使散列表整体的装载因子不高,也会使得数据聚集在某一个区域或桶内,依然会影响散列表的性能。如果散列算法不够高效,也会直接消耗计算性能。


6. 总结

  • 1、散列表是基于散列思想实现的 Map 数据结构,就是通过 hash 函数提取键(Key)的特征值(散列值),再将键值对映射到固定的数组下标中,利用数组支持随机访问的特性,实现 O(1) 时间的存储和查询操作;

  • 2、当数组的长度为 2 的整数幂时,可以将取余运算转换为位运算 hash & (length - 1),提高索引的计算效率;

  • 3、由于散列值算法是压缩映射,所以散列表永远无法避免散列冲突,常用的散列冲突解决方法有开放寻址法和分离链表法;

  • 4、开放寻址(Open Addressing)的核心思想是在出现散列冲突时,在数组上重新探测出一个空闲位置。 经典的探测方法有线性探测、平方探测和双散列探测;

  • 5、分离链表法(Separate Chaining)的核心思想是在出现散列冲突时,将冲突的元素添加到同一个桶(Bucket / Slot)中,桶中的元素会组成一个链表,或者跳表、红黑树等动态数据结构;

  • 6、开放寻址法对装载因子敏感,适合于小数据量且装载因子较小的场景。分离链表法对装载因子的容忍度更高,适合于大数据量且大对象(相对于一个指针)的场景;

  • 7、采用的散列冲突解决方法、装载因子和散列函数设计都会影响散列表性能。

今天,我们聊了散列表的整体设计思想。在后续几篇文章里,我们将讨论散列表的具体实现 —— HashMap。请关注。


参考资料

  • 数据结构与算法分析 · Java 语言描述(第 5 章 · 散列)—— [美] Mark Allen Weiss 著
  • 算法导论(第 11 章 · 散列表)—— [美] Thomas H. Cormen 等 著
  • 散列算法 —— 维基百科
  • 数据结构与算法之美(第 18~22 讲) —— 王争 著,极客时间 出品

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/34929.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

ArcGIS绘制地球

下面这个图是非常不错的,截取自论文的一张图: 学了十几年地理学,最初的兴趣恐怕还是小时候常常摆弄的地球仪;现在终于有机会尝试地球仪风格制作了。 虽然迟到了十几年,不过今天还是有机会“复现”小时候的地球仪。 先…

使用docker-compose部署达梦DEM管理工具,mac m1系列适用

之前搭建了mac m1下基于docker的达梦库(地址),但是没有一个好用的管理端。 用过DBeaver,可以使用自定jar创建dm链接,只做简单查询还行,要是用到一些修改、大文本查看、配置修改等高级点的功能就不行了。 …

Redis-使用java代码操作Redis

目录 Java连接Redis Java链接 测试是否连接 Java操作Redis Redis字符串(String) Redis哈希(Hash) Redis列表&#xff08;List&#xff09; Redis集合&#xff08;Set&#xff09; Java连接Redis 前置条件&#xff1a;Redis的服务要开启 pom依赖 <dependency>&l…

小熊U租港交所上市:市值28亿港元 京东联想腾讯是股东

雷递网 雷建平 11月24日小熊U租母公司凌雄科技集团有限公司&#xff08;简称&#xff1a;“凌雄科技”&#xff0c;股票代码为&#xff1a;“02436”&#xff09;今日在港交所上市。凌雄科技发行价为7.6港元&#xff0c;募资总额为3.37亿港元。凌雄科技开盘价为7.9港元&#xf…

C++17 --- 多态性、虚函数、多态的条件、联编(捆绑,绑定)

一、多态性 1、多态 多态性是面向对象程序设计的关键技术之一。 若程序设计语言不支持多态性&#xff0c;不能称为面向对象的语言。 多态性(polymorphism) 多态性是考虑在不同层次的类中&#xff0c;以及在同一类中&#xff0c;同名的成员函数之间的关系问题。 函数的重载&…

弘玑Cyclone2022产品发布会:超级自动化下的流程挖掘——弘观流程智能

近日&#xff0c;在弘玑Cyclone“智无边界&#xff0c;数字未来”发布会上&#xff0c;弘玑Cyclone2022年超级自动化系列产品全新亮相&#xff0c;首席产品官贾岿博士带领产品团队以创新技术对新时代语境下的数字生产力进行了全新解读。 本文将为大家分享本次发布会重磅推出的…

C++之字符串处理函数

字符串操作函数 C语言中几个处理字符串的函数都是以str开头,处理时默认遇到\0结束操作 这些字符串函数都使用了下面这个头文件: #include <string.h> 测量字符串的长度strlen 函数原型 size_t strlen(const char *s) //s指需要测量的字符串首元素地址注意,测量时不计…

建筑设计中,如何快速获得场地的等高线图?

等高线指的是地形图上高程相等的相邻各点所连成的闭合曲线。把地面上海拔高度相同的点连成的闭合曲线&#xff0c;并垂直投影到一个水平面上&#xff0c;并按比例缩绘在图纸上&#xff0c;就得到等高线。&#xff08;来自百度百科的定义&#xff09; 图新地球软件&#xff0c;…

YOLOS

太多了 yolo v x 现在又s了 Transformer能否从纯序列到序列的角度执行2D目标级识别&#xff0c;而对2D空间结构知之甚少&#xff1f;为了回答这个问题&#xff0c;今天就展示了“You Only Look at One Sequence” (YOLOS)&#xff0c;这是一系列基于朴素视觉变换器的目标检测模…

磨金石教育摄影技能干货分享|古风人像修图与调色技巧

上一篇文章我们介绍了古风人像的拍摄技巧&#xff0c;这期我们再来了解一下后期修图与调色的技巧。 一、修 饰 皮 肤 首先我们把拍好的照片拖到PS里。 1、ctrlj复制一个图层。然后选择污点修复画笔把脸部瑕疵去掉&#xff0c;嘴巴部位使用的是修复画笔。这样我们可以看到人像…

pyenv的安装与简单使用

一、pyenv是什么&#xff1f; pyenv 是 python 的 多环境管理 工具&#xff0c;可以安装多个版本的 python&#xff0c;并为 全局 或 单个应用 设置指定版本 二、pyenv的安装 2.1 下载 pyenv 压缩包 压缩包下载地址&#xff1a;https://github.com/pyenv-win/pyenv-win#rea…

测开 - 自动化测试selenium(WebDriver API) - 细节狂魔

文章目录回顾什么是驱动&#xff1f;驱动的工作原理是什么&#xff1f;一个简单的 Web自动化 演示1、定位元素的方法 - 只介绍两种最常使用的2、元素的操作3、等待3.1、强制等待强制等待的优点 && 缺点3.2、隐式等待隐式等待的优缺点3.3、显示等待显示等待的优缺点&…

五种方法帮你解决电脑内存占用大的问题

有用户反映自己的电脑什么都没开&#xff0c;但是运行内存显示占用90%以上&#xff0c;这是什么情况&#xff1f;运行内存占用大&#xff0c;直接影响了用户的使用体验&#xff0c;下面小编就给大家分享五个解决电脑内存占用大的办法吧。 方法一&#xff1a; 1、右键【我的电脑…

C++ —— 模拟实现vector和迭代器失效

目录 1.成员变量的设计 2.迭代器以及接口设计 3.容量接口 4.reserve的设计 5.resize的设计 6.尾插尾删的设计 7.构造、析构函数 8.运算符重载 9.insert接口设计与迭代器失效 10.erase的设计和迭代器失效 11.双层深拷贝问题 12.完整代码 1.成员变量的设计 成员变量…

Stream流、FiLe和IO流、

package com.streamdemo; import java.util.ArrayList; import java.util.List; /*** 体验Stream流** 创建一个集合&#xff0c;存储多个字符串元素* "张三丰","张无忌","张翠山","王二麻子","张良","谢广坤"** 把…

【Java八股文总结】之面试题(一)

文章目录面试题1、说一下ArrayList和LinkedList区别2、说一下HashMap的Put方法3、ThreadLocal4、说一下JVM中&#xff0c;哪些是共享区&#xff0c;哪些可以作为gc root?5、如何排查项目中遇到的JVM问题?6、如何查看线程死锁?7、线程之间如何进行通讯的?8、介绍一下Spring&…

分布式全局唯一 ID生成器(百度UidGenerator)

文章目录为什么要使用全局ID生成器&#xff1f;使用UUID作为主键&#xff1f;使用数据库主键自增&#xff1f;UidGenerator简介雪花算法snowflakeSpringBoot整合百度UidGenerator为什么要使用全局ID生成器&#xff1f; 在分库分表中必定会面临着一个问题&#xff0c; 就是如何…

steam搬砖项目怎么样

大家好&#xff0c;我是阿阳 Steam搬砖就是利用一些技巧和经验去Steam购买一些低价格商品&#xff0c;我们低价拿到道具&#xff0c;再以低于国内市场价的价格销售&#xff0c;保持了发货的稳定性和速度&#xff0c;赚取了利润。 如果是以前有人给我安利这种看着就不靠谱的赚…

scrapy 使用FilesPipeline和ImagesPipeline

除了爬取文本&#xff0c;我们可能还需要下载文件、视频、图片、压缩包等&#xff0c;这也是一些常见的需求。scrapy提供了FilesPipeline和ImagesPipeline&#xff0c;专门用于下载普通文件及图片。两者的使用方法也十分简单&#xff0c;首先看下FilesPipeline的使用方式。 Fi…

基于Netty的高性能RPC框架(分布式缓存、雪花算法、幂等性)

文章目录前言介绍1. 服务提供2. 安全策略3. 设计模式亮点1. 信息摘要算法的应用2. 心跳机制3. SPI 机制4. IO 异步非阻塞5. RNF 协议快速开始1.依赖1.1 直接引入1.2 maven引入2. 启动 Nacos3. 提供接口4. 启动服务5. 启动客户端5. 额外配置5.1 配置文件5.2 日志配置6. 场景应用…