散列表是实现字典操作的一种有效数据结构。尽管在最坏情况下散列表查找一个元素的与链表中的时间相同,达到θ(n)。然而在实际应用中,在一些合理的假设下,在散列表中查找一个元素的平均时间是O(1)。
散列表是普通数组概念的推广。当实际存储的关键字数目比全部的可能关键字总数要小时,采用散列表称为直接数组寻址的一种有效替代,因为散列表使用一个长度与实际存储的关键字数目成比例的数组来存储。在散列表中,不是直接把关键字作为数组的下标,而是根据关键字计算出相应的下标。
11.1 直接寻址表
当关键字的全域U比较小是,直接寻址是一种简单而有效的技术。假设某应用要用到一个动态集合,其中每个元素都是取自于全域U={0,1,…,m-1}中的一个关键字,m不是一个很大的数。另外,假设没有两个元素具有相同的关键字。
为表示动态集合,用一个数组,或称为直接寻址表,记为T[0..m-1],其中,每个位置,或称为槽,对应全域U中的一个关键字。槽k指向集合汇总一个关键字为k的元素。如果该集合中没有关键字为k的元素,则T[k] = NIL。
几个字典操作实现非常简单:
DIRECT-ADDRESS-SELECT(T, k)
return T[k]
DIRECT-ADDRESS-INSERT(T,x)
T[x.key] = x
DIRECT-ADDRESS-DELETE(T,x)
T[x.key] = NIL
对于某些应用,直接寻址表本身就可以存放动态集合中的元素。使用对象内的一个特殊关键字表明该槽为空槽。而且,通常不必存储该关键字的关键字属性。
11.2 散列表
直接寻址技术的缺点:如果全域U很大,则在一台计算机可用内存容量中,要存储大小为U的一张表T不太实际。还有,实际存储的关键字集合K相对U来说可能很小,使得分配给T的大部分空间都将浪费掉。
当存储在字典中的关键字集合K比所有可能的关键字的全域U要小许多时,散列表需要的存储空间比直接寻址表少得多。同时散列表中查找一个元素的优势得到保持,只需要O(1),但是是针对平均情况。
在直接寻址方式下,具有关键字k的元素被存放在槽K中。在散列方式下,该元素存放在槽h(k)中:即利用散列函数**h,由关键字k计算出槽的位置。函数h将关键字的全域U映射到散列表**T[0..m-1]的槽位上:
h:U→{0,1,…,m-1}
这里散列表的大小m一般比|U|小得多。可以说一个关键字k的元素被散列到槽h(k)上,也可说h(k)是关键字k的散列值。散列函数缩小了数组下标的范围,即减小了数组的大小。
存在问题:两个关键字映射到同一个槽中,称这种情形为冲突。理想的解决办法就是避免所有的冲突。但是,完全避免冲突是不可能的。因此,一方面可以通过设计的散列函数来尽量减少冲突的次数,另一方面仍需要有解决可能出现的冲突。
余下部分介绍一种最简单的冲突解决方法,称为链接法。11.4介绍另一种方法称为开放寻址法。
通过链接法来解决冲突
在链接法中,把散列到同一槽中的所有元素都放在一个链表中。槽 j 中有一个指针,它指向存储所有散列到 j 的元素的链表的表头;如果不存在这样的元素,则槽 j 中为NIL。
字典操作:
CHAINED-HASH-INSERT(T,x)
insert x at the head of list T[h(x,key)]
CHAINED-HASH-SEARCH(T,k)
search for an element with key k in list T[h(k)]
CHAINED-HASH-DELETE(T,x)
delete x from the list T[h(x,key)]
链接法散列的分析
给定一个能存放n个元素的、具有m个槽位的散列表T,定义 T 的装载因子α为n/m,即一个链的平均存储元素数。
链接法的最坏情况:所有的n个关键字都散列到同一个槽中,产生一个长长度为n的链表。这是,最坏情况下查找的时间为θ(n)。
散列方法的平均性能依赖于所选取的散列函数 h,将所有的关键字集合分布在 m 个槽位上的均匀程度。
定理 11.1 在简单均匀散列的假设下,对于用链接法解决冲突的散列表,一次不成功查找的平均时间为θ(1+α)。
定理 11.2 在简单均匀散列的假设下,对于用链接法解决冲突的散列表,一次成功查找所需的平均时间为θ(1+ α)。
如果散列表中槽数至少与表中的元素数成正比,则有n = O(m),从而α = n/m = O(m)/m = O(1).所以,查找操作平均需要常数时间。当链表采用双向链表时,插入操作在最坏情况下需要O(1)时间,删除操作最坏情况需要O(1),因而,全部的字典操作平均情况都可以在O(1)时间内完成。
11.3 散列函数
如何设计散列函数,三种具体方法,其中的两种(用除法进行散列和用乘法进行散列)本质上属于启发式方法,第三种(全域散列)则利用了随机技术来提供可证明的良好性能。
好的散列函数的特点
应满足简单均匀散列假设:每个关键字都被等可能地散列到 m 个槽位中的任何一个,并与其他关键字已散列到哪个槽位无关。
在实际应用中,常常可以运用启发式方法来构造性能好的散列函数。设计过程中,可以利用关键字分布的有用信息。好的散列函数应能将这些相近符号散列到相同槽中的可能性最小化。
一种好的方法导出的散列值,在某种程度上应独立于数据可能存在的任何模式。
最后,注意到散列函数的某些应用可能会要求比简单的均匀散列更强的性质。
11.3.1 除法散列法
通过取 k 除以 m 的余数,将关键字 k 映射到 m 个槽中的某一个上。当应用除法散列时,应避免选择 m 的某些值。例如 m 不应为 2 的幂。
一个不太接近 2 的整数幂的素数,常常是 m 的一个较好的选择。
11.3.2 乘法散列法
第一步:用关键字 k 乘上常数A(0 < A <1),并提取 kA 的小数部分。第二部,用 m 乘以这个值,再向下取整。散列函数为:
h(k) = ⌊m(kA mod 1)⌋
这里”kA mod 1“是取 kA 的小数部分,即 kA - ⌊kA⌋。
优点:对m 的选择不是特别关键,一般选择它为 2 的某个幂次。最佳的选择与待散列的数据的特征有关。Knutb[211]认为A约等于根号5-1除以2=0.618…
11.3.3 全域散列法
随机地选择散列函数,使之独立与要存储的关键字,这种方法称为全域散列。
11.4 开放寻址法
在开放寻址法中,所有的元素都存放在散列表中。也就是说每个表项会包含动态集合的一个元素,会包含NIL。不像链接法,这里既没有链表,也没有元素存放在散列表外。因此,在开放寻址法中,散列表可能会被填满,以至于不能插入任何新的元素。
当然也可以将用作链接的链表存放在散列表未用的槽中,但开发寻址的好处在于它不用指针,而是计算出要存取的槽序列。
为了使用开放寻址法插入一个元素,需要连续地检查撒列表,或称为探查,直到找到一个空槽来放置待插入的关键字为止。为了确定要探查那些槽,将散列函数加以扩充,使之包含探查号以作为其第二个输入参数。对每一个关键字 k,使用开放寻址的探查序列
i = 0
repeat
j = h(k,i)
if T[j] == NIL
T[j] = k
return j
else i = i + 1
until i == m
error "hash table overflow"
过程HASH-SEARCH的输入为一个散列表T和一个关键字k
HASH-SEARCH(T, k)
i = 0
repeat
j = h(k,j)
if T[j] == k
return j
i = i + 1
until T[j] == NIL or i == m
return NIL
删除操作比较困难。当从槽 i 中删除关键字时,不能仅将NIL置于其中来标识它为空。因此,在必须删除关键字的应用中,更常见的做法是采用链接法来解决冲突。
做一个均匀散列的假设:每个关键字的探查序列等可能地为m!中排列的任一种。有三种方法用来计算开放寻址法中的探查序列:线性探查、二次探查、和双重探查。但是这些技术都不能满足均匀散列的假设,因为它们能产生的不同探查序列数都不超过
m2
个。其中,双重散列产生的探查序列数最多,似乎能给出最好的结果。
线性探查
给定一个普通的散列函数,称之为辅助散列函数,给定一个关键字 k,首先探查由辅助函数所给出的槽位T[h’(k)],在探查槽T[h’(k)+1],以此类推,直至T[m-1]。然后,又绕到T[0],T[1],…直到最后T[h’(k)-1]。
线性探查比较容易实现,但是存在一个问题:称为一次群集。随着连续被占用的槽不断增加,平均查找时间也随之不断增加。
二次探查
初始探查位置为T[h’(k)],后续的探查位置要加上一个偏移量,该偏移量以二次的方式依赖于探查序号 i。
双重散列
是用于开放寻址法的最好方法之一。初始探查位置为
T[h1(k)]
,后续的探查位置是前一个位置加上偏移量
h2(k)
模 m。
开发寻址散列的分析
使用开放寻址法,每个槽中至多有只有一个元素。即α <=1.
定理 11.6 给定一个装载因子为α <=1的开放寻址散列表,并假设是均匀散列的,则对于一次不成功的查找,其期望的探查次数至多为1/(1-α).
推论 11.7假设采用的均匀散列,平均情况下,向一个装载因子为α <=1的开放寻址散列表插入一个元素至多需要做1/(1-α)次探查。
定理 11.8 对于一个装载因子为α <=1的开放寻址散列表,一次成功查找中的探查期望数至多为:
11.5 完全散列
使用散列技术通常是个好的选择,不仅它有优异的平均情况性能,而且当关键字集合是静态是,散列表能提供出色的最快情况性能。所谓静态,就是指一旦各关键字存入表中,关键字结合就不再变化了。一种散列方法称为完全散列,如果该方法进行查找时,能在最坏情况下用O(1)次完成。
采用两级的散列方法来设计完全散列方案,在每级上都使用全域散列。
第一级与带链接的散列表基本上是一样的:利用从某一全域散列函数簇中仔细选出的一个散列函数 h,将 n 个关键字散列到 m 个槽中。
然而,采用了一个较小的二次散列表
Sj
及相关的散列函数
hj
,而不是将散列到槽 j 中的所有关键字建立一个链表。利用精心选择的散列函数
hj
,可以确保咋第二级上不出现冲突。