学习《算法导论》第十一章 散列表 总结一

来源:互联网 发布:c语言标识符命名规则 编辑:程序博客网 时间:2024/05/01 09:04

学习《算法导论》第十一章 散列表 总结一

在很多应用中,都要用到一种动态集合结构,它仅支持INSERT, SEARCH和DELETE字典操作。而实现字典的一种有效数据结构就是散列表. 在最坏情况下,在散列表中,查找一个元素的时间与在链表中查找一个元素的时间相同,在最坏情况下都是Θ(n),但在实践中,散列技术的效率是很高的.
散列表是普通数组概念的推广. 但是对于数组来说,可以进行直接寻址,所以可以在Ο(1)时间内访问数组的任意元素.

直接寻址表(数组)

数组的话,是很好理解的,它的SEARCH,INSERT,DELETE操作运行时间都是Ο(1).
但是直接寻址技术存在着一个明显的问题:就是若域U很大,要在机器中存储大小为|U|的一张表T就有点不实际了。另外当实际要存储的关键字集合K相对数组的域U来说可能很小,因而分配给数组的大部分空间都要浪费掉.

散列表

在直接寻址方式下,具有关键字k的元素被存放在槽k中. 在散列方式下,该元素处于h(k)槽中.** 也就是,利用散列函数h,根据关键字k计算出槽的位置.
散列函数h将关键字域U映射到散列表T[0…m - 1]的槽位上:
h:U → {0,1,…,m - 1}**

这时,可以说一个具有关键字k的元素被散列到槽h(k)上,或说h(k)是关键字k的散列值.下图给出了形象的说明.

这里写图片描述

利用散列函数的目的就是要缩小需要处理的下标范围,即我们要处理的值就从|U|降到m了,从而相应地降低了空间开销.
但是,这样有个问题:就是两个关键字可能映射到同一个槽上. 我们将这种情形称为发生了碰撞. 为解决这个问题,我们有两个办法:
1. 通过精心设计的随机散列函数来尽量减少碰撞.
2. 仍需要有解决碰撞的办法.
下面我们先看下一种解决碰撞的解决技术:

通过链接法解决碰撞

在该方法中,把散列到同一槽中的所有元素都放在一个链表中,如下图所示:

这里写图片描述

在采用该种方法解决碰撞问题后,散列表T上的字典操作就很容易实现了:

CHAINED-HASH-INSERST(T, x)    insert x at the head of list T[h(key[x])]CHAINED-HASH-SEARCH(T, k)    search for an element with key k in list T[h(k)]    CHAINED-HASH-DELETE(T, x)    delete x from the list T[h(key[x])] 

散列函数

这里,要学习一些有关如何设计出好的散列函数的问题,介绍了两种方案.

好的散列函数的特点

一个好的散列函数应满足简单一只散列的假设:每个关键字都等可能地散列到m个槽位的任何一个之中去,并与其他的关键字已被散列到哪一个槽位中无关. 一种好的做法是以独立于数据中可能存在的任何模式的方式导出散列值.

将关键字解释为自然数

多数散列函数都假定关键字域为自然数集N = {1, 2, 3, … }. 如果所给关键字不是自然数,则必须有一种方法来将它们解释为自然数. 如一个字符串关键字可被解释为按适当的基数表示的整数, 标识符pt可被解释为十进制整数对(112, 116),然后按128为基数来表示,pt即为(112*128) + 116 = 14452. 在任一应用中,通常都比较容易设计出类似的方法,来将每个关键字解释为一个自然数. 后面都假定所给的关键字为自然数.

除法散列法

首先,给出散列函数为:

h(k) = k mod m

亦即,通过取k除以m的余数,来将关键字k映射到m个槽位中的某一个中去.
如:散列表的大小为m = 12,所给关键字为k = 100,则h(k) = 4. 因为只要一次除法,所以比较快.
当应用除法散列时,要注意m的选择. 例如m不应是2的幂. 可以选作的m的值常常是与2的整数幂不太接近的质数.

乘法散列法

构造乘法散列函数有两个步骤:第一步,用关键字k乘上常数A(0<A<1),并抽取出kA的小数部分,第二步,用m乘以这个值,再取结果的底floor. 总之,散列函数为:

h(k) = |m(kA mod 1) |

乘法散列法的一个优点是对m的选择没有什么特别的要求,一般选择它为2的某个幂次方.

开放寻址法

在开放寻址法中,所有的元素都存放在散列表里,不像是链接法,这儿没有链表,也没有元素存放在散列表外,在这种方法中,散列表可能会被填满,以致于不能插入任何新的元素. 亦即,每个表项要么包含动态集合中的一个元素,要么为NULL.
当查找一个元素时,要检查所有的表项,知道找到所需的元素或者最终发现该元素不在表中.
开放寻址法的好处就是它根本不用指针,而是计算出要存取的各个槽位,这样一来,就节省了空间,从而可以用这些空间来提供更多的槽位.
在开放寻址法中要插入一个元素,可以连续探查散列表的各项,直到找到一个空槽来放置待插入的关键字为止. 检查的顺序是要依赖于待插入的关键字. 对于每一个关键字k,探查序列必须是<0, 1, … m - 1>的一个排列. 下面给出插入关键字k的算法:

// T为散列表,关键字kHASH-INSERT(T, k)1    i <-- 02    repeat j <-- h(k, i) // h(k, i)表示探查的槽位号3           if T[j] = NULL4               then T[j] <-- k5                   return j6               else i++7    until i = m8    error "hash table overflow"

查找关键字k的算法的探查序列与将k插入时的插入算法是一样的. 所以当在查找过程中碰到一个空槽时,查找算法就停止,因为如果k确实在表中的话,也应该在该处,而不是在稍后的位置,这里的假设是关键字没被删除. 算法如下:

// T为散列表,关键字kHASH-SEARCH(T, k)1    i <-- 02    repeat j <-- h(k, i) // h(k, i)表示探查的槽位号3           if T[j] = k4               then return j   5               i++6    until T[j] = NULL or i = m7    return NULL

在开放寻址方法中,删除操作比较困难, 因为当我们从槽i中删除关键字时,不能仅将该槽位标记为NULL来表示它是空的,若这样的话,就有个问题:在插入某关键字K的探查过程中,发现槽位i被占用了,则K就被插到后面的位置上;在将槽i中的关键字删除后,就无法检索关键字K了。
有个解决办法就是:将散列表的每个槽位标记为三种状态:Legitimate, Empty, Deleted. 这样的话,上面的查找和插入算法要修改一下.

有三种方法常用来计算开放寻址中的探查序列:线性探查,二次探查,以及双重探查.

线性探查

给定一个普通的辅助散列函数h1: U →{0, 1, …, m-1},那么线性探查方法采用的散列函数为:

h(k, i) = (h1(k) + i) mod m   i = 0, 1, …, m-1

给定一个关键字k, 则第一次探查的槽是T[h1(k)], 即辅助散列函数所给出的槽. 接下来探查的槽是T[h1(k)+1]…,直到槽T[m-1],然后又卷绕到槽T[0], T[1]…,直到最后的探查槽位T[h1(k) - 1]. 由此可知,初始探查位置确定了整个探查序列.

二次探查

二次探查采用如下的散列函数:

h(k, i) = (h1(k) +c1 i+c2i2) mod m   i = 0, 1, …, m-1

h1(k)是一个辅助散列函数,c1和 c2为辅助常数,初始的探查位置为T[h1(k)],后续的探查位置要在此基础上加上一个偏移量. 这种探查方法的效果要比线性探查的效果好很多.

双重散列

双重散列是用于开放寻址法的最好方法之一. 它采用如下形式的散列函数:

h(k, i) = (h1(k) +i(h2(k)) mod m   i = 0, 1, …, m-1
0 0
原创粉丝点击