对Hash的一些总结

哈希地址的计算方法

除留余数法

若已知整个哈希表的最大长度 m,可以取一个不大于 m 的数 p,然后对该关键字 key 做取余运算,
即:

H(key)= key % p

在此方法中,对于 p 的取值非常重要,由经验得知 p 应该为<=m 的最大质数,因为质数可保证冲突的可能性低一些

直接定址法

H(key)=a * key + b

缺点:hash冲突严重

数字分析法

如果关键字由多位字符或者数字组成,就可以考虑抽取其中某几位作为该关键字对应的哈希地址,在取法上尽量选择变化较多的位,避免冲突发生

平方取中法

对关键字做平方操作,取中间得几位作为哈希地址。

例如关键字序列为{421,423,436},对各个关键字进行平方后的结果为{177241,178929,190096},则可以取中间的两位{72,89,00}作为其哈希地址。

随机数法

取关键字的一个随机函数值作为它的哈希地址,即:H(key)=random(key),此方法适用于关键字长度不等的情况。

注意:这里的随机函数其实是伪随机函数,随机函数是即使每次给定的 key 相同,但是 H(key)都是不同;而伪随机函数正好相反,每个 key 都对应的是固定的 H(key)。

处理冲突的方法

对于无法避免的冲突,需要采取适当的措施去处理。

通常用的处理冲突的方法有以下几种:

开放定址法

核心思想:如果出现散列冲突,就重新探测一个空闲位置,将其插入。

形如: H(key)=(H(key)+ d)MOD m(其中 m 为哈希表的表长,d 为一个增量)
主要有两种方式:

  • 线性探测法:d=1,2,3,…,m-1
    线性探测法其实存在很大问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。
    极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为 O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。

  • 二次探测法:d=1^2 ,- 1 ^ 2,2 ^2,-2 ^2,3 ^2,…
    比线性探测冲突的可能性小很多

链地址法

核心思想:将所有产生冲突的关键字所对应的数据全部存储在同一个线性链表中.

插入数据:O(1)
当插入的时候,我们需要通过散列函数计算出对应的散列槽位,将其插入到对应的链表中即可,所以插入的时间复杂度为O(1)

查找或删除数据:O(k)
当查找、删除一个元素时,通过散列函数计算对应的槽,然后遍历链表找到数据。对于散列比较均匀的散列函数,链表的节点个数k=n/m(其中 n 表示散列表中数据的个数,m 表示散列表中槽的个数)

再哈希法

当产生冲突时,使用另一个哈希函数计算,直到冲突不再发生。

开放寻址法 vs 链表法

可以从 内存利用率、装载因子、使用场景、cpu缓存、序列化等方面进行比较

内存空间利用率链表法对内存的利用率比开放寻址法要高

  • 链表结点可以在需要的时候再创建,而开放寻址法必须事先申请好数组空间。

  • 在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。

  • 链表因为要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。当然,如果我们存储的是大对象,也就是说要存储的对象的大小远远大于一个指针的大小(4 个字节或者 8 个字节),那链表中指针的内存消耗在大对象面前就可以忽略了。

装载因子
链表法比起开放寻址法,对大装载因子的容忍度更高。

  • 开放寻址法只能适用装载因子小于 1 的情况。接近 1 时,就可能会有大量的散列冲突,导致大量的探测、再散列等,性能会下降很多。

  • 但是对于链表法来说,只要散列函数的值随机均匀,即便装载因子变成 10,也就是链表的长度变长了而已,虽然查找效率有所下降,但是比起顺序查找还是快很多。

适用场景

  • 当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。
  • 当数据量比较大、装载因子大、存储大对象时,适合链表法。

灵活程度
比起开放寻址法,链表法更加灵活,因为链表法支持更多的优化策略,
比如极端情况下,所有的数据都散列到同一个桶内,可以退化为红黑树,查找时间又能优化为 O(logn)。

CPU 缓存

  • 开放寻址法的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。
  • 链表中的结点是零散分布在内存中的,不是连续的,所以对 CPU 缓存是不友好的

序列化
开放寻址法基于数组,序列化起来比较简单。链表法包含指针,序列化起来就没那么容易。

增删改查复杂度

  • 开放寻址法
    插入,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。O(n)(n为数组长度)
    但是查找只需O(1)
  • 链表法
    插入数据O(1) 查找删除O(k)(k为链表平均长度)

如何设计一个散列表

一个好的散列表应该具有哪些特性?
支持快速的增删改查操作;
内存占用合理,不能浪费过多空间;
性能稳定,在极端情况下,散列表的性能也不会退化到无法接受的情况。

设计思路:

  • 设计一个高效的哈希地址计算函数即散列函数,要尽可能让散列后的值随机且均匀分布
  • 选择合适的散列冲突解决方法。
  • 定义装载因子阈值
  • 设计合适的动态扩容策略;

装载因子的阈值设置需要权衡时间复杂度和空间复杂度。
如何权衡?

  • 如果内存空间不紧张,对执行效率要求很高,可以降低装载因子的阈值;
  • 相反,如果内存空间紧张,对执行效率要求又不高,可以增加装载因子的阈值。

动态扩容
如何避免一次性扩容耗时过多的情况?分批扩容

为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成
当装载因子触达阈值之后,申请新空间后并不立刻转移数据。
当有新数据要插入时,我们将数据插入新的散列表,并且从老的散列表中拿出一个数据放入新散列表。每次插入都重复上面的过程。

通过这样均摊的方法,将一次性扩容的代价,均摊到多次插入操作中

分批扩容期间的查询操作:先查新散列表,没有再查老散列表。

一致性hash

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值