哈希表的查询效率并不能笼统地认为是时间复杂度O(1),因为他与哈希函数,装载因子和哈希冲突等都有关系。如果哈希函数设计地不好,或者装载因子过高,都有可能导致哈希冲突发生地概率shengg2,查询效率下降。
在有的情况下,如果数据经过哈希函数之后,全部哈希到一个槽中,如果我们使用的是基于链表的冲突解决办法,那么哈希表就会退化为链表,查询的时间复杂度从O(1)退化到O(n)。
设计哈希函数
哈希函数设计的好坏,决定了哈希表冲突概率的大小,也直接决定了哈希表的性能。要想设计一个好的哈希函数,首先,哈希函数的设计不能太复杂。过于复杂的哈希函数,一定会消耗太多计算时间,也就间接的影响哈希表的性能,然后哈希函数生成的值要尽可能地均匀分布,这样才能避免或者最小化哈希冲突。
在实际地开发中,要考虑地元素还有很多,比如关键字地长度,特点,分布以及哈希表地大小等。
解决装载因子过大的问题
之前说过,如果装载因子过大,说明哈希表中的元素较多,空闲位置就较少,哈希冲突的概率就会增大,插入,删除,查找的效率也会降低很多。
对于没有频繁进行插入,删除操作的静态数据集合,可以很好的设计出一个哈希函数。但是如果是频繁进行插入,删除操作的动态数据集合,因为无法事先预估将要加入的数据个数,因此也不能申请一个空间很大的哈希表。随着数据插入的越来越多,装载因子也会越来越大。此时随之而来的问题就是哈希冲突的几率增高,效率降低。
此时我们就可以使用动态扩容技术。
对于哈希表,当装载因子过大的时候,我们可以采用动态扩容技术,重新申请一个更大的哈希表,将原本哈希表中的数据搬移到新的哈希表中。假设每次扩容申请的哈希表是原哈希表的两倍,那么装载因子就会从原本的0.8降低到0.4。
但是随之而来的确实位置的问题,因为哈希表的搬移不像之前的数组一样。因为哈希表变了,数据的存储位置也会发生了改变,所以需要用哈希函数重新计算每个元素应该存储在的位置。如图。
在大部分情况下,插入的数据不会触发扩容,因此,插入操作最好的时间复杂度是O(1)。如果装载因子过高,事先触发了阀值,就会触发扩容机制,将数据搬移到新的哈希表中,因此,最坏的时间复杂度为O(n)。
避免低效的扩容
对于支持动态的哈希表,在大部分情况,插入数据的速度很快,但是在特殊的情况下,如果装载因子已经达到阀值,那么在插入的时候就会先进行扩容,导致插入的数据就会非常慢。
如果我们的项目的代码直接服务于用户,对响应时间的要求比较高,尽管大部分情况下,插入数据的速度很快,但是个别时间速度很慢也不行。所以这种扩容机制就不太适合。那有没有一种解决方法呢?
其实我们可以这样,当插入的数据达到阀值时,我们先扩容一个新的哈希表,但是不马上进行数据的搬移,而是将扩容操作穿插在每一次插入的过程中,分批完成数据的搬移。
当有新的数据需要插入时,除了将新的数据插入到哈希表中,还会从原哈希表中搬移一个数据到新的哈希表中。经过多次插入操作之后原哈希表中的数据就一点点搬移到新的哈希表中了。没有了集中一次性大量数据的操作 。
基于这种扩容方式,在任何情况下,插入数据的时间复杂度都是O(1)。
选择合适的冲突解决方法
在介绍哈希冲突的时候,介绍了两种关于解决哈希冲突的方法,一种是开放寻址法,一种是链表法。在软件开放中,java的LinkedHashMap采用链表法解决冲突,ThreadLocalMap基于线性探测的开放寻址法解决冲突。下面来看看他们之间的优劣还有适用场景。
开放寻址法
优点
对于基于开放寻址法解决冲突的哈希表,数据存储在数组中,可以有效利用CPU缓存加快查询速度。相对于链表解决哈希冲突,基于开放寻址法解决哈希冲突不涉及链表和指针,方便序列化。
缺点
之前说过,基于开放寻址法解决冲突的哈希表,删除数据的操作比较麻烦,需要特殊标记需要删除的元素。而且,在开放寻址法中,所有的数据都存储在一个数组中,比起链表法,发生冲突的概率更高。因此,基于开放寻址法的装载因子不能太高,必须小于1,相比于链表法,装载因子却是可以大于1,所以储存相同大小的数据是,开放寻址法所需要的空间会更多。
适用场景
数据量比较小,装载因子比较小的时候。
链表法
基于链表法解决哈希冲突,数据存储在链表上。链表节点可以在用到时再创建,而数组必须实现创建好。因此链表法对内存的利用率还是比较高的.
还有一点是链表法的装载因子确是可以大于1,就算是装载因子达到了10,性能也不会下降很多。
还有就是,链表的结点需要存储next指针,这样就会消耗额外的 内存空间,对于小对象的存储(大对象除外),有可能会让内存的消耗翻倍。而且,链表中的结点再内存中是零散分布得,对CPU缓存并不会很友好,会造成哈希表得性能下降。
在java中,如果链表得长度过长,可以转换为红黑树来进行存储。
工业级的哈希表分析
下面我们来看看,工业级java hashMap是如何用到这些关键技术的。
1.初始大小
HashMap初始大小为16,这个默认值是可以修改的,如果我们实现知道数据量,就可以修改默认值,减少数据的动态扩容,进而提升效率。
2.装载因子和动态扩容
HashMap最大装载因子是0.75,当HashMap中的元素超过0.75*capacity(哈希表的容量)时,就会自动触发扩容,扩大到原来的两倍。
3.哈希冲突的解决办法
HashMap采用链表法解决哈希冲突,在JDK1.8,HashMap做了一个优化,引入了红黑树,当链表的长度过长时(大于或等于8),链表就会转换为红黑树。当红黑树的结点小于或等于6的时候,又会退化为链表。
4.哈希函数
1 int hash(Object key) {
2 int h = key.hashCode();
3 return (h ^ (h >>> 16)) & (capitity -1); //capicity 表示散列表的大小
4 }
1 public int hashCode() {
2 int var1 = this.hash;
3 if(var1 == 0 && this.value.length > 0) {
4 char[] var2 = this.value;
5 for(int var3 = 0; var3 < this.value.length; ++var3) {
6 var1 = 31 * var1 + var2[var3];
7 }
8 this.hash = var1;
9 }
10 return var1;
11 }