一步步带你深入理解数据结构系列--散列表

时间:2020-12-29 10:33:45

以下所说的有任何疑问或者有任何错误,欢迎评论,但不可能时刻都上着CSDN,所以可以用微博@或私信我。微博:halooooJeffrey

概述

散列方法以最基本的向量作为底层支撑结构,通过适当的散列函数在词条的关键码与向量单元的秩之间建立起映射关系

只要散列表散列函数以及冲突排解策略设计得当,可在期望的常数时间内实现词典的所有接口操作。即运行时间与词典的规模基本无关。

基本概念

1、散列表

逻辑上由一系列可存放词条(key, value)的单元组成,也称作桶,在物理上,各桶单元按其逻辑次序连续排列,所以用向量来实现。如果容量为N,那么合法的地址空间为(0, N)。

2、散列函数

词条与桶地址之间事先约定某种映射关系,描述为关键码空间到桶数组地址空间的函数:hash( ): key -> hash(key)

这个函数称作散列函数。可以理解成自变量x是词条的关键码key,因变量y是散列表中的秩。

3、装填因子和空间利用率

散列表中非空桶的数目与桶单元总数的比值称作装填因子。装填因子太小,空间利用率过低。在实际应用中,经常会出现这种问题,那么如何克服空间利用率低的问题呢?其中之一是散列函数的设计。

散列函数

先假定关键码是[0, R)的整数词典中的词条数记作N,散列表长度记作M,那么:R >> M > N

如何理解这个不等式呢?我们以校园电话薄为例,关键码是所有8位(假定是8位)的电话数字,词条数是校园里的电话。可知所有电话的数目远大于校园里电话的数目。

散列函数hash( )的作用就是将关键码空间[0, R)压缩为散列地址空间[0, M)。


设计原则

  1. 确定性:词条E在散列表中的映射地址hash(E.key)必须完全取决于关键码E.key。
  2. 映射不能太复杂:保证执行时间
  3. 尽量覆盖整个地址空间:提高空间利用率

但是R >> M,必然会导致不同的词条会被映射到相同的地址空间,这时会发生散列冲突,这是不可避免的,所以关键码映射到各桶的概率应尽量接近于1/M,使在地址空间均匀分布,不致某一区域比较紧密而加剧冲突。


除余法

将散列表长度M取作素数,并将关键码key映射至key关于M整除的余数:hash(key) = key mod M

采用除余法时必须将M选做素数,否则关键码被映射至[0, M)范围内的均匀度将大幅降低,发生冲突的概率将随M所含素因子的增多而迅速加大。


MAD法

hash(key) = (a * key + b) mod M 只要常数a和b选取得当,MAD可以很好地克服除余法原有的连续性缺陷。除余法是MAD法的特殊情况。


(伪)随机数法

越是随机,越是没有规律,就越是好的散列函数。任何一个(伪)随机数发生器,本身即是一个好的散列函数。rand(key) mod M rand(key)是系统定义的第key各(伪)随机数。


冲突及排解

1、多槽位法

正如前面所说的发生冲突是因为两个key根据散列函数映射到了相同的散列表中的地址,那么当一个已经占据了这个地址,第二个要怎么办呢。一种简单粗暴的方法就是将第二个叠在第一个的上面。这个结构我们可以想象一下,散列表是一个向量,而向量中的每一个元素还是一个向量,这有点像一个表格。






30





10


7


14

0


2


4

0

1

2

3

4

最下面的一行代表散列表中的地址,这里取M=5。insert的过程是这样的:首先通过hash(key)确定地址,在这个地址的槽位中继续寻找(在表中可以看作是纵向寻找)有没有这个key,如果没有,那么把这个词条插入,如果有,那么插入失败。对于get和remove的操作也是类似的。

但是,这个方法有很明显的缺点,那就是空的槽位太多了,空间利用率不高。还有我们一开始无法确定要将槽位分到多细,即要分多少个,例如上图的纵向分了4个。那么有什么改进方法吗。我们应该很容易的,也很自然的想到纵向的“表格”我们用链表表示。

2、独立链

和多槽位法不同的是用链表代替槽位,那么可以灵活地动态调整各子词典的容量和规模。但是缺点是在查找过程中一旦发生冲突,则需要遍历整个链表,导致查找成本增加


以上两种方法或多或少会有明显的缺点,那么有什么更好的办法吗?


3、闭散列策略

上面两种方法都要引入次级关联结构,实现算法的代码更复杂,更容易出错。一个想法:若新词条与已有词条发生冲突,那么在散列表的内部另外找一个空桶放入,也就是说,各桶并非只能存放特定的一组词条。这个策略叫做开放定址。同时,因可用的散列地址仅限于散列表所覆盖的范围内(不会又另外的结构),称作闭散列。但是我们将发生冲突的词条安顿好以后,我们在以后的查找过程中必须也能找到它,所以要制定可行的查找方案。下面介绍:

线性试探

若vec[hash(key)]已经被占用了,那么第一次去试探vec[hash(key)+1 mod M],如果还是被占用,那么第二次试探vec[hash(key)+2 mod M],依次类推,第i次去试探vec[hash(key)+i mod M],如果为空,那么就放在这里。被试探的桶单元在物理空间上依次连续,其地址构成等差数列,该方法由此得名。


那么对于线性试探,我们该如何查找呢?

因为每一组相互冲突的词条都是有序的,查找过程如下:

1、如果在对应的桶单元发现关键码一样,那么成功。

2、如果发现关键码不一样,那么往下一个桶查找。

3、桶单元为空,那么失败。

这里请注意,我们往下一个桶单元查找时,每个桶单元的key不一定和正在查找的key冲突。我们知道在线性试探法中,冲突的词条会排成一段连续的区域,但是如果其中一个桶被其他的key占用了,那么冲突的词条移动到这个位置时没法放入,根据线性试探法,它会继续移动到下一个桶中。总之不会出现中间隔了一个空桶的情况,这也是我们上面所说的查找步骤能够成立的原因。

因为在查找链中不能有空的桶,那么删除算法不能完全使这个桶为空。


懒惰删除

查找链中任何一环的缺失,都会导致后续词条因无法抵达而丢失,表现为有时无法找到实际已存在的词条。因此若采用开放定址策略,则在执行删除操作时,需同时做特别的调整。

一个简明有效的方法是,为每个桶另设一个标志位,指示该桶尽管目前为空,但此前确曾存放过词条。我们可以采用一个特殊的标志表示删除,但在上面的模板类中,我们另外定义了一个懒惰删除的向量theLazyRemoval,在相应的位置做标记,这个方法缺点是占用空间。但是为了明白地说明散列表的内容,采用这个直观的方法。


所以,此时查找特定的key时只有当空桶且没有懒惰删除标志时才能说明查找失败,如果有懒惰标志,那么还得往下一个桶查找。在插入时,当桶为空或有懒惰删除标志时查找成果,可以插入。