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个槽位的任何一个中去,并与其他的关键字映射到哪个槽位无关。在实践中,通常运用启发式技术来构造好的散列函数,一种好的做法是以独立于数据中可能存在的任何模式的方式导出散列值,例如接下来介绍的“除法散列”。
最开始介绍的直接寻址表也是一种散列方式,其h(k)=k,除此之外,下面介绍其它三种散列方式。
4.1 除法散列法
除法散列法是通过关键字k除以m的余数,来将k映射到m个槽的某一个中去,即散列函数为:
h(k)=kmodm
应用除法散列方式的关键在于m的选择。
4.2 乘法散列法
构造散列函数的乘法方法包括两个步骤:
第一步,用关键字k乘以常数A(0 < A < 1)并取出kA的小数部分;
第二步,用m乘以求出的小数部分,在去结果的底值;
散列函数为:
h(k)=floor(m(kAmod1))
乘法散列的一个优点是其对m的选择没有特殊要求,一般设为2的某个次幂。
4.3 全域散列
以上讨论的散列方法都不可避免的会出现最坏情况,即所有关键字映射到同一个槽内,这是平均检索时间为O(n)。其实,任何一个特定的散列函数都可能出现最坏情况,唯一有效的改进方法为随机的选取散列函数,使之独立于要存储的关键字,这种方法也被称为全域散列,该方法的平均性能最佳。
全域散列性态讨论详见《算法导论》P139~P141。
5 碰撞冲突解决策略
5.1链接法
链接法是一种最简单的碰撞解决技术,该方法选择把散列到同一个槽中的元素都放在一个链表中。例如槽j中有一个指针指向所有散列到j的元素构成的链表的头,如果没有元素映射到此,则该指针为nil。
链接法解决碰撞冲突后,散列表T上的字典操作就很容易实现了。
这里写代码片
由以上讨论可以看出,链接法解决冲突后,对于散列表的插入操作始终可以在O(1)内实现,查找操作时间复杂度与该元素所在链表的长度成线性关系,而删除操作对于双向链表删除一个元素x(指针结点)同样可以在O(1)实现,若是单链表必须首先根据输入参数查找目标结点的前一个结点,故其与查找操作的复杂度相同。