散列是一种用于以常数平均时间执行插入、删除和查找的技术。但是,那些需要元素间任何排序信息的操作将不会得到有效的支持。因此,诸如FindMin,FindMax以及以线性时间将排序过的整个表进行打印的操作都是散列所不支持的。
散列的基本思想
理想的散列表数据结构只不过是一个包含有关键字的具有固定大小的数组。典型情况下,一个关键字就是一个带有相关值(例如工资信息)的字符串。我们把表的大小记作TableSize,并将其理解为散列数据结构的一部分而不仅仅是浮动于全局的某个变量。通常的情况是让表从0到TableSize-1变化。
每个关键字被映射到从0到TableSize-1这个范围中的某个数,并且被放到适当的单元中。这个映射就叫散列函数(hash function),理想情况下它应该运算简单并且应该保证任何两个不同的关键字映射到不同的单元。不过,这是不可能的,因为单元的数目有有限的,而关键字实际上是用不完的。因此,我们寻找一个散列函数,该函数要在单元之间均匀地分配关键字。
这就是散列的基本思想。剩下的问题则是要选择一个函数,决定当两个关键字散列到同一个值到时候(称为冲突(collision))应该做什么以及如何确定散列表的大小。
散列函数
如果输入的关键字是整数,则一般合理的方法就是直接返回"Key mod TableSize"的结果,除非Key碰巧具有某些不理想的性质。在这种情况下,散列函数的选择需要仔细考虑。
例如,若表的大小是10而关键字都以0为个位,则此时上述标准的散列函数就是一个不好的选择。好的办法通常是保证表的大小是素数。当输入的关键字是随机整数时,散列函数不仅算起来简单而且关键字的分配也很均匀。
通常,关键字是字符串,在这种情况下,散列函数需要仔细的选择。
一种选择方法是把字符串中字符的ASCII码加起来。代码如下:
typedef unsigned int Index;Index Hash(const char *Key, int TableSize){ unsigned int HashVal = 0; while(*Key != '0'){ HashVal += *Key++; } return HashVal % TableSize;}
这个散列函数实现起来很简单而建能够很快地算出答案。不过,如果表很大,则函数将不会很好的分配关键字。例如,设TableSize = 10007(10007是素数),并设所有的关键字至多8个字符长。由于char型量的值最多127,因此散列函数只能假设值在0和1016(1016 = 127 x 8)之间。显然这不是一种均匀的分配。
另一个散列函数,代码如下:
typedef unsigned int Index;Index Hash(const char *Key, int TableSize){ return (Key[0] + 27 * Key[1] + 729 * Key[2]) % TableSize;}
这个散列函数假设Key至少有两个字符外加NULL结束符。值27表示英文字母表的字母个数外加一个空格,而729 = 27 x 27。该函数只考虑考察前三个字符,但是,加入它们是随机的,而表的大小像前面那样还是10007,那么我们就会得到一个合理的均匀分配。可是不巧的是,英文不是随机。虽然三个字符()忽略空格有26^3=17576种可能的组合,但查验词汇量足够大的联机词典却揭示:3个字母的不同组合数实际上只有2851。即使这些组合没有冲突,也不过只有表的28%被真正散列到。因此,虽然很容易计算,但是当三列表足够大的时候这个函数还是不合适的。
下面是第三种散列函数。
typedef unsigned int Index;Index Hash(const char *Key, int TableSize){ unsigned int HashVal = 0; while(*Key != '0'){ HashVal = (HashVal << 5) + *Key++; /*****/ } return HashVal % TableSize;}
这个散列函数涉及到关键字中的所有字符,并且一般可以分布得很好。这个函数其实计算的是
这里运用Hoerner法则计算了这个多项式。我们之所以用32代替27,是因为32作乘法不是真的去乘,而是移动二进制5位。为了加速,在代码中的/******/处的加法可以用按位异或来代替。
这个散列函数未必是最好的,但是确实具有极其简单的优点。如果关键字特别长,那么该散列函数计算起来将花费过多的时间,不仅如此,前面的字符还会左移出最终的结果。在这种情况下,通常的做法是不使用所有的字符。此时关键字的长度和性质将影响选择。例如,关键字可能是完整的街道地址,散列函数可以包括街道地址的几个字符,也许是城市名和邮政区码的几个字符。有些程序设计人员通过只使用奇数位置上的字符来实现他们的三列函数,这里有这么一层想法:
用计算散列函数节省下的时间来补偿由此产生的对均匀的分布的函数的轻微干扰。
剩下的主要编程细节是解决冲突的消除问题。如果当一个元素被插入的地方已经有另一个元素存在(散列值相同),那么就产生一个冲突,这个冲突需要消除。解决这种冲突的方法有几种,我们将讨论其中最简单的两种:分离链接法和开放定址法。
分离链接法
其做法就是将散列到同一个值的所有元素保留到一个表中。我们假设关键字是i前10个完全平方数并设散列函数就是Hash(X) = X mod 10。如下图所示:
为执行Find,我们使用散列函数来确定究竟考察哪个表。此时我们以通常的方式遍历该表并返回所找到的被查找项所在的位置。
为执行Insert,我们遍历一个相应的表以检查该元素是否已经处在适当的位置(如果要插入重复元,那么通常要留出一个额外的域,这个域当重复元出现时增1)。如果这个元素是新元素,那么它或者被插入到表的前端,或者被插入到表的末端,哪个容易就执行哪个。当编写程序的时候这是最容易寻址的一种。有时新元素插入到表的前端不仅因为方便,而且还因为新近插入的元素最有可能最先被访问。
代码如下:
#ifndef HASHSEP_H#define HASHSEP_Htypedef int ElementType;typedef unsigned int Index;struct ListNode;typedef struct ListNode *Position;struct HashTbl;typedef struct HashTbl* HashTable;HashTable InitializeTable(int TableSize);void DestoryTable(HashTable H);Position Find(ElementType Key, HashTable H);void Insert(ElementType Key, HashTable H);ElementType Retrieve(Position P);#endif
#include #include #include "HashSep.h"#define MinTableSize (10)struct ListNode{ ElementType Element; Position Next;};typedef Position List;struct HashTbl{ int TableSize; List *TheLists;};static int NextPrime(int N){ int i; if(N%2==0){ N++; } for(;;N+=2){ for(i = 3; i*i<=N; i+=2){ if(N%i == 0){ goto ContOuter; } } return N; ContOuter:; }}Index Hash(ElementType Key, int TableSize){ return Key % TableSize;}HashTable InitializeTable(int TableSize){ HashTable H; int i; if(TableSize < MinTableSize){ return nullptr; } H = (HashTable)malloc(sizeof(struct HashTbl)); if(H == nullptr){ printf("Out of space"); return nullptr; } H->TableSize = NextPrime(TableSize); H->TheLists = (List*)malloc(sizeof(List) * H->TableSize); if(H->TheLists == nullptr){ printf("out of space!!!"); return nullptr; } for(i = 0; i< H->TableSize; i++){ H->TheLists[i] = (List)malloc(sizeof(struct ListNode)); if (H->TheLists[i]==nullptr){ printf("out of space"); return nullptr; } 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 != nullptr && 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 == nullptr){ NewCell = (ListNode*)malloc(sizeof(struct ListNode)); if(NewCell == nullptr){ printf("out of space"); return; } else{ L = H->TheLists[Hash(Key, H->TableSize)]; NewCell->Next = L->Next; NewCell->Element = Key; L->Next = NewCell; } }}ElementType Retrieve(Position P){ return P->Element;}void DestoryTable(HashTable H){ int i; for(i = 0; i < H->TableSize; ++i){ Position P = H->TheLists[i]; Position Tmp; while(P != nullptr){ Tmp = P->Next; free(P); P = Tmp; } } free(H->TheLists); free(H);}
我们定义散列表的装填因子(load factor)为散列表中的元素个数与散列表大小的比值。在上面的例子中这个因子为1。链表的平均长度是1。执行一次查找所需要的工作是计算散列函数值所需要的常数时间加上遍历链表所用的时间。
分离连接散列中,表的大小实际上并不重要,而装填因子才是最重要的。一般法则是使得表的大小尽量与预料的元素个数差不多,也就是说尽可能让装填因子为1。
开放定址法
分离链接散列算法的缺点是需要指针,由于给新单元分配地址需要时间,因此这就导致算法的速度多少有所减慢,同时算法实际上还要求对另一种数据结构的实现,除使用链表解决冲突外,开放定址散列法(Open addressing bashing)是另外一种用链表解决冲突的方法。在开放定址散列算法系统中,如果有冲突发生,那么就尝试选择另外的单元,直到找出空的单元为止。更一般的,单元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。这相当于逐个探测每个单元(必要时可以绕回)以查找出一个空单元。
如上图显示使用与前面相同的散列函数将诸关键字[89, 18, 49, 58, 69]插入到一个散列表中的情况。而此时的冲突解决方法就是F(i) = i。
第一个冲突在插入关键字49时产生;它被放入下一个空闲地址,(9 + 试选1次 mod 10)即地址0,该地址是开放的。关键字58依次18,89,49发生冲突,(8 + 试选3次 mod 10)试选三次之后才找到一个空单元。对69的冲突用类似的方法处理。只要表足够大,总能够找到一个自由单元,但是如此花费的时间是相当多的。更糟的是,即使表相对较空,这样占据的数据单元也会开始形成一些区块。其结果称为一次聚集(primary clustering)。于是,散列到区块中的任何关键字都需要多次试选单元才能够解决冲突,然后该关键字被添加到相应的区块中。
平方探测法
平方探测法是消除线性探测中一次聚集问题的冲突解决方法。平方探测就是冲突函数为二次函数的探测方法。流行的选择是F(i) = i^2。逻辑基本跟线性探测相似。
对线性探测,让元素几乎填满散列表并不是个好主意,因为此时表的性能会降低。对于平方探测更糟:一旦表被填满超过一半,当表的大小不是素数时甚至在表被填满一半之前,就不能保证找到一个空单元了。这是因为最多有表的一半可以用作解决冲突的备选位置。
如果使用平方探测,且表的大小是素数,那么当表至少有一半是空的时候,总能够插入一个新元素。
代码如下:
#ifndef HASHQUAD_H#define HASHQUAD_Htypedef int ElementType;typedef unsigned int Index;typedef Index Position;struct HashTbl;typedef struct HashTbl *HashTabel;HashTabel InitializeTable(int TableSize);void DestroyTable(HashTabel H);Position Find(ElementType Key, HashTabel H);void Insert(ElementType Key, HashTabel H);ElementType Retrieve(Position P, HashTabel H);HashTabel Rehash(HashTabel H);#endif
#include "../cmn/fatal.h"#include "hashquad.h"#include #define MinTableSize 10enum KindOfEntry { Legitimate, Empty , Deleted};struct HashEntry{ ElementType Element; KindOfEntry Info;};typedef struct HashEntry Cell;struct HashTbl{ int TableSize; Cell* TheCells;}; /* Return next prime; assume N >= 10 */static intNextPrime( int N ){ int i; if( N % 2 == 0 ) N++; for( ; ; N += 2 ) { for( i = 3; i * i <= N; i += 2 ) if( N % i == 0 ) goto ContOuter; /* Sorry about this! */ return N; ContOuter: ; }}Index Hash( ElementType Key, int TableSize ){ return Key % TableSize;}HashTabel InitializeTable(int TabelSize){ HashTabel H; int i; if(TabelSize < MinTableSize){ Error("Table size too small"); return nullptr; } H = (HashTabel)malloc(sizeof(struct HashTbl)); if(H == nullptr){ FatalError("Out of space!!!"); } H->TableSize = NextPrime(TabelSize); H->TheCells = (Cell*)malloc(sizeof(Cell)* H->TableSize); if (H->TheCells == nullptr){ FatalError("out of space!!!"); } for(i=0; i< H->TableSize; ++i){ H->TheCells[i].Info = Empty; } return H;}Position Find(ElementType Key, HashTabel 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; ++CollisionNum; if(CurrentPos >= H->TableSize){ CurrentPos -= H->TableSize; } } return CurrentPos;}void Insert(ElementType Key, HashTabel H){ Position Pos; Pos = Find(Key, H); if(H->TheCells[Pos].Info != Legitimate){ H->TheCells[Pos].Info = Legitimate; H->TheCells[Pos].Element = Key; }}HashTabel Rehash(HashTabel H){ int i, OldSize; Cell *OldCells; OldSize = H->TableSize; OldCells = H->TheCells; H = InitializeTable(2 * OldSize); for(i=0; iTheCells[P].Element;}void DestoryTable(HashTabel H){ free(H->TheCells); free(H);}
双散列
我们将要考察的最后一个冲突解决方法是双三列(double hashing)。对于双散列,一种流行的选择是F(i)= i × hash2(X)。这个公式是说,我们将第二个散列函数应用到X并在距离hash2(X), 2hash2(X)等处探测。hash2(X)选择得不好将会是灾难性的。例如,若把99插入到前面例子中的输入中去,则通常的选择hashx(X) = X mod 9 将不起作用。因此函数一定不要算得0值。另外,保证所有的单元都能被探测到也是很重要的。诸如hash2(X) = R - (X mod R)这样的函数将起到良好的作用,其中R为小于TableSize的素数。