8 散列表
如果所有的键都是小整数,我们可以用一个数组来实现无序的符号表,将键作为数组的索引而数组中键i处储存的就是它对应的值。这样我们就可以快速访问任意键的值。在本节中我们将要学习散列表。它是这种简易方法的扩展并能够处理更加复杂的类型的键。我们需要用算术操作将键转化为数组的索引来访问数组中中的键值使用散列的查找算法分为两步。第一步是用散列函数将被查找的键转化为数组的一个索引。 理想情况下,不同的键都能转化为不同的索引值。当然, 这只是理想情况,所以我们需要面对两个或者多个键都会散列到相同的索引值的情况。因此,散列查找的第二步就是一个处理碰撞冲突的过程。在描述了多种散列函数的计算后,我们会学习两种解决碰撞的方法:拉链法和线性探测法。
散列表是算法在时间和空间上作出权衡的经典例子。如果没有内存限制,我们可以直接将键作为(可能是一个超大的)数组的索引,那么所有查找操作只需要访问内存一次即可完成。 但这种理想情况不会经常出现,因为当键很多要的内存太大。另一方面,如果没有时间限制,我们可以使用无序数组并进行顺序查找,这样就只需要很少的内存。而散列表则使用了适度的空间和时间并在这两个极端之间找到了一种平衡。 事实上,我们不必重写代码,只需要调整散列算法的参数就可以在空间和时间之间作出取舍。我们会使用概率论的经典结论来帮助我们选择适当的参数。概率论是数学分析的重大成果。虽然它不在本书的讨论范围之内,但我们将要学习的散列算法利用了这些知识,这些算法虽然简单但应用广泛。使用散列表,你可以实现在一般应用中拥有(均摊后)常数级别的查找和插入操作的符号表。这使得它在很多情况下成为实现简单符号表的最佳选择。
8.1散列函数
我们面对的第一个问题就是散列函数的计算,这个过程会将键转化为数组的索引。如果我们有一个能够保存M个键值对的数组,那么我们就需要一个能够将任 意键转化为该数组范围内的索引([0,M-1]范围内的整数)的散列函数。我们要找的散列函数应该易于计算并且能够均匀分布所有的键,即对于任意键,0到M-1之间的每个整数都有相等的可能性与之对应(与键无关)。这个要求似乎有些难以理解。那么要理解散列,就首先要仔细思考如何去实现这样一个丽数。
散列函数和键的类型有关。严格地说,对于每种类型的键都我们都需要一个与之对应的散列函数。如果键是一个数,比如社会保险号,我们就可以直接使用这个数:如果键是一个字符串, 比如一个人的名字, 我们就需要将这个字符串转化为一个数: 如果健含有多个部分,比如邮件地址,我们需要用某种方法将这些部分结合起来。对于许多常见类型的键,我们可以利用Java提供的默认实现。我们会简略讨论多种数据类型的散列函数。你应该看看它们是如何实现的,因为你也需要为自定义的类型实现散列函数。
8.1.1 典型的例子
假设在我们的应用中,键是美国的社会保险号。一个社会保险号含有9位数字并被分为三个部分,例2如23.45-6789。 第一组数字表示该号码答发的地区(例如,第一键散列值 做列值组号码为035的社会保险号来自罗得岛州,214则来自马里兰州),另两组数字表示个人身份。社会保险号共有10亿(102)个,但假设我们的应用程序只需要处理几百个,我们可以使用一个大小M= 1000的散列表。散列函数的一种实现方法是使用键(社会保险号)中的三个数字。用第三组中的三个数字似乎比用第一-组中的三个数字更好(因为我们的客户不太可能完全平均地分布在各个地区),但下面会讲到,更好的方法是用所有9个数字得到一个整数,然后再考虑整数的散列函数。
8.1.2 正整数
将整数散列最常用方法是除留余鼓法。我们选择大小为素数M的数组,对于任意正整数k,计算k除以M的余数。这个函数的计算非常容易(在Java中为k%M)并能够有效地将键散布在0到M-I的范围内。如果M不是素数,我们可能无法利用键中包含的所有信息,这可能导致我们无法均匀地散列散列值。例如,如果键是十进制数而M为10’,那么我们只能利用键的后k位,这可能会产生一些问题。 举个简单的例子,假设键为电话号码的区号且M = 100。由于历史原因,美国的大部分区号中间位都是0或者1,因此这种方法会将大量的键散列为小于20的索引,但如果使用素数,散列值的分布显然会更好(一个离100 更远的素数会更好)。与之类似,互联网中使用的IP地址也不是随机的,所以如果我们想用除留余数法将其散列就需要用素数(2的幂除外)大小的数组。
8.1.3 浮点数
如果键是0到 1之间的实数,我们可以将它乘以M并四舍五人得到一个0至M-1之间的索引值。尽管这个方法很容易理解,但它是有缺陷的,因为这种情况下键的高位起的作用更大,最低位对散列的结果没有影响。修正这个问题的办法是将键表示为二进制数然后再使用除留余数法( Java就是这么做的)。
8.1.4 字符串
除留余数法也可以处理较长的键,例如字符串,我们只需将它们当作大整数即可。例如,下面的代码就能够用除留余数法计算String S的散列值:
Java的charAt()函数能够返回一个 char值,即一个非负16位整数。如果R比任何字符的值都大,这种计算相当于将字符串当作一个N位的R进制值,将它除以M并取余。一种叫Homner方法的经典算法用N次乘法、加法和取余来计算-个字符串的散列值。只要R足够小,不造成溢出,那么结果就能够如我们所愿,落在0至M-1之内。使用一个较小的素数,例如31,可以保证字符串中的所有字符都能发挥作用。Java 的String的默认实现使用了一个类似的方法。
散列字符串键
int hash = 0;for(int i = 0; i < s.length; i++){hash= (R + hash + s.charAt(i)) % M;}