一、算法描述
前面讨论的查找算法在处理小数据量(顺序查找)或者有序的数据集合(二分查找)时才使用。我们需要更加强大的算法能够查找较大的集合,而且并不需要有序。最常使用的一个方法是使用散列函数来将目标元素的一个或者多个特征转换成一个值,这个值用来索引一个已经索引的散列表,基于散列的查找有着比本章描述的其他算法在平均情况下更好的性能。很多算法的书籍都是在讨论散列表时才介绍基于散列的查找,你也能够
在数据结构的书籍中讨论散列表的章节中找到这个算法。
在一个基于散列的查找,集合C的n个元素首先会加载到一个有着6个桶的散列表A中。键值的概念使得这个操作可行。对于每个元素 e ∈ C e\in C e∈C来说,它能够通过k=key(e)映
射到一个值k,如果 e i = e j e_{i}=e_{j} ei=ej,那么 k e y ( e i ) = k e y ( e j ) key(e_{i})=key(e_{j}) key(ei)=key(ej)(注意,由于键值不唯一,因此反过来可能不成立)。一个散列函数h=hash(e)使用了键值key(e)来将。插人到桶A[h]中。一且散列表A被构造好了,那么查找一个元素t也被转换成在桶A[h]中寻找一个元素t,h=hash(t)。
下图用一个小小的例子表示了基于散列查找的一般模式,基于散列的查找包括:
1.可能键值的全集U。每一个元素 e ∈ C e\in C e∈C将映射到一个键值
2.散列表A存储着原始集合C的n个元素。A也许包含着元素本身或者也包含了元素的
键值、在A中有b个位置。
3.散列函数hash,使用key(e)计算一个整数素引h。
当实现基于散列的查找时,有两个需要注意的地方:散列函数的设计以及如何处理冲突
(当两个键值映射到A的同一个桶),对于b<<|U|,几乎在所有的情况下,冲突都会发生。注意,如果b<n,那么A中没有足够的空间存储原始集合的n个元素。当这种情况出现时,通常的做法是在A中每一个桶中都存储一个元素集合(一般使用链表实现),如上图中的“存储元素”和“存储值”的选项(此外,如果元素直接存于A中,如上图中的“存放元素,没有列表支持”,那么你需要处理冲突,否则之前存放在散列表中的元素可能丢失)。
不适当的散列函数可能会导致一个不均匀的键值分布,这样会导致两个结果:·散列表中
的很多桶会是没有使用过的,浪费了空间,而使用的桶又会因为多个健值同时映射到了
一个桶中,导致了大量的冲突,这样使得性能更加糟糕。
对于大多数输入来说,冲突都是可能存在的,期望的冲突数量增长时,处理冲突的策略对于算法的性能有着非常重要的作用。
寻找的目标元素:必须有一个或者多个特征能够用来作为键值,这些健值组成了全集U。
不像二分查找,原始集合C不需要是有序的。事实上,即使C中的元素在某种程度上是有
序的,散列方法在插入元素到散列表A时也不会尝试去保持其有序性。
基于散列的查找的输入是散列表A,以及寻找的目标元素,如果:在A[h]的指向的链表
中,算法返回真,这里h=hash(t)。如果A[h]是空或者不在A[h]指向的健表中,那么返回
假表示(不在A中(也就是说也不在C中)。存储在A中的列表的元素本身就是键值。
变量n是元素集合C中的元素数目,b表示素引集合A中的桶数目。
基于散列的查找过程,主要涉及散列表(哈希表)的使用,以下是对其详细的步骤和原理介绍:
一、散列表的基本概念
散列表是一种数据结构,它支持通过关键字进行快速查找。在散列表中,每个关键字都通过一个散列函数映射到一个特定的存储位置,这个位置称为散列地址。
二、散列函数的构造
散列函数是散列表的核心,它决定了关键字如何映射到散列地址。构造一个好的散列函数需要遵循以下原则:
散列函数的值域应在散列表的长度范围内。
计算出的散列地址应尽可能均匀分布,以减少冲突。
常见的散列函数构造方法包括:
1.直接地址法:以关键字本身或关键字加上某个常量C作为散列地址。这种方法简单,但可能因关键字分布不连续而造成空间浪费。
2.数字分析法:从关键字中提取数字分布比较均匀的若干位作为散列地址。这种方法适用于事先明确知道所有关键字的数字分布情况。
3.平方取中法:取关键字平方后的中间几位或其组合作为散列地址。这种方法产生的散列地址较为均匀,适用于不知道关键字的全部情况。
4.折叠法:将关键字分割成位数相同的几段(最后一段位数可少一些),然后将它们的叠加和(舍去最高位进位)作为散列地址。这种方法适用于关键字位数较多的情况。
5.随机乘数法:使用一个随机实数与关键字相乘,然后取结果的某几位作为散列地址。这种方法可以产生较为均匀的散列地址分布。
三、散列表的查找过程
1.计算散列地址:给定一个关键字,通过散列函数计算其对应的散列地址。
2.访问散列地址:根据计算出的散列地址,直接访问散列表中的相应位置。
3.比较关键字:如果散列表中的记录与给定关键字相匹配,则查找成功;否则,需要处理冲突。
四、冲突的处理
冲突是指两个不同的关键字映射到同一个散列地址的情况。处理冲突的方法包括:
1.开放定址法:当发生冲突时,通过一定的探测序列在散列表中查找下一个空闲的存储单元。常见的开放定址法包括线性探查法、平方探查法和双散列函数探查法。
2.再散列函数法:事先准备多个散列函数,当第一个散列函数发生冲突时,使用下一个散列函数进行计算,直到找到一个不冲突的散列地址。
3.链地址法:将所有同义词(即具有相同散列地址的关键字)存储在一个单链表中,散列表中只存储这些链表的头指针。这种方法可以避免堆积现象,但查找时需要遍历链表。
4.公共溢出区法:为所有冲突的关键字建立一个公共的溢出区来存放。这种方法在冲突数据很少的情况下性能较好。
五、示例
假设有一个散列表长度为13,散列函数为h(key)=key%13。现在要向表中插入关键字31和58。
插入31:计算h(31)=31%13=5,发现H[5]已被占用,使用线性探查法,探查下一个单元H[6],发现H[6]空闲,将31插入H[6]。
插入58:计算h(58)=58%13=6,发现H[6]已被占用(之前插入了31),继续探查下一个单元H[7],H[7]仍不空闲,再接着向下探查,直到探查到H[9]空闲,将58插入H[9]。
查找过程类似,首先计算关键字的散列地址,然后访问该地址并比较关键字,如果匹配则查找成功,否则按插入时的冲突处理策略继续查找。
二、复杂度分析
也许相比其他的查找方法,基于散列的查找更能够反映出我们设计的优劣,尤其是我们选择了什么样的存储元素的数据集合,理解输人数据的动态性质是非常必要的,这有助于选择合适的数据结构。
基于散列的查找有着非常优秀的性能特征。我们将简略地分析一下。在散列表中查找一个元素可以分为如下几个部分:
1.计算散列值。
2.通过散列值素引到相应元素。
3.如果有冲突,那么寻找到需要找的元素。
所有基于散列的查找算法都包舍头两个部分,但是不同的变种处理冲突的策略是不同的。
计算散列值的开销必须限制在常数时间内。如果你思考一下本节的例子,计算散列值将会和字符串的长度成比例。对于任何有穷单词集合来说,都存在一个最长的字符串,长度为k。如果,是计算最长字符串散列值的时间,那么它计算任何散列值需要st的时间。计算散列值因此被认为是一个常数时间的操作。
算法的第二部分也是时间的操作。如果表存储在二级存储器,根据元素的位置以及在设备上定位元素所需要的时间可能会有相应的改进算法,不过这个也是常数时间的操作。
如果我们能够证明计算的第三部分也是有着常数时间的上界,那么我们就能很容易地证明基于敢列的查找是常数时间的性能。当我们使用链时,我们将会定义一个负载因子,a=n/b,b是散列表中桶的数量,n是存储在散列表中元素的数量。负载因子表示的是在单个链中的平均元素数量。
在链中寻找元素的最坏时间是O(n),只有在所有的元素都被映射到表中同样的桶时。要查找的桶的平均数目是a。如果希望能够知道详细的分析,请参见(Cormen等,2001)的第11章。原书这里是对java代码的比较,待作者学习java后会补充这里的实验分析。
三、适用情况
假设我们创建了一个文本编辑器,并且希望能检查用户输入单词的拼写,有一些免费的单词表可以从网上下载(http://www.wordlist.com)。性能对于这个应用程序来说非常要。我们必须快速地查找这个单词表,否则即使是最慢的打字员,程序也都是不可用的。我们也许需要一个单独的线程,这个线程用来获得文档或者文件的最新修改,并且能够检查单词的拼写(在搜索之前基于散列的查找需要完整的单词,而使用二分查找,我们并不需要一次性输入所有的字母,但是这样程序变得更加复杂了)。
我们必须将这些单词保存在内存中,因为磁盘操作将会导致性能退化太多,一个典型的英语单词列表包含超过200000个单词,而且能够以数组的形式存储在内存中,占用大约2.5MB的内存(如上上个图所示,这个包含了单词本身的空间以及指向单词的指针的空
间),我们使用指针是因为单词的长度是改变的,而且我们希望能够使用最少的内存。
在“二分查找”一节中我们得知,如果使用二分查找需要进行大约18次字符串比较
( l o g ( 200000 ) = 17.61 log(200000)=17.61 log(200000)=17.61)。字符串比较是非常昂贵的,即使我们优化代码,也需要循环比较字节。有时候我们使用汇编语言手动编写这些循环确保能够在特定架构上进行优化,例如确保我们不会在大部分的情况下停顿在流水线上以及在可能时展开循环来填充流水线。目标是最小化字符串比较的次数。
我们首先需要定义一个函数来计算字符串s的键值。这个键值函数的一个目标是产生足够多的不同值,不过并不要求这些值都是唯一的。根据初始字符串的每一块信息来产生值的一个流行的方法是:
k e y ( s ) = s [ 0 ] ∗ 3 1 ( k n − 1 ) + s [ 1 ] ∗ 3 1 ( k n − 2 ) + . . . + s [ l e n − 1 ] key(s)=s[0]^{*}31^{(kn-1)}+s[1]^{*}31^{(kn-2)}+...+s[len-1] key(s)=s[0]∗31(kn−1)+s[1]∗31(kn−2)+...+s[len−1]
s[i)是第认字符(值在0~255之间),len就是字符串s的长度。计算这个函数的值非常简单,下例就是这个函数的实现(根据Open JDK源代码改写),chars是字符数组,即一个宇符串,根据我们的定义,java.lang.String的hashcode()方法是key()函数。
public int hashCode()
{int h - hash;if (h--0){for (int 1 = 0; 1 < chars.length; 1++){h - 31 * h + chars[1];}hash - h;}return h;
}
因为hashCode方法尽量尝试优化,它缓存了已经计算过的散列值,避免重复计算(例如,当且仅当散列是0时的计算值),
下一步,我们构造散列表,我们有n个字符串,那么散列表A的大小应该是多少呢?在最理想的情况下,A有b=n个桶,散列函数是一个从字符申集合到整数[0,n)的一一映射函数。这是在正常情况下不可能发生的,所以我们尝试着构造一个空桶尽可能少的散列表。如果我们的散列函数平均分配健值,我们就能够获得一个比较理想的情况,数组的大小和集合的大小差不多,我们定义hash(s)=key(s)%b,%是取模运算符,返回key(s)除以b的余数。
高水平读者此时就会向基本的散列函数和散列表在这里会起什么作用。因为单词表是静态的,所以我们能够通过构建一个完美的散列函数来做得更好。一个完美的散列函数能够保证在一个特定的键值集合中没有冲突产生。在这种情况下,一个完美的散列函数就能投入使用,在接下来的“算法优化”一节中将讨论这个问题。让我们先在没有这个完美散列函数的情况下会试解决这个问题。
在我们第一次会试解决这个向题时,我们选择了一个数组A,有着217个桶,以及262143个元素。我们的单词表包含213557个单词。如果我们的散列函数能够使字符申完美地分布,那么就会没有冲突产生并且需要大约40000个槽。但是情况并非如此。下表给出了我们单词表中字符串的散列值(注8)分布情况,散列表有着262143个槽。正如你所
见,没有一个槽会存储超过7个字符串,对于非空槽来说,每个槽的平均字符申数量大约是1.46。下表的每一行表明了使用的槽的数量以及有多少字符串被映射到这些植中。散列表中几乎一半的槽(116186个)没有字符串映射到。所以,这个散列函数浪费了大概500K的内存空间——假设每个指针的大小是4个字节,我们不会使用在空条目上使用冲
突处理策略。你也许会非常惊诉这是一个非常优秀的散列函数,如果需要寻找到一个比这个更好的,那么需要一个更加复杂的结构。对于这个数据集来说,只有五对字符串有着相同的键值(例如,hypoplankton和unheavenly有着相同的健值427589249)!
最后,我们需要决定采取什么策略来处理冲突。一个可行的办法是在主散列表存储指向一系列条目的指针,而不是存储一个对象。这个方法,如下图所示,叫做链式。
这个方法的总开销取决于你在每个槽中是元素列表还是nii值(表示没有列表)。当一个槽只有一个元素时,它可以使用列表来存取。作为我们的第一个近似的解决方案,我们将会使用这种方法并且在性能成为瓶颈时改进它。
选择散列函数是在实现基于散列的查找之前必须做的第一个决定。散列已经被研究多年,并且有大量的描述高效散列函数的论文,不过用在查找上使用这些函数简直是杀鸡用牛刀。例如,某些特定的散列函数对于加密非常重要。对于查找来说,一个散列函数必须要有一个好的分布,并且计算速度非常快。
存储器空间是设计基于散列查找时需要考虑的另外一个因素。主存储器A必须能够足够大,以存下所有的查找键值,并且要给冲突键值留下足够的空间。A的大小通常是一个素数。但是,如果我们不使用开放定址的话(见接下来的“算法优化 ”一节),我们能够使用任何数字作为A的大小。实践中一个好的选择是2*-1,即使这个值并不是素数。存储在散列表的元素对内存有直接的影响,考虑图5-3的是如何在链表中存储每个字符串的,所以存储在A中的元素看做链表元素。堆中包含着指向元素的指针。每一个链表都有存储的开销,包含着指向链表中的第一个和最后一个元素。如果你使用Java的LinkedList类,一些很重要的附加字段和类使得实现非常灵活。程序员可以写一个简单得多的链表类,只提供必需的功能,但是确实给基于教列查找算法带来了额外的开销。
如果你使用LinkedList类,假设指针是四个字节,那么A中的每一个非空元素都需要12字节内存。每个字符申元素不可以直接转换成ListElement,需要12个额外字节。对于之前的那个有213557个单词的例子,我们需要5005488字节的额外存储。这种情况是:
1.主表的规模:1048572字节。
2.包含116186个元素的规模:1394232字节。
3.包含213557元素的规模:2562684字节。
如果你使用Java的String类,那么存储字符串还会需要额外的开销。每个字符串有12个字节的额外开销。因此我们就将增加213 557×12=2562684个额外字节,所以,例子中选择的算法需要7568172字节的内存来正常运行。单词表中字符申的实际字符数量只有2099075,我们的算法还需要大约是字符的存储空间4.6倍的额外存储空间。
我们之后将会讨论一些优化内存使用的变种,当内存价格高高在上时,你可以使用这些变种来节省开支。但是,如果你有足够的内存,还有一个合理的并不会产生太多的冲突的散列函数以及一个可以立即使用的链表实现,那么你为什么不选择JDK提供的解决方案呢?
只要散列函数能够将元素均匀分布,那么基于散列的查找将会得到非常好的性能。查找一个元素的平均时间将是常数,也就是O(1)。
有其他的影响实现的因素。主要都是关于如何处理集合的静态或者动态特性。在我们的例子中,我们知道我们的单词表有多大,并且我们不会在单词表中添加或者剩除单词,至少不会在程序执行时操作。但是,如果我们有一个动态的集合,会做很多插入或者副除元素的操作,我们必须为散列表选择一个合适的数据结构来优化性能。在我们的例子
中,我们的冲突处理策略工作得非常好,因为在链表中插入元素只需要常数时间,删除元素的时间和键表长度成比例。如果散列函数能够较好地分布元素,那么每一个健表相对都比较短。
四、算法实现
#include <stdio.h>
#include <stdlib.h>
#include <string.h>//散列表的大小
#define TABLE_SIZE 10// 哈希表中的节点
typedef struct Node {int key;int value;struct Node* next;
} Node;// 哈希表
typedef struct HashTable {Node* table[TABLE_SIZE];
} HashTable;// 创建一个新的节点
Node* create_node(int key, int value) {Node* new_node = (Node*)malloc(sizeof(Node));new_node->key = key;new_node->value = value;new_node->next = NULL;return new_node;
}// 创建哈希表
HashTable* create_table() {HashTable* hash_table = (HashTable*)malloc(sizeof(HashTable));for (int i = 0; i < TABLE_SIZE; i++) {hash_table->table[i] = NULL;}return hash_table;
}// 哈希函数
int hash_function(int key) {return key % TABLE_SIZE;
}// 插入键值对
void insert(HashTable* hash_table, int key, int value) {int index = hash_function(key);Node* new_node = create_node(key, value);// 如果该位置为空,直接插入if (hash_table->table[index] == NULL) {hash_table->table[index] = new_node;} else {// 否则在链表的头部插入new_node->next = hash_table->table[index];hash_table->table[index] = new_node;}
}// 查找键对应的值
int search(HashTable* hash_table, int key) {int index = hash_function(key);Node* temp = hash_table->table[index];// 遍历链表查找键while (temp != NULL) {if (temp->key == key) {return temp->value;}temp = temp->next;}// 如果没找到,返回 -1return -1;
}// 删除键值对
void delete(HashTable* hash_table, int key) {int index = hash_function(key);Node* temp = hash_table->table[index];Node* prev = NULL;// 查找要删除的节点while (temp != NULL && temp->key != key) {prev = temp;temp = temp->next;}// 如果未找到要删除的节点if (temp == NULL) {printf("Key %d not found.\n", key);return;}// 如果找到了要删除的节点if (prev == NULL) {// 要删除的是第一个节点hash_table->table[index] = temp->next;} else {// 要删除的节点在链表中prev->next = temp->next;}free(temp);printf("Key %d deleted.\n", key);
}// 打印哈希表
void print_table(HashTable* hash_table) {for (int i = 0; i < TABLE_SIZE; i++) {printf("Index %d: ", i);Node* temp = hash_table->table[i];while (temp != NULL) {printf("(Key: %d, Value: %d) -> ", temp->key, temp->value);temp = temp->next;}printf("NULL\n");}
}int main() {HashTable* hash_table = create_table();insert(hash_table, 1, 10);insert(hash_table, 2, 20);insert(hash_table, 11, 110); // 产生冲突,将存储在相同的索引位置insert(hash_table, 21, 210); // 冲突print_table(hash_table);int key = 11;int value = search(hash_table, key);if (value != -1) {printf("Key %d has value %d\n", key, value);} else {printf("Key %d not found.\n", key);}delete(hash_table, 11);print_table(hash_table);return 0;
}
这段哈希表的代码可以通过以下几种方式进行优化:
1.哈希函数改进:当前的哈希函数 key % TABLE_SIZE 比较简单,可能在某些数据分布下会产生较多的冲突。可以考虑使用更复杂的哈希函数以减少冲突,如:
乘法哈希法:使用更复杂的数学运算进行哈希映射。
装载因子控制:根据元素数量动态调整哈希表大小,减少冲突。
链表优化(尾插法):插入时使用尾插法而不是头插法,可以让链表保持插入顺序,减少冲突时的顺序混乱,方便查找和删除。
2.内存管理优化:对于较大规模的哈希表,可以使用动态内存管理来动态调整哈希表大小,避免浪费内存或哈希表太小导致频繁冲突。
3.增量扩容:当哈希表中的元素超过一定的装载因子时(如 0.75),可以动态调整哈希表大小并重新哈希已有元素(rehashing),从而降低冲突率,提高性能。
4.使用标记删除:对于删除操作,可以考虑使用标记删除而不是物理删除,特别是当哈希表较大时,删除操作可能会影响查找性能。标记删除可以延迟删除操作,等到需要扩容或重建哈希表时再统一清理。
优化后的代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>#define INITIAL_TABLE_SIZE 10
#define LOAD_FACTOR_THRESHOLD 0.75// 哈希表中的节点
typedef struct Node {int key;int value;struct Node* next;
} Node;// 哈希表
typedef struct HashTable {Node** table;int size; // 当前哈希表大小int count; // 当前表中的元素个数
} HashTable;// 创建一个新的节点
Node* create_node(int key, int value) {Node* new_node = (Node*)malloc(sizeof(Node));new_node->key = key;new_node->value = value;new_node->next = NULL;return new_node;
}// 创建哈希表
HashTable* create_table(int size) {HashTable* hash_table = (HashTable*)malloc(sizeof(HashTable));hash_table->table = (Node**)malloc(sizeof(Node*) * size);hash_table->size = size;hash_table->count = 0;for (int i = 0; i < size; i++) {hash_table->table[i] = NULL;}return hash_table;
}// 哈希函数 (改进版,使用乘法哈希)
unsigned int hash_function(int key, int size) {const double A = 0.6180339887; // 取黄金分割比例return (unsigned int)(size * (key * A - (int)(key * A)));
}// 重新哈希表(扩容)
void resize_table(HashTable* hash_table);// 插入键值对
void insert(HashTable* hash_table, int key, int value) {// 检查装载因子if ((double)hash_table->count / hash_table->size >= LOAD_FACTOR_THRESHOLD) {resize_table(hash_table);}int index = hash_function(key, hash_table->size);Node* new_node = create_node(key, value);// 尾插法插入到链表末尾Node* current = hash_table->table[index];if (current == NULL) {hash_table->table[index] = new_node;} else {while (current->next != NULL) {current = current->next;}current->next = new_node;}hash_table->count++;
}// 查找键对应的值
int search(HashTable* hash_table, int key) {int index = hash_function(key, hash_table->size);Node* temp = hash_table->table[index];// 遍历链表查找键while (temp != NULL) {if (temp->key == key) {return temp->value;}temp = temp->next;}return -1;
}// 删除键值对
void delete(HashTable* hash_table, int key) {int index = hash_function(key, hash_table->size);Node* temp = hash_table->table[index];Node* prev = NULL;// 查找要删除的节点while (temp != NULL && temp->key != key) {prev = temp;temp = temp->next;}// 如果未找到要删除的节点if (temp == NULL) {printf("Key %d not found.\n", key);return;}// 删除节点if (prev == NULL) {hash_table->table[index] = temp->next;} else {prev->next = temp->next;}free(temp);hash_table->count--;printf("Key %d deleted.\n", key);
}// 重新哈希表(扩容函数)
void resize_table(HashTable* hash_table) {int new_size = hash_table->size * 2;Node** new_table = (Node**)malloc(sizeof(Node*) * new_size);for (int i = 0; i < new_size; i++) {new_table[i] = NULL;}// 重新插入所有旧元素到新表中for (int i = 0; i < hash_table->size; i++) {Node* current = hash_table->table[i];while (current != NULL) {Node* next = current->next;int new_index = hash_function(current->key, new_size);// 采用尾插法将节点插入新表current->next = new_table[new_index];new_table[new_index] = current;current = next;}}free(hash_table->table); // 释放旧表hash_table->table = new_table;hash_table->size = new_size;
}// 打印哈希表
void print_table(HashTable* hash_table) {for (int i = 0; i < hash_table->size; i++) {printf("Index %d: ", i);Node* temp = hash_table->table[i];while (temp != NULL) {printf("(Key: %d, Value: %d) -> ", temp->key, temp->value);temp = temp->next;}printf("NULL\n");}
}int main() {HashTable* hash_table = create_table(INITIAL_TABLE_SIZE);insert(hash_table, 1, 10);insert(hash_table, 2, 20);insert(hash_table, 11, 110);insert(hash_table, 21, 210);insert(hash_table, 31, 310); // 扩容触发print_table(hash_table);int key = 11;int value = search(hash_table, key);if (value != -1) {printf("Key %d has value %d\n", key, value);} else {printf("Key %d not found.\n", key);}delete(hash_table, 11);print_table(hash_table);return 0;
}
原书在此是java实现,与C的实现略有不同,后续作者学习java后会填补上这块。
五、算法优化
基于散列查找的一个主要变种是修改了处理冲突的策略。与其在槽中放入一个元素的链表,我们能够使用一种叫做开放定址的技术,这种技术直接将冲突元素存储在散列表A中。如下图所示。使用了开放定址技术,散列表减少了存储开销,例如在冲突的健表中使用指针指向元素。使用开放定址技术,我们将散列函数调整成为带两个参数的函数,h(uJ)=i,uEU,/和)都是在范圈[0,b)中的整数,b是A的规模。一般来说,我们使h(w.0)=i=h(u),h是之前描述过的一个散列函数。如果A[的的槽被占用了,并且这个元素和目标元素不匹配,我们计算h(u,1)的值。如果这个值指向的槽还是被占用了,并且这个元素和目标元素不匹配,那么我们计算h(u,2)的值,我们将重复这个过程,直到目标元素被找到,或者植是空,或者散列函数产生了一个我们之前已经访问过得槽(此时就产生了一个错误)。
假设我们能够确保我们不会重新访问一个槽,那么这种方法的最坏性能是O(b)。开放定址技术在查找时表现非常好。即使在一个不成功的查找中,期望的探测数目也只是1/(1-α),并且最坏情况下,成功查找的性能时(1/a)ln(1/1-a)(注11),详细信息请参见算法导论(Cormen等,2001)。开放定址技术中有两种广泛使用的探测方法。第一种是线性探测,这种方法下,我们的散列函数是 h ( u , j ) = ( h ( u ) + j ) m o d n , h(u,j)=(h(u)+j)\mathrm{mod} n, h(u,j)=(h(u)+j)modn,,当我们发现有冲突时,我们简单地查看散列表中的下一个槽,使用这个散列函数构成一个环形的查找。这个方法受元素的分布范围影响较大,当元素分布比较密集时,将会导致需要探测很多槽,尤其是α接近于1.0时。为了避免这种情况,我们可以使用二次方探测,散列函数变为 h ( u , j ) = ( h ( u ) + f ( j ) ) m o d m , h(u,j)=(h (u)+f(j))\mathrm{mod} m , h(u,j)=(h(u)+f(j))modm,f是的一个二次方函数。
在表5-5(待补充),我们看到在强制使用散列表不进行重散列之后,节省了大量的时间。如果在一个元素插入之前,原散列表的负载因子已经非常高了,那么一个重散列操作是有必要的。当散列表包含的元素比其预想的要多时,那么可以调整自身的大小。最典型的方式就是将共桶的数目加倍然后加一(因为散列表通常包含奇数个桶)。当只有很少的桶可用时,所有表中存在的元素必须重散列以放到新的位置。这是一个昂贵的操作,但是能够减少未来查找的总开销,不过这个操作不能非常频繁,要不然散列表的性能将会退化。当你对散列表的性能不满时,你必须允许散列表能够重散列使得元素平均分布到散列表中。java.util.Hashtable歌认的负载因子是0.75,如果你将这个值设为n/b,那么它将永远不会重散列。
之前的例子中使用了一个静态的字符串集合。当面对这种特殊情况时,我们能够通过使用完美的散列函数,得到最优化的性能。完美的散列使用两个散列函数,我们用一个标准的散列函数来素引主表A,每一个槽,A[i]。指向一个小得多的二级散列表,S,这个散列表和散列函数h,绑定。如果有k个键值映射到槽A[i]。那么S将包含个槽。这看起来浪费了很多内存,但是明智地选择初始散列函数能够减少这种浪费。选择合适的散列函数能够保证在二级表中不会存在冲突,也就是说,我们能够得到一个性能为O(1)的算法。完美散列分析的细节在算法导论(Cormen等,2001)中能够找到。Doug Schmidt(1990)发表过一篇非常优秀的论文,论述了完美散列操作。网络上有一些免费的多种语言的完美散列函数生成器可以下载。
一般来说,虽然有着很多潜在的元素e会得到特定的健值k,但是散列表也许被设计成只能存储其中的一个元素,也就是说,如果e,ECNe,EC,那么i=当且仅当 k e y ( e i ) = k e y ( e j ) key(e_i)=key(e_j) key(ei)=key(ej),有这个限制的原因是使得给定键值key(e)下,能够快速寻找到元素e.如果原始集合C包含两个相同的元素,那么仅仅只有一个会正确存储到散列表A中。
六、引用及参考文献
1.《算法参考手册》