散列表(中)
设计散列函数
如何设计散列函数?
散列函数的设计不能太复杂,且散列函数生成的值要尽可能随机并且均匀分布。
常用方法:
数据分析法、直接寻址法、平方取中法、折叠法、随机数法等
装载因子过大了怎么办?
对装载因子设置阈值,来对散列表进行动态扩容与动态缩容。
装载因子阈值的设置要权衡时间、空间复杂度。如果内存空间不紧张,对执行效率要求很高,可以降低负载因子的阈值;相反,如果内存空间紧张,对执行效率要求又不高,可以增加负载因子的值,甚至可以大于 1。
如何避免低效的扩容?
为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。
如何选择冲突解决方法?
开放寻址法
优点:
- 散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。
- 这种方法实现的散列表,序列化起来比较简单。
缺点:
- 删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。
- 所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。
总结
当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。
链表法(拉链法)
优点:
- 内存的利用率比开放寻址法要高
- 对大装载因子的容忍度更高
缺点:
- 因为要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。
- 因为链表中的结点是零散分布在内存中的,不是连续的,所以对 CPU 缓存是不友好的,这方面对于执行效率也有一定的影响。
总结
基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。
改造
我们对链表法稍加改造,可以实现一个更加高效的散列表。那就是,我们将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是 O(logn)。这样也就有效避免了前面讲到的散列碰撞攻击。
工业级散列表举例分析
Java 中的 HashMap
初始大小
HashMap 默认的初始大小是 16,当然这个默认值是可以设置的。
装载因子和动态扩容
最大装载因子默认是 0.75,当 HashMap 中元素个数超过 0.75*capacity(capacity 表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。
散列冲突解决方法
HashMap 底层采用链表法来解决冲突。即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响 HashMap 的性能。
于是,在 JDK1.8 版本中,为了对 HashMap 做进一步优化,我们引入了红黑树。而当链表长度太长(默认超过 8)时,链表就转换为红黑树。我们可以利用红黑树快速增删改查的特点,提高 HashMap 的性能。当红黑树结点个数少于 8 个的时候,又会将红黑树转化为链表。因为在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。
散列函数
1 |
|
其中,hashCode() 返回的是 Java 对象的 hash code。比如 String 类型的对象的 hashCode() 就是下面这样:
1 |
|
在你熟悉的编程语言中,哪些数据类型底层是基于散列表实现的?散列函数是如何设计的?散列冲突是通过哪种方法解决的?是否支持动态扩容呢?
比如Redis中的hash,set,hset,都是散列表实现,他们的动态扩容策略是同时维护两个散列表,然后一点点搬移数据
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!