好的散列表特性
1、支持快速查询、插入、删除操作
2、内存占用合理,不能浪费过多时间
3、性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况。
如何实现这样一个好的散列表呢
1、设计一个合适的散列函数
2、定义装载因子,并且设置动态扩容策略
3、选择合适的散列冲突解决方法。
如何设计散列函数
1、散列函数设计不能太复杂
过于复杂的散列函数,势必会消耗太多计算时间,也就间接影像散列表性能。
例如在实现word单词拼写检查中,这里的散列函数可以设置为:将单词中的每个字母的ascii编码进位相加,然后再跟散列表的大小求余、取模作为散列值,例如英文单词nice,转化出来的散列值就是这样的:
hash("nice")=(("n" - "a") * 26*26*26 + ("i" - "a")*26*26 + ("c" - "a")*26+ ("e"-"a")) / 78978
2、散列函数生成的值要尽可能的随机且均匀
这样才能避免或者最小化散列冲突,即使出现冲突,散列到每个槽位的数据也会平均,才不会出现某个槽内数据特别多的情况。
装载因子过大怎么办
装载因子越大,说明散列表元素越多,空闲位置越少,散列冲突发生的概率越大,不仅插入数据的过程要多次寻址或者拉很长的链,查的过程也会很慢。
对于静态数据,没有频繁的插入和删除操作,可以很容易根据数据的特点特性、分布从而设计出合适的散列函数。
但是对于动态数据,数据的频繁变动的,动态散列表中的装载因子也会逐渐变多,大到某种程度后散列冲突就变得不可接受。
这时候需要进行动态扩容,例如原来的装载因子是0.8,动态扩容后散列表空间变成原来两倍后,散列因子就变成了0.4,下降了一半。
但是散列表动态扩容在数据迁移操作会比数组数据迁移难,因为散列表的大小变了,导致通过hash计算的存储位置也变了,需要重新通过hash散列函数重新计算每个数据的存储位置。
例如:
对于插入数据:最好情况是不需要扩容,时间复杂度是O(1),最坏情况是装载因子过高,然后申请扩容,时间复杂度是O(n)。
对于删除数据:如果对空间敏感,可以在装载因子小于某个阈值时对散列表进行动态缩容。如果在意效率,能容忍多消耗一点内存空间,就不需要缩容。
高效扩容:
正常的扩容很耗时,可以通过这种方法实现高效扩容,将扩容操作穿插在插入操作的过程中完成:
1、当装载因子到达阈值后,只申请新的内存空间,但是不迁移数据。
2、当有新数据插入时,将新数据插入到新散列表中,同时将一条旧数据从旧散列表取出,存放到新散列表中。
3、通过多次以上插入数据的操作,所有的旧散列表的数据就全部一点一点迁移到了新散列表,这样效率就提高了很多。
如何解决散列冲突方法:
开放寻址法
优点:
1、散列表存储的数据都在数组中,cpu读取数据是将内存中数据一块一块读取到cpu缓存的,因为数据的连续性,所以读取的每一块都有我们想要的数据,下一次读数据的时候就直接从cpu缓存读取数据,降低了读内存的时间消耗。
2、因为数组的连续性,这样的散列表序列化起来比较简单。
缺点:
1、删除数据的时候需要特殊标记已经删除的数据,且所有数据存储在数组中,冲突的代价更高。所以导致开放寻址法的散列表上限不能太大,同时也比链表法更浪费空间。
总结:
当数据量小的时候、装载因子小的时候,适合用开放寻址法。例如java的ThreadLocalMap
链表法
优点:
1、对内存的利用率比开放寻址法高,不需要像开放寻址法那也提前申请。
2、对大装载因子的容忍度更高,寻址法只适用装载因子小于1的情况,但是链表法的装载因子是可以大于1的,即使装载因子变成10,但是只要散列函数的散列值均匀分布,也只是链表变长,虽然查找效率变低,但是比顺序查找还是快。
缺点:
1、执行效率比寻址法低。因为链表是零散分布的,对cpu缓存不友好,导致执行效率偏低。
2、因为要存储指针,所以对小的对象的存储比较耗费内存,当然如果是大对象,对象大小远远大于指针的大小,那么指针在内存的消耗就可以忽略了。
总结
基于链表的散列冲突解决方法适合用存储大对象、大数据量的散列表,且对比寻址法更灵活,支持更多的优化策略,比如用红黑树代替链表之类。
举例:HashMap
1、hashmap初始大小是16,当然这个默认值可以设置,如果预先直到数据量的大概数据量大小,可以更改初始大小,减少动态扩容次数,大大提高hashmap性能。
2、装载因子和动态扩容:最大装载因子是0.75,当hashmap的元素个数超过了0.75*capacity(capacity 表示散列表的容量),就会动态扩容。
3、散列方法:hashmap底层采用了链表法解决散列冲突,但是为了避免拉链过长,严重影响hashmap性能,jdk1.8的适合引用了红黑树,当链表长度超过了8时,链表就转为红黑树,利用红黑树快速增删改查的特点提高hashmap的性能,当链表长度小于6时,又降为链表,因为小数据量时,红黑树的优势不比链表大。
4、散列函数:hashmap的散列函数追求的是简单、均匀
int hash(Object key) {
int h = key.hashCode();
return (h ^ (h >>> 16)) & (capicity -1); //capicity表示散列表的大小
}
其中hashcode()返回的是java对象的hashcode(),比如string类型的hashcode()是:
public int hashCode() {
int var1 = this.hash;
if(var1 == 0 && this.value.length > 0) {
char[] var2 = this.value;
for(int var3 = 0; var3 < this.value.length; ++var3) {
var1 = 31 * var1 + var2[var3];
}
this.hash = var1;
}
return var1;
}