散列表
散列(hashing)是一种无需查找,只用元素的查找键确定元素索引的方法,即通过访问key而直接访问存储的value值
。数组本身就是一个散列表。在key - value之间存在一个映射函数(散列函数),该函数加快了查找的速度。
散列方法不同于顺序查找、二分查找、二叉排序树查找,它不以关键字的比较为基本操作,采用直接寻址技术
(直接通过key映射到内存地址上)。在理想情况下,无须任何比较就可以找到待查关键字,查找的期望时间为O(1)。
当关键字集合很大时,不同的key可能被映射到哈希表的同一地址,即k1≠k2,但H(k1)=H(k2),该现象称为冲突。
实际中,冲突是不可避免的,可通过改进哈希函数的性能和利用处理冲突的方法。
构造哈希函数的技术有:(这里就不展开分析了)
- 直接定址法
- 除留余数法
- 数字分析法
- 平方取中法
- 折叠法
处理冲突的方法有:
- 开放定址法(线性探测法、二次探测法、随机探测法)
- 再哈希法(双散列)
- 链地址法
- 公共溢出区
1、开放定址法
由key得到的散列地址一旦产生了冲突,就去寻找下一个
空的散列地址
,并将value存进去。
(1)线性探测
线性探测:当发生冲突时,从冲突位置的下一个位置起,依次寻找空的散列地址。当探测序列到达散列表末尾,再从散列表的起始位置起继续查找。
例:若散列表表长为11,散列函数为H(key)=key mod 11,有4个键值对(key, value)——(4,101)、(15,128)、(26,70)、(59,31),可以知道它们的key同时映射到索引4的地址。
第一个插入将使用hashTable[4],第二个插入发现hashTable[4]已被占用,于是它向前探测发现hashTable[5]为null,于是占用hashTable[5],同理,第三、第四个向前探测并分别占用了hashTable[6]和hashTable[7]。如下图所示。
线性探测可以检测到散列表的每个位置,因此,只要列表不是满的,线性探测就可以确保add成功。
检索
假若查找key=59对应的值,需要做什么呢?先定位到索引4的地址,然后比较存在索引4的key是否相等,如此下去,直到找到索引7。
假若所找的key不存在散列表中,则查找过程将遇到一个null位置,表明查找不成功。但该结论是否正确呢,还需要考虑删除的过程。
删除
假定上述经过4次插入后,删除元素(15,128)和(26,70)——从一个数组位置删除元素的最简单方式是将null放进这个位置。如下图。
现在若想查找key=59对应的值,查找将会终止于索引5,显然这是不正确的。
为解决这个问题,散列表应该分成3种类型:
- 被占据——该位置已存有元素
- 空闲——含null,该位置重未被用过
- 可用——该位置曾用过
因此,删除不应该将null放进散列表,相反应该将这个位置编码为可用。检索过程中在遇到可用位置时应该继续下去,只有在成功或遇到null位置时才停止。删除过程中的查找遵循相同的方式。
在插入中重复利用散列表的位置
若现在需要插入键值对(37,167),同样key也映射到索引4的地址,索引4已存有元素,于是开始对数组进行查找,直到发现一个null的位置。如图,这个查找将终止于索引8。但新元素应该插入这个位置吗?当然,你可以插入,但这个位置是最适合的吗?没错,你可能已经想到了索引5和索引6的可用位置。也就是,最接近映射点索引4的位置,以便以后可以更快找到它。
因此,添加的元素可存在空闲或可用的位置。
线性探测缺点:会导致一次聚集,即散列表中成组的连续位置被占据。插入过程中的任何冲突都会增加聚群的长度,更大的聚群意味着冲突之后更长的查找时间。更严重的是,聚群可能会合并为更大的聚群。
(2)二次探测
二次探测就是为了解决线性探测的一次聚集问题而出现的。正如上面所说,如果一个key定位到索引k,线性探测将查看从索引k开始的连续位置。
而
二次探测则是考虑索引k+j^2处的位置,即使用索引k、k+1、k+4、k+9,以此类推。当探测序列到达散列表末尾,再从散列表的起始位置起继续查找。
二次探测同样使用了三种状态:被占据、空闲、可用。并且与线性探测的处理方法类似,重复利用可用状态的位置。
二次探测避免了一次聚集问题,但可能导致
二次聚集,但二次聚集通常不是个严重的问题。
二次探测的特点:
- 使用索引k+j^2处的位置来解决冲突
- 如果散列表的长度为素数,则可到达散列表中一半的位置
- 避免了一次聚集,但可导致二次聚集
2、再哈希法(双散列)
线性探测和二次探测均对k加上增量以确定探测顺序——对线性探测是1,对二次探测是j^2——与查找键无关。再哈希法则是使用第二个散列函数,以依赖于查找建的方式计算这些增量。因此,再哈希法既避免了一次聚集又避免了二次聚集。
再哈希法的特点:
- 再哈希法检查的是定位地址k加上由第二个哈希函数生成的增量处的位置。第二个哈希函数应该:(1)不同于第一个散列函数;(2)依赖于查找键;(3)具有非0值。
- 如果散列表的长度为素数,则可到达散列表的所有位置
- 既避免了一次聚集又避免了二次聚集
例:若散列表表长为7(素数),散列函数对为h1(key)=key mod 7,h2(key)=5 - key mod 5,对于查找键16有h1(16)=2,h2(16)=4,则探测序列由2开始,探测以4为增量的位置。如下图。于是探测序列有着如下索引:2,6,3,0,4,1,5,2,...这个序列到达了散列表中的所有位置,然后重复自身。
开放地址法和再哈希法潜在问题: 散列表的位置分成三种状态:被占据、空闲、可用,其中只有空闲位置才含有null。频繁的插入和删除可能导致散列表只含有少量元素,却已经没有含null的位置,这将会导致查找探测序列的方法无法工作。 对散列表及时检测,并增加散列表的长度,则可以纠正。而下面的链地址法没有该问题。 |
3、链地址法
链地址法:将定位同一个索引的查找键以及对应的值储存到同一个单链表中。链地址法提供了一种高效且简单的处理冲突方法,不存在上述方法的问题,然而因为改变了散列表的结构,所以链地址比开放定址需要更多内存。
4、公共溢出区
将散列表分成基本表和溢出表两部分,将发生冲突的记录储存在溢出表中。查找时,先定位key对应的索引地址,先与基本表的value值进行比较,若相等,则查找成功;否则,再到溢出区进行顺序查找。
例:若key集合{47,7,29,11,16,92,22,8,3},散列函数为H(key)=key mod 11,用公共溢出区处理冲突
5、处理冲突的各方案比较
5.1、装填因子
由于处理冲突花费的时间比求散列函数的值多得多,因此它是散列开销的主要原因。为有助于表达这个开销,定义了一个有关散列表填充程度的度量——装填因子。
装填因子是词典长度与散列表长度之比,即
α的最大值取决于使用的处理冲突方案,对于开放定址,当散列表充满时α=1;对于链地址,α没有最大值。
5.2、开放定址的开销
(1)线性探测
使用线性探测时,随着散列表逐渐填满,发生冲突的可能性更大。实际上,在探测序列中查找指定查找键所需的平均比较次数大约是
随着α的增加,即随着散列表逐渐填满,查找的比较次数也增加。当散列表超过半满时,即α超过0.5时,不成功查找的比较次数比成功查找增加得快得多。因此,当散列表超过半满时性能将迅速降低。
使用线性探测的散列表,随着装填因子α的增加,性能将显著降低,为了保持效率,当散列表半满时,需定义一个更大的散列表。
(2)二次探测与再哈希
二次探测形成的二次聚集不像线性探测时发生的一次聚集那样严重,在探测序列中查找指定查找键所需的平均比较次数大约是
随着
α的增加,不成功查找的比较次数比成功查找增加得快一些,不像线性探测那么严重,尽管如此,仍然希望α<0.5以保持效率。
尽管再哈希避免了线性探测与二次探测的聚集问题,但效率的估计仍与二次探测相同。
5.3、链地址的开销
使用链地址时,查找过程中的平均比较次数大约是:
随着α的增加,查找的比较次数只是略微增加。因此,使用链地址的散列表的平均性能不随装填因子α增加而显著降低,为了保持合理的效率,应该保持α<1。
1、链地址的高效,使之成为解决冲突的最好方法 2、在开放定址方案中,再哈希是一个不错的选择,比较次数比线性探测优,并且它的探测序列可到达整个散列表,而二次探测不行。 |
6、再散列
当装填因子过大时,需要扩展散列表,为觉得散列表的新长度,首先将它现在的长度加倍,然后将结果增加到下一个素数。使用方法add将词典中当前的元素插入新散列表。