散列表的实现通常叫做散列。散列是一种用于以常数平均时间执行插入、删除和查找的技术。但是任何排序的信息都不会得到有效的支持。所以FindMax(),FindMin(),以及以线性时间打印的操作都是散列所不支持的。
理想的散列表数据结构值不过是一个包含有关键字的具有固定大小的数组。
关键字映射的函数叫做散列函数,通常散列函数应该运算简单并且保证任何两个不同的关键字映射到不同的单元。不过这是不可能的,因为单元的数目是有限的,然而关键字是用不完的。因此,我们寻找一个散列函数,该函数要在单元之间均匀的分配的关键字。对于两个关键字映射到同一个值的时候,我们称之为冲突,需要设定一个函数来进行处理。
散列函数
对于关键字是整数,则一般合理的方法就是直接返回"Key mod TableSize"的结果,除非Key具有某些不理想的性质。例如:表的大小是10,但是关键字的大小都是0为个位。好的大小通常是保证表的大小是一个素数。
通常,关键字是字符串,在这种情况下,散列函数需要仔细的选择。
一种比较简单的方法是把字符串中的字符的ASCLL码值加起来。下面是这种方式的代码实现:
Indexx Hash(const char *Key, int TableSize){
unsigned int HashVal = 0;
while(*Key != '\0'){
HashVal += *Key++;
}
return HashVal % TableSize;
}
上述的散列函数实现起来简单而且很快地算出答案。不过,如果表很大的话,函数将不会很好的分配关键字。假设TableSize=10007,并且假设所有的关键字最多有8个字符长,127*8=1016,显然这是不均匀的分配。
另一种散列函数有下面的代码所示,假设关键字key至少有两个字符加上NULL结束,729=27^2
假设它们是随机的,而表还是10007的大小,我们就会得到一个合理的均匀分配,虽然3个字符有26^3=17576种可能的组合,但是实际的词汇量却揭示了:3和字母不同的组合数实际上面只有2851种,也只不过有28%的空间被利用上。当表足够大的时候,它们还是不合适的
Index Hash(const char *Key, int TableSize){
return (Key[0] + 27 * Key[1] + 729 * Key[2]) % TableSize;
}
下面的散列函数,涉及到关键字中的所有字符,并且一般可以分布的很好,程序根据Horner法则计算一个(32的)多项式。
Index Hash(const char *Key, int TableSize){
unsigned int HashVal=0;
while(*Key != '\0'){
HashVal = (HashVal<<5) + *Key++;
}
return HashVal % TableSize;
}
之所以使用32是因为可以使用位运算来加速,并且还可以使用按位异或来代替。上述的散列函数的优点是简单且允许溢出。当关键字长的时候,可以选用部分的关键字。有些程序人员通过只使用奇数位置上的字符来实现他们的散列函数。这里的一层想法是:用计算散列函数节省下来的时间来补偿由此产生的对均匀分布的函数的轻微干扰。
剩下的主要问题是解决冲突的消除问题,当一个元素被插入时,另一个元素已经存在(散列值相同),那么产生一个冲突,这个冲突需要消除。解决冲突的方法有很多种,下面介绍的是最简单的两种:分离链接法和开放定址法。
分离链接法
解决冲突的第一种方法通常叫做分离链接法,其做法是将散列到同一个值的所有元素保留到一个表中。为了方便起见,这些表都有表头。
为执行Find,我们使用散列函数来确定究竟考查那个表。此时我们以通常的方式遍历该表并返回所找到的被考查项所在位置。为了执行Insert,我们遍历一个相应的表以检查该元素是否已经处在适当的位置(如果要插入重复元素,那么通常要留出一个额外的域,这个域当重复元出现时增加1)。如果元素是一个新的元素,那么它或者被插入到表的前端,或者被插入到表的末端,那个容易就执行那个。新的元素插入到表的前端,不仅是因为方便,而且还因为新插入的元素最有可能最先被访问到。
下面是具体的实现:
struct ListNode;
typedef struct ListNode *Position;
struct HashTbl;
typedef struct HashTbl *HashTable;
struct ListNode{
ElementType Element;
Position Next;
};
typedef Position List;
struct HashTbl{
int TableSize;
List *TheLists;
};
下面是初始化例程:
HashTable InitializeTable(int TableSize){
HashTable H;
int i;
if(TableSize < MinTableSize){
Error("Table size too");
return NULL;
}
H = malloc(sizeof(struct HashTbl));
if(H == NULL){
FatalError("out of space");
}
H->TableSize = NextPrime(TableSize);
H->TheLists = malloc(sizeof(List)*H->TableSize);
if(H->TheLists == NULL){
FatalError("out of space");
}
for(int i=0; i < H->TableSize; i++){
H->TheLists[i] = malloc(sizeof(struct ListNode));
if(H->TheLists[i] == NULL){
FatalError("Out of space");
}else{
H->TheLists[i]->Next = NULL;
}
}
return H;
}
上面的代码需要注意的是:TheLists是一个数组,它的每个值都是一个指向单元链表的指针。
对Find(Key,H)的调用将返回一个指针,该指针指向包含Key的那个单元。下面是具体的代码实现:
Position Find(ElementType Key, HashTable H){
Position P;
List L;
L = H->TheLists[Hash(Key, H->TableSize)];
P = L->Next;
while(P != NULL && P->Element != Key){
P = P->Next;
}
reutrn P;
}
下一个是插入例程。如果要插入项已经存在,那么我们什么也不做,否则我们就放在表的最前端。下面是插入的代码实现:
void Insert(ElementType Key, HashTable H){
Position Pos, NewCell;
List L;
Pos = Find(Key, H);
if(Pos == NULL){
NewCell = malloc(sizeof(struct ListNode));
if(NewCell == NULL){
FatalError("Out of space");
}else{
L = H->TheLists[Hash(Key, H->TableSize)];
NewCell->Next = L->Next;
NewCell->Element = Key;
L->Next = NewCell;
}
}
}
除链表外,任何的方案都有可能用来解决冲突现象,一颗二叉树甚至是另外一个散列。我们定义散列表的装填因子λ为散列表的元素个数与散列表的大小的比例。在上面的例子中,λ=1.0。表的平均长度为λ。执行一次查找所需要的时间是执行散列函数的常数时间加上链表遍历的时间。成功的查找则需要遍历大约1+(λ/2)个链表,我们期望沿着一个表中途就能找到匹配的元素。表的大小是一个素数可以保证一个好的分布。
开放定址法
分离链表法的缺点是需要使用指针,由于给新的单元分配地址需要时间,因此就导致了算法的速度有些减慢,同时算法实际上面还要使用另外一种数据结构的实现。除了分离链接法之外,开放定址散列法是另外一种不用链表解决冲突的方法。在开放定址散列算法中,如果没有算法冲突,那么就要尝试另外的单元,直到找到空的单元。更一般的,单元h0(X),h1(X),h2(X),等等,其中hi(X) = (Hash(X) + F(i) ) mod TableSize,且F(0)=0。函数F是冲突解决函数方法。因为所有的数据都要置入表内,所以开放定址散列法所需要的表要比分裂链接散列的表大。一般对于开放定址散列算法来说,装填因子应该低于λ=0.5,下面是具体的分析:
线性探测法
在线性探测法中,函数F是i的线性函数,典型情况是F(i)=i。这相当于逐个探测每个单元,以查找一个空单元。
平方探测法
消除线性探测中一次聚集问题的冲突解决方法。平方探测就是冲突函数为二次函数的探测方法,流行的选择是F(i) = i^2,对于线性探测,让元素几乎填满列表并不是个好主意,因为此时列表的性能会降低,对于平方探测来说情况更糟:一旦表被填满超过一半,当表的大小不是素数时甚至在表被填充满一半之前,就不能保证一次找到一个空单元了。这是因为最多有表的一半可以作为冲突解决的备选位置。
开放定址散列表的例程:
typedef unsigned int Index;
typedef Index Position;
struct HashEntry{
ElementType Element;
enum KindOfEntry Info;
}
typedef struct HashEntry Cell;
struct HashTbl{
int TableSize;
Cell *TheCell;
}
如同分离链接散列法一样,Find(Key, H)将返回Key在散列表中的位置。如果Key不出现,那么Find将返回最后的单元。该单元就是当需要时,Key将被插入的地方。此外,因为被标记了Empty,所以表达式Find失败很容易。下面是使用平方探测散列法的Find例程:
Position Find(ElementType Key, HashTable H){
Position CurrentPos;
int CollisionNum;
CollisionNum = 0;
CurrentPos = Hash(Key, H->TableSize);
while(H->TheCells[CurrentPos].Info != Empty && H->TheCells[CurrentPos].Element != Key){
CurrentPos +=2 * ++CollisionNum - 1;
if(CurrentPos >= H->TableSize)
CurrentPos -= H->TableSize;
}
return CurrentPos;
}
使用平方探测散列表的插入例程:
void Insert(ElementType Key, HashTable H){
Position Pos;
Pos = Find(Key, H);
if(H->TheCells[Pos].Info != Legitimate){
H->TheCells[Pos].Info = Legitimate;
H->TheCells[Pos].Element = Key;
}
}
虽然平方探测排除了一次聚集,但是散列到同一位置上的那些元素将探测相同的备选单元。这叫做二次聚集。二次聚集是理论上的一个小遗憾。
双散列
对于双散列,一种流行的选择是F(i) = i·hash2(X)。这个公式是说,我们将第二个散列函数应用到X并在举例hash2(X),2hash2(X)等处探测。hash2(X)选择得不好将会是灾难性的。函数需要保证所有的单元都能探测到也是很重要的。例如:hash2(X) = R - (X mod R) 这样的函数将起到良好的作用。
再散列
对于使用平方探测的开放定址散列法,如果表的元素填得太满,那么操作的运行时间将开始消耗过长,且Insert操作可能失败。这可能发生在有多太多的移动和插入混合的场合。此时,另外的一种解决方法是建立另外一个大约两倍的表,扫描整个原始散列表,计算每个元素的新散列值并将其插入到新表中。
显然这是一种昂贵的操作,其运行时间是O(N),因为有N个元素要再散列而表的大小大约是2N,不过由于不是经常发生,因此实际效果根本没有这么差。
在散列可以用平方探测以多种方法实现,一种做法是只要表满到一半就再散列,另外一种极端的方式是只有插入失败了才进行再散列,第三种方式是途中策略,当表到达某一个装填因子时进行再散列。由于随着装填因子的增加表的性能会有所下降,因此以好的手段实现第三种策略,是一种好的方法。
下面是在再散列的开放定址散列表的实现:
HashTable ReHash(HashTable H){
int i, OldSize;
Cell *OldCells;
OldCells = H->THeCells;
OldSize = H->TableSize;
H = InitializeTable(2 * OldSize);
for(i=0; i<OldSize; i++){
if(OldCells[i].Info == Legitimate)
Insert(OldCells[i].Element, H);
}
free(OldCells);
return H;
}
可扩散列
当数据处理量太大以至于不能装进主存的时候,我们就需要使用可扩散列,此时主要考虑的是检索数据所需要的磁盘存取次数。
在B树中,B树的深度随着M的增大而减小,理论上,我们可以使用足够大的M,使得树的深度是1。这样所有的Find操作只需要查找一次的磁盘,但是由于分支的数量太大,需要花费大量的时间确定分支。如果运行这一步的时间可以大大缩减,那么这将是一个实际可行的方案。
总结
散列表可以用来以常数平均时间实现Insert和Find操作。当使用散列表时,需要注意装填因子这样的细节是特别重要的,否则时间界将不再奏效。当关键值不是短串或整数时,仔细选择散列函数也是非常重要的。
对于分离链接法,虽然装填因子不是很大时性能并不明显降低,但装填因子还是应该接近1,对于开放定址法,除非完全不可避免,否则装填因子不应该超过0.5。如果使用线性探测,那么性能随着装填因子接近1将急速下降。再散列通过使表增加或者收缩来实现,这样就能够保证装填因子在合理范围。
二叉查找树的Insert和Find运算时间的界是O(logN),但是二叉查找树支持需要序的例程而更加强大。使用散列表不可能找出最小的元素,并且O(logN)的时间界也不比O(1)大太多。
在另一方面,散列的最坏情况一般来自于实现的缺憾,而有序的输入却可能是二叉树运行的很差。平衡查找树实现代价相当高,因此,如果不需要序的信息及排序的话,散列是一种比较好的选择。
散列的使用非常的多,编译器使用散列表跟踪源代码中声明的变量。这种数据结构叫做符号表,散列表是这种问题的理想应用,因为只需要Insert 和 Find操作。
散列表对于节点是实际的名字而不是数字的任何图论问题都是有用的。
散列表的第三种运用是在游戏编制中,当程序搜索游戏的不同的运行时,它跟踪通过计算基于位置的散列函数而看到一些位置。如果位置再出现,程序通常通过简单变换来避免昂贵的计算,在游戏程序中,叫做变换表。
散列的另一个用途是在线拼写检验程序,如果错拼检验更重要,那么整个词典都可以预先被散列,单词就能在常数时间内被校验。散列表很合适做这项工作,因为排列排列单词并不重要。