一、引言
说到散列表,java中代表作就是HashMap
应该说HashMap插入和查找的性能真的是很好,插入和查找时间复杂度接近O(1)
(当然如果是多线程场景,就要用ConcurrentHashMap来解决问题)
二、数据结构
以下这些制作精美的图片
来自于王争老师的《算法与数据结构之美》,我只是搬运工!!!
散列表有2个组成部分:
1、散列函数(hash函数)
将插入的数据,通过散列函数计算得到的值,插入到数组的固定位置中!
问题在于,散列函数要满足一下几个基本点:
-1. 散列函数计算得到的散列值是一个非负整数;
-2. 如果key1 = key2,那hash(key1) == hash(key2);
-3. 如果key1 ≠ key2,那hash(key1) ≠ hash(key2)。
-4. 性能不能太差,太差影响查询和插入的时间
其中,第一点理解起来应该没有任何问题。因为数组下标是从0开始的,所以散列函数生成的散列值也要是非负整数。第二点也很好理解。 相同的key,经过散列函数得到的散列值也应该是相同的。
第三点理解起来可能会有问题,我着重说一下。这个要求看起来合情合理,但是在真实的情况下,要想找到一个不同的key对应的散列值都不一样的散列函数,几乎是不可能的。即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。 所以我们几乎无法找到一个完美的无冲突的散列函数,即便能找到,付出的时间成本、计算成本也是很大的,所以针对散列冲突问题,我们需要通过其他途径来解决
第四点也很好理解,如果hash函数太过于复杂,那么插入和查询的时候都要先经过hash函数计算数据的位置,就会影响整体的速度,所以一般都会用上位运算来加快速度
以上内容,大多数出自王争老师
其实对于会冲突非常好理解,你要把无穷尽的数字或者对象,放到有尽的数组中,根据抽屉原理,肯定会有对象hash后,值是一样的,因此这就引出了另一个话题,当值一样的时候如何处理(hash冲突如何解决)?
2、固定大小的数组
由于我们是根据hash函数获得的一个位置,然后在做插入和查找,那么存储数据的对象就需要用那种可以根据下标直接查找到值的数据类型,所以自然而然就选择了数组!
但是用固定大小的数组,就涉及到两个问题:
是否要扩容,扩容时原来的数据怎么处理?
何时扩容?
三、需要思考的问题
1、hash冲突如何解决
大体上分为两种:
(1)、开放寻址法(用人话说,就是再找另外一个位置)
开放寻址法,又有几种方式处理,比如线性探测,二次探测(Quadratic probing)和双重散列(Double hashing)
线性探测:查找到的位置如果已经被占用,直接往后+1,一直到找到下一个空位,然后插入(但是这样的话,删除的时候就不能直接删数据,而要做一个标记,否则在查找的时候会出问题)
二次探测:跟线性探测很像,线性探测每次探测的步长是1,那它探测的下标序列就是hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变 成了原来的“二次方”,也就是说,它探测的下标序列就是hash(key)+0,hash(key)+1的平方,hash(key)+2的平方……
双重散列:意思就是不仅要使用一个散列函数。我们使用一组散列函数hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存 储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。
相关链接:
开放寻址法(作者:小贾生):https://blog.youkuaiyun.com/xiaojiasheng/article/details/47208041
牛人解释哈希的开放寻址法(作者:bing.shao):https://blog.youkuaiyun.com/shaobingj126/article/details/8156675
题外话:
开放寻址法存数据的时候要把key存入数组
查找的时候,从hash得到的位置开始比对key是否相同,不同就+1,一直到遇到了空位,证明数据不存在于散列表了
删除的时候,不能直接把那个位置设置为空,否则会影响查找,需要把删除的那个位置放一个delete标识,证明这里原来是有数据的,只不过现在是空位(可以放数据进去),这样查找的时候,就不会在这个地方断掉
(2)、链表法
就是冲突了之后,在数组的位置上创建一个链表,将数据写到链表中,查询的时候,就扫描相应的链表
结构就变成了数组+链表的形式
当然这里又要考虑到如果链表太长了,那查询和插入的性能也会下降,所以hashmap,当链表长度大于8的时候(准确说是链表长度为8,put进第9个元素的时候,就会转为红黑树)
这个经常有面试官会问,啥时候链表变为红黑树,啥时候红黑树退化为链表,记住两个值,一个是8,一个是6
size为8,再插入数据就会变为红黑树,当size小于6的时候,有可能会将红黑树转成链表
主要还是要看下图的条件是否成立!
拓展文章:
jdk1.8源码解析:HashMap底层数据结构之链表转红黑树的具体时机(作者:赖皮梅):https://www.cnblogs.com/laipimei/p/11282055.html
HashMap1.8之节点删除分析(作者:编号94530):https://article.itxueyuan.com/wy5gBv
2、何时扩容(装载因子)
数据如果都能散列被存在不同位置是最好的,但是还是有可能会撞码,有可能查询会从O(1)退化成链表的O(n)
装载因子的计算公式是:
散列表的装载因子=填入表中的元素个数/散列表的长度
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降
(大多数都是0.7左右,hashmap是0.75,有的语言0.72)
就是说如果我们想存储75个元素,我们应该申请100的空间,不过hashmap会自动帮我们申请2的次方的空间
通过这段代码,我们传入100,会得到128
所以初始值(默认是16)一定是要写的,但是可以写个大概值,hashmap会自动帮你计算成2的次方
具体为什么是这样,我就不深究了,大家可以百度下hashmap的相关文章
深入理解HashMap(一)hashmap所用算法、构造函数(作者:热爱健体的程序猿):https://blog.youkuaiyun.com/weixin_41565013/article/details/93070794
3、扩容的时候,原来的数据怎么处理
大体上就是两种方法
创建一个新的存数据的空间
(1)、老数据全部重新计算新的位置,依次插入新空间中,将老空间引用置空,等待gc回收
(2)、当有新的数据put的时候,再将老空间中拿一个数据放到新空间中,以此类推一直到将老数据搬空(其实就是把一次性操作打散,平衡每次的耗时)
java的hashmap使用的是方法一,但是对于老数据到新空间的位置计算是优化过的,大家有兴趣可以看下面的文章,具体研究,这里就不展开了
相关链接:
HashMap之resize详解(作者:饥饿的java菜鸟):https://blog.youkuaiyun.com/weixin_39667787/article/details/86678215
深入理解HashMap(三)resize方法解析(作者:热爱健体的程序猿):https://blog.youkuaiyun.com/weixin_41565013/article/details/93190786
jdk8之HashMap resize方法详解(深入讲解为什么1.8中扩容后的元素新位置为原位置+原数组长度)(作者:诺浅):https://blog.youkuaiyun.com/qq32933432/article/details/86668385
一开始构思的时候是没想到这篇文章有这么长的,本来只是想讲讲散列表这个数据结构的,但java的hashmap写的太好,可以说的点又太多了,难免要多几句嘴,像大佬们致敬!