散列表

散列表

    • 散列函数
      • 典型的例子
      • 正整数
      • 浮点数
      • 字符串
      • 组合键
      • java的约定
      • 将hashCode()的返回值转化为一个数组索引
      • 软缓存
    • 基于拉链法的散列表
      • 散列表的大小

如果所有的键都是小整数,我们就可以使用一个数组来实现无序的符号表,将键作为数组的索引而数组中键i处存储的就是它对应的值。这样我们就可以快速访问任意键的值。下面我们说的散列表,它是这种简易方法的扩展并能够处理更加复杂的类型的键。我们需要用算数操作将键转化为数组的索引来访问数组中的键值对。

使用散列的查询算法分为两步,第一步是用散列函数将被查找的键转化为数组的一个索引。理想的情况下,不同的键都能转化为不同的所引值。当然,这只是理想情况,所以我们需要面对两个或者多个键都会散列到相同的索引值的情况。因此,散列查找的第二部就是一个处理碰撞冲突的过程。

散列表是算法在时间和空间上做出权衡的经典例子。如果没有内存限制,我们可以直接将键作为(可能是一个超大的)数组的索引,那么所有查找操作只需要访问内存一次即可完成。但这种理想情况不会经常出现,因为当键很多时需要的内存太大。另一方面,如果没有时间限制,我们可以使用无须数组并进行排序查找,这样就只需要很少的内存。而散列表则使用了湿度的空间和时间并在这两个极端之间找到了一种平衡。事实上,我们不必重写代码,只需要调整散列算法的参数就可以在空间和时间之间做出取舍。我们会使用概率论的经典结论来帮助我们选择适当的参数。

散列函数

我们面对的第一个问题就是散列函数的计算,这个过程会将键转化为数组的索引。如果我们有一个能够保存M各键值对的数组,那么我们就需要要一个能够将任意键转化为该数组范围内索引(0,M-1)的散列函数。我们要找的散列函数应该易于计算并且能够均匀分布所有的键,即对任意键,0到M-1之间的每个整数都有相等的可能性与之对应(与键无关)。这个要求似乎有些难以理解。那么要理解散列,就首先要仔细思考如何去实现这样一个函数。
散列函数和键的类型有关。严格的说,对于每种类型的键我们都需要一个与之对应的散列函数。如果键是一个数,比如社会保险号,我们就可以直接使用这个数;如果键是一个字符串,比如一个人的名字,我们就需要将这个字符串转化为一个数;如果键含有多个部分,比如邮件地址,我们需要用某种方法将这些部分结合起来。对于许多常见类型的键,我们可以利用Java提供的默认实现。

典型的例子

假设我们的应用中,键时社会保险号。一个社会保险号含有9位数字并被分为三个部分,例如123-45-6789,社会保险号有10亿个,但假设我们的应用程序只需要处理几百个,我们可以使用一个大小为M=1000的散列表。散列函数的一种实现方式使用键(社会保险号)中的三个数字。用第三组中的三个数字似乎比用第一组中的三个数字更好(因为我们的客户不太可能完全平均的分布在各个地方)。

正整数

将证书散列最常用的方法是除留余数法。我们选择大小为素数M的数组,对于任意正整数k,计算k除以M的余数。这个函数的计算非常容易,并能够有效的将键散布在0到M-1的范围内。如果M不是素数,我们可能无法利用键中包含的所有信息,这可能导致我们无法均匀的三列散列值。例如,如果键是十进制数而M为10的k次方,那么我们只能利用键的后k位,这可能会产生一些问题。举个简单的例子,假设键为电话号码的区号且M=100.可能由于某些原因大部分取号中键位都为0或1,一次这种犯法会将大量的键散列为小于20的索引,但如果使用素数97,散列值的分布显然会更好。

浮点数

如果键是0-1之间的实数,我们可以将它乘以M并四舍五入到一个0-M-1之间的索引值。进过这个方法很容易理解,但它是有缺陷的,因为这种情况下键的高位起的左右更大,最低位对散列的结果没有影响。修正这个问题的办法是将键表示为二进制数然后在使用除余法。

字符串

除留余数法也可以处理较长的键,例如字符串,我们只需要将他们当作大整数即可。

int hash = 0;
for(int i = 0;i < s.length();i++){
hash = (R * hash + s.charAt(i)) % M;
}

组合键

如果键的类型含有多个整形变量,我们可以和String类型一样将他们混合起来。例如,假设被查找的键的类型是Date,其中含有几个整型的域:day,month和year。我们可以这样计算它的散列值:
int hash = (((day * R + month) % M) * R + year) % M

java的约定

每种数据类型都需要相应的散列函数,于是Java令所有数据类型都继承了一个能够返回一个32位整数的hashCode()方法。每一种数据类型的hashCode()方法都必须和equals()方法一直。也就是说,如果a.equals(b)返回true,那么a.hashCode()的返回值必然和b.hashCode()的返回值相同。相反,如果两个对象的hashCode()方法的返回值不同,那么我们就知道这两个对象是不同的。但如果两个对象的hashCode()方法的返回值相同,这两个对象也有可能不同,我们需要使用equals()方法进行判断。所以你要是想自定义散列函数,你需要同时重写hashCode()和equals()两个方法。默认散列函数会返回对象的内存地址,但这只适用于很少的情况。Java为很多常用的数据类型重写了hashCode()方法(包括String,Integer,Double,File和URL)

将hashCode()的返回值转化为一个数组索引

因为我们需要的是数组的索引而不是一个32为的整数,我们在实现中会将默认的hashCode()方法和除留余数法结合起来产生一个0-M的整数,方法如下:
private int hash(Key x){
return (x.hashCode() & 0x7fffffff) % M;
}

这段代码会将符合位屏蔽(将一个32位整数变为一个31位非负整数),然后用除留余法计算它除以M的余数。在使用这样的代码时我们一边将数组的大小M取为素数以充分利用原散列值的所有位。

软缓存

如果散列值的计算很耗时,那么我们或许可以将每个键中使用一个hash变量来保存它的hashCode()的返回值。

基于拉链法的散列表

一个散列函数能够将键转化为数组索引。散列算法的第二步就是碰撞处理,也就是处理两个或者多个键的散列值相同的情况。一种直接的办法是将大小为M的数组中的每个元素指向一条链表,链表中的每个结点都存储了散列值为该元素的索引的键值对。这种发发被称为拉链法,因为发生冲突的原色都被存储在链表中。这个方法的基本思想就是选择足够大的M,使得所有链表都尽可能短以保证高效的查找。查找分两步:首先根据散列值找到对应的链表,然后沿着链表顺序查找相应的键。

散列表的大小

在实现基于拉链表的散列表时,我们的目标是选择适当的数组大小M,既不会因为空链表而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。而拉链法的一个好处就是这并不是关键性的选择。如果存入的键多于预期,查找所需的事件只会比选择更大的数组稍长;如果少于预期,虽然有些空间浪费但查找会非常快。当内存不是很紧张时,可以选择一个足够大的M,使得查找需要的事件变为常数;当内存紧张时,选择尽量大的M仍然能够将性能提高M倍。另一种方法是动态调整数组的大小以保证短小的链表。

散列最主要的目的在于均匀的将键散布开来,因此在计算散列后键的顺序信息就丢失了。如果你需要快速找到最大或者最小的键,或是查找某个范围内的键,散列表都不是合适的选择。

基于拉链法的散列表的实现简单。在键的顺序并不重要的应用中,它可能是最快的。

你可能感兴趣的:(算法)