散列表(算法导论笔记)

散列表

直接寻址表

一个数组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

散列表(算法导论笔记)

时间: 2024-10-10 22:37:38

散列表(算法导论笔记)的相关文章

MIT算法导论笔记

详细MIT算法导论笔记 (网络链接) 第一讲:课程简介及算法分析 第二讲:渐近符号.递归及解法

算法导论笔记——第十~十一章 数据结构(一) 散列

第十章 基本数据结构 栈:可由数组表示 队列:可由数组表示 指针和对象:可由多数组表示.可用栈表示free list 有根数: 二叉树:左右孩子 分支无限制:左孩子右兄弟表示法 第十一章 散列表 数组:为每个元素保留一个位置 散列表:用于实际存储关键字比全部可能关键字少很多时,比如字典操作 解决散列冲突:链接法,开放寻址法 11.2 散列表 用链表法,在简单均匀散列的假设下,一次成功或不成功的查找所需要的平均时间为Θ(1+α),α为load factor. 11.3 散列函数 好的散列函数应(近

算法导论笔记第6章 堆和堆排序

堆排序结合了插入排序和归并排序的有点:它空间复杂度是O(1), 时间复杂度是O(nlgn). 要讲堆排序,先讲数据结构"堆" 堆: 堆是用数组来存放一个完全二叉树的数据结构.假设数组名是A,树的根节点存放在A[1].它的左孩子存放在A[2],右孩子存放在A[3] 即:对于某个下标位i的节点,它的左孩子是A[2i],  右孩子是A[2i+1].  父节点是A[i/2] PARENT(i) return ?i/2? LEFT(i) return 2i RIGHT(i) return 2i

MIT公开课:算法导论 笔记(一)

课程链接:http://open.163.com/special/opencourse/algorithms.html 第一课:算法分析基础 1.介绍插入排序与归并排序,计算并比较最坏运行时间 2.算法分析重点与渐近分析方法 以下为个人笔记,根据字幕整理 第一课 算法分析 总结 解决问题的方法和方式 算法:关于计算机程序性能和资源利用的研究 算法:性能.速度 在程序设计方面,什么比性能更重要呢? 正确性,可维护,健壮性 模块化,安全,用户友好 为什么关注性能? 1.直接决定方法可行不可行 算法能

算法导论笔记(二)二路归并排序

二路归并排序 归并排序采用了一种”分而治之“的策略:将原问题分解成N个规模较小而结构与原问题相似的子问题:递归求解这些子问题,然后合并其结果,从而得到原问题的解. 分治模式一般遵循以下三个步骤: 分解(Divide):将原问题分解成若干子问题: 解决(Conquer):递归地求解各子问题.若子问题足够小,则直接求解: 合并(Combine):将子问题的解合并成原问题的解. ”二路归并"的算法也遵循以下三个步骤: 分解:将原序列中拥有的N个元素分解各含N / 2个元素的子序列: 解决:用合并排序法

算法导论笔记(三)冒泡排序

冒泡排序 重复走访要排序的数列,比较相邻两个元素,如果顺序错误就交换,直到该数列无需再交换为止. 升序冒泡 void BubbleSorting(int arr[], int len) { if (len < 1) throw "Param is wrong. Length is not correct."; if (len == 1) return; int temp; for (int i = 0; i < len - 1; i++) { for (int j = 0;

算法导论笔记——第十五章 动态规划

通常用来解决最优化问题.在做出每个选择的同时,通常会生成与原问题形式相同的子问题.当多于一个选择子集都生成相同的子问题时,动态规划技术通常就会非常有效.其关键技术就是对每个这样的子问题都保存其解,当其重复出现时即可避免重复求解. 分治:划分为互不相交的子问题,递归求解子问题,再将他们的解组合起来. 动态规划(dynamic programming,表格法而非编程)用于子问题重叠的情况. 四个步骤来设计一个动态规划算法: 1 刻画一个最优解的结构特征 2 递归地定义最优解的值 3 计算最优解的值,

算法导论笔记1 - 插入排序 vs 归并排序

import random import time __author__ = 'Administrator' LENGTH = 3000 base = [] for i in range(0, LENGTH): base.append(random.randint(0, LENGTH)) def ins_sort(array): for border in range(1, len(array)): j = border - 1 key = array[border] while j >= 0

算法导论笔记2 - T(n) = O(n) 的最大子数组问题解法

import random __author__ = 'Administrator' LENGTH = 500 base = [] for i in range(0, LENGTH * 2): base.append(random.randint(-1 * LENGTH, LENGTH)) print(base) bsa_i = 0 bsa_j = 1 bsa = base[0] bord = base[0] bord_i = 0 for i in range(1, len(base)): if