1 序
在很多应用中,都要用到一种动态集合结构,它仅支持INSERT、SEARCH以及DELETE三种字典操作。例如计算机程序设计语言的编译程序需要维护一个符号表,其中元素的关键字为任意字符串,与语言中的标识符相对应。实现字典的一种有效数据结构为散列表。
散列表是普通数组的推广,因为可以对数组进行直接寻址,故可以在O(1)的时间内访问数组的任意元素。对于散列表,最坏情况下查找一个元素的时间与在链表中查找的时间相同,为O(n),但是在实践中,散列表的效率通常是很高的,在一些合理的假设下,散列表中查找的期望时间为O(1)。
2 直接寻址表
当关键字的全域U比较小时,直接寻址是一种简单而有效的技术。
为表示动态数组,定义一个数组(直接寻址表)T[0…m-1],其中每个位置对于全域U中的一个关键字。具体方法如下图所示:
字典操作实现伪码:
//查询操作
DirectAddressSearch(T , k)
return T[k];
//插入操作
DirectAddressInsert(T , x)
T[key[x]] = x;
//删除操作
DirectAddressDelete(T , x)
T[key[x]] = NULL;
对于直接寻址表时间复杂度很低,但是其存在的问题是,需要覆盖全域的内存容量,空间复杂度高。因此,对于全域U很大而且内存容量不足的应用问题,直接寻址表不是一个理想的解决方案。
3 散列表
在直接寻址表中,具有关键字k的元素就被放到相应的槽k中。在现在所讨论的散列表中,关键字k的元素的映射位置将由一个散列函数h(k)计算得到。
显然的,采用此种方法,内存空间的占用从全域|U|减少到关键字的个数m,大大节省了内存开销。
但是,这样做会带来问题,两个或多个关键字可能会映射到同一个槽上,也就是所说的碰撞冲突。这与散列函数(下一节介绍)的选取息息相关,当然,我们不仅需要通过精心设计的随机散列函数来减少碰撞,也需要思考找到解决有可能出现碰撞的办法。接下来详细介绍几种散列函数与碰撞冲突解决策略。
4 散列函数
散列函数h(k)是计算关键字k映射位置的一种函数。对于全域U中的m个关键字,一个好的散列函数应该近似的满足简单一致散列的假设:每个关键字等可能的散列到m个槽位的任何一个中去,并与其他的关键字映射到哪个槽位无关。在实践中,通常运用启发式技术来构造好的散列函数,一种好的做法是以独立于数据中可能存在的任何模式的方式导出散列值,例如接下来介绍的“除法散列”。
最开始介绍的直接寻址表也是一种散列方式,其
4.1 除法散列法
除法散列法是通过关键字k除以m的余数,来将k映射到m个槽的某一个中去,即散列函数为:
应用除法散列方式的关键在于m的选择。
4.2 乘法散列法
构造散列函数的乘法方法包括两个步骤:
第一步,用关键字k乘以常数A(0 < A < 1)并取出kA的小数部分;
第二步,用m乘以求出的小数部分,在去结果的底值;
散列函数为:
乘法散列的一个优点是其对m的选择没有特殊要求,一般设为2的某个次幂。
4.3 全域散列
以上讨论的散列方法都不可避免的会出现最坏情况,即所有关键字映射到同一个槽内,这是平均检索时间为O(n)。其实,任何一个特定的散列函数都可能出现最坏情况,唯一有效的改进方法为随机的选取散列函数,使之独立于要存储的关键字,这种方法也被称为全域散列,该方法的平均性能最佳。
全域散列性态讨论详见《算法导论》P139~P141。
5 碰撞冲突解决策略
5.1链接法
链接法是一种最简单的碰撞解决技术,该方法选择把散列到同一个槽中的元素都放在一个链表中。例如槽j中有一个指针指向所有散列到j的元素构成的链表的头,如果没有元素映射到此,则该指针为nil。
链接法解决碰撞冲突后,散列表T上的字典操作就很容易实现了。
这里写代码片
由以上讨论可以看出,链接法解决冲突后,对于散列表的插入操作始终可以在O(1)内实现,查找操作时间复杂度与该元素所在链表的长度成线性关系,而删除操作对于双向链表删除一个元素x(指针结点)同样可以在O(1)实现,若是单链表必须首先根据输入参数查找目标结点的前一个结点,故其与查找操作的复杂度相同。
5.2 开放寻址法
开放寻址法也是一种碰撞冲突解决策略,在该方法中,所有的元素都存放在散列表里。在开放寻址法中,当要插入一个元素时,可以连续的检查或称为探查散列表的各项,直到找到一个空槽来放置待插入的关键字时为止。
这样,散列函数就变为:
h : U X {0,1,…m-1} -> {0,1,…m-1}
对于开放寻址法来说,要求每一个关键字k,探查序列< h(k,0) , h(k,1) , … , h(k ,m-1)>必须是< 0,1, … , m-1>的一个排列,使得当散列表被逐渐填满时,每一个表位最终都可以被视为用来插入新关键字的槽。
三种技术常用来计算开放寻址法中的探查序列:线性探查、二次探查以及双重探查。
i 线性探查
给定一个普通的散列函数
给定一个关键字k,第一次探查的槽是
线性探查方法简单,容易实现,但是其存在着一个问题—一次群集问题。随着时间的推移,被占用的槽也不断增加,因此,平均查找时间也会不断增加,群集现象很容易出现。
ii 二次探测
二次探查采用下面形式的散列函数:
其中
iii 双重散列
双重散列是用于开放寻址法的最好方法之一,因为它所产生的排列具有随机选择的排列的许多特性,散列函数如下:
其中
如下图所示:
与线性探查、二次探查不同的是,这里的探查序列以两种方式依赖于关键字k,因为初始探查位置、偏移量都有可能发生变化。
5.3完全散列
在本章的最后,讨论了一种高效的散列技术—完全散列,它在进行查找时,最坏情况内存访问次数为O(1)。实现思想如下图所示: