哈希表

哈希表(一)——哈希表的大小

哈希表的设计主要是为了查找,为了对内存中的数据进行快速查找,它的查找时间复杂度是O(1)。设计一个哈希表的关键有三个:怎么控制哈希表的长度,怎么设计哈希函数,怎么处理哈希冲突

今天这篇文章先来讨论一下如何设计我们的哈希表的大小

哈希表的长度一般是定长的,在存储数据之前我们应该知道我们存储的数据规模是多大,应该尽可能地避免频繁地让哈希表扩容。但是如果设计的太大,那么就会浪费空间,因为我们跟不用不到那么大的空间来存储我们当前的数据规模;如果设计的太小,那么就会很容易发生哈希冲突,体现不出哈希表的效率。所以,我们设计的哈希表的大小,必须要做到尽可能地减小哈希冲突,并且也要尽可能地不浪费空间,选择合适的哈希表的大小是提升哈希表性能的关键。

当我们选择哈希函数的时候,经常会选择除留余数法,即用存储数据的key值除以哈希表的总长度,得到的余数就是它的哈希值。常识告诉我们,当一个数除以一个素数的时候,会产生最分散的余数。由于我们通常使用表的大小对哈希函数的结果进行模运算,如果表的大小是一个素数,那么这样我们就会尽可能地产生分散的哈希值。

另外,哈希表中还有一个概念就是表的装填因子(负载因子),它的值一般被定义为:

装填因子 a = 总键值对数(下标占用数)/ 哈希表总长度装填因子 a = 总键值对数(下标占用数) /  哈希表总长度

至于为什么要设计这样一个概念,我们可以像,如果一个哈希表中的数据装的越多,是不是越容易发生哈希冲突。如果当哈希表中满到只剩下一个下标可以插入的时候,这个时候我们还要往这个哈希表中插入数据,于是我们可能会达到一个O(n)级别的插入效率,我们甚至要遍历整个哈希表才可能找到那个能存储的位置。

通常,我们关注的是使哈希表平均查找长度最小,把平均查找长度保证在O(1)级别。装填因子a的取值越小,产生冲突的机会就越小,但是也不能取太小,这样我们会造成较大的空间浪费。即如果我们a取0.1,而我们哈希表的长度为100,那我们只装了10个键值对就存不下了,就要对哈希表进行扩容,而剩下90个键值对空间其实是浪费了的。通常,只要a取的合适(一般取0.7-0.8之间),哈希表的平均查找长度就会是常数也就是O(1)级别的

当然,根据数据量的不同,会有不同的哈希表的大小。当数据量小的时候,最好就是能够实现哈希表扩容的机制,即达到了哈希表当前长度的装填因子,我们就需要扩大哈希表大小,一般都是乘2。

 

下面,对上面这些观点进行一个总结,来设计一个效率尽可能高的哈希表大小

  1. 确保哈希表长度是一个素数,这样会产生最分散的余数,尽可能减少哈希冲突
  2. 设计好哈希表装填因子,一般控制在0.7-0.8
  3. 确认我们的数据规模,如果确认了数据规模,可以将数据规模除以装填因子,根据这个结果来寻找一个可行的哈希表大小
  4. 当数据规模可能会动态变化,不确定的时候,这个时候我们也需要能够根据数据规模的变化来动态给我们的哈希表扩容,所以一开始需要自己确定一个哈希表的大小作为基数,然后在此基础上达到装填因子规模时对哈希表进行扩容。

一般来说,可能地哈希表长度取值如下:


 
 
  1. const int_PrimeSize= 28;
  2. static const unsigned long_PrimeList[_PrimeSize] =
  3. {
  4. //ul代表这个数是unsigned long
  5. 53ul, 97ul, 193ul, 389ul, 769ul,
  6. 1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
  7. 49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
  8. 1572869ul, 3145739ul, 6291469ul, 12582917ul, 25165843ul,
  9. 50331653ul, 100663319ul, 201326611ul, 402653189ul, 805306457ul,
  10. 1610612741ul, 3221225473ul, 4294967291ul
  11. };

 

哈希表(二)——哈希函数(字符串哈希算法)

在上一篇文章哈希表的大小提到过一种除留余数法的计算哈希值的函数。这一篇文章来具体说一说,怎么设计哈希函数能够让哈希表更加效率。

哈希函数,是用来计算存储数据的哈希值的,根据存储数据的类型,可以设计不同的哈希函数。一个好的哈希函数(让哈希表效率高的函数),一般都具备下面两个特点:

  1. 速度快(别计算一个哈希值计算了半天,导致效率很低,简单高效就好)
  2. 能够将得到的哈希值均匀地分布在整个哈希表中,保证不产生聚集(不要让一堆数据都聚集在哈希表的一部分,这样之后的数据插入进来就很容易产生哈希冲突)

通常一个哈希函数具有下面的形式

哈希值 = 计算后的存储值 / 哈希表的大小

对于如果存储的数是整数这种类型,我们完全可以不用计算,直接将整数的值作为上式中计算后的存储值。

而对于字符串这种类型,当然不仅仅是字符串,我们都要设计一个相对较好的算法,来计算出它们的存储值。

所以,我们以字符串为例,来介绍一下常见的字符串哈希算法,其他类型的数据都可以用相似的思路来设计适合自己的哈希算法。

字符串哈希算法

马上就能想到的算法:简单地将字符串中每个字符的ASCII码加起来


 
 
  1. size_t stringHash( const string& key){
  2. size_t hashKey = 0;
  3. for( size_t i = 0; i < key.size(); ++i)
  4. hashKey += key[i];
  5. return hashKey;
  6. }

用上面的方法可以很快地算出哈希值,但是如果表很大时,则函数就不能很好的分配。比如我的表的大小是10000,即我的数据规模大概是7000个左右(取装填因子为0.7),但是我的字符最多只有8个字符长,由于ASCII码最大值是127,因此hash函数计算出来的哈希值只能在0-1016之间取值,其中127 * 8 =1016,这就会有一种聚集的效果,这就不是我们上面提到的两点想要的,我们要尽可能地避免聚集。

这个方法可能是刚接触字符串哈希函数的人会马上想到的,但其实我们有很多的优秀的字符串哈希算法。

优秀的字符串哈希算法

BKDR哈希算法


 
 
  1. size_t BKDR_hash( const string& key){
  2. size_t hashKey = 0;
  3. size_t seed = 131; //也可以是31 131 1313 13131 131313
  4. for( size_t i = 0; i < key.size(); ++i)
  5. hashKey += hashKey * seed + key[i];
  6. return hashKey;
  7. }

根据上面的算法,我们就可以根据结果得到非聚合的一些哈希值。

这个算法是效率很高的一个算法,其他的字符串算法可以看这里:字符串哈希算法,是人家总结的一篇文章,涵盖了当今很多的哈希算法。

哈希表(三)——哈希冲突

之前的两篇文章多次提到哈希冲突,这里再解释一下。

所谓哈希冲突,就是两个key值经过哈希函数计算以后得到了相同的哈希值,而一个下标只能存放一个key,这就产生了哈希冲突,如果这个下标其中一个key先存着了,那另一个key就必须通过别的方法找到属于自己的存放位置。

产生了哈希冲突,我们就要解决。选择一个好的解决哈希冲突的方法,也是提高哈希表效率的关键。

开发定址法

为产生冲突的地址Hash(key)求得一个地址序列:

H0,H1,H2,...,Hn        1  <= n <= m - 1 其中m为表的大小

H0 = H(key) Hi = (H(key)+ di)% m  i=1,2,...n

形象地一句话来说,就是根据增量di不断地往后找可以存放的下标。

增量di可以有下面三种取值:

  1. 线性取值,1,2,3....这样,也就是从冲突位置不断往后找下一个可以存放的下标
  2. 二次取值,1,4,9....这样,也就是从冲突位置不断往后找x的二次方的下标,其中x从1开始线性增大
  3. 随机取值,di可以去任意随机值,随机找一个。

开放定制法有很明显的缺点

  • 元素不能删除
  • 当哈希表中元素越来越满时,效率明显下降

再哈希法

这其实就是一次哈希出来结果相同,再用另外的哈希函数来计算,直到不再产生相同的哈希值为止。这就要求我们设计不同的哈希函数,对程序的要求就更高一点,它也和开放定制法一样要保证装载因子,和开发定址法差不多其实。

哈希桶(拉链法)

每个下标中存的都是一个链表,相同哈希值的key直接往下标中的链表后面插入就行了

这种方法的特点是,表的大小和存储的数据数量差不多(大不了每个下标都只放一个节点,如果下标一样的都是放在同一下标的链表中,并没有占据新的下标) ,因此哈希桶的方法没有特别依赖于装载因子,哈希表块满时,它还是可以做到较好的效率,而开发定址法就需要保证装载因子。

当然,哈希桶法并不是万能的,也有它的缺点:

  • 它需要稍微多一点的空间来存放元素,因为还要有一个指向下一个节点的指针。
  • 每次探测也要花费较多的时间,因为它需要间接引用指针,而不是直接访问元素。

但其实,对于上面的缺点,对于现在的电脑来说并不会有太大的影响,所以这些缺点是微不足道的,所以实际使用哈希时,一般都是用哈希桶来解决冲突。

需要注意的是,哈希表也不是能让这个链表无限长的。打个比方,如果我所有的数据的哈希值都是一样,那么只会存在一个下标内,其余下标都没有用到,这就产生了一种极端情况。在这种情况下,我们要查找起来,就等于在一个链表中查找,它的查找效率是O(n),这显然违背了哈希表设计的思维。因此,在某一个链表或是几个链表的长度达到一定长度时,就需要对哈希表扩容,具体细节还是要看怎么设计了。

但是相比于开发定址法,它会发生扩容的频率就要小很多了,因为开发定址法还要保证自己的装填因子,所以它会更加频繁地扩容,所以就效率而言,拉链法还是要优于开发定址法。

 

 

构造哈希表以及二次探测法

      版权声明:本文为博主原创文章,未经博主允许不得转载。          https://blog.csdn.net/Stu_zkl/article/details/82728763        </div>
        <link rel="stylesheet" href="https://csdnimg.cn/release/phoenix/template/css/ck_htmledit_views-f57960eb32.css">
                          <div id="content_views" class="markdown_views prism-atom-one-dark">
        <!-- flowchart 箭头图标 勿删 -->
        <svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
          <path stroke-linecap="round" d="M5,0 0,2.5 5,5z" id="raphael-marker-block" style="-webkit-tap-highlight-color: rgba(0, 0, 0, 0);"></path>
        </svg>
        <p>今天做笔试题时,遇到一道构造哈希表的题,hash函数是 k%11 ,然后一个数组记不清了,然后就是问二次探测法进行,问下面那个是正确,懵逼啊,没做过,不知道,乱选直接下一题,于是有这个博客,赶紧学习一波。</p>

网上查询了一下。


构造哈希表的几种方法

常用方法是直接定址法除留余数法

  • 直接定址法(取关键字的某个线性函数为哈希地址)

类似于这样的式子

f(key) = a × key + b

  • 除留余数法(取关键值被某个不大于散列表长m的数p除后的所得的余数为散列地址)

对于散列表长为m的散列函数公式为:

f( key ) = key mod p ( p ≤ m )

mod是取模(求余数)的意思。事实上,这方法不仅可以对关键字直接取模,也可在折叠、平方取中后再取模。

  • 平方取中法

  • 折叠法

  • 随机数法

  • 数学分析法


哈希冲突(碰撞)以及处理

哈希冲突:既然有哈希函数Hash(key),在有限的空间里,肯定会产生相同的的值(哈希地址),我们称这种情况为哈希冲突(碰撞)。任意的散列函数都不能避免产生冲突。

1. 开发定址法

所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。

  • 线性探测法

fi(key) = (f(key)+di) MOD m (di=1,2,3,......,m-1)

用开放定址法解决冲突的做法是:当冲突发生时,使用某种探测技术在散列表中形成一个探测序列。沿此序列逐个单元地查找,直到找到给定的关键字,或者碰到一个开放的地址(即该地址单元为空)为止(若要插入,在探查到开放的地址,则可将待插入的新结点存人该地址单元)。查找时探测到开放的地址则表明表中无待查的关键字,即查找失败。

ep:我们的关键字集合为{12,67,56,16,25,37,22,29,15,47,48,34},表长为12。 我们用散列函数f(key) = key mod 12。

当计算前S个数{12,67,56,16,25}时,都是没有冲突的散列地址,直接存入:

这里写图片描述

计算key = 37时,发现f(37) = 1,此时就与25所在的位置冲突。

于是我们应用上面的公式f(37) = (f(37)+1) mod 12 = 2。于是将37存入为2的下标位置。

这里写图片描述

接下来22,29,15,47都没有冲突,正常的存入:

这里写图片描述

到了 key=48,我们计算得到f(48) = 0,与12所在的0位置冲突了,不要紧,我们f(48) = (f(48)+1) mod 12 = 1,此时又与25所在的位置冲突。于是f(48) = (f(48)+2) mod 12=2,还是冲突……一直到 f(48) = (f(48)+6) mod 12 = 6时,存入该位置:

这里写图片描述

我们把这种解决冲突的开放定址法称为线性探测法。

  • 二次探测法

考虑深一步,如果发生这样的情况,当最后一个key=34,f(key)=10,与22所在的位置冲突,可是22后面没有空位置了,反而它的前面有一个空位置,尽管可以 不断地求余数后得到结果,但效率很差。

因此我们可以改进di = 12, -12, 22, -22,……, q2, -q2 (q <= m/2),这样就等于是可以双向寻找到可能的空位置。

对于34来说,我 们取di即可找到空位置了。另外增加平方运算的目的是为了不让关键字都聚集在 某一块区域。我们称这种方法为二次探测法。

f(key) = (f(key)+di) MOD m (di = 1^2, -1^2, 2^2, -2^2,……, q^2, -q^2, q <= m/2)

注:1^2 表示是 1的平方。

2. 链地址法

前面我们谈到了散列冲突处理的开放定址法,它的思路就是一旦发生了冲突,就去寻找下一个空的散列地址。那么,有冲突就非要换地方呢,我们直接就在原地处理行不行呢?

答案是可以的,就是链地址法,就好比Java里的HashMap的数据结构一样。

这里写图片描述

关于HashMap源码分析 ——> https://blog.csdn.net/Stu_zkl/article/details/82714325
好了,就写到这了。

      </div>
      <link href="https://csdnimg.cn/release/phoenix/mdeditor/markdown_views-258a4616f7.css" rel="stylesheet">
              </div>
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值