二叉树提供了对一组数据进行各种操作的强大功能,特别是在处理有序数据时二叉查找树非常方便。例如FindMax和FindMin操作,在表数据结构中这两个操作时间复杂度为O(N),但在二叉查找树中这两个操作只需要O(logN)的时间复杂度。但在很多情况下,数据的顺序并不是应用所关心的问题。这一类应用只需要插入,删除,查找这些基本的操作,此时使用二叉树数据结构进行这些操作的时间复杂度也为O(logN),当数据量很大时,这样的操作非常耗时。为了解决这个问题,提出了一种可以在常数时间复杂度情况下进行插入,删除和查找操作的数据结构——散列表(Hash Table)。这种数据结构不支持与顺序有关的操作,但是优点在于处理增删查操作时需要的时间与数据量无关,保证它在数据量很大的情况下依然可以保持高效。
散列的一般思路
理想情况下的散列表就是一个固定大小的数组,记表的大小为TableSize,则表中存储位置的索引变化从0到TableSize-1,其中TableSize也是散列表数据结构的一部分,而不只是浮动于全局的某个变量。关键字(要存储的数据)被映射到这个范围内的某个数,并且被存放到适当的单元中。所谓的映射就是散列,需要散列函数的支持。理想情况下散列函数可以将不同的关键在映射到不同的位置,但实际上这种情况不可能发生,因为关键字的数量一般是远远大于可以映射到的数值范围的。这样就必须处理不同的关键字映射到相同的位置的情况,即解决冲突。
散列函数
如果关键字是整数,那么比较合理的散列函数是Key mod TableSize,同时TableSize最好是素数,这样可以保证散列结果相对比较均匀。通常情况下,关键字是字符串,此时需要仔细考虑散列函数。下面考虑一种TableSize大小为10007这个素数情况下的散列函数选择:
一种简单的思路是将字符串中各个字符值(字符型也可以看做整型的一种)相加:
typedef unsigned int Index;
Index Hash(const char *key,int TableSize)
{
unsigned int hashval=0;
while(*Key!=’ ’)
{
HashVal+=*Key++;
}
return HashVal% TableSize;
}
这种情况下假设字符串长度至多8个字符,因为一个字符的编码最大为127,则8个字符的总大小为1016,TableSize为10007,则Hash表中只有很少的一部分空间可以被真正的分配,因此这不是一种均匀的分配。另一种思路只使用字符串的前三个字符,这样公有26*26*26=17576个可能的值,在TableSize为10007的情况下分布较为均匀,实现如下:
Index Hash(const char * Key,int TableSize)
{
return (key[0]+27*key[1]+729*Key[2])%TableSize;
}
但是实际的统计中,字符串的前三个字符并不是随机的,3个字符的组合实际上只有2851种可能。这样造成这个Hash函数的结果也不是均匀分布的。Hash的第三种尝试是考虑字符串中的所有字符,利用来计算hash值,实现如下:
Index Hash(const char *Key,int TableSize)
{
unsigned int HashVal=0;
while(*Key!=’ ’)
HashVal=(HashVal<<5)+*Key++;
Return HashVal%TableSize;
}
其中选择乘以32的i次幂是因为乘以32等于向左移动5位,速度较快。它替换的是27,表示26个英文字符和一个null符号。实际证明,这种散列函数可以比较好的做到字符串的均匀分布。
解决冲突
在将关键字映射到Hash表中的某一个位置时,不可避免的会遇到不同的关键字映射到表中同一个位置的情况,这种情况下的处理方式有两种,一种是分离链接法,这种方法的主要思路是将关键字链接到表中位置对应的链表中,这样同一个表位置可以保存很多的关键字。另一种方法是开放定址法,这种方法将第一个映射到表中位置的关键字保存到表中对应的位置中,要是下次还有关键字映射到此位置则将通过一定方式的变换将其放到其他的表位置内。使用这种方式解决冲突时,需要散列表容量大于散列表中存放的数据数目。下一篇文章将具体描述这两种解决冲突的方法。