散列表
直接寻址表
一个数组T[0..m-1]中的每个位置分别对应全域U中的一个关键字,槽k指向集合中一个关键字为k的元素,如果该集合中没有关键字为k的元素,则T[k] = NIL
全域U={0,1,…,9}中的每个关键字都对应于表中的一个下标值,由实际关键字构成的集合K={2,3,5,8}决定表中的一些槽,这些槽包含指向元素的指针,而另一些槽包含NIL
直接寻址的技术缺点非常明显:如果全域U很大,则在一台标准的计算机可用内存容量中,要存储大小为|U|的一张表T也许不太实际,甚至是不可能的。还有,实际存储的关键字集合K相对于U来说可能很小,使得分配给T的大部分空间都将浪费掉,此时可以使用散列表改进。
散列表
在直接寻址方式下,具有关键字k的元素被存放在槽k中,在散列方式下,该元素存放在h(k)中;即利用散列函数h,由关键字k计算出槽的位置,这里,函数h将关键字的全域U映射到散列表T[0,..,m-1]的槽位上。
h : U {0,…,m-1}
这里散列表的大小m一般要比|U|小得多,可以说一个具有关键字k的元素被散列到槽h(k)上
这里存在一个缺点:两个关键字可能映射到同一个槽中,这种情形称为冲突,解决冲突的方法有很多。
解决冲突
链接法
在链接法中,把散列到同一槽中的所有元素都放在一个链表中,如下图所示,槽j中有一个指针,它指向存储所有散列到j的元素的链表的表头;如果不存在这样的元素,槽j中为NIL
链表可以是单链表,但双链表的删除操作会更快
分析(查找一个关键字)
给定一个能存放n个元素的,具有m个槽位的散列表T,定义T的装载因子α为n/m,即一个链表的平均存储元素数量,α可以大于,等于或者小于1.
用链接法散列的最坏情况性能很差:所有的n个关键字都散列到同一个槽中,从而产生一个长度为n的链表,这时,最坏情况下查找时间为θ(n),再加上计算散列函数的时间,如果就和用一个链表来链接所有的元素差不多了。
散列方法的平均性能依赖于所选取的散列函数h,将所有关键字集合分布在m个槽位上的均匀程度。
在平均情况下,查找一个关键字有两个结果:查找成功和查找不成功。
在简单均匀散列的情况下,任何尚未被存储在表中的关键字k都等可能地被散列到m个槽中的任何一个,因此,当查找一个关键字k时,在不成功的情况下,查找的期望时间就是查找到链表T[h(k)]末尾的期望时间,这一时间的期望长度为α,于是一次不成功的查找平均要检查α个元素,并且所需要的总时间(包括计算h(k)的时间)为θ(1+α)
在查找成功的情况下,平均需要的时间也是θ(1+α),具体证明参考《算法导论》中文版146页。
总结
上述的分析意味着,如果散列表中槽数至少与表中的元素成正比(比如说,当要散列的元素的数量增加时,散列表T的槽数也要保持同样比例的增长),则有
n = Ο(m),从而α= n/m = Ο(m) / m = Ο(1),所以查找操作平均时间需要常数时间。如果散列的元素的数量增加了,但是散列表的槽数没有增长,此时n = Ο(m)就不成立,散列表的操作时间就和之前的不一样了。
开放寻址法
在开放寻址法中,所有的元素都存放在散列表中,也就是说,每个表项或包含动态集合的一个元素,或包含NIL。当查找某个元素时,要系统地检查所有的表项,知道查找到所需要的元素,或者最终查明该元素不在表中。不像链接法,这里既没有链表,也没有元素存放在散列表外,因此在开放寻址法中,散列表可能会被填满,以至于不能插入任何新的元素,因此装载因子α = n / m绝对不会超过1,也就是说,要散列的元素绝对不会多于槽的数量。
探查方式
线性探查
给定一个普通的散列函数 h ' : U → { 0, 1, …, m - 1 }(称为辅助散列函数),线性探查方法采用的散列函数为:
h ( k , i ) = ( h '( k ) + i ) mod m , i = 0, 1, …, m - 1
给定一个关键字 k ,第一个探查的槽是 T [ h '( k ) ],亦即,由辅助散列函数所给出的槽。接下来探查的是槽 T [ h ' ( k ) + 1 ], …,直到槽 T [ m - 1 ],然后又绕到槽 T [ 0 ], T [ 1 ], …直到最后探查槽 T [ h ' ( k ) - 1 ]。在线性探查方法中,初始探查位置确定了整个序列,故只有 m 种不同的探查序列。
线性探查方法很容易实现,但它存在一个问题,称作一次群集。随着时间的推移,连续被占用的槽不断增加,平均查找时间也随着不断增加。群集现象很容易出现,这是因为当一个空槽前有 i 个满的槽时,该空槽作为下一个将被占用槽的概率是( i + 1 ) / m 。连续被占用槽的序列将会越来越长,因而平均查找时间也会随之增加。
二次探查
二次探查采用如下形式的散列函数:
h ( k , i ) = ( h ' ( k ) + c1 i + c2i2) mod m
其中 h '是一个辅助散列函数, c1 和 c2 为辅助常数(不等于0), i = 0, 1, …, m - 1。初始的探查位置为 T [ h '( k ) ],后续的探查位置要在此基础上加上一个偏移量,该偏移量以二次的方式依赖于探查号 i 。这种探查方法的效果要比线性探查好很多,但是,如果两个关键字的初始探查位置相同,那么他们的探查序列也是相同的,这是因为 h ( k1 , 0 ) = h ( k2 , 0 )蕴含着 h ( k1 , i ) = h ( k2 , i )。这一性质可导致一种程度较轻的群集现象,称为 二次群集。二次探查也只有 m 个不同的探查序列。
双重散列
双重散列 是用于开放寻址法的最好方法之一,它采用如下形式的散列函数:
h ( k , i ) = ( h1 ( k ) + i h2 ( k ) ) mod m
其中 h1 和 h2 为辅助散列函数。初始探查位置为 T [ h1 ( k ) ],后续的探查位置在此基础上加上偏移量 h2 ( k )模 m 。
为能查找整个散列表,值 h2 ( k )要与表的大小 m 互质。确保这个条件成立的一种方法是取 m 为 2 的幂,并设计一个总产生奇数的 h2 。另一种方法是取 m 为质数,并设计一个总是产生较 m 小的正整数的函数 h2 。例如,可以取m为素数,m’略小于m,如下:
h1 ( k ) = k mod m, h2 ( k ) = 1 + ( k mod m’ )
双重散列法中用了 Θ ( m2 )中探查序列。
分析
相对于链接法,开放寻址法的好处在于不需要用到指针,而是计算出槽的序列,于是,不用存储指针而节省的空间,使得可以用同样地空间来提供更多的槽,潜在地减少了冲突,提高了检索速度。
从开放寻址法的散列表中删除元素比较困难,当从槽i中删除关键字时,不能仅仅将NIL置于其中来标识它为空,如果这样做就会出现问题:在插入关键字k时,发现槽i被占用了,则k会插入到后面的位置上;此时将槽i中的关键字删除后,就无法检索到关键字k了,有一个解决方法就是,在槽i中置一个特定的值DELETED替代NIL来标记空槽。当使用特殊的值DELETED时,查找时间就不再依赖于装载因子了,为此,在必须删除关键字的应用中,更常见的方法是采用链接法来解决冲突。
给定一个装载因子为α = n / m,α<1,的开放寻址散列表,并假设是均匀散列的,则对于一次不成功的查找,其期望的探查次数至多为1 / (1 – α)。具体证明参考《算法导论中文版》P155.
1/(1 - α) = 1 + α + α2 + α3 + … 这个界有一个直观的解释,无论如何,总要进行第一次探查,第一次探查发现的是一个已经占用的槽时,必须要进行第二次探查,进行第二次探查的概率大约为α,前两次探查所发现的槽均已被占用时,进行第三次探查的概率大约为α2,等等
如果α是一个常数,一次不成功查找的运行时间为Ο(1),例如,如果散列表一半是满的,一次不成功查找的平均探查次数至多为1 / ( 1- 0.5) = 2,如果散列表90%是满的,则平均探查次数至多为1 / ( 1 – 0.9) = 10
假设采用的是均匀散列,平均情况下,向一个装载因子为α的开放寻址散列表中插入一个元素之多需要做1/(1 - α)次探查,因为插入一个关键字首先要做一次不成功查找,所以插入元素的时间和一次不成功的探查时间相同。
对于一个装载因子为α<1的开放寻址散列表,一次成功查找中的探查期望数至多为(1/α)ln(1/(1-α))。具体证明参考《算法导论中文版》P155.如果散列表是半满的,则一次成功的探查中,探查的期望数小于1.39,如果散列表为90%满的,则探查的期望数小于2.56
散列函数
除法散列法
在 除数散列法 中,通过取 k 除以 m 的余数,来将关键字 k 映射到 m 个槽的某一个中去。亦即,散列函数为:
h ( k ) = k mod m
当应用除数散列时,要注意 m 的选择,可选的 m 值通常是与 2 的整数幂不太接近的质数。
乘法散列法
乘法散列法 包含两个步骤。第一步,用关键字 k 乘上常数 A (0 < A < 1),并抽取出 k A 的小数部分。然后,用 m 乘以这个值,再向下取整。散列函数为:
h ( k ) = floor( m ( k A mod 1 ))
floor()函数为向下取整的意思。乘法方法的一个优点是对 m 的选择没有什么特别的要求,一般选择它为 2 的幂( m = 2p , p 为某个整数)。
例如:h ( k ) = ( A * k mod 2w) rsh (w – r),其中w为计算机的位数(32或者64位),m为槽的数量,rsh(w – r)意思为向右移位w – r 位,A是一个奇数,并且2w-r < A < 2w,m = 2r
全域散列法
任何的散列函数都可能出现最坏情况性态,即 n 个关键字都散列到同一个槽中,使得平均的检索时间为 Θ ( n ):唯一有效的改进方法是随机地选择散列函数,使之独立于要存储的关键字。这种方法称作全域散列。
全域散列的基本思想是在执行开始时,就从一组仔细设计的函数中,随机地选择一个作为散列函数。随机化保证了没有哪一种输入会始终导致最坏情况性态。同时,随机化使得即使是对同一个输入,算法在每一次执行时的性态也是不一样的。这样就可以确保对于任何输入,算法都具有良好的平均情况性态。
设 H 为有限的一组散列函数,它将给定的关键字域 U 映射到{ 0, 1, …, m - 1 }。这样的一组函数称为是 全域的 ,如果对每一对不同的关键字 k , l ∈ U ,满足 h ( k ) = h ( l )的散列函数 h ∈ H 的个数至多为 | U | / m 。换言之,如果从 H 中随机选择一个散列函数,当关键字 k ≠ l 时,两个发生碰撞的概率不大于 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 )的期望时间来处理任何包含了 n 个 INSERT, SEARCH , DELETE 操作的操作序列,该序列中包含了 O ( m )个 INSERT 操作。
设计一个全域散列函数类
1.选择一个素数,用m表示
2.把k分解成r+1位的数,k = <k0, k1, k2, … kr> 0 <= kr <= m-1
3.选择一个数a = <a0 , a1 , … , ar >,每一个ai都从{0, 1, … , m-1}中随机选择
4.Ha ( k ) = mod m
完全散列
可以采用两级的散列方法来设计完全散列方案,在每级上都使用全域散列,如下图所示:
外层的散列函数为h(k) = ((ak + b) mod p)mod m,一个二级散列表Sj中存储了所有散列到槽j中的关键字,其大小为mj=nj2,并且相关的散列函数为hj(k) = ((ajk + bj)mod p)mod mj
第一级与带连接的散列表基本上一样:利用从某一全域组中仔细选出的一个散列函数h,将n个关键字散列到m个槽中。
然而,采用一个较小的二次散列表Sj,及相关的散列函数hj,而不是将散列到槽j中的所有关键字建立一个链表,利用精心选择的散列函数hj,可以确保在第二级上不出现冲突。
为了确保第二级上不出现冲突,需要让散列表Sj的大小mj为散列到槽j中的关键字数nj的平方,尽管mj对nj的这种二次依赖看上去可能使得总体存储需求很大,但是可以通过适当地选择第一级散列函数,可以将预期使用的总体存储空间限制为Ο(n)。
二级散列冲突的概率
如果从一个全域散列函数类中随机选出散列函数h,将n个关键字存储在一个大小为m = n2的散列表中,那么表中出现冲突的概率小于1/2
上述定理的意思:对于一个从H中随机选出的散列函数h,较有可能不会发生冲突,给定待散列的包含n个关键字的集合K,只需要几次随机的尝试,就能比较容易地找出一个没有冲突的散列函数h。
完全散列所需空间
如果从某一个全域散列函数类中随机选出散列函数h,用它将n个关键字存储到一个大小为m = n的散列表中,并将每个二次散列表的大小设置为mj = nj2,则在一个完全散列方案中,存储所有二次散列表所需要的存储总量的期望值小于2n