词典
逻辑上的词典,是由一组数据构成的集合,其中各元素都是由关键码和数据项合成的词条(entry)。
映射(map)结构与词典结构一样,也是词条的集合。
二者的差别仅仅在于,映射要求不同词条的关键码互异,而词典则允许多个词条拥有相同的关键码。
除了静态查找,映射和词典都支持动态更新,二者统称作符号表。
散列
散列(Hashing) 是一种将任意大小的输入数据映射为固定大小的输出数据的过程。这个输出数据通常称为散列值或哈希值。
散列表(hashtable) 是散列方法的底层基础,逻辑上由一系列可存放词条(或其引用)的单元组成,故这些单元也称作桶(bucket)或桶单元;与之对应地,各桶单元也应按其逻辑次序在物理上连续排列。
往往直接使用数组来实现这种线性的底层结构,此时的散列表亦称作桶数组(bucket array)。若桶数组的容量为 R
,则其中合法秩的区间 [0, R)
也称作地址空间(address space)。
散列函数
一组词条在散列表内部的具体分布,取决于所谓的散列(hashing)方案——事先在词条与桶地址之间约定的某种映射关系,可描述为从关键码空间到桶数组地址空间的函数:
h
a
s
h
(
)
:
k
e
y
→
h
a
s
h
(
k
e
y
)
hash() : key \to hash(key)
hash():key→hash(key).
这里的 hash()
称作散列函数(hash function)。反过来,hash(key)
也称作key
的散列地址(hashing address),亦即与关键码key
相对应的桶在散列表中的秩。
假定关键码均为[0, R)
范围内的整数。将词典中的词条数记作N
,散列表长度记作M
,于是通常有:R >> M > N
散列函数hash()
的作用可理解为,将关键码空间[0, R)
压缩为散列地址空间[0, M)
。
设计准则
确定性: 无论所含的数据项如何,词条E在散列表中的映射地址hash(E.key)
必须完全取决于其关键码E.key
。
简单性: 映射过程自身不能过于复杂,唯此方能保证散列地址的计算可快速完成,从而保证查询或修改操作整体的O(1)
期望执行时间。
覆盖性: 所有关键码经映射后应尽量覆盖整个地址空间[0, M)
,唯此方可充分利用有限的散列表空间。也就是说,函数hash()
最好是满射。
散列冲突: 关键码不同的词条被映射到同一散列地址的情况。随机越强、规律性越弱的散列函数越好。
除余法
将散列表长度M
取作为素数,并将关键码key
映射至key
关于M
整除的余数:
h
a
s
h
(
k
e
y
)
=
k
e
y
m
o
d
M
hash(key) = key \mod M
hash(key)=keymodM
采用除余法时必须将M
选作素数,否则关键码被映射至[0, M)
范围内的均匀度将大幅降低,发生冲突的概率将随M
所含素因子的增多而迅速加大。
词条集中到散列表内少数若干桶中(或附近)的现象,称作词条的聚集(clustering)。
显然,好的散列函数应尽可能此类现象,而采用素数表长则是降低聚集发生概率的捷径。
一般地,散列表的长度M
与词条关键码间隔T
之间的最大公约数越大,发生冲突的可能性也将越大。
因此,若M
取素数,则简便对于严格或大致等间隔的关键码序列,也不致出现冲突激增的情况,同时提高空间效率。
MAD 法
以素数为表长的除余法尽管可在一定程度上保证词条的均匀分布,但从关键码空间到散列地址空间映射的角度看,依然残留有某种连续性。
比如,相邻关键码所对应的散列地址,总是彼此相邻;极小的关键码,通常都被集中映射到散列表的起始区段——其中特别地,0值居然是一个“不动点”,其散列地址总是0,而与散列表长度无关。
例如,在如图(a)所示,将关键码:{ 2011, 2012, 2013, 2014, 2015, 2016 } 插入长度为M = 17
的空散列表后,这组词条将存放至地址连续的6
个桶中。尽管这里没有任何关键码的冲突,却具有就“更高阶”的均匀性。
MAD法将关键码key映射为:
(
a
×
k
e
y
+
b
)
m
o
d
M
(a \times key + b ) \mod M
(a×key+b)modM,其中M
仍为素数,a > 0,b > 0
,且 a mod M != 0
尽管运算量略有增加,但只要常数a和b选取得当,MAD法可以很好地克服除余法原有的连续性缺陷。
按MAD法的散列结果将图(b)所示,a = 31和b = 2时,各关键码散列的均匀性相对于图(a)有了很大的改善。
伪随机数法
越是随机、越是没有规律,就越是好的散列函数。
任何一个(伪)随机数发生器,本身即是一个好的散列函数。比如,可直接使用C/C++语言提供的rand()函数,将关键码key映射至桶地址:rand(key) mod M
其中rand(key)
为系统定义的第key
个(伪)随机数。
由于不同计算环境所提供的(伪)随机数发生器不尽相同,故在将某一系统中生成的散列表移植到另一系统时,必须格外小心。
散列:排解冲突
散列表的基本构思,可以概括为:开辟物理地址连续的桶数组ht[]
,借助散列函数hash()
,将词条关键码 key
映射为桶地址hash(key)
.
无论散列函数设计得如何巧妙,也不可能保证不同的关键码之间互不冲突。
冲突及其排解
多槽位法
将彼此冲突的每一组词条组织为一个小规模的子词典,分别存放于它们共同对应的桶单元中。例如,统一将各桶细分为更小的称作槽位(slot)的若干单元,每一组槽位可组织为向量或列表。
通过槽位细分排解散列冲突:上图将各桶细分为四个槽位。只要相互冲突的各组关键码不超过4个,即可分别保存于对应桶单元内的不同槽位。
针对关键码key
的任一操作都将转化为对一组槽位的操作。
多槽位法的缺陷: 可能所有(或接近所有)的词条都冲突于单个桶单元,其余所有的桶都处于空闲状态。
独立链法
令相互冲突的每组词条构成小规模的子词典,不过 采用列表(而非向量) 来实现各子词典。
利用建立独立链排解散列冲突:上图令各桶内相互冲突的词条串接成一个列表。
相对于多槽位法,独立链法可更为灵活地动态调整各子词典的容量和规模,从而有效地降低空间消耗。但在查找过程中一旦发生冲突,则需要遍历整个列表,导致查找成本的增加。
公共溢出法
在原散列表(图(a))之外另设一个词典结构D_overflow
(图(b)),一旦在插入词条时发生冲突就将该词条转存至D_overflow
中。就效果而言,D_overflow
相当于一个存放冲突词条的公共缓冲池,该方法也因此得名。
利用公共溢出区解决散列冲突:策略构思简单、易于实现,在冲突不甚频繁的场合不失为一种好的选择。
闭散列策略
尽管就逻辑结构而言,独立链等策略便捷而紧凑,但绝非上策。仅仅依靠基本的散列表结构,且就地排解冲突,反而是更好的选择。
若新词条与已有词条冲突,则只允许在散列表内部为其寻找另一空桶。如此,各桶并非注定只能存放特定的一组词条;从理论上讲,每个桶单元都有可能存放任一词条。
因为散列地址空间对所有词条开放,故这一新的策略亦称作开放定址;同时,因可用的散列地址仅限于散列表所覆盖的范围之内,故亦称作闭散列。相应地,此前的策略亦称作封闭定址或开散列。
线性试探法
开放定址策略最基本的一种形式是:在插入关键码key
时,若发现桶单元ht[hash(key)]
已被占用,则转而试探桶单元ht[hash(key) + 1]
;若ht[hash(key) + 1]
也被占用,则继续试探ht[hash(key) + 2]
;…;如此不断,直到发现一个可用空桶。
为确保桶地址的合法,最后还需统一对M
取模。因此准确地,第i
次试探的桶单元应为:ht[(hash(key)+i) mod M], i=1, 2, 3,...
被试探的桶单元在物理空间上依次连贯,其地址构成等差数列。
查找链
采用开放地址策略时,散列表中每一组相互冲突的词条都将被视作一个有序序列,对其中任何一员的查找都需借助这一序列。对应的查找过程,可能终止于三种情况:
1)在当前桶单元命中目标关键码,则成功返回;
2)当前桶单元非空,但其中关键码与目标关键码不等,则须转入下一桶单元继续试探;
3)当前桶单元为空,则查找以失败返回。
例如,M = 17的散列表,设采用除余法定址,采用线性试探法排解冲突。
若从空表开始,依次插入5个相互冲突的关键码 { 2011, 2028, 2045, 2062, 2079 },则结果应如图(a)所示。此后,针对其中任一关键码的查找都将从:ht[hash(key)] = ht[5]
出发,试探各相邻的桶单元。可见,与这组关键码对应的桶单元ht[5, 10)
构成一个有序序列,对其中任一关键码的查找都将沿该序列顺序进行,故该序列亦称作查找链。
沿查找链试探的过程,与对应关键码此前的插入过程完全一致。
对于长度为n
的查找链,失败查找长度就是n + 1
;在等概率假设下,平均成功查找长度为
⌈
n
/
2
⌉
\lceil n/2 \rceil
⌈n/2⌉。
尽管相互冲突的关键码必属于同一查找链,但反过来,同一查找链中的关键码却未必相互冲突——多组各自冲突的关键码所对应的查找链,有可能相互交织和重叠。
局部性
线性试探法中组成各查找链的词条,在物理上保持一定的连贯性,具有良好的数据局部性,故系统缓存的作用可以充分发挥,查找过程中几乎无需I/O操作。尽管闭散列策略同时也会在一定程度上增加冲突发生的可能,但只要散列表的规模不是很小,装填因子不是很大,则相对于I/O负担的降低而言,这些问题都将微不足道。
相对于独立链等开散列策略,闭散列策略的实际应用更为广泛。
懒惰删除
查找链中任何一环的缺失,都会导致后续词条因无法抵达而丢失,表现为有时无法找到实际已存在的词条。因此若采用开放定址策略,则在执行删除操作时,需同时做特别的调整。
为每个桶另设一个标志位,指示该桶尽管目前为空,但此前确曾存放过词条。
在将桶ht[9]
作此标记(以X
示意)之后,对后继词条的查找仍可照常进行,而不致中断。这一方法既可保证查找链的完整,同时所需的时间成本也极其低廉,称作懒惰删除法。
设有懒惰删除标志位的桶,应与普通的空桶一样参与插入操作。
聚集现象
线性试探法虽然简明紧凑,但各查找链均由物理地址连续的桶单元组成,因而会加剧关键码的聚集趋势。
线性试探法会加剧聚集现象,而平斱试探法则会快速跳离聚集区段。
平方试探法
在试探过程中若连续发生冲突,则按如下规则确定第j
次试探的桶地址:
(
h
a
s
h
(
k
e
y
)
+
j
2
)
mod
M
,
j
=
0
,
1
,
2
,
.
.
.
(hash(key) + j^2 ) \space \text{mod}\space M, j = 0, 1, 2, ...
(hash(key)+j2) mod M,j=0,1,2,...
局部性
平方试探法之所以能够有效地缓解聚集现象,是因为充分利用了平方函数的特点——顺着查找链,试探位置的间距将以线性(而不再是常数1的)速度增长。于是,一旦发生冲突,即可“聪明地”尽快“跳离”关键码聚集的区段。
确保试探必然终止
线性试探法中,只要散列表中尚有空桶,则试探过程至多遍历全表一遍,必然终止。
平方试探法存在空桶却永远无法抵达。
好消息是:只要散列表长度M
为素数且装填因子
λ
≤
50
%
\lambda \leq 50\%
λ≤50%,则平方试探迟早必将终止于某个空桶。
装填因子(load factor)是指哈希表中元素的数量除以哈希表的大小。 在词典中,装填因子可以用来衡量词典的使用效率。这里也可以将散列表中非空桶的数目与桶单元总数的比值称作装填因子
随机试探法
借助(伪)随机数发生器来确定试探位置。具体地,第j次试探的桶地址取作:rand(j) mod M ...
(rand(i)
为系统定义的第j
个(伪)随机数)。同样地,在跨平台协同的场合,出于兼容性的考虑,这一策略也须慎用。
再散列
需要选取一个适宜的二级散列函数
hash
2
(
)
\text{hash}_2 ()
hash2(),一旦在插入词条 (key, value)
时发现
h
t
[
hash
(
k
e
y
)
]
ht[\text{hash}(key)]
ht[hash(key)] 已被占用,则以
hash
2
(
k
e
y
)
\text{hash}_2 (key)
hash2(key) 为偏移增量继续尝试,直到发现一个空桶。
被尝试的桶地址依次应为:
[
hash
(
k
e
y
)
+
1
×
hash
2
(
k
e
y
)
]
%
M
[\text{hash}(key) + 1 \times \text{hash}_2 (key)] \% M
[hash(key)+1×hash2(key)]%M
[
hash
(
k
e
y
)
+
2
×
hash
2
(
k
e
y
)
]
%
M
[\text{hash}(key) + 2 \times \text{hash}_2 (key)] \% M
[hash(key)+2×hash2(key)]%M
[
hash
(
k
e
y
)
+
3
×
hash
2
(
k
e
y
)
]
%
M
[\text{hash}(key) + 3 \times \text{hash}_2 (key)] \% M
[hash(key)+3×hash2(key)]%M
取
hash
2
(
k
e
y
)
=
1
\text{hash}_2(key) = 1
hash2(key)=1 时即是线性试探法。
桶排序
给定[0, M)
内的n
个互异整数(
n
≤
M
n \leq M
n≤M),如何高效地对其排序?
取M = 10
和n = 5
的一个实例:
使用最简单的散列函数hash(key) = key
,将这些整数视作关键码并逐一插入散列表中。最后,顺序遍历一趟该散列表,依次输出非空桶中存放的关键码,即可得到原整数集合的排序结果。
算法借助一组桶单元实现对一组关键码的分拣,故称作桶排序。
该算法所用散列表共占O(M)
空间。散列表的创建和初始化耗时O(M)
,将所有关键码插入散列表耗时O(n)
,依次读出非空桶中的关键码耗时O(M)
,故总体运行时间为O(n + M)
。
允许重复
若允许输入整数重复,又该如何高效地实现排序?
这次需要处理散列冲突。采用独立链法排解冲突。在将所有整数作为关键码插入散列表之后,只需一趟顺序遍历将各非空桶中
的独立链依次串接起来,即可得到完整的排序结果。而且只要在串联时留意链表方向,甚至可以确保排序结果的稳定,故如此实现的桶排序算法属于稳定算法。
依然只需为维护散列表而使用O(M)
的额外空间;算法各步骤所耗费的时间也与前一算法相同,总体运行时间亦为O(n + M)
。
最大间隙
任意n
个互异点都将实轴切割为n + 1
段,除去最外侧无界的两段,其余有界的n - 1
段中何者最大?
若将相邻点对之间的距离视作间隙,则该问题可直观地表述为,找出其中的最大间隙。
普通算法: 先将各点按坐标排序;再顺序遍历,依次计算出各相邻点对之间的间隙;遍历过程中只需不断更新最大间隙的记录,则最终必将得到全局的最大间隙。
第一步常规排序即需O(nlogn)
时间,所以在最坏情况下总体运行时间将不可能少于这一下界。
散列算法: 通过一趟顺序扫描找到最靠左和最靠右的点,将其坐标分别记作lo
和hi
;然后,建立一个长度为n
的散列表,并使用散列函数
hash
(
x
)
=
⌊
(
n
−
1
)
∗
(
x
−
l
o
)
/
(
h
i
−
l
o
)
⌋
\text{hash}(x) = \lfloor(n - 1) * (x - lo) / (hi - lo)\rfloor
hash(x)=⌊(n−1)∗(x−lo)/(hi−lo)⌋ 将各点分别插入对应的桶单元,其中x
为各点的坐标值,hash(x)
为对应的桶编号:相当于将有效区间[lo, hi)
均匀地划分为宽度w = (hi - lo) / (n - 1)
的n - 1
个左闭右开区间,分别对应于第0
至n - 2
号桶单元;另外,hi
独自占用第n - 1
号桶。
然后,对散列表做一趟遍历,在每个非空桶(黑色)内部确定最靠左和最靠右的点,并删除所有的空桶(白色)。最后,只需再顺序扫描一趟散列表,即可确定相邻非空桶之间的间隙,记录并报告其中的最大者,即为全局的最大间隙。
在最坏情况下,累计运行时间也不超过O(n)
。