散列(Hash)表查找

在前面介绍的线性结构和树形结构的查找中,记录在表中所处的位置与记录的关键字之间不存在确定关系,因此,在这些结构中查找记录时需要进行一系列的关键字比较。这类查找方法建立在“比较”的基础上,因此查找的效率取决于比较的次数。而在理想的情况下,对散列表进行查找的时间复杂度为 O(1),即与表中元素的个数无关。
对于散列查找,应掌握散列表的构造、冲突处理方法(各种方法的处理过程)、查找成功和查找失败的平均查找长度、散列查找的特征和性能分析。

一、散列表的基本概念

1. 散列函数与散列表

1)散列函数的定义

一个把查找表中的关键字映射成该关键字对应的地址的函数,记为 Hash(key) = Addr(这里的地址可以是数组下标、索引或内存地址等)。

2)散列表的定义

根据关键字而直接进行访问的数据结构。也就是说,散列表建立了关键字和存储地址之间的一种直接映射关系。因此,在理想情况下,对散列表进行查找的时间复杂度为 O(1) 。

2. 理想散列、桶和起始桶的概念(仅作了解)

1)理想散列的定义

字典可以通过散列(hashing)算法实现,从而提供一种高效的存储和访问方式。实现方法是:字典里的键值对 p = (k, v) 中的关键字 k 通过散列函数 f (也称哈希函数)的映射得到散列值 f(k),该值是键值对 p = (k,v) 存储或即将存储在散列表(也称哈希表)中的具体位置。

字典(dictionary)是一种数据结构,是由一些形如 (k, v) 的键值对所组成的集合,允许通过键 k 快速查找对应的值 v。
【注意】:任意两个数对,其关键字都不等。
在散列表存储字典的键值对 (k, v) 时,通常存储的是整个键值对,即同时存储键 k (用于定位数据的位置)和对应的值 v(实际存储的数据)。这使得可以通过键 k 快速查找对应的值 v,从而可以高效地进行插入、查找和删除操作。

如果键值对 p 的关键字是 k(即 p = (k, v) ),散列函数为 f,那么在理想情况下(散列表位置的数量等于字典中关键字的个数),p 在散列表中的位置为 f(k) 。暂时假定散列表的每一个位置最多能够存储一个记录。为了搜索关键字为 k 的键值对 p,先要计算出 f(k),然后查看在散列表的 f(k) 处是否已经存在一个键值对。

–> 如果存在,便找到了该键值对。(也可以删除该键值对,为此只需使散列表的 f(k) 位置为空)
–> 如果不存在,散列表就不包含该键值对,可以把该键值对插在 f(k) 的位置上。

2)理想散列的时间复杂度

在刚刚描述的理想情况下,初始化一个空散列表需要的时间为 O(b)(b 为散列表位置的数量),而查找、插入、删除操作的时间均为 Θ(1)。

虽然理想的散列方法在许多字典的应用中非常有效,但在某些情况下,由于关键字的变化范围太广,使用散列表并不合适或者效果不佳。简单来说,散列方法的目的是将关键字映射到一个固定的大小范围内。然而,如果关键字的种类和数量极其庞大,或者关键字的分布非常不均匀,就可能导致散列表的效果下降。例如,可能会出现大量的哈希冲突(多个关键字映射到同一个位置),使得查找和插入操作变得低效。

3)桶和起始桶的定义

当关键字的范围太大,不能用理想的方法表示时,可以采用并不理想的散列表和散列函数:散列表位置的数量比关键字的个数少,散列函数把若干个不同的关键字映射到散列表的同一个位置上。散列表的每一个位置称为一个(bucket),对关键字为 k 的键值对,f(k) 称为起始桶(home bucket),桶的数量等于散列表的长度或大小。因为散列函数可以把若干个关键字映射到同一个桶,所以桶要能够容纳多个键值对。通常我们考虑两种极端情况:第一种情况是每一个桶只能存储一个键值对;第二种情况是每一个桶都是一个可以容纳全部键值对的线性表。

3. 冲突和溢出

当两个不同的关键字所对应的起始桶相同时(散列函数可能会把两个或两个以上的不同关键字映射到同一地址),冲突(collision)就发生了。这些发生碰撞的不同关键字称为同义词

一方面,设计得好的散列函数应尽量减少这样的冲突;另一方面,由于这样的冲突总是不可避免的,所以还要设计好处理冲突的方法。

因为一个桶可以存储多个键值对,因此发生碰撞也不是什么大问题,只要起始桶足够大,所有对应同一个起始桶的键值对都可以存储在一起。但如果存储桶已经没有多余的空间去存储一个新的键值对时,溢出(overflow)就发生了。

【拓展】单就冲突而言并不可怕,可怕的是它会带来溢出,除非一个桶可以容纳无限多个键值对,否则插入时的溢出就不是那么容易解决的问题了。当映射到散列表中任何一个桶里的关键字数量大致相等时,冲突和溢出的平均数最少,均匀散列函数(uniform hash function)便是这样的函数,那些在实际应用中性能表现好的均匀散列函数被称为良好散列函数(good hash function)。

二、散列函数的构造方法

在构造散列函数时,必须注意以下几点:
① 散列函数的定义域必须包含全部需要存储的关键字,而值域的范围则依赖于散列表的大小或地址范围。
② 散列函数计算出来的地址应该能等概率、均匀地分布在整个地址空间中,从而减少冲突的发生。
③ 散列函数应尽量简单,能够在较短的时间内计算出任意一个关键字对应的散列地址。

下面介绍常用的散列函数:

在不同的情况下,不同的散列函数具有不同的性能,因此不能笼统地说哪种散列函数最好。在实际选择中,采用何种构造散列函数的方法取决于关键字集合的情况,但目标是尽量降低产生冲突的可能性。

1. 直接定址法

直接取关键字 key 的某个线性函数值为散列地址,散列函数 H(key) 为:
H(key) = key 或 H(key) = a × key + b(其中,a 和 b 是常数)

这种方法计算最简单,且不会产生冲突。它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间的浪费。

2. 除留余数法

这是一种最简单、最常用的方法,假定散列表表长为 m,取一个不大于 m 但最接近或等于 m 的质数 p,利用以下公式把关键字 key 转换成散列地址。散列函数 H(key) 为:
H(key) = key % p

除留余数法的关键是选好 p,使得每个关键字通过该函数转换后等概率地映射到散列空间上的任意一个地址,从而尽可能减少冲突的可能性。

3. 数字分析法

设关键字是 r 进制数(如十进制数),而 r 个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些(每种数码出现的机会均等);而在某些位上分布不均匀(只有某几种数码经常出现),此时应选取数码分布较为均匀的若干位作为散列地址。

这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。

4. 平方取中法

顾名思义,这种方法取关键字的平方值的中间几位作为散列地址。具体取多少位要视实际情况而定。这种方法得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀。

适用于关键字的每位取值都不够均匀或均小千散列地址所需的位数。

三、处理冲突的方法

应该注意到,任何设计出来的散列函数都不可能绝对地避免冲突。为此,必须考虑在发生冲突时应该如何处理,即为产生冲突的关键字寻找下一个“空”的 Hash 地址。

用 Hi 表示处理冲突中第 i 次探测得到的散列地址。假设得到的另一个散列地址 H1 仍然发生冲突,只得继续求下一个地址 H2,以此类推,直到 Hk 不发生冲突为止,则 Hk 为关键字在散列表中的地址。

1. 开放定址法

【每一个桶只能存储一个键值对的情况】

所谓开放定址法,是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。其数学递推公式为:
Hi = (H(key) + di) % m
式中,H(key) 为散列函数;i = 0, 1, 2, …, k(k <= m -1);m 表示散列表表长;di 为增量序列。取定某一增量序列后,对应的处理方法就是确定的。通常有以下4 种取法:

1)线性探测法

又称线性探测再散列法,di = 0, 1, 2, …, m - 1

这种方法的特点是:冲突发生时,顺序查看表中下一个单元(探测到表尾地址 m - 1 时,下一个探测地址是表首地址 0 ),直到找出一个空闲单元(当表未填满时一定能找到一个空闲单元)或查遍全表。

假设要查找关键字为 k 的键值对,首先搜索起始桶 f(k),然后把散列表当做环表继续搜索下一个桶,直到以下情况之一发生为止:
① 存有关键字 k 的桶己找到,即找到了要查找的键值对;
② 到达一个空桶;
③ 又回到起始桶 f(k) 。
情况 ② 和 ③ 都说明关键字为 k 的键值对不存在。

线性探测法可能使第 i 个散列地址的同义词存入第 i + 1 个散列地址,这样本应存入第 i + 1 个散列地址的元素就争夺第 i + 2 个散列地址的元素的地址……从而造成大量元素在相邻的散列地址上“聚集”(或堆积)起来,大大降低了查找效率。

2)平方探测法

又称二次探测法,di = 12, -12, 22, -22, … , k2, -k2 ,其中 k <= m / 2,散列表长度 m 必须是一个可以表示成 4 × k + 3 的素数。

平方探测法是一种处理冲突的较好方法,可以避免出现“堆积”问题,它的缺点是不能探测到散列表上的所有单元,但至少能探测到一半单元。

3)双散列法

di = i × Hash2(key) 。需要使用两个散列函数,当通过第一个散列函数 H(key) 得到的地址发生冲突时,则利用第二个散列函数 Hash2(key) 计算该关键字的地址增量。它的具体散列函数形式如下:
Hi = (H(key) + i × Hash2(key)) % m
初始探测位置 H0 = H(key) % m 。i 是冲突的次数,初始为 0 。
在双散列法中,最多经过 m - 1 次探测就会遍历表中所有位置,回到 H0 位置。

4)伪随机序列法

di =伪随机数序列

【注意】:采用开放地址法时,不能随便物理删除表中已有的元素,否则会截断其他同义词元素的查找路径。因此,要删除一个元素时,可以做一个删除记,进行逻辑删除。但这样做的副作用是:执行多次删除后,表面上看起来散列表很满,实际上有许多位置未利用,因此需要定期维护散列表,把带有删除标记的元素进行物理删除。

2. 拉链法(链接法,chaining)

1)方法

【每一个桶都是一个可以容纳全部键值对的线性表】

显然,对于不同的关键字可能会通过散列函数映射到同一地址,为了避免非同义词发生冲突,可以把所有的同义词存储在一个有序线性链表中,这个线性链表由其散列地址唯一标识。假设散列地址为 i 的同义词链表的头指针存放在散列表的第 i 个单元中,因而查找、插入和删除操作主要在同义词链中进行。拉链法适用于经常进行插入和删除的情况。

例如,关键字序列为{19, 14, 23, 01, 68, 20, 84, 27, 55, 11, 10, 79} ,散列函数 H(key) = key % 13,若用拉链法处理冲突,建立的表如下图所示:

2)一种改进的实现方法(仅作了解)

在下图的每个链表上增加一个尾结点,可以改进一些程序的性能。尾结点的关键字值最起码要比插入的所有键值对的关键字都大。在下图中,尾节点的关键字用 ∞ 来表示。有了尾结点,就可以省去一些程序中出现的大部分对空指针的检验操作。需要注意的是,下图中每个链表都有不同的尾结点,而实际上,所有链表可共用一个尾结点。

3)线性探测与拉链法的比较

把线性探查与不带尾节点的链式散列进行比较。

首先考察空间需求:线性探查需要的空间小于有序链式散列需要的空间。
从平均数上看:使用链表时要检查的结点数比使用线性探测和随机探查时要检查的桶数少。

四 、散列查找及性能分析

1. 散列表的查找过程

散列表的查找过程与构造散列表的过程基本一致。对于一个给定的关键字 key,根据散列函数可以计算出其散列地址,执行步骤如下:
1)初始化: Addr = Hash(key);
2)检测查找表中地址为 Addr 的位置上是否有记录,若无记录,返回查找失败;若有记录,比较它与 key 的值,若相等,则返回查找成功标志,否则执行步骤 3) 。
3)用给定的处理冲突方法计算“下一个散列地址”,并把 Addr 置为此地址,转入步骤 2)。

例如,关键字序列{19, 14, 23, 01, 68, 20, 84, 27, 55, 11, 10, 79} 按散列函数 H(key) = key % 13 和线性探测处理冲突构造所得的散列表 L 如下图所示。

① 给定值 84 的查找过程为:
I、首先求得散列地址 H(84) = 6,因 L[6] 不空且 L[6] != 84;
II、则找第一次冲突处理后的地址 H1 = (6 + 1) % 16 = 7,而 L[7] 不空且 L[7] != 84;
III、则找第二次冲突处理后的地址 H2 = (6 + 2) % 16 = 8,L[8] 不空且 L[8] = 84,查找成功,返回记录在表中的序号 8 。

② 给定值 38 的查找过程为:
I、先求散列地址 H(38) =12,L[12] 不空且 L[12] != 38;
II、则找下一地址 H1 = (12 + 1) % 16 = 13,由于 L[13] 是空记录,故表中不存在关键字为 38 的记录。

查找各关键字的比较次数如下图所示:

平均查找长度 ASL 为:
ASL = ( 1 × 6 + 2 + 3 × 3 + 4 + 9) / 12 = 2.5

【注】对同一组关键字,设定相同的散列函数,则不同的处理冲突的方法得到的散列表不同,它们的平均查找长度也不同。

2. 平均查找长度与装填因子α

从散列表的查找过程可见:

1)虽然散列表在关键字与记录的存储位置之间建立了直接映像,但由于“冲突"的产生,使得散列表的查找过程仍然是一个给定值和关键字进行比较的过程。因此,仍需要以平均查找长度作为衡量散列表的查找效率的度量。

2)散列表的查找效率取决于三个因素:散列函数、处理冲突的方法和装填因子。
装填因子。散列表的装填因子一般记为 α,定义为一个表的装满程度,即:
α = 表中记录数 n / 散列表长度 m
散列表的平均查找长度依赖于散列表的装填因子 α,而不直接依赖于 n 或 m 。直观地看,α 越大,表示装填的记录越“满“,发生冲突的可能性越大,反之发生冲突的可能性越小。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值