【算法导论】学习笔记——第11章 散列表

11.1 直接寻址表
当关键字的全域U很小,可采用直接寻址的方式。假设动态集合S的元素都取自全域U={0, 1, ..., m-1}的一个关键字,并且没有两个元素具有相同的关键字。
为表示动态集合,使用直接寻址表(diret-address table),记为T[0...m-1],其中的每个位置称为槽(slot)。直接寻找表就是按照数组索引,缺点明显。基本操作如下:

1 DIRECT-ADDRESS-SEARCH(T, k)
2     return T[k]
3 
4 DIRECT-ADDRESS-INSERT(T, x)
5     T[x.key] = x
6 
7 DIRECT-ADDRESS-DELETE(T, x)
8     T[x.key] = NULL


11.1-4 在大数组上以直接寻址的方式实现字典,并保证每个存储对象占用O(1)的空间,SEARCH、DELETE、INSERT的操作时间均为O(1);并且数据结构初始化的时间为O(1)。
思路:不妨设动态几何包含n个关键字,覆盖{0,1...m-1}的全域范围。则辅助数组的大小被限定为n,而大数组则>>m。不难想到可以利用栈来存入关键字,但是这样SEARCH、INSERT、DELETE操作无法达到O(1)。
因此,换个思路,将关键字存储在栈里面,而大数组则保存栈的位置。通过关键字匹配以及栈顶与数组中位置的大小可以确定唯一的元素。
代码实现如下:

 1 int T[MAXN];
 2 int stack[MAXM], top=0;
 3 
 4 int Direct_Address_Search(int T[], int k) {
 5     if (T[k]>=0 && T[k]<top && stack[T[k]]==k)
 6         return T[k];
 7     return -1;
 8 }
 9 
10 void Direct_Address_Insert(int T[], int k) {
11     T[k] = top;
12     stack[top++] = k;
13 }
14 
15 void Direct_Address_Delete(int T[], int x) {
16     if (x < 0)
17         return ;
18     --top;
19     T[stack[top]] = x;
20     stack[x] = stack[top];
21 }

11.2 散列表
散列表即哈希,利用散列函数(hash function)h,由关键字k计算出槽的位置。函数h将关键字的全域U映射到散列表T[0...m-1]的槽位上:
  h: U -> {0,1,...,m-1}
这里的散列表的大小m一般要比|U|小得多。当然,这就会引发一个问题:两个关键字可能会映射到一个槽中。我们称这种情形为冲突(collision)。

通过链接法解决冲突
链接法,即把散列到同一槽中的所有元素都放在一个链表中。给定一个能存放n个元素的、具有m个槽位的散列表T,定义T的装载因子(load factor)α为n/m,即一个链的平均存储元素数。
散列方法的平均性能依赖于所选取的散列函数h,将所有的关键字集合等可能地散列到m个槽中的任何一个,且与其他元素被散列到什么位置上无关。我们称这个散列为简单均匀散列(simple uniform hashing)。
对于j=0,1,...,m-1,列表T[j]的长度用n_j表示,于是有n = n_0 + n_1 + ... + n_(m-1),并且n_j的期望值为E[n_j] = α = n/m。
定理11.1 在简单均匀散列的假设下,对于用链接法解决冲突的散列表,一次不成功查找的平均时间为Θ(1+α)。
定理11.2 在简单均匀散列的假设下,对于用链接法解决冲突的散列表,一次不成功查找的平均时间为Θ(1+α)。
这也意味着如果散列表中槽数至少与表中的元素数成正比,则有n=O(m),从而α = n/m = O(m)/m = O(1)。所以,查找操作平均需要O(1)的时间,当链表采用双向链表时,插入操作在最坏情况下需要O(1)的时间,删除操作在最坏情况下也需要O(1)的时间。因而,全部的字典操作可以再O(1)的时间内完成。

11.2-1 n个关键字散列到长度为m的数组T中,假设采用均匀散列,期望的冲突数是多少?
解 E[h(k)=h(l), k!=l] = n*(n-1)/2 * 1/m = n(n-1)/2m。
11.2-3 O(1+α/2)
11.2-4

伪代码如下:

  1 #define MAXN 1005
  2 #define MAXM 105
  3 
  4 typedef struct Slot_t {
  5     bool free;
  6     int val;
  7     Slot_t *prev, *next;
  8 } Slot_t;
  9 
 10 Slot_t T[MAXN];
 11 Slot_t *free;
 12 
 13 int hash(int val) {
 14     return (val+1)%MAXM;
 15 }
 16 
 17 void init_Slot() {
 18     for (int i=0; i<MAXN-1; ++i) {
 19         T[i].free = true;
 20         if (i)
 21             T[i].prev = &T[i-1];
 22         T[i].next = &T[i+1];
 23     }
 24     T[0].prev = NULL;
 25     T[MAXN-1].free = true;
 26     T[MAXN-1].prev = &T[MAXN-2];
 27     T[MAXN-1].next = NULL;
 28     free = T[0];
 29 }
 30 
 31 Slot_t *Direct_Address_Search(Slot_t T[], int val) {
 32     int key = hash(val);
 33     Slot_t *p = &T[key];
 34 
 35     while ( p!=NULL && !p->free ) {
 36         if (p->val == val)
 37             return p;
 38         p = p->next;
 39     }
 40     return NULL;
 41 }
 42 
 43 void Direct_Address_Insert(Slot_t T[], int val) {
 44     if (free == NULL)
 45         return ;
 46     int key = hash(val);
 47     Slot_t *p;
 48 
 49     if (T[key].free) {
 50         T[key].val = val;
 51         T[key].free = false;
 52         // cut the T[key] from the free list
 53         if (T[key].prev != NULL)
 54             T[key].prev->next = T[key].next;
 55         if (T[key].next != NULL)
 56             T[key].next->prev = T[key].prev;
 57         T[key].prev = T[key].next = NULL;
 58     } else {
 59         if (hash(T[key].val) != key) {
 60             memcpy(free, T[key], sizeof(Slot_t));
 61             T[key].val = val;
 62             T[key].prev = T[key].next = NULL;
 63         } else {
 64             T[free].free = false;
 65             T[free].val = val;
 66             T[free].prev = &T[key];
 67             T[free].next = T[key].next;
 68             if (T[key].next != NULL)
 69                 T[key].next->prev = &T[free];
 70             T[key].next = &T[free];
 71         }
 72         free = free->next;
 73     }
 74 }
 75 
 76 void Direct_Address_Delete(Slot_t T[], int val) {
 77     int key = hash(val);
 78     Slot_t *p = Direct_Address_Search(val), *q;
 79 
 80     if (p == NULL)
 81         return ;
 82     if (p == &T[key]) {
 83         if (p->next == NULL) {
 84             p->free = true;
 85             p->next = free;
 86             free = p;
 87         } else {
 88             // copy p->next to p
 89             q = p->next;
 90             memcpy(p, p->next, sizeof(Slot_t));
 91             p->prev = NULL;
 92             // delete p->next
 93             if (p->next != NULL)
 94                 p->next->prev = p;
 95             q->free = true;
 96             q->next = free;
 97             free = q;
 98         }
 99     } else {
100         p->free = true;
101         p->prev->next = p->next;
102         if (p->next != NULL)
103             p->next->prev = p->prev;
104         p->next = free;
105         free = p->next;
106     }
107 }

 11.2-6
解:已知n = L_0 + L_1 + ... + L_(m-1), max{L_0, L_1, L_(m-1)} = L。求均匀随机选择某一元素的期望。
E[1/n * Sigma(1..n, (L_hash(xi)+1)/2)] = 1/2n * [ Sigma(i=0..(m-1), L_i*L_i) + n] < 1/n * [Sigma(i=0..(m-1), L_i*L_i) + mL] < L/n * [Sigma(i=0..(m-1), L_i) + m] = L/n * (n+m) = L*(1+m/n) = L*(1+1/α) = O(L*(1+1/α))

11.3 散列函数
除法散列与乘法散列本质上属于启发式方法,而全域散列则利用了随机技术来提供可证明的良好性能。
好的散列函数的特点
好的散列函数应(近似地)满足简单均匀散列假设:每个关键字都被等可能地散列到m个槽位中的任何一个,并与其他关键字已散列到哪个槽位无关。
除法散列法
在用来设计散列函数的除法散列法中,通过取k除以m个余数,将关键字k映射到m个槽位中的某一个上,即散列函数为:
h(k) = k mod m
一个不太接近2的整数幂的素数,常常是m个一个较好的选择。
乘法散列法
构造散列函数的乘法散列法包含两个步骤:第一步,用关键字k乘上常数A(0<A<1),并提取kA的小数部分。第二步,用m乘以这个值,再向下取整。总之,散列函数为:
h(k) = floor(m(kA mod 1))
这里kA mod 1是取kA的小数部分,即kA - ceil(kA)。

11.3-1
由于需要在长度为n的链表中搜索,因此先计算源串的hash(key),再用这个值不断与链表中元素的hash(key)值进行比较,若相等则返回该节点指针。

11.3-2


11.3-3

11.3-4

1 int hash(int key) {
2     double A = (sqrt(5.0)-1)/2;
3     double tmp = key*A;
4     return (int) ( m*(tmp - (int)tmp) );
5 }

11.4 开放寻址法
在开放寻址法(open addressing)中,所有的元素存放在散列表里。也就是说,每个表象或包含动态集合的一个元素,或包含NIL。当查找某个元素时,要系统地检查所有的表项,直到找到所需的元素,或者最终查明该元素不在表中。不像链接法,这里既没有链表,也没有元素存在散列表外。因此在开放寻址法中,散列表可能被填满。为了使用开放寻址法插入一个元素,需要连续地检查散列表,或称为探查(probe)。有三种主要的探查方法:
线性探查
给定一个普通的散列函数h‘:U -> {0, 1, ..., m-1}, 称之为辅助散列函数(auxiliary hash),线性探查(linear probing)方法采用的散列函数为:
h(k, i) = (h‘(k) + i) mod m, i = 0, 1, ..., m-1
给定一个关键字k,首先探查T[h‘(k)],即由辅助散列函数所给出的槽位。再探查槽T[h‘(k)+1],以此类推,直至槽T[m-1].然后,又绕到T[0], T[1], T[0], ..., 直到最后探查到T[h‘(k)-1]。在线性探查方法中,初始探查位置决定了整个序列,故只有m种不同的探查序列。
线性探查方法比较容易实现,但它存在一个问题,称为一次群集(primary cluster)。随着连续被占用的槽不断增加,平均查找时间也随之不断增加。群集现象很容易出现,这是因为当一个空槽前有i个满的槽时,该空槽为下一个将被占用的概率是(i+1)/m。连续被占用的槽就会越来越长,因而平均查找时间也会越来越大。
二次探查
二次探查(quadratic probing)采用如下形式的散列函数:

其中h’是一个辅助散列函数,c1和c2为正的辅助常数。初始的探查位置为T[h‘(k)],后续的探查位置要加上一个偏移量,该偏移量以二次的方式依赖于探查序号i。这种探查方法的效果要比线性探查好得多。此外,如果两个关键字的初始探查位置相同,那么它们的探查序列也是相同的,这是因为h(k1, 0) = h(k2, 0)蕴含着h(k1, i) = h(k2, i)。这一性质可能导致一种轻度的群集,称为二次群集(secondary cluster)。像在线性探查中一样,初始探查位置决定了整个序列,这样也仅有m个不同的探查序列被用到。
双重散列
双重散列(double hashing)是用于开放寻址法的最好方法之一,因为它所产生的排列具有随机选择排列的许多特性。双重散列采用如下形式的散列函数:

其中h1和h2均为辅助散列函数。初始探查位置为T[h1(k)],后续的探查位置是前一个位置加上偏移量h2(k)摸m。
定理11.6  给定一个装载因子为α=n/m<1的开放寻址散列表,并假设是均匀散列的,则对于一次不成功的查找,其期望的探查次数最多为1/(1-α)。
推论11.7  假设采用的是均匀散列,平均情况下,向一个装载因子为α的开放寻址散列表中插入一个元素至多需要做1/(1-α)次探查。
定理11.8 对于一个装载因子为α<1的开放寻址散列表,一次成功查找中的探查期望数至多为(1/α)ln(1/(1-α))

11.4-1
线性探查  32 88  -  -   4 15 28 17 59 31 10
二次探查  22 -  88 17 4  -   28 59 15 31 10
双重散列  22 -  59 17 4  15 28 88  -  31 10

11.4-2

 1 HASH-INSERT(T, k)
 2     i = 0
 3     repeat
 4         j = h(k, i)
 5         if T[j]==NIL or T[j]==DELETED
 6             T[j] = k;
 7             return j
 8         else i = i + 1
 9     until i==m
10     error "hash table overflow"
11 
12 HASH_DELETE(T, k)
13     i = HASH_SEARCH(T, k)
14     if i!=NIL
15         T[i] = DELETED

 

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。