哈希表

一、哈希表
1、定义

​ 散列表(Hash table,哈希表),是根据key而直接进行访问的数据结构。可以通过把关键字映射到表中一个位置来访问记录,以加快查找的速度。整个映射函数叫做散列函数,存放记录的数组叫做散列表,在有些文章或开源代码中也称之为slot(槽)或bucket(桶)。

2、原理
###### 3、特点
  • 优点:通过关键实现一对一查找速度非常快
  • 缺点:存在冲突
二、分类
1、静态哈希

​ 拥有固定的slot(桶)数,哈希函数对同样的key值永远映射出唯一的地址值。如果数据是固定不变的,那么静态哈希较为实用。

2、动态哈希

​ 在哈希表定长为基础,当关键字较多时,哈希表出现聚集时,性能会急剧下降。

​ 动态哈希通常是在发生冲突后slot数量翻倍增长,而增长后毕竟哈希函数也变了,所以要把旧slot里的元素重新放置。

二、哈希冲突

​ 每个存储单元可以容纳一个或多个关键字信息,理想状态下,完美的散列函数能为关键字找到唯一的独占的桶,但大多情况下用到的散列函数是不完美的,会存在冲突。影响产生冲突多少与以下三个因素有关:

  • 散列函数是否均匀
  • 处理冲突的方法
  • 散列表的负载因子:填入表中的元素个数/散列表的长度,由于表长是定值,所以填入表中的元素越多,产生冲突的可能性就越大。

​ 哈希冲突避免的方法有:

  • 对于静态哈希表,有确定的策略可以找到这样的h来达到最小完美哈希。

  • 对于动态哈希表,必须使用非确定性的策略来构成哈希函数h,才有可能保证避免和希冲突。

​ 哈希冲突解决的方法有:

  • 开放寻址法(再散列法):其基本思想是当关键字key哈希值value=h(key)出现冲突时,以value为基础,产生另一个哈希值value1,如果value1仍然冲突,再以value为基础,产生另一个哈希地址value2,直到找出一个不冲突的哈希值,将相应元素存入其中。

  • 再哈希法:这种方法同时构造多个不同的哈希函数,在发生冲突时,使用其他散列函数计算哈希值,直到冲突不再发生。

  • 链地址法:对于选定的哈希表长度为m,则可将哈希表定义为一个长度为m的指针数据[0…m-1],指针数组中的每个指针指向哈希函数结果相同的单链表。

三、动态哈希

​ 动态哈希有多种实现方式:多hash表、可扩展的动态散列和线性散列。

​ 通常,当一个hash表冲突较多时,需要考虑采用动态hash方式,来减小后续擦欧总继续在该桶上的冲突,减轻该桶负担,最简单且最容易的方式就是多hash表。

1、多hash表

​ 多hash表即采用多个hash表的方式扩展原hash表。

​ 如图,哈希函数h(k) = k % 5,每个桶最多含4个关键字。

​ 当插入关键字5时,哈希值为0,应该插入到Bucket1中,而且Bucket1有空闲位置,直接插入即可。

​ 当插入关键字3时,哈希值为3,应该插入到Bucket3中,但是Bucket3已满,此时新建一张哈希表来完成插入,结果如下图:

​ 通过图可得知,原来的一个hash表分裂为两个hash表,采用折冲方式,多个hash表共用一个hash函数,且目录项的个数也随之增多,分别指向对应的桶。对于此种方式,执行插入、查找和删除操作时,均需先求得hash值value。

  • 插入:得到对应hash表个数,并分别获取各个hash表的value位置上的索引项,若其中某个项指向的桶存在空闲位置,则插入之。同时,在插入时,可保持过个hash表在某个索引项上桶中元素的个数近似相等。若不存在空闲位置,则简单进行表分裂。
  • 查找:由于某个记录值可能存在当前hash结构的多个表中,因此需同时在多个hash表的同一个位置上进行查找操作,等待所有的查找结束后,方可判断元素是否存在。
  • 删除:与查找流程类似。需要注意的是,若删除操作导致某个hash表元素为空,此时可将该表从结构中剔除。

此方式优点是:思想简单,实现不复杂。缺点:分裂导致内存占用较大;当数据集中是,桶利用率很低。

2、可扩展的动态散列

​ 引入一个仅存储桶指针的索引数组,用翻倍的索引项数来代替翻倍的桶的数目,且每次值分裂有益处的桶,从而减少翻倍的代价。
在这里插入图片描述
​ 全局位深度(Global Depth):表示取哈希值的低几位作为索引,如hash(k) == 0100,全局深度为2,则取低两位00归属到00索引的桶中,而且哈希表索引项索引项数始终等于2^Global Depth。

​ 本地位深度(Local Depth):每个桶有一个本地位深度,表示当前桶中元素的低几位是一样的。

  • 插入:计算出哈希值,在根据全局位深度匹配索引项,如果找到的桶未满,则直接插入即可;如果桶已满,则判断当前桶的本地深度L与全局位深度G的大小关系:如果L==G,此时只有一个指针指向当前桶,则扩展索引,本地位深度和全局位深度均+1,索引项翻倍,重组当前桶的元素;如果L<G,此时不止一个指针指向当前桶,故不需要翻倍索引项,只需分裂出一个桶,将本地位深度+1,然后重组当前桶元素即可。

  • 查找:计算key对应的哈希值value,根据当前hash表的全局位深度,对value取其后G位,位数不够用0填充,找到对应的索引项,从而找到对应的桶,在桶中逐一进行比较

  • 删除:先查找定位元素,然后删除。如果删除后桶为空,则可以将该桶预期兄弟桶进行合并,并使局部位深度-1。

  • 分裂示例:

    当插入key=13时,hash(13) == 1101,对应索引01的桶Bucket2未满直接插入;

    再插入key=20时,hash(20) == 10100,对应索引00的桶Bucket1已满,且桶本地位深度等于全局位深度,需要扩展索引,全局位深度和本地位深度均+1,并重组当前桶的元素。
    在这里插入图片描述

  • 分裂示例

    继续插入key=25时,hash(25) == 11001,对应索引001的桶Bucket2,桶已满且本地位深度小于全局位深度,当前桶存在两个指针,只需要分裂出一个桶即可,并将桶的本地位深度+1,如下图:
    在这里插入图片描述
    可扩展的动态散列可动态进行桶的增长,且增长的同时,用索引项的翻倍代替桶数翻倍的传统方法,可用性更好;缺点是当散列的数据分布不均或偏斜较大时,会使得索引项数目很大数据桶的利用率低,还有索引的增长速度是指数级,扩展较快。

3、线性散列

​ 线性散列能随数据的插入或删除,适当的对hash桶数进行调整,一次只分裂一个桶,线性散列不需要存放数据桶指针的专门索引项,且能够自然的处理数据桶已满的情况,允许更灵活的选择桶分裂的实际。

​ 轮转分裂进化:各个桶轮流进行分裂,当一轮分裂完成之后,进行下一轮分裂,于是分裂从头开始。用Level表示当前的轮数,其值从0开始。假定哈希表初始桶数为N,则值LogN(2为底)是指表示N个数需要的最少二进制位数。

  • 概念
    • h0,h1…:一系列哈希函数,后者的范围总是前者的两倍
    • N:桶的初始个数,必须是2的幂次方
    • di:多少比特位用于表示N,N=2di
    • Level:当前轮数,没轮的初始桶数等于N*2level
    • Next:一个指针指向需要分裂的桶,每次发生分裂的桶总是有Next决定,与当前值被插入的桶已满或溢出无关
    • Load Factor:负载因子,当桶中记录数达到该值时进行分裂;也可以选择桶满时才进行分裂。

​ 当某个桶发生溢出时,可以将溢出元素以链表的形式链在桶后;可以监控整张哈希表或桶的负载因子,视情况选择是否分裂。

  • 插入示例

    • 初始状态N=4,Level=0,Next指向第一个桶
      在这里插入图片描述

    • 插入key=37,hash(37) = 37 = 100101,h0(37)=01(取低两位),对应编号1的桶,此时桶未满直接插入
      在这里插入图片描述

    • 插入key=43,hash(43) = 43 = 101011,h0(43)=11(取低两位),对应编号3的桶,此时桶已满需要分裂:
      在这里插入图片描述

      • 首先把43链在桶3后面
      • 然后分裂Next指向的桶0,产生一个新桶,桶的编号=N+Next=4+0=100,Next指向下一个桶;最后把桶0的元素按照h1散列重组
    • 继续插入key=29,hash(29) = 29 = 11101,h0(29)=01(取低两位),对应编号1的桶,此时桶进行分裂,不过这次Next指向的桶就是当前桶
      在这里插入图片描述

    • 向桶中连续插入17、33、41,引起分裂
      在这里插入图片描述

    • 继续向桶1插入57,引起分裂,需要注意这时Next指针已经遍历 过初始桶的四个桶,第一轮结束,Level+1,Next指向第一个桶,第二轮初始为八个桶N=8
      在这里插入图片描述

  • 查找示例

    假如要查找某个关键字key对应的位置,如果Next<=hLevel(Key)<=N-1,则查找hLevel列的与低di位对应的桶,否则查找hLevel+1列的与低di+1位对应的桶。

    此图中N=4,Level=0,Next=2,d0=2
    在这里插入图片描述

    • 比如查询 key=44,h0(44) = 00,00(= 0) 不在 2 和 3 之间,则查询 h1 列与 44 低三位 (100) 对应的桶,找到编号为 4 的桶,再从桶找到对应位置;

    • 假如查询的是 key=7,h0(7) = 11,11(= 3) 在 2 和 3 之间,则查询 h0 列与 7 低两位 (11) 对应的桶,找到编号为 3 的桶,再从桶中找到对应位置

      在此图中 N = 8,Level = 1,Next = 0,d1 = 3
      在这里插入图片描述

    • 假如查询 key=44,h1(44) = 100,100(= 4) 在 0 和 7 之间,所以查询 h1 列与 100 对应的桶,即编号为 4 的桶;

  • 删除:删除操作是插入操作的逆操作,若溢出块为空,则可释放。若删除导致某个桶元素变空,则 Next 指向上一个桶。当 Next 减少到 0,且最后一个桶也是空时,则 Next 指向 N/2 - 1 的位置,同时 Level 值减 1。

​ 线性散列比可扩展动态散列更灵活,且不需要存放数据桶指针的专门索引项,节省了空间;但如果数据散列后分布不均匀,导致的问题可能会比可扩展散列还严重

四、动态大小调整

​ 随着哈希表的装载因子上升,哈希冲突的概率会不断上升,直到装载因子超过 1 时,必然发生哈希冲突(抽屉原理)。对于动态哈希表,由于 U 的大小不能预先得知,所以必然需要动态调整哈希表的大小。常见的策略是当装载因子超过某一阈值后,线性扩展哈希表的大小为原来的若干倍;当装载因子低于某一阈值时,线性收缩哈希表的大小为原来的若干分之一。使用两个阈值的原因是为了避免抖动。由于 R 发生变化,因此对应的哈希函数也必须发生变化。调整大小时,另行分配内存,然后将原哈希表中的所有元素 rehash 后存储到新的哈希表中,这种策略称为 Copy All 策略。尽管该操作可以均摊到插入操作中,使得整体的均摊时间复杂度仍为一个常数,但是这一策略会带来较长时间的停顿。为了改善这一问题,又有其他改进策略,其中较为知名的有:

  • Linear Hashing:同时使用 2 个哈希函数来解决动态调整大小的问题

当哈希数组需要扩张大小时,从前向后进行,当前正在扩张的 bucket 下标记为 p。对于在 p 之前的位置,使用hi+1,对于 p 及其之后的位置仍然使用 hi。这样就可以非常平滑的,每次操作只扩张一个 bucket,而不需要把所有的元素都 rehash。 不过这样做有一个缺点,就是有可能有一个位置上比较靠后的 bucket 一直比较拥挤,经过很多次插入后,才能对这个 bucket 进行扩张以缓解性能下降。对于这一问题,Spiral Storage 的方法处理的比较好。

  • Spiral Storage

    Spiral Storage 总是将负载更多的放在哈希表靠前的位置上,而非均匀地将负载分配到整个哈希表中。这样尽管是像 Linear Hashing 一样,总是从哈希表的头部开始进行 bucket 的分裂,也不会有不及时处理非常满的 bucket 的问题。

Spiral Storage 的思路是这样的。哈希表的负载从前向后逐渐降低;扩展大小时,需要将表头的 bucket 中的元素分配到多个新 bucket 中并添加到哈希表的末尾,并且依然保持负载从前向后逐渐下降的性质。假设每去掉表头的一个 bucket 就添加 d 个新 bucket,称 d 为哈希表的增长因子。考虑到哈希表是非线性增加大小的,应该采用一个非线性增长的哈希函数族,将 U 映射到 R。易发现指数函数满足这样的性质。

  • Extendible Hashing

将 bucket 和 bucket 的索引分别存放,使用 bucket 对应 key 的前缀对其进行索引,这样在扩展哈希表的大小时,就无需复制所有对象调整索引部分即可。

公众号:编程之蝉 专注后台开发、CDN、算法、大数据,欢迎关注,阅读最新更新
公众号:编程之蝉

动态哈希思想

哈希

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值