散列表的实现一般叫作散列。散列是一种用于以常数平均时间执行插入、删除和查找的技术。可是任何排序的信息都不会获得有效的支持。因此FindMax(),FindMin(),以及以线性时间打印的操做都是散列所不支持的。前端
理想的散列表数据结构值不过是一个包含有关键字的具备固定大小的数组。git
关键字映射的函数叫作散列函数,一般散列函数应该运算简单而且保证任何两个不一样的关键字映射到不一样的单元。不过这是不可能的,由于单元的数目是有限的,然而关键字是用不完的。所以,咱们寻找一个散列函数,该函数要在单元之间均匀的分配的关键字。对于两个关键字映射到同一个值的时候,咱们称之为冲突,须要设定一个函数来进行处理。算法
散列函数数组
对于关键字是整数,则通常合理的方法就是直接返回"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,显然这是不均匀的分配。spa
另外一种散列函数有下面的代码所示,假设关键字key至少有两个字符加上NULL结束,729=27^2指针
假设它们是随机的,而表仍是10007的大小,咱们就会获得一个合理的均匀分配,虽然3个字符有26^3=17576种可能的组合,可是实际的词汇量却揭示了:3和字母不一样的组合数实际上面只有2851种,也只不过有28%的空间被利用上。当表足够大的时候,它们仍是不合适的code
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操做。
散列表对于节点是实际的名字而不是数字的任何图论问题都是有用的。
散列表的第三种运用是在游戏编制中,当程序搜索游戏的不一样的运行时,它跟踪经过计算基于位置的散列函数而看到一些位置。若是位置再出现,程序一般经过简单变换来避免昂贵的计算,在游戏程序中,叫作变换表。
散列的另外一个用途是在线拼写检验程序,若是错拼检验更重要,那么整个词典均可以预先被散列,单词就能在常数时间内被校验。散列表很合适作这项工做,由于排列排列单词并不重要。