Fork me on GitHub

算法导论读书笔记(11)

散列表简介

在很多应用中,都要用到一种动态集合结构,它仅支持 INSERTSEARCHDELETE 字典操作。而 散列表 (hash table)就是实现字典的一种有效的数据结构。在最坏情况下,从散列表中查找一个元素的时间与在链表中查找一个元素的时间相同,都是 Θ ( n ),但在实践中,散列技术的效率是很高的。在一些合理的假设下,从散列表中查找一个元素的期望时间是 O ( 1 )。

散列表是普通数组的推广。因为可以对数组进行直接寻址,故可以在 O ( 1 )时间内访问数组的任意元素。如果存储空间允许,我们可以提供一个数组,为每个可能的关键字保留一个位置,就可以应用直接寻址技术。

当实际存储的关键字数比可能的关键字总数小的时候,采用散列表就比直接数组寻址更加有效,因为散列表通常采用的数组尺寸与所要存储的关键字数是成正比的。在散列表中,不是直接把关键字用作数组下标,而是根据关键字计算出下标。

直接寻址表

当关键字的全域 U 比较小时,直接寻址是一种简单而有效的技术。假设某应用要用到一个动态集合,其中每个元素都取自全域 U = { 0, 1, …, m - 1 },并假设元素的关键字各不相同。

可以用数组(或称 直接寻址表T [ 0 .. m - 1 ]表示动态集合,其中每个位置(或称 )对应全域 U 中的一个关键字。下图说明了这个方法;槽 k 指向集合中关键字为 k 的元素。如果该集合中没有关键字为 k 的元素,则 T [ k ] = NIL

几个字典操作也很简单:

DIRECT-ADDRESS-SEARCH(T, k)
1 return T[k]
DIRECT-ADDRESS-INSERT(T, x)
1 T[x.key] = x
DIRECT-ADDRESS-DELETE(T, x)
1 T[x.key] = NIL

显而易见,这些操作执行起来只需 O ( 1 )的时间。

散列表

直接寻址技术有一个明显的问题:如果全域 U 很大,那么在内存中存储大小为 | U | 的一张表 T 就有点不实际,甚至是不可能。此外,如果实际要存储的关键字集合 K 相对于 U 来说可能很小,那么因而分配给 T 的大部分空间都要浪费掉。

当存储在字典中的关键字集合 K 比所有可能的关键字域 U 要小很多时,散列表需要的存储空间要比直接寻址表少很多。特别地,在保持仅需 O ( 1 )时间即可在散列表中查找一个元素的好处的情况下,存储要求可以降至 Θ ( | K | )。唯一的问题是这个界是针对平均时间的,而对直接寻址来说,它对最坏情况也成立。

在直接寻址方式下,具有关键字 k 的元素被存放在槽 k 中。在散列方式下,该元素处于 h ( k )中,亦即,利用 散列函数 h ,根据关键字 k 计算出槽的位置。函数 h 将关键字域 U 映射到 散列表 T [ 0 .. m - 1 ]的槽位上:

h : U → { 0, 1, …, m - 1 }

这时,可以说一个具有关键字 k 的元素被 散列 到槽 h ( k )上,或者说 h ( k )是关键字 k散列值 。下图给出了形象的说明。采用散列函数的目的就在于缩小需要处理的下标范围,即我们要处理的值从 | U | 降到 m 了,从而相应地降低了空间开销。

这样做有一个问题:两个关键字可能映射到同一个槽上。这种情形称为 碰撞 (collision)。理想的解决方案是完全避免碰撞。可以考虑选用合适的散列函数 h 。在选择时的一个主导思想,就是使 h 尽可能的“随机”,从而避免或者最小化碰撞。(当然,一个散列函数 h 必须是确定的,即某一给定的输入 k 应始终产生相同的结果 h ( k )。)但是,由于 | U | > m ,故必然有两个关键字的散列值相同,所以想要完全避免碰撞时不可能的。

链接法解决碰撞

链接法 是一种最简单的碰撞解决技术。在链接法中,把散列到同一槽中的所有元素都放在一个链表中。如下图所示,槽 j 中有一个指针,它指向由所有散列到 j 的元素构成的链表的头:如果不存在这样的元素,则 j 中为 NIL

相应操作如下:

CHAINED-HASH-INSERT(A, x)
1 insert x at the head of list T[h(x.key)]
CHAINED-HASH-SEARCH(T, k)
1 search for an element with key k in list T[h(k)]
CHAINED-HASH-DELETE(T, x)
1 delete x from the list T[h(x.key)]

链接法散列的分析

给定一个能存放 n 个元素的,具有 m 个槽位的散列表 T ,定义 T装载因子 (load factor) αn / m ,即一个链中平均存储的元素数。我们的分析以 α 来表达, α 可以小于,等于或大于 1 。

用链接法散列的最坏情况性能很差:所有的 n 个关键字都散列到同一个槽中,从而产生出一个长度为 n 的链表。这时,最坏情况下查找的时间为 Θ ( n )。

散列方法的平均性态依赖于所选取的散列函数 h 在一般情况下,将所有的关键字分布在 m 个槽位上的均匀程度。此处假设任何元素散列到 m 个槽中每一个的可能性都是相同的,且与其他元素已被散列到什么位置上是独立无关的。称这个假设为 简单一致散列 (simple uniform hashing)。

对于 j = 0, 1, …, m - 1,列表 T [ j ]的长度用 nj 表示,这样有:
n = n0 + n1 + … + nm1
nj 的平均值为E[ nj ] = α = n / m

假定可以在 O ( 1 )时间内算出散列值 h ( k ),从而查找具有关键字 k 的元素的时间线性地依赖于表 T [ h ( k )]的长度 nh(k) 。先不考虑计算散列函数和寻址槽 h ( k )的 O ( 1 )时间,只看为比较元素的关键字是否为 k 而检查的表 T [ h ( k )]中的元素数。共有两种情况:查找成功和查找不成功。

定理
对一个用链接技术来解决碰撞的散列表,在简单一致散列的情况下,一次不成功查找的期望时间为 Θ ( 1 + α )。

定理
在简单一致散列的假设下,对于用链接技术解决碰撞的散列表,平均情况下一次成功的查找需要 Θ ( 1 + α )。

这一结论说明,如果散列表中槽数至少与表中的元素数成正比,则有 n = O ( m ),从而 α = n / m = O ( m ) / m = O ( 1 )。即平均来说,查找操作需要常量时间。又知道插入操作和删除操作在最坏情况下都需要 O ( 1 )时间。因而,全部的字典操作平均情况下都可以在 O ( 1 )时间内完成。

散列函数

下面介绍的是三种设计散列函数的方案。一个好的散列函数应(近似地)满足简单一致散列的假设:每个关键字都等可能地散列到 m 个槽位的任何一个之中去,并与其他的关键字已被散列到哪一个槽位中无关。

除数散列法

除数散列法 中,通过取 k 除以 m 的余数,来将关键字 k 映射到 m 个槽的某一个中去。亦即,散列函数为:

h ( k ) = k mod m

当应用除数散列时,要注意 m 的选择,可选的 m 值通常是与 2 的整数幂不太接近的质数。

乘法散列法

乘法散列法 包含两个步骤。第一步,用关键字 k 乘上常数 A (0 < A < 1),并抽取出 k A 的小数部分。然后,用 m 乘以这个值,再取结果的底(floor)。散列函数为:

h ( k ) = FLOOR( m ( k A mod 1 ))

乘法方法的一个优点是对 m 的选择没有什么特别的要求,一般选择它为 2 的幂( m = 2pp 为某个整数)。

全域散列

任何的散列函数都可能出现最坏情况性态,即 n 个关键字都散列到同一个槽中,使得平均的检索时间为 Θ ( n ):唯一有效的改进方法是随机地选择散列函数,使之独立于要存储的关键字。这种方法称作 全域散列 (universal hashing)。

全域散列 的基本思想是在执行开始时,就从一族仔细设计的函数中,随机地选择一个作为散列函数。随机化保证了没有哪一种输入会始终导致最坏情况性态。同时,随机化使得即使是对同一个输入,算法在每一次执行时的性态也是不一样的。这样就可以确保对于任何输入,算法都具有良好的平均情况性态。

H 为有限的一组散列函数,它将给定的关键字域 U 映射到{ 0, 1, …, m - 1 }。这样的一组函数称为是 全域的 (universal),如果对每一对不同的关键字 klU ,满足 h ( k ) = h ( l )的散列函数 hH 的个数至多为 | U | / m 。换言之,如果从 H 中随机选择一个散列函数,当关键字 kl 时,两个发生碰撞的概率不大于 1 / m ,这也正是从集合{ 0, 1, …, m - 1 }中随机地,独立地选择 h ( k )和 h ( l )时发生碰撞的概率。

定理
如果 h 选择一组全域的散列函数,并用于将 n 个关键字散列到一个大小为 m 的,用链接法解决碰撞的表 T 中。如果关键字 k 不在表中,则 k 被散列至其中的链表的期望长度E[ nh(k) ]至多为 α 。如果关键字 k 在表中,则包含关键字 k 的链表的期望长度E[ nh(k) ]至多为 1 + α

推论
对于一个具有 m 个槽位的表,利用全域散列和链接法解决碰撞,需要 Θ ( n )的期望时间来处理任何包含了 nINSERTSEARCHDELETE 操作的操作序列,该序列中包含了 O ( m )个 INSERT 操作。

开放寻址法

开放寻址法 (open addressing)中,所有的元素都存放在散列表中。亦即,每个表项或包含动态集合的一个元素,或包含 NIL 。当查找一个元素时,要检查所有的表项,直到找到所需的元素,或最终发现该元素不在表中。该方法的装载因子 α 绝对不会超过 1 。

在开放寻址法中,当要插入一个元素时,可以连续地检查(或称 探查 )散列表的各项,直到找到一个空槽来放置待插入的关键字为止。检查的顺序不一定是 0, 1, …, m - 1 (这种顺序下的查找时间为 Θ ( n )),而是要依赖于待插入的关键字。为了确定要探查哪些槽,应该将散列函数加以扩充,使之包含探查号(从 0 开始)以作为其第二个输入参数。这样,散列函数就变为:

h : U ⅹ { 0, 1, …, m - 1 } → { 0, 1, …, m - 1 }

对开放寻址法来说,要求对每一个关键字 k探查序列

< h ( k , 0 ), h ( k , 1 ), …, h ( km - 1 ) >

必须是< 0, 1, …, m - 1 >的一个排列,使得当散列表逐渐填满时,每一个表位最终都可以被视为用来插入新关键字的槽。下面是插入和查找的伪码。

HASH-INSERT(T, k)
1  i = 0
2  repeat
3      j = h(k, i)
4      if T[j] == k
5          T[j] == k
6          return j
7      else
8          i = i + 1
9 until i == m
10 error "hash table overflow"
HASH-SEARCH(T, k)
1 i = 0
2 repeat
3     j = h(k, i)
4     if T[j] == k
5         return j
6     i = i + 1
7 until T[j] == NIL or i == m
8 return NIL

在开放寻址法中,对散列表元素的删除操作执行起来比较困难。当我们从槽 i 中删除关键字时,不能仅将 NIL 置于其中来标识它为空。否则就会有个问题:在插入某关键字 k 的探查过程中,发现 i 被占用了,则 k 被插入到后面的位置上。在将槽 i 中的关键字删除后,就无法检索关键字 k 了。解决的办法就是在槽 i 中置一个特定的值 DELETED ,而不用 NIL 。这样要对过程 HASH-INSERT 作相应的修改。当时,当使用特殊值 DELETED 时,查找时间就不再依赖于装载因子 α 了。因此,在必须删除关键字的应用中,往往采用链接法来解决碰撞。

在我们的分析中,作了一个 一致散列 的假设,即假设每个关键字的探查序列是< 0, 1, …, m - 1 >的 m! 种排列中的任一种的可能性是相同的。一致散列将前面定义过的 简单一致散列 的概念加以一般化,推广到散列函数的结果不只是一个数,而是一个完整的探查序列的情形。

在实践中,常用三种技术来计算开放寻址法中的探查序列:线性探查,二次探查,以及双重散列。

线性探查

给定一个普通的散列函数 h ' : U → { 0, 1, …, m - 1 }(称为 辅助散列函数 ), 线性探查 (linear probing)方法采用的散列函数为:

h ( ki ) = ( h '( k ) + i ) mod mi = 0, 1, …, m - 1

给定一个关键字 k ,第一个探查的槽是 T [ h '( k ) ],亦即,由辅助散列函数所给出的槽。接下来探查的是槽 T [ h ' ( k ) + 1 ], …,直到槽 T [ m - 1 ],然后又卷绕到槽 T [ 0 ], T [ 1 ], …直到最后探查槽 T [ h ' ( k ) - 1 ]。在线性探查方法中,初始探查位置确定了整个序列,故只有 m 种不同的探查序列。

线性探查方法很容易实现,但它存在一个问题,称作 一次群集 (primary clustering)。随着时间的推移,连续被占用的槽不断增加,平均查找时间也随着不断增加。群集现象很容易出现,这是因为当一个空槽前有 i 个满的槽时,该空槽作为下一个将被占用槽的概率是( i + 1 ) / m 。连续被占用槽的序列将会越来越长,因而平均查找时间也会随之增加。

二次探查

二次探查 (quadratic probing)采用如下形式的散列函数:

h ( ki ) = ( h ' ( k ) + c1 i + c2 i2 ) mod m

其中 h '是一个辅助散列函数, c1c2 为辅助常数(不等于0), i = 0, 1, …, m - 1。初始的探查位置为 T [ h '( k ) ],后续的探查位置要在此基础上加上一个偏移量,该偏移量以二次的方式依赖于探查号 i 。这种探查方法的效果要比线性探查好很多,但是,如果两个关键字的初始探查位置相同,那么他们的探查序列也是相同的,这是因为 h ( k1 , 0 ) = h ( k2 , 0 )蕴含着 h ( k1i ) = h ( k2i )。这一性质可导致一种程度较轻的群集现象,称为 二次群集 (secondary clustering)。二次探查也只有 m 个不同的探查序列。

双重散列

双重散列 是用于开放寻址法的最好方法之一,它采用如下形式的散列函数:

h ( ki ) = ( h1 ( k ) + i h2 ( k ) ) mod m

其中 h1h2 为辅助散列函数。初始探查位置为 T [ h1 ( k ) ],后续的探查位置在此基础上加上偏移量 h2 ( k )模 m

为能查找整个散列表,值 h2 ( k )要与表的大小 m 互质。确保这个条件成立的一种方法是取 m 为 2 的幂,并设计一个总产生奇数的 h2 。另一种方法是取 m 为质数,并设计一个总是产生较 m 小的正整数的 h2

双重散列法中用了 Θ ( m2 )中探查序列。

对开放寻址散列的分析。

对开放寻址散列的分析也是以散列表的装载因子 α = n / m 来表达的。在开放寻址法中,由于每个槽中至多只有一个元素,因而 n <= m ,这意味着 α <= 1 。

定理
给定一个装载因子为 α = n / m < 1 的开放寻址散列表,在一次不成功的查找中,期望的探查数至多为 1 / ( 1 - α )。假设散列是一致的。

如果 α 是一个常数,根据上述定理,一次不成功查找的运行时间为 O ( 1 )。

推论
平均情况下,向一个装载因子为 α 的开放寻址散列表中插入一个元素时,至多需要做 1 / ( 1 - α )次探查。假设散列是一致的。

定理
给定一个装载因子为 α < 1 的开放寻址散列表,一次成功查找中的期望探查数至多为:
( 1 / α ) ln ( 1 / ( 1 - α ))
假定散列是一致的,且表中的每个关键字被查找的可能性是相同的。

posted on 2014-04-12 18:54  sungoshawk  阅读(5457)  评论(0编辑  收藏  举报