算法导论(十一)--散列表

来源:互联网 发布:mac取消所有密码 编辑:程序博客网 时间:2024/06/07 01:17
算法导论(十一)--散列表 (2012-04-11 17:26)
标签: 转载 

原文地址:算法导论(十一)--散列表作者:yourtommy


直接寻址表

假设一个集合里关键字的全域U比较小时,可以使用直接寻址。比如全域为0~9的整数集,可以用一个大小为10的数组来存放该集合的子集:

它的相关操作比较直接:


  1. DIRECT_ADDRESS_SEARCH(T, k){
  2. 1   return T[k];
  3. }

  4. DIRECT_ADDRESS_INSERT(T, x){
  5. 1   T[x.key]= x;
  6. }

  7. DIRECT_ADDRESS_DELETE(T, x){
  8. 1   T[x.key]= NIL;
  9. }

以上操作运行时间都为O(1)。


散列表
大多数情况下,关键字的全域都是比较大的,我们很难用一个如此大的数组来存放数据,而且实际使用到的关键字可能会比全域的数量小很多,所以我们可以用一个较小的数组,利用散列函数h,根据关键字k计算出数组中的位置。
下图把一个全域为U的集合通过散列函数h映射到大小为m的数组中:

在选择散列函数时,我们会尽可能的把全域中的关键字均匀地散列到数组中,但有时我们仍不能避免出现碰撞(collision),即不同的关键字被散列到同一个槽里。链接法就是解决碰撞的一种方式:

在碰撞情况出现时,用一个链表维护被映射到同一个槽里的元素。它的相关操作的伪代码如下:


  1. CHAINED_HASH_INSERT(T, x){
  2. 1   insert x at the head of list T[h(x.hey)];
  3. }

  4. CHAINED_HASH_SEARCH(T, k){
  5. 1    search for an element with key k in list T[h(k)];
  6. }

  7. CHAINDED_HASH_DELETE(T, x){
  8. 1    delete x from the list T[h(x.key)];
  9. }


下面来分析下链接法的性能。hash函数的运行时间为O(1),所以插入和删除都为常量时间O(1)。(注意,插入和删除都是以元素x而非关键字k作为输入,所以不用查找。)

接下来分析查找特定元素的性能。对于一个存放了n个元素,具有m个槽的散列表T,定义它的装载因子(load factor)a为n/m,即每个槽里链表的平均长度。a可以小于、等于或大于1。

最坏情况下,所有的元素都被散列到同一个槽里,这样查找的运行时间为Ө(n)

为了分析平均情况,我们假设所有元素被散列到m个槽中的每一个的可能性是相同的,这个假设为简单一致散列(simple uniform hashing)
查找不成功的情况下,(即关键字k不在散列表中,)我们会遍历某个槽的链表的所有元素,而该链表的元素数量的平均值为a,所以这种情况下运行时间为Ө(1+a)
在查找成功的情况下,遍历的次数是由x在其所在链表中位置决定的。因为每次插入都是插在链表头上,所以x前的元素都是在x之后放入散列表的。

设 k_i为第i个插入到表中的元素,i = 1, 2, ..., n。而k_j为在k_i后插入到表中的元素,j = i+1, ..., n。定义指示器变量X_i_j = I{h(k_i) = h(k_j)},即两个元素被散列到同一个槽。在k_i已经存在于散列表的情况下,k_j和k_i被散列到到同一个槽的概率为1/m,即E[X_i_j] = 1/m。我们就是要求在x_i之后插入到表且与x_i被散列到同一个槽的元素个数(包括x_i本身)的期望值:
E[1/n * ∑<i=1→n>(1 + ∑<j=i+1→n>X_i_j)]
=  1/n * E[∑<i=1→n>(1 + ∑<j=i+1→n>X_i_j)]
=  1/n * E[∑<i=1→n>(1 + ∑<j=i+1→n>1/m)]
=  1 + 1/(m*n) * ∑<i=1→n>(n-i)
=  1 + 1/(m*n) * ∑<i=1→n> n - ∑<i=1→n> i
=  1 + 1/(m*n) * (n^2 - n*(n+1) / 2)
=  1 + (n-1) / (2*m) = 1 + a/2 + a/(2*n)

上面求出便是为成功找出某特定元素,要遍历的元素的平均个数。所以在平均情况下,一次成功的查找所需的时间为Ө(1+a/2+a/(2*n)) = Ө(1+a)。所以综上所述,平均情况下散列表查找的运行时间为Ө(1+a)。于是得到运行时间与散列表的装载因子有关当装载因子与元素数目n成正比时,散列表插入、删除与查找的运行时间都为O(1)。


散列函数

一个好的散列函数应该(近似地)满足简单一致散列的假设:元素被等可能地散列到各槽中。散列函数都假定关键字域为自然数集N={0, 1, 2, ...}。如果关键字不是自然数,必须有一种方法来将它们解释为自然数。比如字符串pt所对应的ASCII码为(112, 116),我们以128为基数,pt可以表示为(112*128)+116 = 14452。


除法散列法通过取k除以m的余数,来将关键字k映射到m个槽的某一个中去。亦即,散列函数为:
h(k) = k mod n
m 的值不应为2的幂,因为如果m = 2^p,则h(k)就是k的最低p位所表示的数字。我们常常选与2的整数幂不太接近的质数。比如,有n=2000个元素,用链接法解决碰撞,我们可以忍受不成功的查找平均要检查3个元素,所以我们选择接近2000/3,但不接近2的任何次幂的m = 701。


乘法散列法包含两个步骤:1、用关键字乘上常数A(0<A<1),并抽取出k*A的小数部分。2、用m乘以这个抽取出来的小数,再对乘积向下取整(floor)。总之,散列函数为:
h(k) = floor(m*(k*A mod 1) )
mod 1的意思就是取小数部分。k*A mod 1即为k*A - floor(k*A)。

乘法方法的一个优点是对m的选择没什么特别的要求。一般选m为2的某次幂(2^p,p为某个整数),因为在计算机上比较容易实现:假设计算机字长为w,我们限制A值为形如s/(2^w)的分数,其中0<s<2^w。这样我们做下操作:
1、将k与s相乘,它将得到一个2*w位的乘积,值形如R1*2^w + R0;
2、对低w位的R0,取高位的p位,便是散列后的结果。
操作如下图:

对上面的操作解释下,k * A = k*s/(2^w) = (R1*2^w + R0) / (2^w)。因为R1*2^w+R0是一个整数(因为s和k都为整数),所以除以2^w后,R1便是小数点左部的整数部分,而R0成了小数点右侧的小数部分。于是k*A mod 1 = .R0。而m = 2^p,所以m * (k*A mod 1) = 2^p * .R0,等同于.R0左移p位,所以R0中高的p位成了整数部分,floor(m*(k*A mod 1))只取R0的高p位,这便是h(k)的值。

尽管上述方法对任意A值都适用,但对某些值效果更好。Knuth[185]认为
A ≈ (√5 - 1) / 2 = 0.618 033 988 7...
是比较理想的值。举个例子,在32位机上,字长w=32,取A为形如s/(2^32)的分数,为使它与(√5 - 1) / 2最接近,于是取s为2 654 435 769。


全域散列

为了尽可能地避免最坏情况的发生,我们不使用某个特定的散列函数,而是准备好一系列的散列函数,在执行开始时随机选择一个作为之后的散列函数。这种方法称作全域散列(universal hashing)

设H为有限的一组散列函数,它将给定的关键字域U映射到{0, 1, ..., m-1}中,这样的一个函数组称为是全域的(universal),如果它满足以下条件:
对每一对不同的关键字k,l ∈ U,满足h(k) = h(l)的散列函数h∈H的个数至多为|H| / m。换言之,如果从H中随机选择一个散列函数,当k≠l时,两者发生碰撞的概率不大于1/m。

对使用全域散列函数的散列表,其用链接法处理碰撞的,包含某关键字k的链表的期望长度至多为1+a,其中a为装载因子。


下面是一个全域散列函数类:
选择足够大的质数p,使每一个可能的关键字k都落到[0, p-1]的范围内。设Z_p = {0, 1, ..., p-1},设Z_p* = {1, 2, ..., p-1}。对于任何a∈Z_p*,和任何b∈Z_p,定义散列函数:
h_a_b (k) = ((a*k+b) mod p) mod m
所有这样的散列函数构成的函数簇为:
H_p_m = {h_a_b:a∈Z_p*和b∈Z_p}

证明略。有兴趣可以参考原书11.3节。


开放寻址法

开放寻址法(open addressing)中,所有元素都存放在散列表里,每个表项或包含一个元素,或包含NIL,但不会包含链表或其它的处于散列表外的辅助结构。

当插入一个元素时,如果映射的位置已经被其它元素占用,则通过散列函数再产生另一个映射值(称为探查),直到找到空槽或发现表中没有空槽为止。同样,散列函数也需要相应的变化:
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}的一个排列,即散列函数h在连续对同一个关键字k进行散列时,每次得到的都是不一样的值。

下面是插入元素的伪代码:


  1. HASH_INSERT(T, k){
  2. 1   i = 0
  3. 2   do {
  4. 3     j = h(k, i);
  5. 4     if T[j]== NIL {
  6. 5       T[j]= k;
  7. 6       return j;
  8. 7     }
  9. 8     else
  10. 9       i += 1;
  11. 10  } while i≠m
  12. 11  error "hash table overflow"
  13. }

再插入过程,如果有碰撞发生,就增加h的第二个参数的值,直到找到空槽或上溢为止。

下面是查找关键字k的伪代码:


  1. HASH_SEARCH(T, k){
  2. 1   i = 0;
  3. 2   do {
  4. 3     j = h(k, i);
  5. 4     if (T[j]== k)
  6. 5       return j;
  7. 6     else
  8. 7       i += 1;
  9. 8   } while i ≠ mand T[j] ≠ NIL
  10. 9   return NIL;
  11. }

删除操作执行起来比较困难,当我们从槽i中删除关键字时,不能简单地让T[i]=NIL,因为这样会破坏查找的过程。假设关键字k在i之后插入到散列表中,如果T[i]被设为NIL,那么查找过程就再也找不到k了。解决这个问题的方法是引入一个新的状态DELETED,而不是NIL,这样在插入过程中,一旦发现DELETED的槽,便可以在该槽中放置数据,而查找过程不需要任何改动。但如此一来,查找时间就不再依赖于装载因子了,所以在必须删除关键字的应用中,往往采用链接法来解决碰撞。


有三种技术常用来计算开放寻址法中的探查序列:线性探查二次探查双重探查

给定一个普通的散列函数h':U→ {0, 1, ..., m-1}(称为辅助散列函数),线性探查(linear probing)方法采用的散列函数为:
h(k, i) = (h'(k) + i) mod m, i = 0, 1, ..., m-1
它在碰撞发生后,便依次探查当前槽的后一个槽,到T[m-1]后绕回到T[0]继续探查,直到最开始发生碰撞的槽的前一个槽。
线性探查方法比较容易实现,但它存在一个问题,称作一次群集(primary clustering)。随着时间的推移,连续被占用的槽不断增加,平均查找的时间也随着不断增加。

二次探查(quadratic probing)采用如下形式的散列函数:
h(k, i) = (h'(k) + c1*i + c2*i^2) mod m
c1 和c2为常量。这种探查方法的效果比线性探查好很多,但c1, c2, m的取值受到限制。此外,如果两个关键字的初始探查位置相同,那么它们的探查序列也是相同的,即h(k1, 0) = h(k2, 0)意味着h(k1, i) = h(k2, i),这一性质可导致一种程度较轻的群集现象,称为二次群集(secondary clustering)

双重散列是用于开放寻址法的最好方法之一,因为它产生的排列近似于随机选择的排列。它采用如下形式的散列函数:
h(k, i) = (h1(k) + i*h2(k)) mod m
为了能查找整个散列表,值h2(k)要与表的大小m互质。有两种方法:1、m为2的幂,而h2总产生奇数;2、取m为质数,h2则总是产生比m小的正整数。

线性探查和二次探查都只能产生m种不同的序列,而双重散列可以产生m^2种,这样已经与“理想的”一致散列的性能很接近了。


开放寻址法的性能分析

给定一个装载因子a = n/m < 1的开放寻址散列表,定义随机变量X为在一次不成功的查找中所做的探查数,定义事件A_i(i = 1, 2, ...)为进行了第i次探查,且探查到的是一个已被占用的槽的事件,那么事件{X≥i}即为事件A_1∩A_2∩...∩A_(i-1)的交集。因为有n 个元素,m个槽,所以Pr{A_1} = n/m,在第一次碰撞发生后,我们便在其它的槽中寻找空槽,此时再遇碰撞概率Pr{A_2|A_1}为(n-1)/(m-1),因为剩下n-1个元素要分布到余下的m-1个槽中。同理,在第j次尝试时,遇碰撞的概率Pr{A_j | A_1∩A_2∩...A_(j-1)}为(n-j-1)/(m-j-1)。于是:
Pr{X≥i} = n/m * (n-1)/(m-1) * (n-2)/(m-2) * ... * (n-i-2)/(m-i-2) ≤ (n/m)^(i-1) = a^(i-1)
所以对X求期望来确定探查数的平均值:
E[X] = ∑<i=1→∞> Pr{X≥i} ≤ ∑<i=1→∞> a^(i-1) = ∑<i=0→∞> a^i = 1/(1-a) (无限等比数列求和)

根据上面的结论,假设采用的是一致散列,平均情况下,向一个装载因子为a的开放寻址散列表插入一个元素时,至多需要做1/(1-a)次探查

对于散列表中的任何关键字k,设它为第i+1个插入到表中的元素,那么要找到它,则至多需要做1/(1-a)=1/(1-i/m)=m/(m-i)次探查,所以我们对所有的关键字求探查数的平均:
1/n*∑<i=0→n-1> m/(m-i) = m/n * ∑<i=0→n-1> 1/(m-i) 
= m/n * (∑<j=0→m> 1/j - ∑<j=0→m-n> 1/j) (即1/(m-n+1) + ... + 1/m = (1+1/2+...+ 1/(m-n) + 1/(m-n+1)) - (1+1/2+...+1/(m-n)))
= 1/a * (∑<j=0→m> 1/j - ∑<j=0→m-n> 1/j)
≤ 1/a * (ln(m) - ln(m-n)) (调和级数求和)
= 1/a * ln(m/(m-n))
= 1/a * ln(1/(1-a))

所以一次成功的查找平均需要探查的次数为1/a * ln(1/(1-a))


完全散列

如果某种散列技术可以在查找时,最坏情况内存访问次数为O(1)的话,则称其为完全散列(perfect hashing)。当关键字集合是静态的时,这种最坏情况的性能是可以达到的。所谓静态就是指一旦各关键字存入表中后,关键字集合就不再变化了。

我们可以用一种两级的散列方案来实现完全散列,其中每级上采用的都是全域散列。如下图:

首先第一级使用全域散列把元素散列到各个槽中,这与其它的散列表没什么不一样。但在处理碰撞时,并不像链接法一样使用链表,而是对在同一个槽中的元素再进行一次散列操作。也就是说,每一个(有元素的)槽里都维护着一张散列表,该表的大小为槽中元素数的平方,例如,有3个元素在同一个槽的话,该槽的二级散列表大小为9。不仅如此,每个槽都使用不同的散列函数,在全域散列函数簇h(k) = ((a*k+b) mod p) mod m中选择不同的a值和b值,但所有槽共用一个p值如101。每个槽中的(二级)散列函数可以保证不发生碰撞情况。

可以证明,当二级散列表的大小为槽内元素数的平方时,从全域散列函数簇中随机选择一个散列函数,会产生碰撞的概率小于1/2。(证明略,详细参考原书 11.5节。)所以每个槽随机选择散列函数后,如果产生了碰撞,可以再次尝试选择其它散列函数,但这种尝试的次数是非常少的。

虽然二级散列表的大小要求是槽内元素数的平方,看起来很大,但可以证明,当散列表的槽的数量和元素数量相同时(m=n),所有的二级散列表的大小的总量的期望值会小于2*n,即Ө(n)。(证明过程参考有所原书11.5节。)