散列表

哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如memcached)的核心其实就是在内存中维护一张大的哈希表,而HashMap的实现原理也常常出现在各类的面试题中,重要性可见一斑。本篇博客是博主在进行复习总结时所写,中间有些内容会参考之前的看过的高质量博客进行讲述。如果错误,还望指出,共同进步~

一、哈希表

在讨论哈希表之前,我们先大概了解下其他数据结构在新增,查找等基础操作执行性能

数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)。
线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)。
二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。
哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。
  散列表叫“Hash Table”,散列表用的是数组支持下标随机访问数据的特性,它是数组的一种扩展。
散列表用的是数组支持下标随机访问时时间复杂度时O(1)的特性。通过散列函数把元素的key映射到下标,然后将数据存储在数组中对应下标的位置。当我们按照键值key查询元素时,我们用同样的散列函数,将key转换为数组下标,从对应的数组下标的位置取数据。
散列表中最重要的是散列函数和散列冲突。

散列函数

散列函数定义为hash(key),其中key表示元素的键值,hash(key)的值表示经过散列函数计算得到的散列值。
散列函数设计的好坏决定了散列冲突的概率,也决定散列表的性能。
散列函数设计满足的三点要求:

  • 1.散列函数计算得到的散列值时一个非负整数。(因为数组下标熊0开始)
  • 2.如果key1=key2,则hash(key1)=hash(key2)
  • 3.如果key1!=key2,则hash(key1)!=hash(key2)

前两点好理解,第三点在实践中,找到一个不同的key对应的散列值都不一样的散列函数是无法满足的,即使时署名的MD5,SHA,CRC等哈希算法也无法完全避免这种散列冲突。而且数组的存储空间有限,也会加大散列冲突的概率。

解决散列冲突的办法

既然再好的散列函数也无法完全解决散列冲突。我们常用的散列冲突解决方法有两类:开放寻址法和链表法。
1.开放寻址法
开放寻址法的核心思想:如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。
探测方法有:

  • 1.1 线性探测(最常用)
    当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
    下图黄色方块表示空闲区域,橙色表示已存储了数据。
    在这里插入图片描述
    在这里插入图片描述

  • 1.2 二次探测
    和线性探测相似,线性探测的步长是1,hash(key)+0,hash(key)+1,…,hash(key)+n;二次探测的步长变成原来的二次方,探测下标序列是hash(key)+0,hash(key)+12,hash(key)+22,…,hash(key)+n2)

  • 1.3 双重散列(不只使用一个散列函数,而是使用一组散列函数hash1(key),hash2(key),…hashn(key) 先使用第一个散列函数,如果计算得到的存储位置已经被占用(即出现散列冲突),再使用第二个散列函数,以此类推,知道找到空闲的存储位置))等。

不管是用那种探测方法,当散列表找那个空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。

我们用转载因子来表示空位的多少:
装载因子的计算公式是:装载因子=填入表中的元素个数/散列表的长度
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。
散列表和数组一样,支持插入、查找和删除操作。这里需要注意的是,在做元素删除操作时候,将删除的元素特殊标记为deleted。所以桑线性探测查找的时候,遇到标记为deleted的空间,并非停下来,而是继续向下探测。

2.链表法
在散列表中的每个位置(一般我们称之为桶或者槽)都会对应一条链表,所有散列值相同的元素我们都会放在相同槽位对应的链表中。  在这里插入图片描述

如何高效的设计散列表

1.散列函数的设计不能太复杂
散列函数生成的值尽可能随机并且分布均匀,避免或者最小化散列冲突,而且即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内的数据特别多的情况。

2. 装载因子
对于散列表来说,装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率越大。不仅插入数据的过程要多次寻址或者拉很长的链,查找的过程也会因此变得很慢。

对于没有频繁插入和删除的静态数据集合来说,我们很容易根据数据的特点、分布等,设计出完美的、极少冲突的散列函数。

对于动态散列表来说,数据集合是频繁变动的,事先无法预估要加入的数据个数,所以我们无法事先申请一个足够大的散列表。随着数据慢慢加入,装载因子就会慢慢变大。当装载因子大到一定程度之后,散列冲突就会变得不可接受。

3. 扩容问题
对于散列表,当装载因子过大时,我们可以向数组,栈,队列那样进行动态扩容,重新申请一个跟大的散列表,将数据搬移到这个新散列表中。针对数组的扩容,数据搬移操作比较简单。但是,针对散列表的扩容,数据搬移操作复杂很多。因为散列表的大小变了,数据存储位置变了,所以我们需要通过散列函数重新计算每个数据的存储位置。

在动态扩容中,为了避免低效的扩容,解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只需申请新空间,但并不将老的数据搬移到新散列表中。当有新数据插入时,我们将新数据插入到新散列表中,并且从老的散列表中拿出一个数据放入到新散列表中。每次插入一个数据到散列表,我们都会重复这样的操作。经过多次插入操作之后,老的散列表中的数据一点点的搬移到新散列表中。这样就没有了集中的一次性数据搬移,插入操作就会变得很快。这期间的查询操作,为了兼容新、老散列表中的数据,先从新散列表中查找,没有找到则再取老散列表中查找。通过这种均摊的方法,任何时候插入一个数据的时间复杂度是O(1)。

4. 散列冲突解决办法:开放寻址法和链表法
Java中LinkedHashMap采用链表法解决冲突,ThreadLocalMap通过线性探测的开放寻址法来解决冲突。

开放寻址法的优点:开放寻址法不像链表法,需要拉很多链表。散列表中的数据都存储在数组中,可以有效地利用CPU缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较简单。链表法包含指针,序列化复杂。
开放寻址法的缺点:用开放寻址法解决冲突的散列表,删除数据比较复杂,需要特殊标记已经删除掉的数据。而且,数据全部存储在一个数组中,比起链表冲突的代价更高。
所以开放寻址法适合数据量比较小,装载因子小的应用场景。

链表法的优点:对于内存的利用率比开放寻址要高,链表节点可以在需要的时候在创建,无需像开放寻址法那样事先申请。链表法比起开放寻址法,对于大转载因子的容忍度要高。开放寻址法只能使用装载因子小于1的情况,当装载因子接近于1时就可能会有大量的冲突,导致大龄的探测、再散列等,性能会下降很多。但对于链表来说,只要散列函数的值随机均匀,即使装载因子变成10,也就是链表长度变长了而已,虽然查找效率变慢,但相比顺序查找而言会快很多。
所以基于链表的散列冲突处理方法比较适合存储大独享、大数据量的散列表,而且比开放寻址法,他更加灵活,支持更多的优化策略,比如红黑树代替链表。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值