散列表(hash table,也叫哈希表)是能够通过给定的关键字的值直接访问到具体对应记录的一个数据结构,通常我们把这个关键字称为 k e y key key,把对应的记录称为 v a l u e value value。
在介绍散列表之前,先介绍一种直接寻址数组,然后引出散列表的概念,并给出常见的散列函数以及冲突解决方法。
直接寻址表(数组)
当关键字的全域 U = { 0 , 1 , . . . , m − 1 } U=\{0,1,...,m-1\} U={0,1,...,m−1} 比较小时,直接寻址是一种简单而有效的技术。假设没有两个元素具有相同的关键字。
此时为了表示动态集合,我们用一个数组,或称为直接寻址表(direct-address table),记为
T
[
0..
m
−
1
]
T[0..m-1]
T[0..m−1]。
其中每个位置对应着全域
U
U
U 中的一个关键字,称为槽(slot)。
槽
k
k
k 指向集合中一个关键字为
k
k
k 的元素,如果该集合中没有关键字为
k
k
k 的元素,则
T
[
k
]
=
N
U
L
L
T[k]=NULL
T[k]=NULL。
直接寻址表查找一个元素只需要
O
(
1
)
O(1)
O(1) 的时间,该时间复杂度适用于最坏情况。
直接寻址技术的缺点:如果全域
U
U
U 很大,而实际存储的关键字集合
K
K
K 相对
U
U
U 来说可能很小,使得分配给数组
T
T
T 的大部分空间都将浪费掉。
散列表
在直接寻址方式下,具有关键字
k
k
k 的元素被存放在槽
k
k
k 中。
而在散列方式下,该元素存放在槽
h
(
k
)
h(k)
h(k) 中,即利用散列函数(hash function)
h
h
h,由关键字
k
k
k 计算出槽的位置
h
(
k
)
h(k)
h(k)。
散列函数
h
h
h 将关键字的全域
U
U
U 映射到散列表
T
[
0..
m
−
1
]
T[0..m-1]
T[0..m−1] 的槽位上,这里散列表的大小
m
m
m 一般要比
∣
U
∣
|U|
∣U∣ 小得多。
也就是说,相比于直接寻址方法,散列函数缩小了数组下标的范围,即减小了数组的大小,使其由
∣
U
∣
|U|
∣U∣ 减小为
m
m
m。
散列表的基本方法描述如下:
这里存在的问题是:两个关键字可能映射到同一个槽中。称这种情况为冲突(collision)。因为
∣
U
∣
>
m
|U| > m
∣U∣>m,所以至少有两个关键字其散列值相同,所以要想完全避免冲突是不可能的。
因此,我们一方面可以通过精心设计的散列函数来尽量减少冲突的次数,另一方面仍需要有解决可能出现冲突的办法。
尽管最坏情况下,散列表中查找一个元素的时间与链表中查找的时间相同,达到了 O ( n ) O(n) O(n)。然而在实际应用中,散列查找的性能是极好的。在一些合理的假设下,在散列表中查找一个元素的平均时间为 O ( 1 ) O(1) O(1)。
散列函数
一个好的散列函数应(近似地)满足简单均匀散列假设:
(1)计算简单:散列函数不应该有很大的计算量,否则会降低查找效率。
(2)分布均匀:散列函数值即散列地址,要尽量均匀分布在地址空间,这样才能保证存储空间的有效利用并减少冲突。
- 直接定址法:取关键字或关键字的某个线性函数值为散列地址。即 h ( k ) = k h(k) = k h(k)=k 或 h ( k ) = a ⋅ k + b h(k)=a\cdot k + b h(k)=a⋅k+b,其中 a , b a,b a,b 为常数(这种散列函数叫做自身函数)
- 数字分析法:假设关键字是以 r r r 为基的数,并且哈希表中可能出现的关键字都是事先知道的,则可取关键字的若干数位组成哈希地址。
- 平方取中法:取关键字平方后的中间几位为哈希地址。通常在选定哈希函数时不一定能知道关键字的全部情况,取其中的哪几位也不一定合适,而一个数平方后的中间几位数和数的每一位都相关,由此使随机分布的关键字得到的哈希地址也是随机的。取的位数由表长决定。
- 折叠法:将关键字分割成位数相同的几部分(最后一部分的位数可以不同), 然后取这几部分的叠加和(舍去进位)作为哈希地址。
- 除留余数法:取关键字被某个不大于散列表表长 m m m 的数 p p p 除后所得的余数为散列地址,即 h ( k ) = k m o d p , p ≤ m h(k) = k\ mod\ p,\ p\leq m h(k)=k mod p, p≤m。对 p p p 的选择很重要, p p p 一般取小于等于表长 m m m 的最大素数。不仅可以对关键字直接取模,也可在折叠法、平方取中法等运算之后取模。
- 随机数法:选择一个随机函数,取关键字的随机函数值作为散列地址,即 h ( k ) = r a n d o m ( k ) h(k)=random(k) h(k)=random(k),其中 r a n d o m random random 是一个伪随机函数,产生 [ 0 , m − 1 ] [0,m-1] [0,m−1] 之间的随机数。通常,当关键字长度不等时采用此法较恰当。
冲突解决方法
链接法
最简单的解决冲突的方法就是链接法(chaining),用链接法处理冲突构造的散列表叫做开散列表。
在链地址法中,把散列到同一槽中的所有元素都放在一个链表中,槽
k
k
k 中存储一个指向所有散列到
k
k
k 的元素的链表的表头;如果不存在这样的元素,则槽
k
k
k 中的指针为
N
U
L
L
NULL
NULL。
链表可以是单链表,也可以是双向链表。
链接法的性能分析
设 n n n 个记录存储在长度为 m m m 的散列表 T T T 中,则定义 T T T 的装载因子(load factor) α \alpha α 为 n / m n/m n/m,即一个链的平均存储元素数。
下面讨论链接法的查找性能,分两种情况来考虑:
在第一种情况中,查找不成功:表中没有一个元素的关键字为
k
k
k;
在第二种情况中,成功地查找到关键字为
k
k
k 的元素。
用链接法散列的最坏性能很差:所有的
n
n
n 个关键字都散列到同一个槽中,从而产生出一个长度为
n
n
n 的链表,这时最坏情况下查找的时间为
O
(
n
)
O(n)
O(n)。
用链接法散列的平均性能依赖于所选取的散列函数
h
h
h 将所有的关键字集合分布在
m
m
m 个槽位上的均匀程度。在简单均匀散列的假设下(即每个链表包含元素个数的期望值为
E
[
n
j
]
=
α
=
n
/
m
E[n_j]=\alpha=n/m
E[nj]=α=n/m):
(1)一次不成功查找的平均时间为
O
(
1
+
α
)
O(1+\alpha)
O(1+α)。当查找一个关键字
k
k
k 时,在不成功的情况下,尚未被存储的关键字
k
k
k 都等可能地被散列到
m
m
m 个槽中的任何一个。查找的期望时间就是查找至链表
T
[
h
(
k
)
]
T[h(k)]
T[h(k)] 末尾的期望时间,这一链表的期望长度为
α
\alpha
α,于是一次不成功查找平均要检查
α
\alpha
α 个元素,加上计算
h
(
k
)
h(k)
h(k) 的时间,所以需要的总时间为
O
(
1
+
α
)
O(1+\alpha)
O(1+α)。
(2)一次成功查找所需的平均时间也为
O
(
1
+
α
)
O(1+\alpha)
O(1+α)。
对于成功的查找来说,情况略有不同,每个链表被查找到的概率与它所包含的元素数成正比。然而在简单均匀的假设下,期望的查找时间仍然是
O
(
1
+
α
)
O(1+\alpha)
O(1+α)。
如果散列表中槽数至少与表中的元素数成正比,则有 n = O ( m ) n=O(m) n=O(m),从而 α = n / m = O ( m ) / m = O ( 1 ) \alpha = n/m = O(m)/m=O(1) α=n/m=O(m)/m=O(1),所以查找操作平均需要常数时间,那么插入(先查找是否包含该关键字的元素,不包含则直接插入到表头)和删除操作(先查找到关键字所在的位置,且链表采用双链表方便删除)也只需要 O ( 1 ) O(1) O(1) 的时间。因而,全部的字典操作平均情况下都可以在 O ( 1 ) O(1) O(1) 时间内完成。
开放寻址法
在开放寻址法(open addressing)中,所有的元素都存在散列表中,不像链接法,这里既没有链表,也没有元素存放在散列表外,用开放寻址法处理冲突得到的散列表叫闭散列表。
基本思想:当冲突发生时,使用某些探查(probe)技术在散列表中形成一个探查序列(probe sequence),沿此序列逐个单元查找,直到找到一个空槽来放置待插入的关键字为止。检查的顺序不一定是 0 , 1 , . . . , m − 1 0,1,...,m-1 0,1,...,m−1(这中顺序下的查找时间为 O ( n ) O(n) O(n)),而是要依赖于带插入的关键字。
为了确定要探查哪些槽,我们将散列函数加以扩充,使之包含探查号(从 0 开始)以作为其第二个输入参数,这样散列函数就变为:
h
:
U
×
{
0
,
1
,
.
.
.
,
m
−
1
}
→
{
0
,
1
,
.
.
.
,
m
−
1
}
h:U \times \{0,1,...,m-1\} \rightarrow \{0,1,...,m-1\}
h:U×{0,1,...,m−1}→{0,1,...,m−1}
对每一个关键字
k
k
k,使用开放寻址法的探查序列:
<
h
(
k
,
0
)
,
k
(
k
,
1
)
,
.
.
.
,
h
(
k
,
m
−
1
)
>
<h(k,0),k(k,1),...,h(k,m-1)>
<h(k,0),k(k,1),...,h(k,m−1)>
是
<
0
,
1
,
.
.
.
,
m
−
1
>
<0,1,...,m-1>
<0,1,...,m−1> 的一个排列,使得当散列表逐渐填满时,每一个表位最终都可以被考虑为用来插入新关键字的槽。
线性探查
基本思想:当发生冲突时,从冲突位置的下一个位置起,依次寻找空的散列地址。在线性探查中,初始探查位置决定了整个序列。
设
h
′
(
k
)
h^{'}(k)
h′(k) 是一个普通的散列函数(称为辅助散列函数,auxiliary hash function),线性探查方法采用的散列函数为:
h
(
k
,
i
)
=
(
h
′
(
k
)
+
i
)
m
o
d
m
,
i
=
0
,
1
,
.
.
.
,
m
−
1
h(k,i)=(h^{'}(k) + i)\ mod\ m,\ i=0,1,...,m-1
h(k,i)=(h′(k)+i) mod m, i=0,1,...,m−1
给定一个关键字
k
k
k,首先探查槽
T
[
h
′
(
k
)
]
T[h^{'}(k)]
T[h′(k)],即由辅助散列函数所给出的槽位。如果被占用了,那么再探查槽
T
[
h
′
(
k
)
+
1
]
T[h^{'}(k)+1]
T[h′(k)+1],以此类推,直至槽
T
[
m
−
1
]
T[m-1]
T[m−1],然后又绕到槽
T
[
0
]
,
T
[
1
]
,
.
.
.
T[0],T[1],...
T[0],T[1],...,直到最后探查到槽
T
[
h
′
(
k
)
−
1
]
T[h^{'}(k)-1]
T[h′(k)−1]。
例:关键字取值集合为 {47, 7, 29, 11, 16, 92, 22, 8, 3},辅助散列函数为
h
′
(
k
)
=
k
%
11
h^{'}(k)=k \% 11
h′(k)=k%11,用线性探查法处理重提,散列表如下图所示:
线性探查法比较容易实现,但缺点是存在 一次群集(primary clustering) 问题,随着连续被占用的槽不断增加,平均查找时间也随之不断增加。
二次探查
二次探查(quadratic probing)采用如下形式的散列函数:
h
(
k
,
i
)
=
(
h
′
(
k
)
+
c
1
i
+
c
2
i
2
)
m
o
d
m
,
i
=
0
,
1
,
.
.
.
,
m
−
1
h(k,i)=(h^{'}(k) + c_1i + c_2i^2\ )\ mod\ m,\ i=0,1,...,m-1
h(k,i)=(h′(k)+c1i+c2i2 ) mod m, i=0,1,...,m−1
其中
h
′
h^{'}
h′ 是一个辅助散列函数,
c
1
,
c
2
c_1,c_2
c1,c2 为正的辅助常数。
给定一个关键字 k k k,初次探查的位置为 T [ h ′ ( k ) ] T[h^{'}(k)] T[h′(k)],后续的探查位置要加上一个偏移量,该偏移量以二次的方式依赖于探查序号 i i i。
像线性探查一样,二次探查法的初始探查位置决定了整个序列,因此也会导致一种轻度的群集,称为 二次群集(secondary clustering)。
双重散列
双重散列(double hashing)是用于开放寻址法的最好方法之一,因为它所产生的排列具有随机选择排列的许多特性。双重散列采用如下形式的散列函数:
h
(
k
,
i
)
=
(
h
1
(
k
)
+
i
⋅
h
2
(
k
)
)
m
o
d
m
,
i
=
0
,
1
,
.
.
.
,
m
−
1
h(k,i)=(h_1(k)+i\cdot h_2(k))\ mod\ m,\ i=0,1,...,m-1
h(k,i)=(h1(k)+i⋅h2(k)) mod m, i=0,1,...,m−1
其中
h
1
h_1
h1 和
h
2
h_2
h2 均为辅助散列函数。初始探查位置为
T
[
h
1
(
k
)
]
T[h_1(k)]
T[h1(k)],后续的探查位置是前一个位置加上偏移量
h
2
(
k
)
h_2(k)
h2(k) 再模
m
m
m。因此,不像线性探查或者二次探查,这里的探查序列以两种不同方式依赖于关键字
k
k
k,因此初始探查位置、偏移量或者二者都可能发生变化。
为了能查找整个散列表,值
h
2
(
k
)
h_2(k)
h2(k) 必须要与表的大小
m
m
m 互素。有一种简便的方法能确保这个条件成立,就是取
m
m
m 为 2 的幂,并让
h
2
h_2
h2 总产生奇数。另一种方法是取
m
m
m 为素数,并设计一个总返回比
m
m
m 小的正整数的函数
h
2
h_2
h2。例如,我们可以取
m
m
m 为素数,并取:
h
1
(
k
)
=
k
m
o
d
m
,
h
2
(
k
)
=
1
+
(
k
m
o
d
m
′
)
h_1(k)=k\ mod\ m,\ h_2(k) = 1 + (k\ mod\ m^{'})
h1(k)=k mod m, h2(k)=1+(k mod m′)
其中
m
′
m^{'}
m′ 略小于
m
m
m(比如,
m
−
1
m-1
m−1)。
因为每一对可能的 ( h 1 ( k ) , h 2 ( k ) ) (h_1(k),h_2(k)) (h1(k),h2(k)) 都会产生一个不同的探查序列,因此对于 m m m 的每一种可能取值,双重散列的性能看起来就非常接近”理想的“均匀散列的性能。
例:此处散列表的大小 m = 13 m=13 m=13,辅助散列函数 h 1 ( k ) = k m o d 13 h_1(k)=k\ mod\ 13 h1(k)=k mod 13, h 2 ( k ) = 1 + ( k m o d 11 ) h_2(k)=1+(k\ mod\ 11) h2(k)=1+(k mod 11)。因为 14 ≡ 1 ( m o d 13 ) 14\equiv 1(mod\ 13) 14≡1(mod 13),且 14 ≡ 3 ( m o d 11 ) 14\equiv 3(mod\ 11) 14≡3(mod 11),故在探查了槽 1 和槽 5,并发现它们被占用后,关键字 14 被插入到槽 9 中。
开放寻址法性能分析
定理:给定一个装载因子 α = n / m < 1 \alpha=n/m<1 α=n/m<1 的开放寻址散列表,并假设是均匀散列的,则对于一次不成功的查找,其期望的探查次数至多为 1 / ( 1 − α ) 1/(1-\alpha) 1/(1−α)。
推论:假设采用的是均匀散列,平均情况下,向一个装载因子为 α \alpha α 的开放寻址散列表中插入一个元素至多需要做 1 / ( 1 − α ) 1/(1-\alpha) 1/(1−α) 次探查。
定理:对于一个装载因子为
α
<
1
\alpha < 1
α<1 的开放寻址散列表,一次成功查找中的探查期望数至多为:
1
α
l
n
1
1
−
α
\frac{1}{\alpha}ln\frac{1}{1-\alpha}
α1ln1−α1
证明详见《算法导论(第三版)》第 11 章。