转载自:http://www.cnblogs.com/absfree/p/5508570.html
一、概述
符号表是一种用于存储键值对(key-value pair)的数据结构,我们平常经常使用的数组也可以看做是一个特殊的符号表,数组中的“键”即为数组索引,值为相应的数组元素。也就是说,当符号表中所有的键都是较小的整数时,我们可以使用数组来实现符号表,将数组的索引作为键,而索引处的数组元素即为键对应的值,但是这一表示仅限于所有的键都是比较小的整数时,否则可能会使用一个非常大的数组。散列表是对以上策略的一种“升级”,但是它可以支持任意的键而并没有对它们做过多的限定。对于基于散列表实现的符号表,若我们要在其中查找一个键,需要进行以下步骤:
首先我们使用散列函数将给定键转化为一个“数组的索引”,理想情况下,不同的key会被转为不同的索引,但在实际应用中我们会遇到不同的键转为相同的索引的情况,这种情况叫做碰撞。解决碰撞的方法我们后面会具体介绍。
得到了索引后,我们就可以像访问数组一样,通过这个索引访问到相应的键值对。
以上就是散列表的核心思想,散列表是时空权衡的经典例子。当我们的空间无限大时,我们可以直接使用一个很大的数组来保存键值对,并用key作为数组索引,因为空间不受限,所以我们的键的取值可以无穷大,因此查找任何键都只需进行一次普通的数组访问。反过来,若对查找操作没有任何时间限制,我们就可以直接使用链表来保存所有键值对,这样把空间的使用降到了最低,但查找时只能顺序查找。在实际的应用中,我们的时间和空间都是有限的,所以我们必须在两者之间做出权衡,散列表就在时间和空间的使用上找到了一个很好的平衡点。散列表的一个优势在于我们只需调整散列算法的相应参数而无需对其他部分的代码做任何修改就能够在时间和空间的权衡上做出策略调整。
二、散列函数
介绍散列函数前,我们先来介绍几个散列表的基本概念。在散列表内部,我们使用桶(bucket)来保存键值对,我们前面所说的数组索引即为桶号,决定了给定的键存于散列表的哪个桶中。散列表所拥有的桶数被称为散列表的容量(capacity)。
现在假设我们的散列表中有M个桶,桶号为0到M-1。我们的散列函数的功能就是把任意给定的key转为[0, M-1]上的整数。我们对散列函数有两个基本要求:一是计算时间要短,二是尽可能把键分布在不同的桶中。对于不同类型的键,我们需要使用不同的散列函数,这样才能保证有比较好的散列效果。
我们使用的散列函数应该尽可能满足均匀散列假设,以下对均匀散列假设的定义来自于Sedgewick的《算法》一书:
(均匀散列假设)我们使用的散列函数能够均匀并独立地将所有的键散布于0到M – 1之间。
以上定义中有两个关键字,第一个是均匀,意思是我们对每个键计算而得的桶号有M个“候选值”,而均匀性要求这M个值被选中的概率是均等的;第二个关键字是独立,它的意思是,每个桶号被选中与否是相互独立的,与其他桶号是否被选中无关。这样一来,满足均匀性与独立性能够保证键值对在散列表的分布尽可能的均匀,不会出现“许多键值对被散列到同一个桶,而同时许多桶为空”的情况。
显然,设计一个较好的满足均匀散列假设的散列函数是不容易的,好消息是通常我们无需设计它,因为我们可以直接使用一些基于概率统计的高效的实现,比如Java中许多常用的类都重写了hashCode方法(Object类的hashCode方法默认返回对象的内存地址),用于为该类型对象返回一个hashCode,通常我们用这个hashCode除以桶数M的余数就可以获取一个桶号。下面我们以Java中的一些类为例,来介绍一下针对不同数据类型的散列函数的实现。
三、使用拉链法处理碰撞
使用不同的碰撞处理方式,我们便得到了散列表的不同实现。首先我们要介绍的是使用拉链法来处理碰撞的散列表的实现。以这种方式实现的散列表,每个桶里都存放了一个链表。初始时所有链表均为空,当一个键被散列到一个桶时,这个键就成为相应桶中链表的首结点,之后若再有一个键被散列到这个桶(即发生碰撞),第二个键就会成为链表的第二个结点,以此类推。这样一来,当桶数为M,散列表中存储的键值对数目为N时,平均每个桶中的链表包含的结点数为N / M。因此,当我们查找一个键时,首先通过散列函数确定它所在的桶,这一步所需时间为O(1);然后我们依次比较桶中结点的键与给定键,若相等则找到了指定键值对,这一步所需时间为O(N / M)。所以查找操作所需的时间为O(N / M),而通常我们都能够保证N是M的常数倍,所以散列表的查找操作的时间复杂度为O(1),同理我们也可以得到插入操作的复杂度也为O(1)。
四、使用线性探测法处理碰撞
1. 基本原理与实现
线性探测法是另一种散列表的实现策略的具体方法,这种策略叫做开放定址法。开放定址法的主要思想是:用大小为M的数组保存N个键值对,其中M > N,数组中的空位用于解决碰撞问题。
线性探测法的主要思想是:当发生碰撞时(一个键被散列到一个已经有键值对的数组位置),我们会检查数组的下一个位置,这个过程被称作线性探测。线性探测可能会产生三种结果:
命中:该位置的键与要查找的键相同;
未命中:该位置为空;
该位置的键和被查找的键不同。
当我们查找某个键时,首先通过散列函数得到一个数组索引后,之后我们就开始检查相应位置的键是否与给定键相同,若不同则继续查找(若到数组末尾也没找到就折回数组开头),直到找到该键或遇到一个空位置。由线性探测的过程我们可以知道,若数组已满的时候我们再向其中插入新键,会陷入无限循环之中。
动态调整数组大小
在我们上面的实现中,数组的大小为桶数的2倍,不支持动态调整数组大小。而在实际应用中,当负载因子(键值对数与数组大小的比值)接近1时,查找操作的时间复杂度会接近O(n),而当负载因子为1时,根据我们上面的实现,while循环会变为一个无限循环。显然我们不想让查找操作的复杂度退化至O(n),更不想陷入无限循环。所以有必要实现动态增长数组来保持查找操作的常数时间复杂度。当键值对总数很小时,若空间比较紧张,可以动态缩小数组,这取决于实际情况。
五、参考资料
《算法(第四版》(Sedgewick等)