简介
散列表(Hash Table)的实现常常叫做散列(Hash)。散列是一种用于常数时间进行插入、删除和查找的技术。但是,那些需要元素箭任何排序信息的操作将不会得到有效的支持。因此,FindMin、FindMax以及以线性时间按排序将整个表进行打印的操作是散列表不支持的。
理想的散列表数据结构只不过是一个包含有关键字的具有固定大小的数组。典型情况下,一个关键字就是带有相关值的字符串。把表的大小记为:TableSize。每个关键字被映射到从0到TableSize - 1这个范围中的某个数,并且被放到适当的单元内。这个映射就叫做散列函数(hash function)。理想情况下,它应该运算简单并且保证任何两个不同的关键字映射到不同的单元。不过,这是不可能的,因为单元的数目是有限的,而关键字实际上却是无穷的。因此,寻找散列函数时,应该尽量在单元之间均匀的 分配关键字。剩下的问题就是要选择一个函数,决定当两个关键字散列到同一个值的时候(即发生冲突)应该做些什么以及如何确定散列表的大小。
散列函数
如果输入的关键字是整数,那么一般合理的方法就是直接返回“Key mod TableSize”的结果,同时,表的大小最好选择为素数。但是,通常,关键字是字符串。这种情形下,散列函数需要仔细的选择。
一种选择方法是把字符串中字符的ASCII码值加起来。这种方法的缺点很明显,如果表很大,则函数将不会很好的分配关键字。例如,假定TableSize = 10007(素数),并设所有的关键字至多8个字符长,由于char型变量的值最多是127,因此散列函数只能取值在0到1016(127 * 8)之间,这显然不是一种均匀分配。
另一种散列函数假设Key至少有两个字符外加NULL结束符,值27表示英文字符个数外加空格,而729 = 27 * 27,该函数只考察前三个字符,但是,假如他们是随机的,而表的大小还是10007,那么就会得到一个合理的均匀分配。但是,英文不是随机的,虽然3个字符(忽略空格)有26*26*26=17576中可能的组合,但是,实际上联机词典显示,3个字母的不同组合数实际上只有2851,即使这些组合没有冲突,也只用掉不到28%的空间。所以这种方法也不是很好。所以,对于关键字为字符串时,一定要仔细考虑选择的散列函数。上两种散列函数代码如下:
typedef unsigned int Index;
Index Hash(int key, int TableSize)
{
return key % TableSize;
}
Index Hash(const char *key, int TableSize)
{
unsigned int HashVal = 0;
while (*key != '\0'){
HashVal += *key++;
}
}
Index Hash(const char *key, int TableSize)
{
return (key[0] + 27 * key[1] + 729 * key[2]);
}
冲突解决
目前,解决冲突的办法一般有三个,分离链接法,开放地址法,再散列,其中,开放地址法包括:一次线性探测,二次线性探测,双散列。
分离链接
其解决冲突的主要做法是将散列到同一个值的所有元素保留到一个表中。为方便起见,这些表都有表头,因此,表的实现与链表其实是一样的,如果空间紧凑,则可以去掉表头。如右图所示:
为了执行Find,需要使用散列函数来确定究竟考察哪个表,此时可以用通常的方式遍历该表并返回所找到的被查找项所在的位置。为了执行Insert,同样遍历一个相应的表以检查该元素是否已经处在适当的位置(如果要插入重复的元素,那么通常要留出一个额外的域,这个域当重复元出现时增1)。如果这个元素是个新的元素,那么它或者被插到表的尾端,或者前端,哪个容易就执行哪个。删除元素则是链表中的删除操作的直接实现,此处不再赘述。
装填因子:
定义散列表的装填因子lamda为散列表中的元素个数与散列表大小的比值。表的平均长度为lamda。执行一次查找所需要的工作是计算散列函数值所需要的常数时间加上遍历表所用的时间。在一次不成功的查找中,遍历的链接数平均为lamda。成功的查找则需要遍历大约1+0.5*lamda。它保证必然会遍历一个链接,而我们也期望沿着一个表的中途就能找到匹配的元素。这就指出,表的大小实际并不重要,而装填因子才是重要的。分离链接散列的一般法则是使得表的大小尽量与预料的元素个数差不多,换句话说,即lamda尽可能为1。
代码如下:
typedef int ElementType;
typedef unsigned int Index;
#ifndef _HashSep_H_
struct ListNode;
typedef struct ListNode *Position;
struct HashTbl;
typedef struct HashTbl *HashTable;
HashTable InitializeTable( int TableSize);
void DestroyTable( HashTable H );
Position Find( ElementType Key, HashTable H );
void Insert( ElementType Key, HashTable H );
ElementType Retrieve( Position P );
#endif
实现文件:hashsep.c
#include "hashsep.h"
#include <stdlib.h>
#include <stdio.h>
#define MinTableSize (10)
struct ListNode
{
ElementType Element;
Position Next;
};
typedef Position List;
struct HashTbl
{
int TableSize;
List *TheLists; //注意,TheList实际上是一个指向ListNode结构的指针的指针
};
static Index Hash(ElementType key, int TableSize)
{
return key % TableSize;
}
static int NextPrime( int N ) //取大于N的下一个素数
{
int i, flag = 0;
if (N % 2 == 0)
N++;
for (;;N+=2)
{
flag = 0;
for (i = 2; i * i < N; ++i)
{
if (N % i == 0)
{
flag = 1;
break;
}
}
if (!flag)
return N;
}
}
HashTable InitializeTable(int TableSize)
{
HashTable H;
int i;
if (TableSize < MinTableSize)
{
printf("Table size too small!");
return NULL;
}
H = (HashTable)malloc(sizeof(struct HashTbl)); //初始化Hash表
if (H == NULL)
{
printf("Malloc space for HashTable failed!");
return NULL;
}
H->TableSize = NextPrime(TableSize);
H->TheLists = malloc( sizeof( List ) * H->TableSize ); //为链表分配空间
if (H->TheLists == NULL)
{
printf("Malloc space for list failed!");
return NULL;
}
for (i = 0; i < H->TableSize; ++i)
{
H->TheLists[ i ] = malloc( sizeof( struct ListNode ) ); //为每个表头分配空间
if( H->TheLists[ i ] == NULL )
{
printf("Malloc space for list failed!");
return NULL;
}
else
H->TheLists[ i ]->Next = NULL;
}
return H;
}
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;
return P;
}
void Insert( ElementType Key, HashTable H )
{
Position Pos, NewCell;
List L;
Pos = Find(Key, H);
if ( Pos == NULL )
{
NewCell = (Position)malloc(sizeof(struct ListNode));
if (NewCell == NULL)
{
printf("Insert::Out of space!");
return;
}
L = H->TheLists[Hash(Key, H->TableSize)];
NewCell->Element = Key;
NewCell->Next = L->Next;
L->Next = NewCell;
}
}
ElementType Retrieve( Position P )
{
return P->Element;
}
void DestroyTable( HashTable H )
{
int i;
for (i = 0; i < H->TableSize; ++i)
{
Position P = H->TheLists[i];
Position Tmp;
while (P != NULL)
{
Tmp = P->Next;
free(P);
P = Tmp;
}
}
free(H->TheLists);
free(H);
}
开放地址法
分离链接散列算法的缺点是需要指针,由于给新单元分配地址需要时间,因此不可避免的会影响算法的速度。而开放地址法是另一种不需要链表解决冲突的办法。这种办法的原理是:当有冲突发生时,那么就要尝试选择另外的单元,直到找出空的单元为止。更一般的,h0(x),h1(x),h2(x),等等相继被试选,其中,hi(x)=(Hash(X) + F(i)) mod TableSize,且F(0) = 0。函数F是解决冲突的办法。因为所有的数据都要放入表内,所以开放地址散列法所需要的表要比分离链接散列更大。一般来说,对于开放地址法,装填因子lamda应该低于0.5。
线性探测法
即解决冲突函数F为:F(i)= i。即从冲突位置开始,依次查看该冲突位置后的每个位置,看是否已经放入元素。这种方法,只要表足够大,总会找到空的单元,将新元素插入到散列表中。但是,如此花费的时间是相当大的。即使表相对较空,已经插入的元素会开始形成一些区块,其结果形成一次聚集,于是,散列到区块中的任何关键字都需要多次试选单元才能解决冲突。
平方探测法
平方探测是消除线性探测中一次聚集问题的冲突解决方法。平方探测就是冲突函数为二次函数的探测方法,流行的选择是:F(i)=i*i。对于线性探测,让元素几乎填满散列表并不是一个好主意,因为此时表的性能会降低。对于平方探测情况甚至更糟,一旦表被填满超过一半,当表的大小不是素数时甚至在表被填满一半之前,就不能保证一次找到一个空单元了。虽然平方探测排除了一次聚集,但是散列到同一位置上的那些元素将探测相同的备选单元,这叫做二次聚集。
双散列
顾名思义,即两个散列函数,对于双散列,一种流行的选择是:F(i)=i*hash_2(X)。这个公式是说,将第二个散列函数用到X并在距离hash_2(X),2hash_2(X),3hash_2(X)等处探测。一定要注意hash_2(X)的选择,选择不好是灾难性的。
在开放地址散列表中,标准的删除操作不能进行。因为相应的单元可能已经引起过冲突,元素绕过它存在了别处。因此,开放地址散列表需要懒惰删除。
下面给出平方探测的代码:
头文件hashquad.h:
typedef int ElemtypeType;
#ifndef _HashQuad_H
struct HashTbl;
typedef struct HashTbl *HashTable;
HashTable InitializeTable( int TableSize );
void DestroyTable( HashTable H );
Position Find (ElemtypeType Key, HashTable H );
void Insert( ElemtypeType Key, HashTable H );
ElemtypeType Retrieve( Position P, HashTable H );
HashTable Rehash( HashTable H );
#endif
实现文件:hashquad.c
#include "hashquad.h"
#include <stdio.h>
#include <stdlib.h>
typedef unsigned int Index;
typedef Index Position;
#define MinTableSize (10)
enum KindOfEntry {Legitimate, Empty, Deleted};
struct HashEntry
{
ElemtypeType Element;
enum KindOfEntry Info;
};
typedef struct HashEntry Cell;
struct HashTbl
{
int TableSize;
Cell *TheCells;
};
static int NextPrime( int N )
{
if (N % 2 == 0)
++N;
int i = 0;
int flag = 0;
for (;;N += 2)
{
flag = 0;
for (i = 2; i * i < N; ++i)
{
if (N % i == 0){
flag = 1;
break;
}
}
if (flag == 0)
return N;
}
}
Index Hash( ElementType Key, int TableSize )
{
return Key % TableSize;
}
HashTable InitializeTable( int TableSize )
{
HashTable H;
int i;
if (TableSize < MinTableSize)
{
printf("Tabsize is too small!");
return NULL;
}
H = malloc(sizeof(struct HashTbl));
if (H == NULL)
{
printf("Initial hashtable failed, out of space!");
return NULL;
}
H->TableSize = NextPrime(TableSize);
H->TheCells = malloc(sizeof(Cell) * H->TableSize);
if (H->TheCells == NULL)
{
printf("Initial elements failed, out of space!");
return NULL;
}
for (i = 0; i < H->TableSize; ++i)
{
H->TheCells[i].Info = Empty;
}
return H;
}
Position Find(ElementType Key, HashTable H)
{
Position CurrentPos;
int CollisionNum = 0;
CurrentPos = Hash(Key, H->TableSize);
while (H->TheCells[CurrentPos].Info != Empty && H->TheCells[CurrentPos].Element != Key)
{
CurrentPos += 2 * ++CollisionNum - 1; //因为x*x = (x-1)(x-1)+2*x-1
if (CurrentPos > H->TableSize)
CurrentPos -= H->TableSize;
}
return CurrentPos;
}
void Insert( ElemtypeType 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;
}
}
ElemtypeType Retrieve( Position P, HashTable H )
{
return H->TheCells[P].Element;
}
void DestroyTable( HashTable H )
{
free(H->TheCells);
free(H);
}
再散列
对于使用平方探测的发放地址散列法,如果表的元素填的太满,那么操作的运行时间开始消耗过长,且Insert操作可能失败。这可能发生于有太多的移动和插入混合的场合。此时,一种解决方法是建立另一个大约两倍大的表(而且使用一个相关的新散列函数),扫描整个原始表,计算每个未删除元素的新散列值并将其插入到新表中。
代码如下:
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;
}