关于散列表入门级的知识,在这篇文章里面写过一些: [算法]散列表( Hash Table)
咱们今天再来详细讲一讲散列表.
散列表与数组
散列表最重要的就是,给我一个 key 值,我就能知道对应的 value 值.
在基础的数据结构里面,哪儿种数据结构能做到这样?
是不是数组?只要你给我一个数组的下标,我就能知道这个下标对应的值.
所以,散列表用的是数组支持按照下标随机访问数据的特性,散列表就是数组的一种扩展.
可以说,没有数组的话,就没有散列表.
散列函数
OK ,在了解散列表与数组关系之后,咱们来说说散列函数.
在上面我们已经知道,散列表和数组的关系,也就是散列表利用了数组访问数据时间复杂度为 O( 1 ) 的特性,但是 key 值,是如何生成 value 值的呢?
利用的就是散列函数
在构造散列函数时,有三点基本要求:
- 散列函数计算得到的散列值是一个非负整数;
- 如果 key1 = key2 ,则经过散列函数散列之后得到的值也应该相等
- 如果 key1 ≠ key2 ,那么经过散列函数散列之后得到的值也不应该相等
这三点要求应该挺容易理解的.首先数组下标是从 0 开始的,所以散列函数计算得到的散列值不能是一个非负整数;其次, key 值相等的,那么经过散列函数散列之后得到的 value 值也应该是一样的;当然,如果 key 值不相等,那么经过散列函数散列之后得到的 value 值也应该不一样.
这三点要求看起来挺少的,满足一下应该不过分吧?
不,太过分了!
你想想呀, key 值不相等,就要有一个不同的 value 值,比如 “散列表” 这可以是一个 key 值吧?那 “散,列,表” 是不是另外一个 key 值?那 “散列,表” 是不是另外一个 key 值.
发现问题了嘛?也就是 key 只要稍微不一样,哪怕是多了一个符号,或者一个字母,就是不一样的 key 值,每一个 key 都要对应一个不同的 value 值,如果要满足这个要求,需要耗费很大的空间时间成本.
所以,目前为止,还没有一个完美的散列函数,使得散列之后的 value 值不重复.
这就引出了,散列冲突.
散列冲突
既然到目前为止,还没有一个完美的散列函数,无法避免散列冲突,那咱们就曲线救国一下,遇到散列冲突了,该如何解决呢?
常用的散列冲突解决方法有两类:
- 开放寻址法
开放寻址法的核心思想就是,如果出现了散列冲突,那就重新探测一个空闲位置,插入进来.
比如很经典的线性探测法:出现了散列冲突?行嘞,那咱们就从当前位置开始,再往下瞅瞅哪儿个位置是空的呗,有空的就插进去
比如很经典的二次探测法:出现了散列冲突?好呀,这次探测的下标序列是 +0 ,接下来看看 +1² 的位置有没有空闲,还是没有?那就 +2² 继续下去,直到找到空闲位置为止. - 链表法
散列函数散列之后,存放值不再是一个空闲位置,而是一个链表.
一个空闲位置只能存放一个值,咱们不用了,改成一个链表,如果有两个值,很简单,这个链表的 next 指针指向要插入的值就好了.
散列冲突解决办法适合哪些应用场景?
散列冲突的两种主要解决办法是:开放寻址法 + 链表法.
那为什么在 Java 中 LinkedHashMap 就采用链表法来解决冲突,而 ThreadLocalMap 则是通过线性探测法来解决冲突呢?
换句话说,在什么场景下使用开放寻址法比较好,在什么场景下使用链表法比较好?这两者之间又有什么优劣呢?
- 开放寻址法
咱们先来看开放寻址法:在开放寻址法中,散列表中的数据都存储在数组中,这样就可以有效利用 CPU 缓存来加快查询速度.此外使用这种方法实现的散列表,序列化起来就比较简单.但是链表法中包含指针,序列化起来就没有那么容易.
那么,开放寻址法有什么不足呢?我觉得最重要的一点就是,冲突的代价比较高.当出现冲突时,它不像链表那样,直接修改 next 指针就可以了,而是在空闲的位置中寻找,这样就会导致最后找不到空闲位置.
所以,如果我们的数据量比较小,填入表中的元素较少时,使用开放寻址法比较合适.这也是为什么 Java 中 ThreadLocalMap 使用开放寻址法解决散列冲突的原因. - 链表法
链表法优点在于对内存的利用率比较高.因为链表节点可以在需要的时候创建,而不是在一开始就申请好.
其次,在开放寻址法中,如果有大量的散列冲突,就会导致大量的探测再散列,这样会使得性能下降,但是对于链表法来说,不会出现上述情况,只是会使链表长度变长.但即使链表变长,查找起来也还是快于开放寻址法的.
但是链表法因为要存放指针,所以是很消耗内存的.
基于以上,如果数据量较大,那么链表指针的内存消耗在很大数据量面前就可以忽略掉.
所以,如果应用场景是数据量超级大的话,此时可以考虑使用链表法来解决.
装载因子( load factor )
谈散列表的话,过不去装载因子这一关.
装载因子是散列表性能的衡量标准之一,它在衡量什么呢?
还记得散列表和数组之间的关系嘛?没有数组的话,就没有散列表.
所以散列表还是基于数组的,那么散列表能够承载的数据就是有限的,当散列表存储的数据超过数组索引的长度时,一定会出现散列冲突.
装载因子计算公式:装载因子 = 填入表中的元素 / 散列表的长度
从公式中我们能够看出,装载因子越大,说明空闲位置越少,冲突越多,就会导致散列表的性能下降.
通过读 HashMap 源码我们能够知道,最大装载因子默认是 0.75 ,也就是当 HashMap 中元素个数超过 0.75*capacity ( capacity 表示散列表的容量)时,就会启动扩容.
至于为什么要选 0.75 这个数值,我们可以在注释中找到答案:
Ideally, under random hashCodes, the frequency of nodes in bins follows a
Poisson distribution (http://en.wikipedia.org/wiki/Poisson_distribution)
with a parameter of about 0.5 on average for the default resizing
threshold of 0.75
扯到了泊松分布,这种事情我不是很擅长,就交给维基百科吧: https://en.wikipedia.org/wiki/Poisson_distribution
散列冲突再优化
既然讲到了 HashMap ,那就讲讲 HashMap 对散列冲突做的优化.
HashMap 底层采用链表法来解决冲突.不管负载因子和散列函数设计的多么合理,在现实中,总是很容易被摁在地上摩擦,也就避免不了出现链表过长的情况,而在链表过长的情况下,会严重影响 HashMap 的性能.
所以在 JDK 1.8 版本中,引入了红黑树,目的就是为了对 HashMap 做进一步的优化.
当链表长度太长(默认超过 8 )时,链表就转换为红黑树.此时我们可以利用红黑树快速增删改查的特点,提高 HashMap 的性能.当红黑树节点个数少于 8 个的时候,又会将红黑树转化为链表.
因为在数据量较小的情况下,红黑树需要维护平衡,相比于链表,性能上的优势不是很明显.
散列表扩容
在装载因子那里说了,也就是当 HashMap 中元素个数超过 0.75*capacity ( capacity 表示散列表的容量)时,就会启动扩容.
扩容可不是简单的把散列表的长度扩大就没有了.它需要经历下面这两个步骤:
- 扩容,创建一个新的散列表,长度是原来的 2 倍
- 重新 Hash .将原来的数据重新遍历,通过散列函数重新计算每个数据的存储位置
为了避免低效扩容,我们可以选择一部分一部分散列,不需要在一个时刻全部散列,当然了这个也是看你的数据量大小,如果很小,一下子都扩容完毕没有任何问题,完全 OK .
接下来就不详细讲了,读者可以去看看 HashMap 的源码,耐心读一读相信你会有收获.
- 参考:
极客时间 — <数据结构与算法之美>
<漫画算法:小灰的算法之旅>
以上,就是想要分享的内容了
感谢您的阅读哇~