假设设计一个员工信息存储系统,用他们的电话号码做为key,并且要让下面的这些查询操作尽可能的高效:
- 插入一个电话号码以及相关的信息.
- 搜索一个电话号码以及相关的信息.
- 删除一个电话号码以及相关的信息.
一般都可以考虑使用下面的数据结构来存储不同电话号码的信息。
- 电话号码和记录的数组。
- 电话号码和记录的链表。
- 电话号码做为key的平衡二叉树。
- 直接访问数据表。
对于数组和链表,我们需要花费线性时间来搜索,在实际应用中比较耗时。我们使用数组并将数据排好序,利用二分查找搜索电话号码,需要O(Logn)的时间,但是插入和删除会变得耗时,因为要保持排序。
使用平衡二叉树,可以得到比较稳定的搜索,插入以及删除时间。所有的这些操作可以确保在O(Logn) 时间之内。
另一种方法是是使用直接访问数据表,建立一个大的数组且使用电话号码做为索引。如果没有提供号码,则这个数组元素为NIL,否则数组元素会保存一个指向电话记录的指针。这个方案的时间复杂度是前面所有方案中最小的,可以在O(1)的时间内完成所有操作。例如,如果要插入一个电话号码,首先使用给定的号码创建一条记录,然后使用号码做为索引,将指向这个记录的指针保存下来。
但是,这个方案有很多的实际操作限制。首先的问题是需要大量的内存空间。例如,如果号码由n个数字组成,则需要O(m * 10n)的空间,其中m是记录指针的大小。另一个问题是编程语言中的整形,可能没有n位数这么大。
基于上述限制,直接访问表并不常使用。在所有的这些数据结构中,哈希是最佳方案。使用哈希,可以得到O(1)的平均查找时间以及O(n)的最坏查找时间。
哈希是基于直接访问表的一个改进。方案是使用哈希函数,将给定的电话号码或者其它key,转换为一个小的数值,并将此小数值做为称为哈希表中的索引。
哈希函数: 将一个很大的电话号码数值转换为小数值的函数。映射得到的小数值做为哈希表的索引。
一个好的哈希函数应该具备下面特征:
1) 有效可计算性(Efficiently computable)。
2) 必须均匀地映射key(即每个key对应每个表位置)。
例如,对于电话号码,一个坏的哈希函数可能使用前3位数字,而可能更好的函数应该是考虑后3位数字。注意,这并不一定是最佳哈希函数,可能存在更好的方法。
哈希表: 即一个数组,元素存储的是指向相关电话号码记录的指针。如果没有号码能映射到某个索引,则这个索引代表的元素为NIL。
冲突处理: 因为哈希函数处理的是一个很大的整数或者字符串,而映射到的范围是一个比较小的数值,则很有可能两个key拥有相同的映射值。对于新插入的key,映射到了一个已存在的位置上时,这种情况称之为冲突,并且必须使用一些冲突检测技术来进行处理。下面是两种处理冲突的方法:
- 链接法(Chaining):主要思想是将哈希表中的每一项指向一个记录链表,这个链表拥有相同的哈希值。链表法相对比较简单,但是需要额外的内存。
- 开放定址法(Open Addressing): 这个方法的基本思想是:当发生地址冲突时,按照某种方法继续探测哈希表中的其他存储单元,直到找到空位置为止。
下面是对这两种方法的具体介绍:
1. 链接法
a. 链接法解决冲突的方法
将所有关键字为同义词的结点链接在同一个单链表中。若选定的散列表长度为m,则可将散列表定义为一个由m个头指针组成的指针数组T[0..m-1]。凡是散列地址为i的结点,均插入到以T[i]为头指针的单链表中。T中各分量的初值均应为空指针。在链接法中,装填因子α可以大于1,但一般均取α≤1。
b.链接法的优点
(1)链接法处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短;
(2)由于链接法中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况;
(3)开放定址法为减少冲突,要求装填因子α较小,故当结点规模较大时会浪费很多空间。而链接法中可取α≥1,且结点较大时,链接法中增加的指针域可忽略不计,因此节省空间;
(4)在用链接法构造的散列表中,删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。而对开放地址法构造的散列表,删除结点不能简单地将被删结点的空间置为空,否则将截断在它之后填人散列表的同义词结点的查找路径。这是因为各种开放地址法中,空地址单元(即开放地址)都是查找失败的条件。因此在用开放地址法处理冲突的哈希表上执行删除操作,只能在被删结点上做删除标记,而不能真正删除结点。
c. 链接法的缺点
指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间,而若将节省的指针空间用来扩大哈希表的规模,可使装填因子变小,这又减少了开放定址法中的冲突,从而提高平均查找速度。
2. 开放定址法
它的过程可用下式描述:
H i ( key ) = ( H ( key )+ d i ) mod m ( i = 1,2,…… , k ( k ≤ m – 1))
其中: H ( key ) 为关键字 key 的直接哈希地址, m 为哈希表的长度, di 为每次再探测时的地址增量。
采用这种方法时,首先计算出元素的直接哈希地址 H ( key ) ,如果该存储单元已被其他元素占用,则继续查看地址为 H ( key ) + d 2 的存储单元,如此重复直至找到某个存储单元为空时,将关键字为 key 的数据元素存放到该单元。
增量 d 可以有不同的取法,并根据其取法有不同的称呼:
( 1 ) d i = 1 , 2 , 3 , …… 线性探测再散列;
( 2 ) d i = 1^2 ,- 1^2 , 2^2 ,- 2^2 , k^2, -k^2…… 二次探测再散列;
( 3 ) d i = 伪随机序列/伪随机再散列;
a.线性探查法(Linear Probing)
该方法的基本思想是:
将散列表T[0..m-1]看成是一个循环向量,若初始探查的地址为d(即h(key)=d),则最长的探查序列为:
d,d+l,d+2,…,m-1,0,1,…,d-1
即:探查时从地址d开始,首先探查T[d],然后依次探查T[d+1],…,直到T[m-1],此后又循环到T[0],T[1],…,直到探查到T[d-1]为止。
探查过程终止于三种情况:
(1)若当前探查的单元为空,则表示查找失败(若是插入则将key写入其中);
(2)若当前探查的单元中含有key,则查找成功,但对于插入意味着失败;
(3)若探查到T[d-1]时仍未发现空单元也未找到key,则无论是查找还是插入均意味着失败(此时表满)。
聚集或堆积现象
用线性探查法解决冲突时,当表中i,i+1,…,i+k的位置上已有结点时,一个散列地址为i,i+1,…,i+k+1的结点都将插入在位置i+k+1上。把这种散列地址不同的结点争夺同一个后继散列地址的现象称为聚集或堆积(Clustering)。这将造成不是同义词的结点也处在同一个探查序列之中,从而增加了探查序列的长度,即增加了查找时间。若散列函数不好或装填因子过大,都会使堆积现象加剧。
b.二次探查法(Quadratic Probing)
二次探查法的探查序列是:
hi=(h(key)+i*i)%m 0≤i≤m-1 //即di=i2
即探查序列为d=h(key),d+12,d+22,…,等。
该方法的缺陷是不易探查到整个散列空间。
c.双重散列法(Double Hashing)
该方法是开放定址法中最好的方法之一,它的探查序列是:
hi=(h(key)+i*h1(key))%m 0≤i≤m-1 //即di=i*h1(key)
即探查序列为:
d=h(key),(d+h1(key))%m,(d+2h1(key))%m,…,等。
该方法使用了两个散列函数h(key)和h1(key),故也称为双散列函数探查法。
注意:定义h1(key)的方法较多,但无论采用什么方法定义,都必须使h1(key)的值和m互素,才能使发生冲突的同义词地址均匀地分布在整个表中,否则可能造成同义词地址的循环计算。
【例】 若m为素数,则h1(key)取1到m-1之间的任何数均与m互素,因此,我们可以简单地将它定义为:
h1(key)=key%(m-2)+1
【例】对例9.1,我们可取h(key)=key%13,而h1(key)=key%11+1。
【例】若m是2的方幂,则h1(key)可取1到m-1之间的任何奇数。