[数据结构]-散列表(哈希表)

复习自《数据结构-邓俊辉版》

散列表

散列表(hashtable)是散列方法的底层基础,逻辑上由一系列可存放词条((或其引用)的单元组成,故这些单元也称作桶(bucket)或桶单元;与之对应地,各桶单元也应按其逻辑次序在物理上连续排列。因此,这种线性的底层结构用向量来实现再自然不过。为简化实现并进一步提高效率,往往直接使用数组,此时的散列表亦称作桶数组。若桶数组的容量为R,则其中合法秩的区间[0,R)也称作地址空间。

散列函数

一组词条在散列表内部的具体分布,取决于所谓的散列方法——事先在词条与桶地址之间约定的某种映射关系,可描述为从关键码空间到桶数组地址空间的函数:
hash() : key -> hash(key) (其实就是键值对)

这里的hash()称作散列函数。反过来,hash(key)也称作key的散列地址,亦即与关键码key相对应的桶在散列表中的秩。

实例:手机里的电话簿,其中每个姓名都有对应的电话号码
假设你要创建一个类似这样的电话簿,将姓名映射到电话号码。这就可以用到散列表,以C++为例,我们可以写出如下代码

unordered_map<string,string> phone_book;//unordeded_map是stl容器中 可以看作哈希表,在<>中的第一个string代表姓名(key),第二个string代表电话号码(value)
phone_book["张三"]="123942";
phone_book["李四"]="124423";

当我们需要查找张三的电话时,只需要输出phone_book[“张三”]即可,有点像数组的存储和读取,都能在O(1)时间内完成随机访问。

当然这是我们已经知道phone_book中一定有张三的情况,我们才会直接输出。当我们不知道是否有张三这个人的时候呢?我们能直接输出么?我们如果此时要找是否有叫王五的人,如果我们用以下代码会如何呢?

cout<<phone_book["王五"]<<endl;

这么做的后果是什么呢?我们回顾一下散列表的键值对,尽管我们电话簿中没有王五这个人,但是我们在进行映射的时候,会帮我们映射到空字符串上,其实等价于 phone_book[“王五”]=""
这个时候我们我们虽然知道电话簿里没有王五这个人,但是同时给电话簿中王五添加了电话号为空串,那显然是不对的,好在stl提供了方法帮我们查找是否有元素, 运用find()函数进行元素的查找,如果不存在的话则会返回到一个end迭代器

if(phone_book.find("王五")==phone_book.end()){
    cout<<"没有王五这个人"<<endl;
}
else{
    cout<<"有王五这个人,电话是"<<phone_book["王五"]<<endl;
}

减少冲突

当我们的数据量特别大的时候,key值不同的元素可能会映射到散列表上的同一地址,即key1≠key2,但hash(key1)=hash(key2),这种现象就称之为冲突。冲突是不可避免的,我们只能通过改进散列函数来减少冲突。接下来介绍几种常见方法。

  • 除余法:将散列表长度M取作为素数,并将关键码key映射至key关于M整除的余数:
    hash(key) = key mod M

例如:已知待散列元素为(18,75,60,43,54,90,46)为减少冲突,可取M表长=13,结果如下:

h(18)=18 % 13=5    h(75)=75 % 13=10    h(60)=60 % 13=8 
 h(43)=43 % 13=4    h(54)=54 % 13=2    h(90)=90 % 13=12   
 h(46)=46 % 13=7

此时没有冲突。
请注意,采用除余法时必须将M选作素数,否则关键码被映射至[0,M)范围内的均匀度将大幅降低,发生冲突的概率将随M所含素因子的增多而迅速加大。

  • MAD法(multiply-add-divide method):以素数为表长的除余法尽管可在一定程度上保证词条的均匀分布,但从关键码空间到散列地址空间映射的角度看,依然残留有某种连续性。比如,相邻关键码所对应的散列地址,总是彼此相邻;极小的关键码,通常都被集中映射到散列表的起始区段——其中特别地,0值居然是一个“不动点”,其散列地址总是0,而与散列表长度无关。

例如:关键码{2011,2012,2013,2014,2015,2016}
插入长度为M=17的空散列表后,这组词条将存放至地址连续的6个桶中(5~10)。尽管这里没有任何关键码的冲突,缺具有就"更高阶"的均匀性。
为弥补这一不足,可采用所谓的MAD法将关键码key映射为:
(a × key + b) mod M,其中M仍为素数,a > 0, b > 0,且a mod M ≠ 0
此类散列函数需依次执行乘法、加法和除法(模余)运算,故此得名。

  • 更多的散列函数

    • 数字分析法:从关键码key特定进制的展开中抽取出特定的若干位,构成一个整形地址。比如,若取十进制展开中的奇数位,则有
      hash(123456789) = 13579
    • 平方取中法:从关键码key的平方的十进制或二进制展开中取居中的若干位,构成一个整形地址。比如,若取平方后十进制展开中居中的三位,则有
      hash(123)= 15129 -> 512
    • 折叠法:将关键码的十进制或二进制展开分割成等宽的若干段,取其总和作为散列地址。比如,若以三个数位为分割单位,则有
      hash(123456789) =123 + 456 +789 =1368
  • (伪)随机数法
    上述各具特点的散列函数,验证了我们此前的判断:越是随机、越是没有规律,就越是好的散列函数。按照这一标准,任何一个(伪)随机数生成器,本身即是一个好的散列函数。比如可直接使用C/C++语言提供的rand()函数,将关键码key映射至桶地址:
    rand(key) mod M

处理冲突

无论散列函数设计得如何巧妙,也不可能保证不同的关键码之间互不冲突。常用的处理冲突方法有如下几种。

  • 线性试探法(开放地址法)
    在插入关键码key时,若发现桶单元ht[hash(key)]已被占用,则转而试探桶单元ht[hash(key)+1];若ht[hash(key)+1]也被占用,则继续试探ht[hash[key]+2];…;如此不断,直到发现一个可用空桶。当然,为确保桶地址的合法,最后还需统一对M取模。因此,第i次试探的桶单元应为:
    ht[(hash(key) + i) mod M] ,i=1,2,3…
    如此,被试探的桶单元在物理空间上依次连贯,其地址构成等差数列,该方法由此得名。

    • 平方试探法:缓解聚集现象,按照如下规则
      (hash(key) + j^2 )mod M, j=0,1,2,…
      各次试探的位置到起始位置的距离,以平方速率增长,该方法因此得名。
  • 独立链法(链地址法)
    令各桶内相互冲突的关键码接成一个单链表,此方法灵活地动态调整各子词典的容量和规模,从而有效降低空间消耗。但在查找过程中一旦发生冲突,则需要遍历整个列表,导致查找成本的增加

  • 再散列法
    同时构造多个不同的散列函数:当某个哈希地址发出冲突时,选择另外一个哈希函数,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。

装填因子

散列表的装填因子很容易计算。
装填因子=散列表包含的元素数/位置总数

当散列表使用数组来存储数据,因此你需要计算数组中被占用的位置数。例如数组中存放了2个数,而实际一共有5个位置,则装填因子为2/5,即0.4。

假设要在散列表中存储100种商品的价格,而该散列表包含100个位置.在最佳情况下,每个商品都有自己的位置.这个散列表的装填因子为1。

如果这个散列表只有50个位置呢?装填因子将为2。不可能让每种商品都有自己的位置,因为没有足够的位置!装填因子大于1意味着商品数量超过了数组的位置数。一旦装填因子开始增大,你就需要在散列表中添加位置,这被称为调整长度。
一个不错的经验规则是:一旦装填因子大于0.7,就调整散列表的长度。

时间复杂度比较

——哈希表(平均情况)哈希表(最糟情况)数组链表
查找O(1)O(n)O(1)O(n)
插入O(1)O(n)O(n)O(1)
删除O(1)O(n)O(n)O(1)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值