学习笔记|数据结构——散列表

学习笔记|数据结构——散列表

散列表
利用数组支持下表随机访问数据,时间复杂度为O(n)的特性,对数组进行扩展
思想:通过散列函数将元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。按照键值查询元素时,用同样的散列函数,将键值转化为数组下标,从对应的数组下标的位置取数据
举例:存在90名选手,想通过编号快速找到对应选手信息。利用散列思想,将参赛选手的编号当作键(key)或者关键字。用来标识一个选手。==把参赛编号转化为数组下标的映射方法叫做散列函数(哈希函数),散列函数计算得到的值叫做散列值(哈希值)。==将哈希值当作数组下标,对选手信息进行储存。
散列表示意图
散列函数
顾名思义,散列函数为一个函数,定义为hash(key),其中key标识元素的键值,hash(key)的值表示经过散列函数计算得到的散列值
散列函数设计基本要求:
1、散列函数计算得到的散列值是一个非负整数
因为数组下标是从0开始的,所以散列函数生成的散列值也是非负整数
2、如果键值相同,那哈希值也应该相同
3、如果键值不相同,那哈希值应该不同

几乎无法找到一个完美的无冲突的散列函数
解决散列冲突的方法
1、开放寻址法
线性探测
如果出现了散列冲突,就重新探测一个空闲位置,将其插入。
线性探测:如果存储位置已经被占用,从当前位置开始,依次往后查找,看是否有空闲位置,若遍历到尾部都没有空闲位置,再从表头开始找,直到找到空闲位置,将其插入到这个位置
线性探测策略的查找操作:通过散列函数求出要查找元素的键值对应的散列值,比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的目标值,否则顺序往后依次查找。如果遍历到数组中的空闲位置,还没找到,就说明要查找的元素并不在散列表中
线性探测策略的删除操作:找到要删除的目标元素,将删除的元素特殊标记为deleted。当线性探测查找的时候,遇到标记为deleted的空间,并不是停下来,而是继续往下探测。
线性探测的问题:当插入的数据越来越多时,散列冲突发生的可能性越来越大,空闲位置越来越少,极端情况下,可能需要探测整个散列表,最坏情况下时间复杂度为O(n)。

线性探测示意图
二次探测
和线性探测很想,线性探测每次的探测步长是1,二次探测的步长变成原来的二次方,0 12 22 32
双重散列
使用一组散列函数 hash1(key),hash2(key),hash3(key)…,先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,以此类推,直到找到空闲位置的存储位置

装载因子
计算公式:填入表中的元素个数/散列表长度
为了保证散列表的操作效率,尽可能保证散列表中有一定比例的空闲槽位,用装在因子表示空位的多少
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降

链表法(更常用):
在散列表中,每个桶或者槽会对应一条链表,所有散列值相同的元素都放到相同槽位对应的链表中
槽位中放的是对应链表的头结点。每个结点类的定义和单链表中的一直,包括key,value,next
链表法示意图
插入操作:通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,插入时间复杂度是O(1)
查找、删除操作:通过散列函数计算出对应的槽,这时候用全量进行遍历对比,找到目标值,注意散列表中存储的不仅仅是哈希值,还有全量的数据信息
这两个操作的时间复杂度跟链表的长度K成正比,也就是O(K),k=n/m,其中n表示散列中数据的个数,m表示散列表中槽的个数

散列表的应用思路
1、假设有10万条URL访问日志,如何按照访问次数给URL排序
遍历10万条数据,以URL为key,访问次数为value,存入散列表,同时记录下访问次数的最大值K,时间复杂度O(n)。如果K不是很大,可以使用桶排序,时间复杂度是O(n)。如果K非常大(比如大于10万),使用快速排序,时间复杂度是O(nlogn)
2、有两个字符串数组,每个数组大约有10万条字符串,如何快速找出数组中两个相同的字符串
以第一个字符串数组构建散列表,key为字符串,vlaue为出现的次数。遍历第二个字符串数组,以字符串为key在散列表中查找,如果value大于零,说明存在同样字符串,时间复杂度O(n)

打造工业级水平的散列表
工业级散列表特性:
1、支持快速查询、插入、删除操作
2、内存占用合理,不能浪费过多的内存空间
3、性能稳定,极端情况下,散列表性能也不会极度退化
工业散列表设计思路:
1、设计一个合适的散列表函数
2、定义装载因子阈值,并且设计动态扩容策略
3、选择合适的散列冲突解决方法
如果散列函数设计的不好,或者装载因子过高,会导致散列冲突发生的概率升高,查询效率下降。在极端情况下,所有的数据都散列在同一个槽中,这时,散列表退化成链表,查询时间复杂度就从O(1)退化到O(n)。
这样有可能查询操作消耗大量CPU或者线程资源,导致系统无法响应其他请求,从而达到拒绝服务攻击目的,这也是散列表碰撞攻击的基本原理
如何设计散列函数
设计不能太复杂,生成的值要尽可能随机且均匀,还需要考虑key的长度、特点、分布、还有散列表的大小
举例:用散列函数处理手机号码,因为手机前几位重复的可能性很大,但是后面几位就比较随机,可以取手机号的后思维作为散列值,这种设计方法,叫做数据分析法
将单词中的每个字母的ASCII码值进位相加,然后再跟散列表的大小求余,取模,作为散列值。比如nice,hash("nice")=(("n"-"a")*26*26*26+("i"-"a")*26*26+("c"-"a")*26+("e"-"a"))/78978

装载因子过大
通过散列表扩容降低
插入一个数据,最好情况下,不需要扩容,最好时间复杂度是O(1).最坏情况下,需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度是O(n) 均摊情况下,时间复杂度接近最好情况 O(1)
随着数据的删除,散列表中的数据会越来越少,空心啊空间会越来越多。如果我们对空间消耗非常敏感,可以在装载因子小于某个值后,启动动态缩容
装载因子阈值的设置要权衡时间、空间复杂度。如果内存空间不紧张,对执行效率比较高,可以降低负载因子的阈值。如果空间内存紧张,执行效率要求不高,可以增加负载因子的值,甚至可以大于1
避免低效扩容
大部分情况下,动态扩容散列表都很块,但是当装载因子已经达到阈值,需要先扩容再插入,这时候就会慢到无法接受。为了解决一次性扩容耗时过多的情况,可以将扩容操作穿插在插入操作过程中,分批完成。
当装载因子触达阈值后,只申请新空间,但并不将老的数据搬移到新散列表中,当有新数据插入时,将新数据插入到新散列表,并从老的散列表拿出一个数据放到新散列表,这样一点一点就移过来了
对于查询操作,为了兼容老、新散列表,先从新散列表中查找,如果没有找到,再去旧表中查询
这种方式,任何情况下,插入时间复杂度都是O(1)
扩容示意图
选择冲突解决方法
开放寻址法:适合数据量比较小,装载因子小的时候
优点:数据都存储在数组中,可以有效地利用CPU缓存加速查询速度,序列化起来比较简单
缺点:删除数据麻烦,需要标记已删除的数据。因为数据都在数组中,冲突代价更高,因此装载因子不能太大,这也导致空间内存浪费严重
链表法:适合存储大对象、大数据量的散列表
优点:因为链表结点可以随用随建,不需要像开放寻址法那样实现申请好;对大装载因子容忍度更高。可以将链表改成跳表、红黑树等动态结构,更灵活,避免散列表碰撞攻击
缺点:因为链表结点要存储指针,因此对于小对象的存储,比较耗内存;由于结点在内存中不是连续的,对CPU缓存不友好
Java中HashMap工业级散列表分析
1、初始大小:默认初始大小16,可以修改默认值
2、最大装载因子默认0.75,每次扩容为原来的两倍大小
3、散列冲突解决方法:底层采用链表,当链表长度超过8,链表转换成红黑树;当结点小于8个,转为链表。因为数据量小的情况下,红黑树要维护平衡,比起链表,性能优势不明显
4、散列函数:简单高效、分布均匀

int hash(Object key){
int h = key.hashCode();
return(h^(h>>>16))&(capacity-1);

hashcode本身是个32位整型值,对于不同对象必须保持唯一。获取对象的hashcode后,先进行移位运算,再和自己做异或运算,即(h = key.hashCode())^h>>>16,将高16位移到低16位,这样计算出来的整型值将具有高位低位的性质。最后用hash表当前的容量减1,做位与运算,计算出数组中的位置
为什么要用容量减一?
因为A%B = A&(B-1),所以,(h^(h>>>16))&(capacity-1)=(h^(h>>>16))%capacity,本质上用了除留余数法

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值