散列表是什么?

声明

本文章是学习王争老师在极客时间专栏——《数据结构与算法之美》的学习总结,文章很多内容直接引用了专栏下的回复,推荐大家购买王争老师的专栏进行更加详细的学习。本文仅供学习使用,勿作他用,如侵犯权益,请联系我,立即删除

定义

散列表使用的是数组支持按照下标随机访问数据的特性,因此它的查找时间复杂度为O(1)。
我们通过散列函数将Key映射为数组下标,然后将数据存储在数组中对应下标的位置上。当我们按照Key查找元素时,使用相同的散列函数将key映射为数组下标,从数组下标位置上取出数据。

散列函数

构造散列函数时,需满足以下条件:

  • 散列函数得到hash值必须是一个非负整数;
  • 如果key1 = key2,则hash(key1) = hash(key2);
  • 如果key1 != key2, 则hash(key1) != hash(key2).
    但是,在真实的情况下,要想找到一个不同的key对应的散列值都不一样的散列函数,几乎是不可能的。即时MD5,SHA,CRC等hash函数,也无法完全避免散列冲突

解决散列冲突

开放寻址法
  1. 线性探测:遍历散列表时,一步一步走,直到有空闲存储位置便停下来,将数据插入。缺点,若散列表插满时,时间复杂度为O(n)。优点:精确度高
  2. 二次探测:与线性探测不同的地方只是把步数换成了2^n。缺点:精确度低。
    双重散列:使用一组散列函数,没当一个散列函数发生冲突时,便使用组中的下一个散列函数,直到找到空闲位置。
  3. 双重散列:使用一组散列函数,没当一个散列函数发生冲突时,便使用组中的下一个散列函数,直到找到空闲位置。

不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子(load factor)来表示空位的多少。

散列表的装载因子 = 填入表中的元素个数/散列表的长度

使用场景:当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。

链表法

链表寻址法:当散列函数发生冲突时,将数据封装成节点插入链表,作为尾节点。插入的时间复杂度为O(1),删除和查找的时间复杂度与链表的长度有关.

当散列表使用数组+链表实现时,若数据经过散列函数全部放进一个槽里面,那么散列表就退化成了链表,这时它的查询复杂度就为O(n)。

如何避免在散列冲突的情况下,散列表性能的急剧下降?如何避免散列表退化成链表?

  • 支持动态扩容的散列表:在装载因子超过指定的具体阀值时,就需要动态扩容。
  • 动态扩容:散列表的动态扩容不仅包含数据的搬移,而且还会使用散列函数生成新的key,时间复杂度大于O(n)。
    1. 一次性扩容:当装载因子达到阀值时,申请当前散列表*2的散列表,将数据全部进行散列函数并搬移到新的散列表中。时间复杂度大于O(n);
    2. 均摊扩容:当装载因子达到阀值时,只申请两倍空间的新散列表,当再次向旧的散列表插入数据时,做两步操作:1.将插入的数据经过散列函数插入新的散列表;2. 从旧的散列表中读取一个数据插入到新的散列表中。时间复杂度一直都是O(1)。
      动态扩容的阀值:threod = factor*capacity; 即 阈值=装载因子*散列表长度

使用场景:基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。

散列表的经典案例: HashMap

HashMap:一个Node<K,V<[] table的映射。K与V可以是任意类型。

初始大小:默认的散列长度(capcity)为16,可以动态指定散列长度。若已知数据长度,可以通过指定散列表长度的方式,减少动态扩容,提高散列表的效率。

装载因子:默认的最大装载因子是0.75,因此hash Map的动态扩容阀值F=0.75*capcity。

散列冲突解决方案:采用链表法。优点是使用链表可以有效的减少动态扩容,因为发生冲突时,不需要在散列表中查找空闲位置并插入,增长装载因子。但是,当出现散列碰撞(所有的数据通过散列函数得到的key都相同)散列表退化为链表时,查找,删除的时间复杂度降为O(n)。因此,在jdk8以后,当同一槽中的链表长度大于8时,将链表改为红黑树。这样,即使发生散列碰撞,查找删除的时间复杂度也可以保持O(nlgn)。

散列函数:原则是散列函数必须简单,均匀分布。

Hash Map的散列函数:

// 干货:存在A与B,当B为2的次幂时,A%B=A&(B—1)。
int hash(Object key) {
int h = key.hashCode();
return (h ^ (h <<< 16)) & (capicity -1); //capicity 表示散列表的大小
}

从LRU缓存淘汰算法引起的思考?

  • 使用链表
    我们需要维护一个按照访问时间从大到小有序排列的链表结构。因为缓存大小有限,当缓存空间不够,需要淘汰一个数据的时候,我们就直接将链表头部的结点删除。
    当要缓存某个数据的时候,先在链表中查找这个数据。如果没有找到,则直接将数据放到链表的尾部;如果找到了,我们就把它移动到链表的尾部。因为查找数据需要遍历链表,所以单纯用链表实现的 LRU 缓存淘汰算法的时间复杂很高,是 O(n)。

  • 使用散列表+双向链表
    我们使用双向链表存储数据

    class Node{
        T data;
        Node prev;
        Node next;
        Node hnext; // 作用是什么?
    }
    

    由于我们的散列表使用链表法解决散列冲突,所以每个节点会在两条链中。一条链是双向链表,另一条链式散列表中的链表。prev和next是为了将节点串在双向链表中,hnext指针是为了将节点串在散列表的链中。
    时间复杂度:查找数据为O(1),删除数据为O(1),添加数据为O(1)。

总结:

散列表这种数据结构虽然支持非常高效的数据插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的。也就说,它无法支持按照某种顺序快速地遍历数据。如果希望按照顺序遍历散列表中的数据,那我们需要将散列表中的数据拷贝到数组中,然后排序,再遍历。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值