第七章:绪论
7.1 查找的基本概念
基本概念
1)查找:在数据集合中寻找满足某种条件的数据元素的过程称为查找。
2)查找表(查找结构):用于查找的数据集合称为查找表。
3)静态查找表:若一个查找表操作,只涉及到 ① 查找某个特定数据元素是否在查找表中 ② 检索满足条件的某个特定的数据元素的各种属性 ,则无需动态的修改查找表,此类表为静态查找表。
4)动态查找表:若一个查找表操作,涉及到 ① 在查找表中插入一个数据元素 ② 从查找表中删除某个数据元素 ,则需要动态的插入或修改查找表,此类表为动态查找表。
静态查找表的查找方式有:顺序查找,折半查找,散列查找等;
动态查找表的查找方式有:二叉排序树的查找,散列查找等。二叉平衡树和 B 树都是二叉排列树的改进。
5)平均查找长度(ASL):在查找过程中,一次查找的长度是指需要比较的关键字次数,而平均查找长度是所有查找过程中所进行关键字比较次数的平均值。
A
S
L
=
∑
i
=
1
n
P
i
C
i
ASL=\sum_{i=1}^n P_iC_i
ASL=i=1∑nPiCi
其中,
n
n
n 是查找表的长度;
P
i
P_i
Pi 是查找第
i
i
i 个元素的概率,一般认为每个元素的查找概率相等,即
P
i
=
1
n
P_i=\frac{1}{n}
Pi=n1 ;
C
i
C_i
Ci 是找到第
i
i
i 个数据元素所需进行的比较次数。平均查找长度是衡量查找算法效率的最主要的指标。
7.2 顺序查找和折半查找
顺序查找
顺序查找也称为线性查找,它对顺序表和链表都是适用的。顺序查找通常分为对一般的无序线性表的顺序查找和对按关键字有序的线性表的顺序查找。
1、一般线性表的顺序查找
基本思想:从线性表的一端开始,逐个检查关键字是否满足给定条件。
下面给出算法:
typedef int ElemType;
typedef struct {
ElemType *elem;//元素存储空间基址,默认数组0位不使用
int TableLen;//表的长度
}SSTable;//查找表
int Search_Seq(SSTable ST,ElemType key){
ST.elem[0]=key;//哨兵
int i;
for (i=ST.TableLen; ST.elem[i]!=key; i--);//从后向前找,找到则结束
return i;//如果表中不存在key,查找到i为0时退出循环
}
在上面算法中,较为值得一提的是将ST.elem[0]称为哨兵。引入它的目的是使得Search_Seq内的循环不必判断数组是否会越界,因为满足i==0时,循环一定跳出。这可以避免很多不必要的判断语句,而代价仅仅是一个很小的空间。
对于有
n
n
n 个元素的表,给定值
k
e
y
key
key 与表中第
i
i
i 个元素相等,即定位第
i
i
i 个元素时,需进行
n
−
i
+
1
n-i+1
n−i+1 次关键字的比较,所以有下面的结论:
查找成功时,顺序查找的平均长度为:
A
S
L
成
功
=
∑
i
=
1
n
P
i
(
n
−
i
+
1
)
ASL_{成功}=\sum_{i=1}^{n}P_i(n-i+1)
ASL成功=i=1∑nPi(n−i+1)
当每个元素查找概率相等时,即
P
i
=
1
n
P_i=\frac{1}{n}
Pi=n1 时,有
A
S
L
成
功
=
∑
i
=
1
n
P
i
(
n
−
i
+
1
)
=
n
+
1
2
ASL_{成功}=\sum_{i=1}^{n}P_i(n-i+1)=\frac{n+1}{2}
ASL成功=i=1∑nPi(n−i+1)=2n+1
查找不成功时,与表中各关键字比较的次数显然是
n
+
1
n+1
n+1,所以
查找失败时,顺序查找不成功的平均查找长度为:
A
S
L
不
成
功
=
n
+
1
ASL_{不成功}=n+1
ASL不成功=n+1
顺序查找的缺点:当 n n n 较大时,平均查找长度较大,效率低;
顺序查找的优点:对数据元素的存储没有要求,顺序存储和链式存储皆可,且对表中记录的有序性也没有要求。
注意:对线性的链表查找只能进行顺序查找。
2、有序表的顺序查找
若在查找前就已经知道表是关键字有序的,则查找失败时可以不用再比较到表的另一端就能返回失败信息,从而降低顺序查找失败的平均查找长度。
基本思想:假设表L示按关键字从小到大排列的,查找的顺序是从前往后,待查找元素的关键字为
k
e
y
key
key,当查找到第
i
i
i 个元素时,发现第
i
i
i 个元素对应的关键字小于
k
e
y
key
key,但第
i
+
1
i+1
i+1 个元素对应的关键字大于
k
e
y
key
key ,这时就可返回查找失败的信息了。
如下表示例图所示
在有序线性表的顺序查找中,查找成功的平均查找长度和一般线性表的顺序查找一样:
A
S
L
成
功
=
∑
i
=
1
n
P
i
(
n
−
i
+
1
)
ASL_{成功}=\sum_{i=1}^{n}P_i(n-i+1)
ASL成功=i=1∑nPi(n−i+1)
查找失败时,查找指针一定走到了某个失败结点,所查找的长度等于它上面的父结点的所在层数。查找不成功的平均查找长度在相等查找概率的情形为:
A
S
L
不
成
功
=
∑
i
=
1
n
P
i
(
l
i
−
1
)
=
1
+
2
+
.
.
.
+
n
+
n
n
+
1
=
n
2
+
n
n
+
1
ASL_{不成功}=\sum_{i=1}^{n}P_i(l_i-1)=\frac{1+2+...+n+n}{n+1}=\frac{n}{2}+\frac{n}{n+1}
ASL不成功=i=1∑nPi(li−1)=n+11+2+...+n+n=2n+n+1n
上式中,
q
i
q_i
qi 是到达第
i
i
i 个失败结点的概率,在相等查找概率的情形下,它为
1
n
+
1
\frac{1}{n+1}
n+11 ,
l
i
l_i
li 是第个失败结点所在的层数。
注意:有序线性表的顺序查找和后面的折半查找的思想是不一样,且有序线性表的顺序查找中的线性表可以是链式存储结构
折半查找(二分查找)
折半查找仅适用于有序列表。
基本思想:假设查表表升序排序,先给定值key与表中中间位置的元素比较,若相等,则查找成功,返回该元素的存储位置;若不等且给定值key大于中间元素,则将检查范围缩小到后半部分,继续进行同样的查找;若定值key小于中间元素,将检查范围缩小到前半部分,继续同样的查找。重复到找到为止,或确定表中没有所需要查找的元素,则查找失败。
int Binary_Search(SSTable ST,ElemType key){
int low=1,high=ST.TableLen,mid;
while (low<=high) {
mid=(low+high)/2;
if (ST.elem[mid]==key) {
return mid;//寻找成功并返回位置
}
else if(ST.elem[mid]>key){
high=mid-1;//从前半部分继续查找
}
else{
low=mid+1;//从后半部分开始查找
}
}
return -1;//查找失败
}
例如,已知11个元素的有序表 { 7,10,13,16,19,29,32,33,37,41,43 },以下说明查找 11 的过程
判定树:折半查找的过程可以用二叉树来描述,称为判定树。例如上面的查找过程可以用下面的判定树表示:
从判定树可以看出,查找成功时的查找长度等于从根节点到目的结点的路径上的结点数,而查找不成功时的查找长度为从根结点到对应失败结点的父结点的路径上的结点数;每个结点值均大于左子结点值,均小于右子结点值。且有序序列有 n 个元素,则对应的判定树有 n 个圆形的非叶结点和 n+1 个方形结点。
在等概率查找时,查找成功的平均查找长度为:
A
S
L
成
功
=
1
n
∑
i
=
1
n
l
i
=
1
n
(
1
×
1
+
2
×
2
+
.
.
.
+
h
×
2
h
−
1
)
ASL_{成功}=\frac{1}{n}\sum_{i=1}^{n}l_i=\frac{1}{n}(1\times1+2\times2+...+h\times2^{h-1})
ASL成功=n1i=1∑nli=n1(1×1+2×2+...+h×2h−1)
=
n
+
1
n
log
2
(
n
+
1
)
−
1
≈
log
2
(
n
+
1
)
−
1
=\frac{n+1}{n}\log_{2}(n+1)-1\approx\log_{2}(n+1)-1
=nn+1log2(n+1)−1≈log2(n+1)−1
式中,h是树的高度,并且元素个数为 n 时树高
h
=
┌
log
2
(
n
+
1
)
┐
h=\ulcorner \log_2(n+1) \urcorner
h=┌log2(n+1)┐。
注意该查找法仅适用于顺序存储结构,不适合于链式存储结构,且要求元素按关键字有序排列。
折半查找的时间复杂度为 O ( log 2 n ) O(\log_2n) O(log2n)
分块查找(索引顺序查找)
吸收了顺序查找和折半查找各自的优点,既有动态结构,又适用于快速查找。
基本思想:将查找表分为若干子块。块内的元素可以无序,但块之间是有序的,即第一个块中的最大关键字小于第二个块中的所有记录的关键字,第二个块中最大的关键字小于第三个块中的所有记录的关键字,以此类推。再建立一个索引表,索引表中的每个元素含有各个块中最大的关键字和各块中的第一个元素的地址,索引表按关键字有序排列。
分块查找的过程分为两步:第一步是在索引表中确定待查记录所在的块,可以顺序查找或折半查找索引表;第二步是在块内顺序查找。
例如,关键码集合为 { 88,24,72,61,21,6,32,11,8,31,22,83,78,54 },按照关键码值 24,54,78,88,分为4个块和索引表,如下所示:
设索引查找和块内查找的平均长度分别为
L
I
,
L
S
L_I,L_S
LI,LS,则分块查找的平均查找长度为:
A
S
L
=
L
I
+
L
S
ASL=L_I+L_S
ASL=LI+LS
将长度为n的查找表均匀的分成 b 块,每块有 s 个记录,在等概率的情况下
若在块内和索引表中均采用顺序查找,则平均查找长度为:
A
S
L
=
L
I
+
L
S
=
b
+
1
2
+
s
+
1
2
=
s
2
+
2
s
+
n
2
s
ASL=L_I+L_S=\frac{b+1}{2}+\frac{s+1}{2}=\frac{s^2+2s+n}{2s}
ASL=LI+LS=2b+1+2s+1=2ss2+2s+n
此时,若
s
=
n
s=\sqrt{n}
s=n,则平均查找长度取最小值
n
+
1
\sqrt{n}+1
n+1
若对索引表采用折半查找时,则平均查找长度为:
A
S
L
=
L
I
+
L
S
=
┌
log
2
(
b
+
1
)
┐
+
s
+
1
2
ASL=L_I+L_S=\ulcorner \log_2(b+1) \urcorner + \frac{s+1}{2}
ASL=LI+LS=┌log2(b+1)┐+2s+1
7.3 B树和B+树
B树(多路平衡查找树)
B树的阶:B树中所有结点的孩子个数的最大值称为B树的阶,通常用m表示。
B树:一棵
m
m
m 阶B树或为空树,或为满足如下特性的
m
m
m 叉树:
1)树中每个结点至多有m棵子树,即至多含有
m
−
1
m-1
m−1 个关键字。
2)若根结点不是终端结点,则至少有两棵子树。
3)除根结点外的所有非叶结点至少有
┌
m
2
┐
\ulcorner \frac{m}{2} \urcorner
┌2m┐ 棵子树,即至少含有
┌
m
2
┐
−
1
\ulcorner \frac{m}{2} \urcorner-1
┌2m┐−1 个关键字
4)所有非叶结点的结构如下:
其中,
K
i
(
i
=
1
,
2
,
.
.
.
,
n
)
K_i(i=1,2,...,n)
Ki(i=1,2,...,n) 为结点的关键字,且满足
K
1
<
K
2
<
.
.
.
<
K
n
K_1<K_2<...<K_n
K1<K2<...<Kn ;
P
i
(
i
=
1
,
2
,
.
.
.
,
n
)
P_i(i=1,2,...,n)
Pi(i=1,2,...,n) 为指向子树根结点的指针,且
P
i
−
1
P_i-1
Pi−1 所指子树中所有结点的关键字均小于
K
i
K_i
Ki,
P
i
P_i
Pi所指子树中所有结点的关键字均大于
K
i
K_i
Ki ;
n
(
┌
m
2
┐
⩽
n
⩽
m
−
1
)
n (\ulcorner \frac{m}{2} \urcorner \leqslant n\leqslant m-1)
n(┌2m┐⩽n⩽m−1)为结点中关键字的个数。
5)所有的叶结点都出现在同一层次上,并且不带任何信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)
B树是所有结点平衡因子均为0的多路平衡查找树
如图所示的B树中所有结点的最大孩子数 m = 5 m=5 m=5,因此它是一棵5阶B树,在m阶B树中结点最多可以有m个孩子。
上图所示的5阶B树含有如下性质:
1)结点的孩子个数等于该结点中关键字个数加1.
2)如果根结点没有关键字就没有子树,此时B树为空;如果根结点有关键字,则其子树必然大于等于两棵。因为子树个数等于关键字个数加1。
3)除根结点外的所有非终端结点至少有
┌
m
2
┐
=
3
\ulcorner \frac{m}{2} \urcorner=3
┌2m┐=3 棵子树(即至少有
┌
m
2
┐
−
1
=
2
\ulcorner \frac{m}{2} \urcorner-1=2
┌2m┐−1=2 个关键字),至多有 5 棵子树(即至多有 4 个关键字)。
4)结点中关键字从左到右递增有序,关键字两侧均有指向子树的指针,左边指针所指的子树所有关键字均小于该关键字,右边指针所指子树的所有关键均大于该关键字
5)所有叶结点均在第4层,代表查找失败的位置。
1、B树的高度(磁盘存取次数)
B树中的大部分操作所需的磁盘存取次数与B树的高度成正比。
B树的高度包括最后的不带任何信息的叶结点那一层
若
n
⩾
1
n\geqslant1
n⩾1,则对任意一棵包含
n
n
n 个关键字、高度为
h
h
h、阶数位
m
m
m 的B树:
1)因为B树中每个结点最多有
m
m
m棵子树,
m
−
1
m-1
m−1个关键字,所有在一棵高度为
h
h
h的
m
m
m阶B树中关键字的个数应满足
n
⩽
(
m
−
1
)
(
1
+
m
+
m
2
+
.
.
.
+
m
h
−
1
)
=
m
h
−
1
n\leqslant(m-1)(1+m+m^2+...+m^{h-1})=m^h-1
n⩽(m−1)(1+m+m2+...+mh−1)=mh−1,因此有
h
⩾
log
m
(
n
+
1
)
h\geqslant\log_{m}{(n+1)}
h⩾logm(n+1)
2)若让每个结点中的关键字个数达到最少,则容纳同样多关键字的B树的高度达到最大。
由B树的定义:第一层至少有1个结点,第二层至少有2个结点,且除根结点外每个非终端结点至少有
┌
m
2
┐
\ulcorner \frac{m}{2} \urcorner
┌2m┐ 棵子树,则第三层至少有
2
┌
m
2
┐
2\ulcorner \frac{m}{2} \urcorner
2┌2m┐ 个结点 … 第
h
+
1
h+1
h+1 层至少有
2
(
┌
m
2
┐
)
h
−
1
2(\ulcorner \frac{m}{2} \urcorner)^{h-1}
2(┌2m┐)h−1 个结点,注意到第
h
+
1
h+1
h+1 层是不包含任何信息的叶结点。
对于关键字个数为
n
n
n 的B树,叶结点即查找不成功的结点为
n
+
1
n+1
n+1,由此有
n
+
1
⩾
2
(
┌
m
2
┐
)
h
−
1
n+1\geqslant2(\ulcorner \frac{m}{2} \urcorner)^{h-1}
n+1⩾2(┌2m┐)h−1,即
h
⩽
log
┌
m
2
┐
(
n
+
1
2
)
+
1
h\leqslant\log_{\ulcorner \frac{m}{2} \urcorner}{(\frac{n+1}{2})+1}
h⩽log┌2m┐(2n+1)+1
2、B树的查找
在B树进行查找与二叉查找树类似,只是每个结点都是多个关键字的有序表,在每个结点上所做的不是两路分支决定,而是根据该结点的子树所做的多路分支决定。
B树的查找包括两个基本操作:1)在B树中找结点;2)在结点内找关键字。
由于B树常存储在硬盘上,因此前一个查找操作是在硬盘上进行的,而后一个查找操作是在内存中进行的,即在找到目标结点后,先将结点信息读入内存,然后在结点内采用顺序查找或折半查找。
如上图查找关键字42:
根结点关键字为22,而42大于22,所以转到右子树上;
右孩子结点有两个关键词,且36<42<45,所以下一步选择36和45中间的子树;
查到关键字42,查找成功。
3、B树的插入
与二叉查找树的插入操作相比,B树的插入操作复杂很多。
将关键字 key 插入到B树的过程如下:
1)定位:利用前述的B树查找算法,找到插入该关键字的最低层中的某个非叶结点。
2)插入:在B树中,每个非失败的结点关键字个数都在区间
[
┌
m
2
┐
−
1
,
m
−
1
]
[\ \ulcorner \frac{m}{2} \urcorner -1\ ,\ m-1\ ]
[ ┌2m┐−1 , m−1 ]内。
插入后的结点关键字个数小于
m
m
m ,可以直接插入;
插入后检查被插入结点内关键字的个数,当插入后结点关键字个数大于
m
−
1
m-1
m−1时,必须对结点进行分量。
3)分裂:取一个新结点,在插入 key 后的原节点,从中间位置
(
┌
m
2
┐
)
(\ulcorner \frac{m}{2} \urcorner)
(┌2m┐) 将其中的关键字分为两部分;
左部分包含的关键放在原结点,右部分包含的关键字放在新结点,中间位置
(
┌
m
2
┐
)
(\ulcorner \frac{m}{2} \urcorner)
(┌2m┐) 的结点插入原结点的父结点中。
若此时导致其父结点关键字个数也超过上限,则继续在父结点进行这种分裂,直到这个过程传到根结点为止,进而导致B树高度增1。
如下图的3阶B树:
4、B树的删除
B树的删除操作与插入操作类似,但更复杂一些。
1)当被删关键字
k
k
k 不在终端结点(最低层非叶结点)中时;
可以用
k
k
k 的前驱(或后继)
k
′
k^\prime
k′ 来替代
k
k
k ,然后在相应的结点中删除
k
′
k^\prime
k′。
如下图所示
2)当被删关键字在终端结点(最低层非叶子结点)中时,有下列三种情况:
A. 直接删除关键字。若被删除关键字所在结点的关键字个数 ⩾ ┌ m 2 ┐ \geqslant\ulcorner \frac{m}{2} \urcorner ⩾┌2m┐,表明删除该结点后仍满足B树的定义,则直接删去该关键字。
B. 兄弟够借。若被删除关键字所在结点删除前的关键字个数 = ┌ m 2 ┐ − 1 =\ulcorner \frac{m}{2} \urcorner-1 =┌2m┐−1,且与此结点相邻的右(或左)兄弟结点的关键字个数 ⩾ ┌ m 2 ┐ \geqslant\ulcorner \frac{m}{2} \urcorner ⩾┌2m┐,则需要利用父子换位发调整该结点、右(或左)兄弟结点及其双亲结点位置。
如下图所示,删除4阶B树的关键字65,且右兄弟关键字个数 ⩾ ┌ m 2 ┐ = 2 \geqslant\ulcorner \frac{m}{2} \urcorner=2 ⩾┌2m┐=2,将71取代原65的位置,74调整到原71的位置。
C. 兄弟不够借。
- 若被删除关键字所在结点删除前的关键字个数 = ┌ m 2 ┐ − 1 =\ulcorner \frac{m}{2} \urcorner-1 =┌2m┐−1,且与此结点相邻的右(或左)兄弟结点的关键字个数均 = ┌ m 2 ┐ − 1 =\ulcorner \frac{m}{2} \urcorner-1 =┌2m┐−1,则将关键字删除后与左(或右)兄弟结点及双亲结点中的关键字进行合并。
如下图所示,删除4阶B树的关键字5,它及其右兄弟结点的关键字个数 = ┌ m 2 ┐ − 1 = 1 =\ulcorner \frac{m}{2} \urcorner-1=1 =┌2m┐−1=1,故在删除5后将60合并到65结点中。
-
在合并过程中,双亲结点中的关键字个数会减1。若其双亲结点是根结点且关键字个数减少至0(根结点关键字个数为1时,有2棵子树),则直接将根节点删除,合并后的结点称为根;
-
若双亲结点不是根结点,且关键字个数减少到 ┌ m 2 ┐ − 2 \ulcorner \frac{m}{2} \urcorner-2 ┌2m┐−2,则又要与它自己的兄弟结点进行调整或合并操作,直到符合B树的要求为止
B+树
B+树是数据库在应用B树时的一种变形树
一个 m 阶的 B+ 树需满足下列条件:
1)每个分支结点最多有 m 棵子树(孩子结点)。
2)非叶根结点至少有两棵子树,其他每个分支结点至少有
┌
m
2
┐
\ulcorner \frac{m}{2} \urcorner
┌2m┐ 棵子树
3)结点的子树个数与关键字个数相等
4)所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互链接起来。
5)所有分支结点(可视为索引的索引)中仅包含它的各个子结点(即下一级的索引块)中关键字的最大值及指向其子结点的指针。
如下图的4阶B+树所示。可以看出,分支结点的某个关键字是其子树中最大关键字的副本。通常在B+树中有两个头指针:一个指向根结点,另一个指向关键字最小的叶结点。
可以对B+树进行两种查找运算:一种是从最小关键字开始的顺序查找,另一种是从根结点开始的多路查找
m阶的B+树与m阶的B树的主要差异如下:
1)在B+树中,具有 n 个关键字的结点只含 n 棵子树,每个关键字对应一棵子树;
而在B树中,具有 n 个关键字的结点会含有 n+1 棵子树
2)在B+树中,每个结点(非根内部结点)的关键字个数 n 的范围是 ┌ m 2 ┐ ⩽ n ⩽ m \ulcorner \frac{m}{2} \urcorner \leqslant n\leqslant m ┌2m┐⩽n⩽m(根结点: 1 ⩽ n ⩽ m 1 \leqslant n\leqslant m 1⩽n⩽m)
而在B树中,每个结点(非根内部结点)的关键字个数n的范围是 ┌ m 2 ┐ ⩽ n ⩽ m − 1 \ulcorner \frac{m}{2} \urcorner \leqslant n\leqslant m-1 ┌2m┐⩽n⩽m−1(根结点: 1 ⩽ n ⩽ m − 1 1 \leqslant n\leqslant m-1 1⩽n⩽m−1)
3)在B+树中,叶结点包含信息,所有非叶结点仅起索引作用,非叶结点中的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址。
4)在B+树中,叶结点包含全部关键字,即在非叶结点中出现的关键字也会出现在叶结点中;
而在B树中,叶结点(最外层内部结点)包含的关键字和其他结点包含的关键字是不重复的。
7.4 散列表
散列函数:一个吧查找表中的关键字映射称该关键字对应的地址的函数,记为
H
a
s
h
(
k
e
y
)
=
A
d
d
r
Hash(key)=Addr
Hash(key)=Addr(这里的地址可以是数组下表、索引或内存地址)
冲突和同义词:散列函数可能会把两个或两个以上的不同关键字映射到同一地址,称这种情况为冲突,这些发生碰撞的不同关键字称为同义词。
散列表:根据关键字而直接访问的数据结构。也就是说,散列表建立了关键字和存储地址之间的一种直接映射关系。
理想情况下,对散列表进行查找的时间复杂度为O(1),即与表中元素的个数无关。
下面分别结束常用的散列函数和处理冲突的方法。
散列函数的构造方法
构造散列函数,必须注意一下几点:
1)散列函数的定义域必须包含全部需要存储的关键字,而值域的范围则依赖于散列表的大小或地址范围。
2)散列函数计算出来的地址应该能等概率、均匀的分布在整个地址空间中,从而减少冲突的发生。
3)散列函数应尽量简单,能够在较短的时间内计算出任一关键字对应的散列地址。
1、直接定址法
直接取关键字的某个线性函数值为散列地址,散列函数为
H
(
k
e
y
)
=
k
e
y
或
H
(
k
e
y
)
=
a
×
k
e
y
+
b
H(key)=key或H(key)=a\times key+b
H(key)=key或H(key)=a×key+b
式中,
a
a
a 和
b
b
b 是常数。
这种方法计算最简单,且不会产生冲突。它适合关键字的分布基本连续的情况,若关键字不连续,空位较多,会造成存储空间的浪费
2、除留余数法
假定散列表表长为
m
m
m,取一个不大于
m
m
m 但最接近或等于
m
m
m 的质数
p
p
p,利用公式把关键字转换成散列地址,散列函数为
H
(
k
e
y
)
=
k
e
y
%
p
H(key)=key\%p
H(key)=key%p
除留余数法的关键是选好
p
p
p,使得每个关键字通过该函数转换后等概率的映射在散列空间上的任一地址,从而尽可能减少冲突的可能性
这种方法最简单且最常用。
3、数字分析法
设关键字是 r r r 进制数(十进制数),而 r r r 个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等;而在某些位上分布不均匀,只有某几种数码经常出现,此时应选取数码分布较为均匀的若干位作为散列地址。
这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。
4、平方取中法
顾名思义,这种方法取关键字的平方值的中间几位作为散列地址。具体取多少位要视实际情况而定。
这种方法得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀,适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数。
处理冲突的方法
应该注意到设计出来的散列函数可能会有冲突情况发生。为此我们必须要考虑在发生冲突是应该如何处理,即为产生冲突的关键字寻找下一个空的 H a s h Hash Hash 地址。
用 H i H_i Hi 表示处理冲突中第 i i i 次探测得到的散列地址。假设处理冲突得到的散列地址 H 1 H_1 H1 仍发生冲突,只得继续求下一个地址 H 2 H_2 H2,以此类推,直到 H k H_k Hk 不发生冲突为止,则 H k H_k Hk 为关键字在表中的地址。
1、开放定址法
可存放新表项的空闲地址既向它的同义词表项开放,又向它非同义词表项开放。其数学递推公式为
H
i
=
(
H
(
k
e
y
)
+
d
i
)
%
m
H_i=(H(key)+d_i)\%m
Hi=(H(key)+di)%m
式中,
H
(
k
e
y
)
H(key)
H(key)为散列函数,
i
=
0
,
1
,
2
,
.
.
.
,
k
(
k
⩽
m
−
1
)
i=0,1,2,...,k(k\leqslant m-1)
i=0,1,2,...,k(k⩽m−1);
m
m
m 表示散列表表长;
d
i
d_i
di 为增量序列。
在开放定址的情况下,不能随便的物理删除表中已有的元素,因为若删除元素,则会截断其他具有相同散列地址的元素的查找地址。因此,删除一个元素时,可给它做一个删除标记,进行逻辑删除。但这样做的副作用是:执行多次删除后,表面上看起来散列表很满,实际上有许多位置未利用,因此需要定期维护散列表。
取定某一增量序列 d i d_i di 后,对应的处理方法是确定的。通常有以下4种取定增量 d i d_i di 的方法:
1)线性探测法
当
d
i
=
0
,
1
,
2
,
.
.
.
,
m
−
1
d_i=0,1,2,...,m-1
di=0,1,2,...,m−1时,层位线性探测法。这种方法的特点是:冲突发生时,顺序查看表中下一个单位(探测到表尾
m
−
1
m-1
m−1 时,下一个探测地址是表首地址0)直到找到一个空闲单元(当表未填满时一定能找到一个空闲单元)或查遍全表。
线性探测法可能使第 i i i 个散列地址的同义词存入第 i + 1 i+1 i+1 个散列地址,这样本应存入第 i + 1 i+1 i+1 个散列地址的元素就争夺第 i + 2 i+2 i+2 个散列地址的元素的地址 . . . . . ..... ..... 从而造成大量元素在相邻的散列地址上"聚集"(或堆积)起来,大大降低了查找效率。
2)平方探测法
当
d
i
=
0
2
,
1
2
,
−
1
2
,
2
2
,
−
2
2
,
.
.
.
,
k
2
,
−
k
2
d_i=0^2,1^2,-1^2,2^2,-2^2,...,k^2,-k^2
di=02,12,−12,22,−22,...,k2,−k2时,称为平方探测法,其中
k
⩽
m
/
2
k\leqslant m/2
k⩽m/2,散列表长度m必须是一个可以表达成
4
k
+
3
4k+3
4k+3 的素数,又称二次探测法
平方探测法是一种处理冲突较好的方法,可以避免出现"堆积"问题,缺点是不能探测到散列表的所有单元,但至少能探测到一半的单元
3)再散列法
当
d
i
=
H
a
s
h
2
(
k
e
y
)
d_i=Hash_2(key)
di=Hash2(key)时,称为再散列法,又称双散列法。需要使用两个散列函数,当通过第一个散列函数
H
(
k
e
y
)
H(key)
H(key) 得到的地址发生冲突时,则利用第二个散列函数
H
a
s
h
2
(
k
e
y
)
Hash_2(key)
Hash2(key) 计算该关键字的地址增量。它的具体散列函数形式如下:
H
i
=
(
H
(
k
e
y
)
+
i
×
H
a
s
h
2
(
k
e
y
)
)
%
m
H_i=(H(key)+i\times Hash_2(key))\%m
Hi=(H(key)+i×Hash2(key))%m
初始探测位置 H 0 = H ( k e y ) % m H_0=H(key)\%m H0=H(key)%m。 i i i 是冲突的次数,初始为 0 0 0 。在再散列法中,最多经过 m − 1 m-1 m−1 次探测就会遍历表中所有位置,回到 H 0 H_0 H0 位置。
4)伪随机序列法。当 d i = di= di= 伪随机数序列时,称为伪随机序列法。
2、拉链法(链接法)
显然对于不同的关键字可能会通过散列函数映射到同一地址,我们可以把所有同义词存储在一个线性链表中,这个线性链表由其散列地址唯一标识
假设散列地址 i i i 为同义词链表的头指针存放在散列表的第 i i i 个单元中,因而查找、插入和删除操作主要在同义词链中进行。拉链法适用于经常插入和删除的情况。
例如,关键字序列为 {19,14,23,01,68,20,84,27,55,11,10,79} ,散列函数 H ( k e y ) = k e y % 13 H(key)=key\%13 H(key)=key%13,用拉链法处理冲突,建立的表如下所示:
散列查找及性能分析
散列表的查找过程基本与构造散列列表的过程一致。对于一个给定的关键字key,根据散列函数可以计算出其散列地址,执行步骤如下:
初始化:
A
d
d
r
=
H
a
s
h
(
k
e
y
)
Addr=Hash(key)
Addr=Hash(key)
1)检测查找表中地址为
A
d
d
r
Addr
Addr 的位置上是否有记录,若无记录,返回查找失败;若有记录,比较它与
k
e
y
key
key的值,若相等,则返回查找成功标志,否则执行步骤2。
2)用给定的处理冲突方法计算"下一个散列地址",并把
A
d
d
r
Addr
Addr 置为此地址,转入步骤1。
例如:关键字序列 {19,14,23,01,68,20,84,27,55,11,10,79} 按散列函数
H
(
k
e
y
)
=
k
e
y
%
13
H(key)=key\%13
H(key)=key%13和线性探测处理冲突构造所得的散列表 L 如下所示
给定值 84 的查找过程为:首先求得散列地址
H
(
84
)
=
6
H(84)=6
H(84)=6,因
L
[
6
]
L[6]
L[6] 不空且
L
[
6
]
≠
84
L[6] \neq84
L[6]=84,则找第一次冲突处理后的地址
H
1
=
(
6
+
1
)
%
16
=
7
H_1=(6+1)\%16=7
H1=(6+1)%16=7,而
L
[
7
]
L[7]
L[7] 不空且
L
[
7
]
≠
84
L[7] \neq84
L[7]=84,则找第二次冲突处理后的地址
H
2
=
(
6
+
2
)
%
16
=
8
H_2=(6+2)\%16=8
H2=(6+2)%16=8,而
L
[
8
]
L[8]
L[8] 不空且
L
[
8
]
=
84
L[8] =84
L[8]=84,查找成功,返回记录在表中的序号 8。
给定值 38 的查找过程为:先求散列地址
H
(
38
)
=
12
H(38)=12
H(38)=12,而
L
[
12
]
L[12]
L[12] 不空且
L
[
12
]
≠
38
L[12] \neq38
L[12]=38,则找第一次冲突处理后的地址
H
1
=
(
12
+
1
)
%
16
=
13
H_1=(12+1)\%16=13
H1=(12+1)%16=13,由于
L
[
13
]
L[13]
L[13] 是空记录,所以表中不存在关键字为38的记录
查找各关键字的比较次数如下所示
平均查找长度 ASL 为:
A
S
L
=
(
1
×
6
+
2
+
3
×
3
+
4
+
9
)
×
1
12
=
2.5
ASL=(1\times6+2+3\times3+4+9)\times\frac{1}{12}=2.5
ASL=(1×6+2+3×3+4+9)×121=2.5
散列表的查找效率取决于三个因素:散列函数、处理冲突的方法和装填因子
装填因子:定义为一个表的装满程度。即
α = 表 中 记 录 数 n 散 列 表 长 度 m \alpha=\frac{表中记录数\ n}{散列表长度\ m} α=散列表长度 m表中记录数 n
散列表的平均查找长度依赖于散列表的装填因子 α \alpha α ,不直接依赖于 n 和 m 。直观的看, α \alpha α 越大,表示装填的记录越满,发生的冲突的可能性越大,反之发生冲突的可能性越小。