散列表,Hash冲突,HashMap

一、散列表

1.概念

英文名“Hash Table”,又称哈希表
是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
哈希表存储时把Key通过一个固定的算法函数既所谓的哈希函数转换成一个整型数字,然后就将该数字对数组长度进行取余,取余结果就当作数组的下标,将value存储在以该数字为下标的数组空间里。
而当使用哈希表进行查询的时候,就是再次使用哈希函数将key转换为对应的数组下标,并定位到该空间获取value,如此一来,就可以充分利用到数组的定位性能进行数据定位。
优点: 哈希表运算速度极快,不论哈希表中有多少数据,查找、插入、删除只需要接近常量的时间即0(1)的时间复杂度。
缺点: 它是基于数组的,数组创建后难于扩展,某些哈希表被基本填满时,性能下降得非常严重
但当不同的关键字经过散列函数的计算得到了相同的散列地址时,就会出现哈希冲突

2.哈希函数常用方法:

(1)直接定址法:
取关键字或关键字的某个线性函数值为哈希地址:H(key) = key 或 H(key) = a·key + b
其中a和b为常数,这种哈希函数叫做自身函数。

注意: 由于直接定址所得地址集合和关键字集合的大小相同。因此,对于不同的关键字不会发生冲突。但实际中能使用这种哈希函数的情况很少。
(2)相乘取整法:
首先用关键字key乘上某个常数A(0 < A < 1),并抽取出key.A的小数部分;然后用m乘以该小数后取整。
注意:该方法最大的优点是m的选取比除余法要求更低。比如,完全可选择它是2的整数次幂。虽然该方法对任何A的值都适用,但对某些值效果会更好。建议选取 0.61803……。
(3)平方取中法:
取关键字平方后的中间几位为哈希地址。
通过平方扩大差别,另外中间几位与乘数的每一位相关,由此产生的散列地址较为均匀。这是一种较常用的构造哈希函数的方法。
将一组关键字(0100,0110,1010,1001,0111)
平方后得(0010000,0012100,1020100,1002001,0012321)
若取表长为1000,则可取中间的三位数作为散列地址集:(100,121,201,020,123)。
(4)除留余数法:
取关键字被数p除后所得余数为哈希地址:H(key) = key MOD p (p ≤ m)。
注意:这是一种最简单,也最常用的构造哈希函数的方法。它不仅可以对关键字直接取模(MOD),也可在折迭、平方取中等运算之后取模。值得注意的是,在使用除留余数法时,对p的选择很重要。一般情况下可以选p为质数或不包含小于20的质因素的合数。
(5)随机数法:
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即 H(key) = random (key),其中random为随机函数。通常,当关键字长度不等时采用此法构造哈希函数较恰当。

二、哈希冲突(散列冲突)

为解决哈希冲突,我们有以下几种方法:

1. 开放寻址法

(1)线性探测(Linear Probing)(最好情况为O(1)。最坏情况为 O(n))

  1. 插入:如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
  2. 查找:过程和插入一样,找到对应数组下标后,对比x与数组中存储的值是否相等,若不等则依次往后查找
  3. 删除:删除的元素,特殊标记为 deleted。当线性探测查找的时候,遇到标记为 deleted 的空间,并不是停下来,而是继续往下探测。

(2)二次探测(Quadratic probing)
和线性探测(Linear Probing)一样,只不过每次步长为2。
(3)双重散列(Double hashing)
所谓双重散列,即使用两个或两个以上的散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。

2. 链表法

拉链法又叫链地址法,适合处理冲突比较严重的情况。基本思想是把所有关键字为同义词的记录存储在同一个线性链表中。
在这里插入图片描述
在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中:

3.再哈希法

再哈希法又叫双哈希法,有多个不同的Hash函数,当发生冲突时,使用第二个,第三个,…,等哈希函数
计算地址,直到无冲突。虽然不易发生聚集,但是增加了计算时间。

4.建立公共溢出区

建立一个基本表,基本表的大小等于哈希表的大小。建立一个溢出表,所有哈希地址的第一个记录都存在基本表中,所有发生冲突的数据,不管哈希算法得到的地址是什么,都放入溢出表中。
但是有一个缺点就是,必须事先知道哈希表的可能大小,而且溢出表里的数据不能太多,否则影响溢出表的查询效率。实际上就是要尽量减少冲突。

5. 链表法与开放寻址法优缺点

1.开放寻址法
优点: 散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较简单。链表法包含指针,序列化起来就没那么容易。
缺点: 开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。当数据量比较小、装载因子小的时候,适合采用开放寻址法。

2.链表法
优点: 链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。
缺点: 链表因为要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,(如果我们要存储的对象的大小远远大于一个指针的大小那链表中指针的内存消耗在此对象面前就可以忽略)。而且因为链表中的结点是零散分布在内存中的,不是连续的,所以对 CPU 缓存有很大考验,所以对执行效率也有一定的影响。

三、HashMap

HashMap 数据结构为 数组+链表,其中:链表的节点存储的是一个 Entry 对象,每个Entry 对象存储四个属性(hash,key,value,next)
HashMap里面没有出现hash冲突时,没有形成单链表时,hashmap查找元素很快,get()方法能够直接定位到元素,但是出现单链表后,单个bucket 里存储的不是一个 Entry,而是一个 Entry 链,系统只能必须按顺序遍历每个 Entry,直到找到想搜索的 Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),那系统必须循环到最后才能找到该元素。

1.初始化 HashMap

初始化HashMap时提供了有参构造和无参构造,无参构造中,容器默认的数组大小 为 16,加载因子 为0.75。
(1)数组初始大小(initialCapacity):
HashMap散列表的 默认的初始大小是 16,当然这个默认值是可以设置的,如果事先知道大概的数据量有多大,可以通过修改默认初始大小,减少动态扩容的次数,这样会大大提高 HashMap 的性能。
(2)装载因子(loadFactor):
当创建 HashMap 时,有一个默认的装载因子(load factor),其默认值为 0.75,这是时间和空间成本上一种折衷:增大负载因子可以减少 Hash 表(就是那个 Entry 数组)所占用的内存空间,但会增加查询数据的时间开销,而查询是最频繁的的操作,
(3)容器的阈值:
容器的阈(yu)值为 initialCapacity * loadFactor,默认情况下阈值为 16 * 0.75 = 12

2.HashMap的动态扩容

HashMap 使用 “懒扩容” ,只会在调用put方法的时候才进行判断, 当 HashMap 中元素个数超过容器的阈值时候,才会进行扩容。扩容时:

  1. 将数组长度扩容为原来的2 倍
  2. 将原来数组中的元素进行重新放到新数组中

在JDK1.7中,采用的是transfer()方法其中为2个循环,所以在多线程下当2个线程操作统一条链表就有可能出现链表闭合。而在JDK1.8中是顺序拷贝,所以就不会产生jdk7的死锁

注意:每次扩容之后,都要会新计算原来的 Entry 在新数组中的位置,所以Entry 在数组中的位置发生变化了,因为由HashMap计算索引位置公式h & (length-1);(h 为key 的 hash值;length 是数组长度)可知,当length变化时,其计算结果也会变化

3.HashMap的散列函数

我们一般对哈希表的散列很自然地会想到用hash值对length取模(即除法散列法),Hashtable中也是这样实现的,这种方法基本能保证元素在哈希表中散列的比较均匀,但取模会用到除法运算,效率很低,HashMap中则通过h&(length-1)的方法来代替取模,同样实现了均匀的散列,但效率要高很多。

那么length的取值需要注意什么吗?
首先,length为2的整数次幂的话,h&(length-1)就相当于对length取模,这样便保证了散列的均匀,同时也提升了效率;其次,length为2的整数次幂是偶数,这样length-1为奇数,奇数的最后一位是1,这样便保证了h&(length-1)的最后一位可能为0,也可能为1(这取决于h的值),即与后的结果可能为偶数,也可能为奇数,这样便可以保证散列的均匀性,而如果length为奇数的话,很明显length-1为偶数,它的最后一位是0,这样h&(length-1)的最后一位肯定为0,即只能为偶数,这样任何hash值都只会被散列到数组的偶数下标位置上,这便浪费了近一半的空间,因此,length取2的整数次幂,是为了使不同hash值发生碰撞的概率较小,这样就能使元素在哈希表中均匀地散列。

4.JDK1.7与JDK1.8对于HashMap底层实现的不同

在JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响 HashMap 的性能。于是,在 JDK1.8 版本中,为了对 HashMap 做进一步优化,我们引入了红黑树。
JDK8新增的属性TREEIFY_THRESHOLD是树化阀值,TREEIFY_THRESHOLD是链表化阀值
当链表长度大于树化阀值(默认超过 8)时,链表就转换为红黑树。 我们可以利用红黑树快速增删改查的特点,提高 HashMap 的性能。在JDK1.8中,HashMap转化为红黑树大致分为三个步骤:

  1. 将链表转化为二叉树
  2. 验证是否满足红黑树的五大特征
    五大特征:
    1. 节点是红色或者黑色的
    2. 根节点是黑色的
    3. 每个叶节点(包括空节点)是黑色的
    4. 每个红色节点的两个子节点都是黑色,即不能有两个连续的红色节点
    5. 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点
  3. 对二叉树进行左右旋转操作

当红黑树结点个数小于链表化阀值(默认为6)的时候,又会将红黑树转化为链表。 因为在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。

新节点插入顺序:jdk1.7在头部插入,jdk1.8在尾部插入

  • 8
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值