三.散列表(哈希表)原理

来源:互联网 发布:sql注入教程 编辑:程序博客网 时间:2024/05/22 10:31

散列表的基本概念

散列表(hash table),是根据关键码值(Key value)而直接进行访问的数据结构。它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度,使用数组进行存储,而其数组的下标是是根据关键字通过映射函数计算出来的。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

散列表是普通数组概念的推广,由于普通数组可以直接寻址,使得能在O(1)的时间内访问数组中的任意位置,如果存储空间允许,我们可以提供一个数组,为每个可能的关键字保留一个位置,以利用直接寻址技术的优势,但是这样做的缺点是明显的:如果可能的关键字数目很多,则要存储一个很大的数组,这样不太实际,甚至有时候是不可能的,而且对整个可能的关键字集合,实际上存储的关键字数目可能很小,使得分配的大部分空间都浪费掉了。

当实际存储的关键字数目比全部可能的关键字总数要小时,采用散列表就成为直接数组寻址的一种有效替代,因为散列表使用散列函数缩小了数组下标的范围,减小数组的大小。

散列函数

介绍散列函数前,我们先来介绍几个散列表的基本概念。在散列表内部,我们使用桶(bucket)来保存键值对,我们前面所说的数组索引即为桶号,决定了给定的键存于散列表的哪个桶中。

现在假设我们的散列表中有M个桶,桶号为0到M-1。我们的散列函数的功能就是把任意给定的关键字(key)转为[0, M-1]上的整数。我们对散列函数有两个基本要求:一是计算时间要短,二是尽可能把键分布在不同的桶中。对于不同类型的键,我们需要使用不同的散列函数,这样才能保证有比较好的散列效果。

一个好的散列函数应尽可能得满足简单均匀散列假设:每个关键字都被等可能地散列到散列表中的所有桶号上,并与其他关键字已散列到哪个桶号无关。当然,有些应用会要求比简单均匀散列假设更强的性质,例如要求某些很近似的关键字具有截然不同的散列值。

多数散列函数都假定关键字的全域为自然数集N={0,1,2,……}。因此,如果关键字不是自然数,就需要将关键字

1.除法散列法

除法散列法通过取关键字k除以m的余数,将k映射到m个桶中的其中一个上,即散列函数为:
图2
在使用除法散列法时,要注意的地方是m的取值。通常,在m值的选择中,不宜选择2的幂,因为这样做了取余数运算后,相当于直接拿出了k值的低比特位。如 m = 2^p, h(k)就是k的最低p个比特。一般来说,应该选择一个适当的质数作为m,并且,m不能过于靠近2的幂,所有一个不太接近2的整数幂的素数是一个较好的选择。

2.乘法散列法

乘法散列法需要两个步骤:
第一步,用关键字k乘上常熟A(0 < A < 1),并提取kA的小数部分。
第二步,用m乘以这个值,再向下取整,其散列函数为:
图3
乘法散列法的一个优点是对m的选择不是特别关键,一般选择它为2的某个幂次(比如p次幂)。假设某计算机的字长为w位,而k正好可用一个单字表示。限制A为形如图4的一个分数,其中s是一个取自图5的整数,先用w为整数图6乘上k,其结果是一个2w位的值图7这里r1的为乘积的高位字,r0为乘积的低位字。所求的p为散列值中,包含了r0的p个最高有效位。

该方法对任何的A值都适用,但是对某些值效果更好,最佳的选择与带散列的数据有关。TAOCP的作者Knuth认为
图8
也就是我们所知的黄金分割比是一个比较理想的选择。通过这样的设定,这个哈希函数的执行得到了一定的简化。如k=123456,m=16384(即2^14),w=32 时, 可将A表示为特定的形式:2654435769/(2^32),那么k * s = 327706022297664,其高位为76300,低位为17612864,所以低位的32位的二进制表示为00000001000011001100000001000000,其中低位的14个最高有效位为1000011,所以产生的散列值为67。

3.平方取中法

取关键字平方后的中间几位数字作为哈希函数的输入值。一个数的平方中间几位与每位数字都有关。平方取中法适用于不知道关键字的分布且位数又不是很大的情况。

4.折叠法

将关键字从左到右分割成位数相等的几部(最后一部分位数允许短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。折叠法事先不需要知道关键的分布,适用于关键字位数较多的情况。

4.全域散列法

针对特定的散列函数,如果选择特定的某些关键字,就有可能把这些关键字全部散列到同一个桶号上,任何一个特定的散列函数都可能出现这种情况。唯一有效的改进方法是随机地选择散列函数,使之独立于要存储的关键字,这种方法称为全域散列。

全域散列法在执行开始时,就从一组精心设计的函数中,随机选择一个作为散列函数,因为随机地选择散列函数,算法在每一次执行时都会不同,这样就可以确保对于任何输入,算法都有较好的平均情况性能。

例子
以Java中的一些类为例,来介绍一下针对不同数据类型的散列函数的实现。

String类的hashCode方法如下所示:

    public int hashCode() {        int h = hash;        if (h == 0 && value.length > 0) {            char val[] = value;            for (int i = 0; i < value.length; i++) {                h = 31 * h + val[i];            }            hash = h;        }        return h;    }

hashCode方法中的value是一个char[]数组,存储中字符串的的每字符。我们可以看到在方法的最开始我们会把hash赋给h,这个hash就表示之前计算的hashCode,这样以来若之前已经计算过这个字符串对象的hashCode,这次我们就无需再计算了,直接返回之前计算过得即可。这种把hashCode缓存的策略只对不可变对象有效,因为不可变对象的hashCode是不会变的。

Integer类的hashCode方法如下所示:

    public int hashCode() {        return value;    }

其中value表示Integer对象所包装的整型值,所以Integer类的hashCode方法仅仅是简单的返回了自身的值。

Double类的hashCode方法如下所示:

    public int hashCode() {        long bits = doubleToLongBits(value);        return (int)(bits ^ (bits >>> 32));    }

我们可以看到Double类的hashCode方法首先会将它的值转为long类型,然后返回低32位和高32位异或的结果作为hashCode。

冲突的概念

这里存在一个问题,因为散列函数是确定的,某一个给定的输入应始终产生相同的结果,而散列函数是一种压缩映射,所以两个不同的关键字就可能映射成相同的下标。这种情况称之为冲突。

解决冲突的方法

好的散列函数只能尽可能的减少冲突,但是想要完全避免冲突是不可能的,所以需要解决冲突的方法。

1. 链接法

在链接法中,通过把散列到同一个下标的所有元素都放到一个链表中。该散列表的每一个成员都包括一个链表,其中存储着所有散列值相同的元素。如下图:
图9

2.开放寻址法

在开放寻址法中,所有的元素都存放在散列表中,每个表项要么包含一个元素要么为空。开放寻址法的好处在于它不使用指针,而是计算出存取的桶号,减少了使用使用链表额外的空间消耗,使得同样的空间可以来提供更多的桶,潜在地减少了冲突,提高了检索的速度。

为了使用开放寻址法插入一个元素,需要连续地检查散列表,或称之为探查,直到找出一个空的桶来存放待插入的关键字为止。常用在计算开放寻址法中的探查序列:线性探查、二次探查和双重探查,这些技术都不能满足均匀散列假设,因为它们能产生的不同的探查序列数都不超过m平方个。(均匀散列要求有m!个探查序列)。

1).线性探查

给定一个普通的散列函数图10,称之为辅助散列函数,线性探查方法使用的散列函数为:
图11

线性探查比较容易实现,但是关键字堆积严重,效率较低,仅有m种不同的探查序列。

2).二次探查

图12

辅助散列函数图13,c1、c2为正的辅助常熟,i=0,1, … ,m-1。二次探查可减轻关键字堆积的情况,但依然仅有m种不同的探查序列。

3).双重散列

双重散列是用于开放寻址法的最好方法之一,它采用的散列函数如下:
图14
其中h1和h2均为辅助散列函数。为了能查找整个散列表,值h2(k)必须要与表的大小互素。有一种简便的方法确保这个条件成立,就是取m为2的幂,并设计一个总产生奇数的函数h2,。另一种方法是取m为素数,并设计一个总返回较m小的正整数的函数h2。

当m为素数或者2的幂时,双重散列法中用到了图15中探查序列,对于m的每一种取值,双重散列的性能看起来就非常接近理想的均匀散列的性能。

3.再散列法

事先准备多个散列函数,冲突发生时,就更换一个散列函数重新计算散列地址。

4.公共溢出区法

为所有的冲突的关键字建立一个公共的溢出区并保存。在查找时,对给定值通过散列函数计算出散列地址后,先与基本表相应位置进行比对,若相等,查找成功;若不想等,则到溢出表顺序查找对应记录。在冲突很少的情况下,公共溢出区法效率还是非常高的,结构如下图:
图16

原创粉丝点击