散列表
散列表
散列表是普通数组概念的推广。由于普通数组可以直接寻址,使得能在O(1)时间内访问数组中的任意位置。如果存储空间允许,我们可以提供一个数组,为每个可能的关键字保留一个位置,以利用直接寻址技术的优势。
直接寻址表
当关键字的全域U比较小时,直接寻址是一种简单而有效的技术。假设某应用要用到一个动态集合,其中每个元素都是取自于全域U={0,1,……,m-1}中的一个关键字,这里m不是一个很大的数。另外,假设没有两个元素具有相同的关键字。
为了表示动态集合,我们用到一个数组,或称为直接寻址表(direct-address table),记为T[0…m-1]。其中每个位置,或称为槽(slot),对应全域U中的一个关键字。
槽k指向集合中一个关键字为k的元素。如果该集合中没有关键字为k的元素,则T[k]=NIL。
对于某些应用,直接寻址表本身就可以存放动态集合中的元素。也就是说,并不是每个元素的关键字以及卫星数据都放在直接寻址表外部的一个对象中,再由表某个槽的指针指向该对象,而是直接把该对象存放在表的槽中,从而节省了空间。
直接寻址表中的字典操作
散列表
直接寻址的缺点是非常明显地:如果全域U很大,则在一台标准的计算机可用内存容量中,要存储大小为|U|的一张表T也许不太实际,甚至是不可能的。
在直接寻址方式下,具有关键字k的元素被存放在槽k中。在散列方式下,该元素存放在槽h(k)中;即利用散列函数(hash function)h, 由关键字计算出槽的位置。这里,函数h将关键字的全域U映射到散列表(hash table)T[0,…,m-1]的槽位上:
这里散列表的大小m一般要比|U|小很多。我们可以说一个具有关键字k的元素被散列到槽h(k)上,也可以说h(k)是关键字k的散列值。散列函数缩小了数组下标的范围,即减小了数组的大小,使其由|U|减小为m。
还存在一个问题:连个关键字可能映射到同一个槽中。我们称这种情况为冲突(collision)。理想的解决方法是避免所有的冲突。我们可以试图选择一个合适的散列函数h来做到这一点。但是要想完全避免冲突是不可能的。因此,我们一方面可以通过精心设计的散列函数来尽量减少冲突的次数,另一方面仍需要解决可能出现冲突的办法。常用的解决散列的方法有链接法(chaining),以及开放寻址法(open addressing)。
在链表法中,把散列到同一槽中的所有元素都放在一个链表中
如图所示,槽j中有一个指针,它指向存储所有散列到j的元素的链表的表头;如果不存在这样的元素,则槽j中未NIL。
散列函数
这里介绍三种具体方法,其中的两种方法(用除法进行散列和用乘法进行散列)本质上属于启发式方法,而第三种方法(全域散列)则利用了随机技术。一个好的散列函数应(近似地)满足简单均匀散列假设:每个关键字都被等可能地散列到m个槽位中的任何一个,并与其它关键字已散列到哪个槽位无关。多数散列函数都假定关键字的全域为自然数集N={0,1,2,…}。因为,如果所给关键字不是自然数,就需要找到一种方法来讲它们转换为自然数。例如,一个字符串可以被转换为按适当的基数符号表示的整数。
除法散列法
在用来设计散列函数的除法散了法中,通过取k除以m的余数,将关键字k映射到m个槽中的某一个上,即散列函数为:
一个不太接近2的整数幂的素数,常常是m的一个较好的选择。
乘法散列法
构造散列函数的乘法散列法包括两个步骤。第一步,用关键字k乘上常数A(0<A<1),并提取kA的小数部分。第二步,用m乘以这个值,再向下取整。总之,散列函数为:
乘法散列法的一个优点是对m的选择不是特别关键,一般选择它为2的某个幂次。虽然这个方法对任何的A值都适用,但对某些值效果更好。最佳的选择与待散列的数据的特征有关。
全域散列法
如果让一个恶意的对手来针对某个特定的散列函数选择要散列的关键字,那么他会将n个关键字全部散列到同一个槽中,使得平均的检索时间为O(n)。任何一个特定的散列函数都可能出现这种令人恐怖的最坏情况。唯一有效的改进方法是随机地选择散列函数,使之独立于要存储的关键字。这种方法称为全局散列(universal hashing),不管对手选择了怎么样的关键字,其平均性能都很好。
开放寻址法
在开放寻址法(open addressing)中,所有的元素都存放在散列表里。也就是说,每个表项或包含动态集合的一个元素,或包含NIL。当查找某个元素时,要系统地检测所有的表项,直到找到所需的元素,或者最终查明该元素不在表中。不像链接法,这里既没有链表,也没有元素存放在散列表外。因此在开放寻址法中,散列表可能被填满,以至于不能插入任何新的元素。该方法导致额一个结果便是装载因子a绝对不会超过1。
开放寻址的好处就在于它不用指针,而是计算出要存取的槽序列。于是,不用存储指针而节省的空间,使得可以用同样的空间来提供更多的槽,潜在地减少了冲突,提高了检索速度。
为了使用开放寻址法插入一个元素,需要连续地检查散列表,或称为探查(probe),直到找到一个空槽来放置待插入的关键字为止。检查的顺序不一定是0,1,2,3…,m-1(这种顺序下的查找时间为O(n)),而是依赖于待插入的关键字。为了确保要探查那些槽,我们将散列函数加以扩充,使之包含探查号(从0开始)以作为其第二个输入参数。这样,散列函数加以扩充,使之包含探查号(从0开始)以作为其第二个输入参数。这样,散列函数就变为:
对每一个关键字k,使用开放寻址法的探查序列(probe sequence)
是<0,1,…,m-1>的一个排列,使得当散列表逐渐填满时,每一个表位最终都可以被考虑为用来插入新关键字的槽。
查找关键字k的算法的探查序列与将k插入时的算法一样。因此,查找过程中碰到一个空槽时,查找算法就(非成功地)停止,因为如果k在表中,它就应该在此处,而不会在探查序列随后的位置上(之所以这样说,是假定了关键字不会从散列表中删除)。
在我们的分析中,做一个均匀散列(uniform hashing)的假设:每个关键字的探查序列等可能地为<0,1,…,m-1>的m!种排列中的任一种。然而,真正的均匀散列是难以实现的,在实际中,常常用它的一些近似方法。有三种技术常常用来计算开放寻址法中的探查序列:线性探查,二次探查和双重探查。这几种技术都能保证对每个关键字k,<h(k,0), h(k,1),…, h(k,m-1)>都是<0,1,…,m-1>的一个排列。
线性探查:给定一个普通的散列函数,称之为辅助散列函数(auxiliary hash function),线性探查(linear probing)方法采用的散列函数为:
二次探查(quadratic probing):采用如下形式的散列函数:
其中是一个辅助散列函数,和为正的辅助常数,i=0,1,…,m-1。
双重散列(double hashing):是用于开放寻址法的最好方法之一,因为它所产生的排列具有随机选择排列的许多特性。双重散列采用如下形式的散列函数:
其中和均为辅助散列函数。