散列表

散列表用的是数组支持按照下标随机访问数据的特征,所以散列表其实就是数组一种扩展。
通过散列函数把元素的键值映射为下标。

散列函数

散列函数设计的基本要求:

  • 1.散列函数计算得到的散列值是一个非负数
  • 2.如果key1 = key2,那hash(key1) == hash(key2)
  • 3.如果key1 != key2,那hash(key1) != hash(key2)
    第三点在真实的情况下,要想找到一个不同的 key 对应的散列值都不一样的散列函数,几乎是不可能的。即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。
散列冲突

1.开放寻址法:如果出现散列冲突,重新探测一个空闲位置,将其插入。

  • 性性探测:如果某个数据经过散列函数散列之后,存储位置已经被占用了,就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。缺点:当散列表中插入的数据越来越多时,探测的最坏时间复杂度是O(n)。
    在这里插入图片描述
  • 二次探测:探测的步长变为2
  • 双重散列:先用一个散列函数,计算得到存储位置已经被占用,再用第二个散列函数找到空闲的存储位置。

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

  • 散列表的装载因子=填入表中的元素个数/散列表的长度
  • 装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。

2.链表法
链表法是一种更加常用的散列冲突解决办法,相对简单,在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。
在这里插入图片描述

  • 时间复杂度:插入O(1),查找和删除的时间复杂度和链表长度k成正比,也就是O(k),理论上k = n/m,n表示散列表中数据个数,m表示散列表中“槽”的个数。
问题场景

1.word文档中单词拼写检查功能如何实现?

  • 常用的英文单词20万个左右,假设单词平均长度为10个字母,平均一个单词占用10个字节的内存空间,20万英文单词就占2MB,就算放大十倍20MB对于现在的计算机来说完全可以存储,所以用散列表来存储整个英文单词词典
  • 当用户输入某个英文单词,我们拿用户输入的单词去散列表中查找

2.有 10 万条 URL 访问日志,如何按照访问次数给 URL 排序?

  • 遍历10w条数据,url映射成key,访问次数为value,存入散列表,同时记录访问次数的最大值k,时间复杂度为O(N)。
    3.有两个字符串数组,每个数组大约有 10 万条字符串,如何快速找出两个数组中相同的字符串?
  • 以第一个字符数组构建散列表,key为字符串,value为出现次数,遍历第二个字符串key在散列表中查找,如果value为大于0,说明存在相同字符串,时间复杂度O(n)。

打造一个工业级的散列表

抵抗散列表碰撞攻击原理:数据经过散列函数之后,都散列到同一个槽里,如果使用给予链表,查询时间复杂度从O(1)退化成O(n),消耗大量CPU或者线程资源,导致系统无法响应其他请求,从而达到拒绝服务式攻击DoS的目的。
工业级的散列表特性:

  • 支持快速查询、查询、插入、删除操作;
  • 内存占用合理
  • 性能稳定,极端条件下不会退化成无法接受的情况
    如何设计:
  • 设计一个合适散列函数
  • 定义装载因子阈值,并且设计动态扩容策略
  • 选择合适的散列冲突解决方法
设计原则
  • 散列函数不能太复杂
  • 散列函数生成值要尽可能均匀分布
    例子:学生运动会
  • 把编号中后两位作为散列值。
    例子2:手机号码
  • 因为手机号码重复可能性不大,但后几位就比较随机,可以取手机号后四位作为散列值,“数据分析法”
    例子3:word拼写检查功能
  • 将每个字母的ascll码“进位”相加,然后再跟散列列表大小取余、取模,作为散列值。

散列函数的设计方法还有很多,比如直接寻址法、平方取中法、折叠法、随机数法等。

装载因子过大怎么办?

当装载因子过大,空闲位置就会少,散列冲突概率大,不仅插入数据多次寻址或者拉很长的链,查找也很慢。

  • 方法:动态扩容,重新申请一个更大的散列表,将数据搬移到新的散列表中,每次扩容申请比之前两倍的空间。假如之前装载因子0.8,那么扩容之前就变为0.4
  • 动态扩容,因为散列表的大小变了,数据存储位置变了,所以需要通过散列函数重新计算每个数据的存储位置。
    在原来的散列表中,21 这个元素原来存储在下标为 0 的位置,搬移到新的散列表中,存储在下标为 7 的位置。
    在这里插入图片描述
  • 插入一个数据,最好情况下,不需要扩容,最好时间复杂度是 O(1)。最坏情况下,散列表装载因子过高,启动扩容,我们需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度是 O(n)。用摊还分析法,均摊情况下,时间复杂度接近最好情况,就是 O(1)。
  • 对于动态散列表,随着数据的删除,散列表中的数据会越来越少,空闲空间会越来越多,可以动态缩容。
如何避免低效地扩容?
  • 解决一次性扩容耗时过多的情况,可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。
  • 当有新数据要插入时,将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了,插入一个数据的时间复杂度都是 O(1)
    在这里插入图片描述
  • 对于查询操作,先查询新散列表
如何选择冲突解决方法?

1.1. 开放寻址法

  • 优点:不像链表法,需要拉很多链表,散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度,序列化起来比较简单。
  • 缺点:解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。

所以当数据量比较小、装载因子小的时候,适合采用开放寻址法。
2.链表法

  • 优点:内存的利用率比较高,链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。对大装载因子的容忍度更高,开放寻址法只能适用装载因子小于 1 的情况。
  • 缺点:链表需要存储指针,所以比较消耗内存,而且是不连续存储,对CPU缓存不友好。如果存储的是大对象,也就是说要存储的对象的大小远远大于一个指针的大小(4 个字节或者 8 个字节),那链表中指针的内存消耗在大对象面前就可以忽略了。

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

工业级散列表举例分析

java中的HashMap
1.初始大小

  • HashMap的默认大小是16,可以修改默认大小来减少动态扩容的次数,提高HashMap性能。
    装载因子和动态扩容
  • 最大装载因子默认是0.75,当HashMap中元素个数超过0.75*capacity(capacity表示散列表的容量)的时候,就会启动扩容,扩2倍。
    散列冲突解决办法
  • HashMap底层采用链表来解决冲突,即使负载因子和散列函数设计得再合理,也避免不了拉链过长的情况,拉链过长会影响HashMap性能。
  • JDK1.8,引入红黑树优化,当链表长度超过8时,链表就会转化成红黑树,小于8就会自动转换成链表,因为当数据量少时,红黑树要维护平衡,比起链表优势不明显。
    散列函数
int hash(Object key) {
    int h = key.hashCode()return (h ^ (h >>> 16)) & (capitity -1); //capicity表示散列表的大小
}
  • 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;
}
实际问题
  • 哪些数据类型底层是基于散列表实现的?散列函数是如何设计的?散列冲突是通过哪种方法解决的?是否支持动态扩容呢?

为什么散列表和列表经常在一起使用

  • 如果希望按照顺序遍历散列表中的数据,那我们需要将散列表中的数据拷贝到数组中,然后排序,再遍历。
  • 如果希望按顺序遍历散列表中的数据的时候,都需要先排序,那效率势必会很低。为了解决这个问题,我们将散列表和链表(或者跳表)结合在一起使用。

LRU缓冲淘汰算法

  • 借助散列表可以把时间复杂度降低为O(1)
    具体实现
  • 当缓冲大小有限,空间不够,需要淘汰一个数据时,直接将链表头部的结点删除
  • 当要缓存某个数据时,先在链表中查找这个数据,如果未找到,直接将数据放到链表尾部,如果找到了,移动到链表尾部,因为查找数据要遍历链表,所以单纯用链表实现的LRU缓冲淘汰算法时间复杂度O(n)。
    一个缓冲(cache)系统主要包含的操作:
  • 缓存中添加,删除,查找一个数据

这三个操作用散列表时间复杂度O(n)将为O(1),具体实现
在这里插入图片描述

解决冲突,前驱和后继指针为了将结点在双向链表中,hnext指针为了将结点串在散列表的拉链中

  • 如何添加一个数据。需要先看这个数据是否已经在缓存中。如果已经在其中,需要将其移动到双向链表的尾部;如果不在其中,还要看缓存有没有满。如果满了,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;如果没有满,就直接将数据放到链表的尾部。
Redis有序集合

有序结合中,每个成员对象有2个属性:key(键值)和score(分值),通过 score 和key来查找数据
例子:用户积分排行榜有一个功能:可以通过用户的 ID 来查找积分信息,也可以通过积分区间来查找用户 ID 或者姓名信息。这里包含 ID、姓名和积分的用户信息,就是成员对象,用户 ID 就是 key,积分就是 score。
细化Redis 有序集合的操作:

  • 添加一个成员对象
  • 按照键值来删除 或 查找一个成员对象
  • 按照分值区间查找数据,比如查找积分[100,356]之间成员对象
  • 按分值从大到小排序成员变量

方法:按照分值和键值将成员对象组织成跳表的结构

LinkedHashMap 是通过双向链表和散列表这两种数据结构组合实现的。LinkedHashMap 中的“Linked”实际上是指的是双向链表,并非指用链表法解决散列冲突。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值