文章目录
- 第七章 查找
- 一、查找
- (一)基本概念
- (二)对查找表的常见操作
- (三)查找算法的评价指标
- (四)总结
- 二、顺序查找
- (一)顺序查找算法思想
- (二)顺序查找的实现
- (三)查找效率分析
- (四)顺序查找的优化(对有序表)
- ※.用查找判定树分析ASL
- (五)顺序查找的优化(被查概率不相等)
- (六)总结
- 三、折半查找
- (一)折半查找的算法思想
- 1.一个查找成功的例子
- 2.一个查找失败的例子
- (二)折半查找的实现
- (三)查找效率分析
- (四)折半查找判定树的构造
- 1.练习
- 2.结论
- (五)总结
- (六)拓展思考
- 1.折半查找是否一定更快
- 2.mid的取法问题
- 四、分块查找
- (一)分块查找的算法思想
- 1.查找成功的例子
- 2.查找失败的例子
- 3.结论
- 4.用折半查找查索引
- (1)情形一:索引表中包含该关键字
- (2)情形二:索引表中不包含该关键字,但能查找成功
- (3)情形三:查找失败
- (二)查找效率分析(ASL)
- 1.采用顺序查找
- 2.采用折半查找
- 3.特殊情形
- (三)总结
- (四)拓展思考
- 五、B树
- (一)回顾:二叉查找树(BST)
- (二)5叉查找树
- 1.查找成功的例子
- 2.查找失败的例子
- (三)如何保证查找效率
- (四)B树
- (五)B树的高度
- 1.最小高度
- 2.最大高度
- 3.结论
- (六)总结
- 六、B树的插入和删除
- (一)B树的插入
- ※.概括
- (二)B树的删除
- 1.引言
- 2.对终端节点的删除
- (1)兄弟够借
- (2)兄弟不够借
- (三)总结
- 七、B+树
- (一)对比:分块查找
- (二)B+树
- (三)B+树的查找
- (四)B+树 VS B树
- (五)总结
- 八、散列查找
- (一)散列查找概念(Hash Table)
- (二)处理冲突的方法——拉链法
- 拉链法的小优化
- (三)如何进行散列查找
- (四)常见的散列函数
- 1.除留余数法
- 2.直接定址法
- 3.数字分析法
- 4.平方取中法
- (五)处理冲突的方法——开放定址法
- 1.线性探测法
- (1)线性探测法
- (2)查找操作
- (3)删除操作
- (4)查找效率分析(ASL)
- 2.平方探测法
- 3.伪随机序列法
- (六)处理冲突的方法——再散列法
- (七)总结
第七章 查找
一、查找
- 基本概念
- 查找算法的效率评价
(一)基本概念
查找——在数据集合中寻找满足某种条件的数据元素的过程称为查找
查找表(查找结构)——用于查找的数据集合称为查找表,它由同一类型的数据元素(或记录)组成
关键字——数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的。
例如,对学生信息的查找:
查找表——学生成绩信息(线性结构,可顺序可链式存储)
数据元素(记录)——每个学生的信息
关键字——学号。(学号可以唯一标识,而姓名不能唯一标识)
例如,微信用户之间的关系图:
查找表——微信用户数据集(图结构)
数据元素(记录)——每个用户的信息
关键字——微信号。(而微信名、昵称不能唯一标识)
查找表并不是一个新的数据结构,只是你要查找的数据元素的一个集合而已。
(二)对查找表的常见操作
①查找符合条件的数据元素(单纯的查找,不会对数据元素进行修改)
②插入、删除某个数据元素(会导致查找表中的数据有所改变)
如果某查找表只需要进行①操作,即只需要进行查的操作,这种查找表就叫做静态查找表。仅关心查找速度即可。
如果也要进行操作②,即需要查找,也需要插入、删除,这种查找表叫做动态查找表。除了查找速度,也要关心插/删操作是否方便实现。(就涉及到要用什么数据结构,用什么存储结构来实现)
(三)查找算法的评价指标
查找长度——在查找运算中,需要对比关键字的次数称为查找长度
平均查找长度(ASL,Average Search Length)——所有查找过程中进行关键字的比较次数的平均值
通常认为查找任何一个元素的概率都相同。(除非题目特殊说明了)
之前我们学习二叉排序树的时候,就计算过它的ASL。
对二叉排序树,查找成功的平均查找长度、查找失败的平均查找长度。
评价一个查找算法的效率时,通常考虑查找成功/查找失败两种情况的ASL。
ASL的数量级反映了查找算法时间复杂度。
(四)总结
查找
- 基本概念
- 查找:找到符合条件的数据元素
- 查找表
- 由同一类型的数据元素组成
- 静态查找表:只需要查找操作
- 动态查找表:除了查找,还需要增/删数据元素
- 关键字:唯一标识数据元素的数据项
- 查找算法的效率评价
- 平均查找长度ASL
- 通常考虑查找成功、查找失败两种情况下的ASL
二、顺序查找
- 算法思想
- 算法实现
- 算法优化
(一)顺序查找算法思想
顺序查找,又叫“线性查找”,通常用于线性表。
算法思想:从头到尾挨个找(或者反过来也可以)。
(二)顺序查找的实现
typedef struct { //查找表的数据结构(顺序表)
ElemType *elem; //动态数组基址
int TableLen; //表的长度
}SSTable;
//顺序查找
int Search_Seq(SSTable ST, ElemType key) {
int i;
for(i=0; i<ST.TableLen && ST.elem[i]!=key; ++i);
//查找成功,则返回元素下标;查找失败,则返回-1
return i==ST.TableLen ? -1 : i;
}
课本当中也给了另外一种实现方法:
顺序查找的实现(哨兵)
typedef struct { //查找表的数据结构(顺序表)
ElemType *elem; //动态数组基址
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; //查找成功,则返回元素下标,查找失败,则返回0
}
哨兵的思想就是,将数组的0号位空出来,查找表的数据从下标1处开始存。并将0号位置存入要查找的关键字。然后从后往前查找,若查找到“哨兵”本身才才能够判定相等(i为0),则说明在查找表中没有查找到。
这个算法的好处是不需要考虑i会越界的问题。因为即使查找失败,在查找到哨兵的位置处也会返回。
(三)查找效率分析
就拿带“哨兵”的来考虑吧。
共有n个数据,每个数据的查找概率相同,即为1/n。
到第i个数据处查找成功,即为i*(1/n)
,再累加和,即得查找成功下的ASL。
若查找失败,则需判断n+1次,即得查找失败时的ASL。
A
S
L
成
功
=
1
+
2
+
3
+
.
.
.
+
n
n
=
n
+
1
2
A
S
L
失
败
=
n
+
1
ASL_{成功}=\frac{1+2+3+...+n}{n}=\frac{n+1}{2}\\ ASL_{失败}=n+1
ASL成功=n1+2+3+...+n=2n+1ASL失败=n+1
无论查找成功还是查找失败,查找时间复杂度都是O(n)。
(四)顺序查找的优化(对有序表)
对于查找表中元素是有序存放的情况。(递增/递减)
例如:7 13 19 29 37 43
我们要查找的目标为:21
若从前往后查找,那么,当我们访问到29
时,就已经说明查找失败,就不必继续向后检查了。
对于这样一棵“查找判定树”来说,查找失败的情况有n+1种,由于概率相同,则每种情况的概率是1/(n+1)
若访问到第一个数据时就能够判定查找失败(即目标数据小于7),则为1*(1/(n+1))
访问到第i个数据时就判定查找失败,则为i*(1/(n+1))
。求累加和,即得查找失败的ASL。
A
S
L
失
败
=
1
+
2
+
3
+
.
.
.
+
n
+
n
n
+
1
=
n
2
+
n
n
+
1
ASL_{失败}=\frac{1+2+3+...+n+n}{n+1}=\frac{n}{2}+\frac{n}{n+1}
ASL失败=n+11+2+3+...+n+n=2n+n+1n
※.用查找判定树分析ASL
如上图所示,即为查找判定树。
其中,有n+1个“失败结点”,有n个“成功结点”。
- 一个成功结点的查找长度 = 自身所在层数
- 一个失败节点的查找长度 = 其父节点所在层数
默认情况下,各种失败情况或成功情况都等概率发生。
(五)顺序查找的优化(被查概率不相等)
例如:7 13 19 29 37 43
其中,被查概率:
7:15%
13:5%
19:10%
29:40%
37:28%
43:2%
如果我们仍然按照之前的方式从前往后查找,则可以得出
A
S
L
成
功
=
1
∗
0.15
+
2
∗
0.05
+
3
∗
0.1
+
4
∗
0.4
+
5
∗
0.28
+
6
∗
0.02
=
3.67
ASL_{成功}=1*0.15+2*0.05+3*0.1+4*0.4+5*0.28+6*0.02=3.67
ASL成功=1∗0.15+2∗0.05+3∗0.1+4∗0.4+5∗0.28+6∗0.02=3.67
不难看出,其实我们可以把被查概率大的放在靠前位置:
即:29 37 7 19 13 43
这样可以使得查找成功时的平均查找长度缩短
A
S
L
成
功
=
1
∗
0.4
+
2
∗
0.28
+
3
∗
0.15
+
4
∗
0.1
+
5
∗
0.05
+
6
∗
0.02
=
2.18
ASL_{成功}=1*0.4+2*0.28+3*0.15+4*0.1+5*0.05+6*0.02=2.18
ASL成功=1∗0.4+2∗0.28+3∗0.15+4∗0.1+5∗0.05+6∗0.02=2.18
(六)总结
顺序查找
- 算法实现
- 从头到尾(或从尾到头)挨个找
- 适用于顺序表、链表,表中元素有序无序都可以
- 可在0号位置存”哨兵“,从尾到头挨个查找。好处是,循环时无需判断下标是否越界
- 优化
- 若表中元素有序
- 当前关键字大于(或小于)目标关键字时,查找失败
- 优点:查找失败时ASL更小
- 查找判定树
- 成功结点的关键字对比次数 = 结点所在层数
- 失败结点的关键字对比次数 = 其父节点所在层数
- 若各个关键字被查概率不同
- 可按被查概率降序排列
- 优点:查找成功时ASL更小
- 若表中元素有序
- 时间复杂度:O(n)
三、折半查找
- 算法思想
- 算法实现
- 查找判定树
- 折半查找效率
(一)折半查找的算法思想
折半查找,又称“二分查找”,仅适用于有序的顺序表。
1.一个查找成功的例子
有序,说的是其中元素按递增/递减的次序存放。顺序表,说的是按照带下标的数组的方式存储的。
首先,我们定义low和high。
low指向第一个元素,high指向最后一个元素。
然后使最中间的元素为mid。(mid=(low+high) / 2)
由于我们的查找目标33>mid,所以只可能在右边区域。(即下标6~10范围)
因此,我们需要把low指向下标为6的位置。
注:只有在[low, high]之间才有可能找到目标关键字
同样地,我们取low和high的最中间元素mid,并判断目标元素在左边区域,还是右边区域。
由于我们的查找目标33<mid,所以只可能在左边区域。(即下标6~7范围)
因此,我们需要把high指向下标为7的位置。
同样地,我们取low和high的最中间元素mid,mid = (low+high) / 2 = (6+7)/2 = 6
,因此mid指向下标6的位置。
由于我们的查找目标33>mid,所以只可能在右边区域。(即下标7)
因此,我们需要把low指向下标为7的位置。
同样地,我们取mid = (low+high) / 2。因此mid指向下标为7的位置。
此时,我们的查找目标33==mid。查找成功。
2.一个查找失败的例子
首先,让low和high指向整个数组的头和尾部。
然后令mid = (low+high)/2 = 5。然后看目标元素在mid的左、右、相等?
由于12<mid,则只可能在左边区域。因此将high指向4。
令mid = (low+high) / 2 = 2。然后看目标元素在mid的左、右、相等?
由于12<mid,则只可能在左边区域。因此将high指向1。
令mid = (low+high) / 2 = 0。然后看目标元素在mid的左、右、相等?
由于12>mid,则只可能在右边区域。因此将low指向1。
令mid = 1。然后看目标元素在mid的左、右、相等?
由于12>mid,则只可能在右边区域。因此将low指向2。
此时,low>high,查找失败。
(二)折半查找的实现
typedef struct { //查找表的数据结构(顺序表)
ElemType *elem; //动态数组基址
int TableLen; //表的长度
}SSTable;
//折半查找
int Binary_Search(SSTable L, ElemType key) {
int low=0, high=L.TableLen-1, mid;
while(low<=high) {
mid = (low+high) / 2; //取中间位置
if(L.elem[mid]==key)
return mid; //查找成功则返回所在位置
else if(L.elem[mid]>key)
high = mid-1; //从前半部分继续查找
else
low = mid+1; //从后半部分继续查找
}
return -1; //查找失败,返回-1
}
注意,以上代码是基于我们的查找表内元素是升序排列的。如果是降序排列,则大于/小于判定后的操作要进行相应的更改。(原理是一样的)
到此,我们可以理解,为什么折半查找仅适用于有序的顺序表。
用顺序表,我们才可以直接根据下标找到中间位置mid。如果查找表是用链表,我们要找到中间元素,只能从头开始依次往后找。链表不具有随机存取的特性,而顺序表有。
(三)查找效率分析
对于查找算法的查找效率分析,我们都是基于查找判定树来看的。
接下来我们研究一下查找判定树该怎么画。
(四)折半查找判定树的构造
如果当前low和high之间有奇数个元素,则mid分隔后,左右两部分元素个数相等。
分隔后,左右两部分均为奇数个元素。
如果当前low和high之间有偶数个元素,则mid分隔后,左半部分比右半部分少一个元素。
并且分隔以后,左半部分是偶数个元素,右半部分是奇数个元素。
折半查找的判定树中,若mid = ⌊(low+high)/2⌋
,则对于任何一个结点,必有:
右子树结点数 - 左子树结点数 = 0或1
(也就是,左子树结点数不可能比右子树的多)
1.练习
若mid = ⌊(low+high)/2⌋
,画出含1个元素、2个元素、3个元素…16个元素的查找表对应的折半查找判定树,注:暂不考虑失败结点。(Key:右子树结点数 - 左子树结点数 = 0或1)
对于1个元素,就不说了。
对于2个元素的判定树,2号元素一定是右子树,如下。
这是由于,左子树结点数不可能比右子树结点数多。
- 对于3个元素的判定树
一定不可能是这个样子,因为右子树最多比左子树多1个结点。而此图,对于1来说,其右子树比左子树多2个结点了。
所以一定是如下所示
- 对于4个元素的判定树,只有可能是下图所示
- 中间的不再赘述。16个元素的判定树,如下所示:
2.结论
- 不难发现,折半查找的判定树一定是平衡二叉树。
(任何一个结点的左子树、右子树深度之差,都不会超过1)
- 另外,折半查找的判定树中,只有最下面一层是不满的。
因此,元素个数为n时,树高h=⌈log₂(n+1)⌉
。
(和完全二叉树的树高,计算方法是一样的)
- 而这个树高的数量级,就反映了折半查找算法的时间复杂度。
- 因此,折半查找的时间复杂度为
O(log₂n)
- 因此,折半查找的时间复杂度为
- 判定树结点关键字:
左 < 中 < 右
,满足二叉排序树的定义。 - 失败结点:n+1个(等于成功结点的空链域数量)
(五)总结
折半查找
- 适用范围:只适用于有序的顺序表
- 算法思想
- 在[low, high]之间找目标关键字,每次检查
mid = (low+high)/2
- 根据mid所指元素与目标关键字的大小调整low或high,不断缩小low和high的范围
- 若
low > high
则查找失败
- 在[low, high]之间找目标关键字,每次检查
- 判定树
- 构造
- 由mid所指元素将原有元素分割到左右子树中
- Key:右子树结点数 - 左子树结点数 = 0或1
- 特性
- 折半查找的判定树是平衡化的二叉排序树(左<中<右)
- 折半查找判定树,只有最下面一层是不满的
- 若查找表有n个关键字,则失败结点有n+1个
- 树高h = ⌈log₂(n+1)⌉ (不包含失败结点)
- 构造
- 时间复杂度:O(log₂n)
(六)拓展思考
1.折半查找是否一定更快
折半查找的时间复杂度 = O(log₂n)
顺序查找的时间复杂度 = O(n)
那么,折半查找的速度一定比顺序查找更快?
显然不是,如下例子
所以我们只能说,大部分情况下,折半查找比顺序查找更快。但是不能说,任何情况下,折半查找都一定比顺序查找更快。
2.mid的取法问题
另外,刚才我们构造判定树时,mid = ⌊(low+high)/2⌋,是向下取整。
如果我们让mid = ⌈(low+high)/2⌉
,向上取整,那么判定树是什么样的?
稍加思考,不难想到,按照这样取mid的话。
- 如果当前low和high之间有偶数个元素,则mid分隔后,左半部分比右半部分多一个元素。
折半查找的判定树中,若mid = ⌈(low+high)/2
,则对于任何一个结点,必有:
左子树结点数 - 右子树结点数 = 0或1
四、分块查找
- 算法思想
- 查找效率分析(ASL)
分块查找一般是在选择题中进行考察,很少考察代码的书写。所以重点是理解思想,和手算模拟。
(一)分块查找的算法思想
对于这个数组,看上去是没什么规律的,其实,如果仔细观察,可以发现。
如果把这些元素分块,会发现,第一块都是小于等于10的;第二块都是小于等于20的;……。
这个数组看上去是乱序的,但是当我们把它分成一小块一小块的小区间后,会发现,各个区间内其实是有特点的,区间与区间之间相比较来看是有序的。
于是我们可以给这个查找表,建立上一级的索引。
“索引表”中保存每个分块的最大关键字和分块的存储区间。
特点:块内无序、块间有序。
将每一块看作一个整体的话,会发现块与块之间呈一个递增/递减的顺序。
//索引表
typedef struct {
ElemType maxValue;
int low, high;
}Index;
//顺序表存储实际元素
ElemType List[100];
1.查找成功的例子
如果我们要查找的元素为:22
。
那么,先在索引表中查找:
10,小于22;
20,小于22;
30,大于等于22。
因此,要查找的22一定是在"30"这个分块内。
接下来,就从“30”这个块表示的区间,即数组的[6, 8]
内寻找。
27,不对;
22,对。
查找成功。
2.查找失败的例子
若查找目标为:29
。
同样地,29应该是在“30”这个块内。因此,我们从查找表的下标为[6, 8]
范围内进行查找。
遍历完下标为6、7、8的元素后,均未匹配成功。此时遍历到9号元素,而下标为9,超出了“30”这个块的范围,即查找失败。
3.结论
分块查找,又称索引顺序查找,算法过程如下:
①在索引表中确定待查记录所属的分块(可顺序、可折半)
②在块内顺序查找(因为每个分块内的元素都是乱序的,因此只能顺序查找)
4.用折半查找查索引
(1)情形一:索引表中包含该关键字
若查找目标为索引表中包含的关键字,则经过若干次mid即可找到。(即折半查找的“查找成功”的情形)
(2)情形二:索引表中不包含该关键字,但能查找成功
若查找目标为19,则过程如下:
- 首先,low和high各指向头(0号)、尾部(4号),mid指向中间(2号)
- 由于19<mid,则令high = mid-1,high指向1号。此时mid指向0号。
- 由于19>mid,则令low = mid + 1,low指向1号。此时mid指向1号。
- 由于19<mid,则令high = mid - 1,high指向0号。
- 此时
low > high
。若按照折半查找的规则,实际上属于是“查找失败”了。但实际上对于索引表来说,我们的查找目标其实是在low
指向的块当中。
因此,若索引表中不包含目标关键字,则折半查找索引表最终停在low > high
,要在low所指分块内查找。
原因:
最终low左边一定小于目标关键字,high右边一定大于目标关键字。(因为key>mid,则low=mid+1;key<mid,则high=mid-1)而分块存储的索引表中保存的是各个分块的最大关键字。
(3)情形三:查找失败
我们省略中间具体过程。
最终会停在上图所示位置。
和上面一模一样:若索引表中不包含目标关键字,则折半查找索引表最终停在low > high
,要在low所指分块内查找。
但此时low所指向的,已经超出了索引表。所以这种情况就是查找失败。
(二)查找效率分析(ASL)
1.采用顺序查找
如果对索引表采用的是顺序查找。
则查找到7这个元素,需要先在索引表中对比1次,再在块内对比1次,共2次。
查找到10这个元素,需要先在索引表中对比1次,再在块内对比2次,共3次。以此类推。
若索引表采用顺序查找,则
7:2次;
10:3次;
13:3次……
总之,最后可以算出平均查找长度ASL。
2.采用折半查找
如果对索引表采用的是折半查找。
则查找到30这个元素,需要在索引表中对比1次,再在块内对比3次,共4次。没问题。
但是,对于查找到27这个元素,需要在索引表中对比1次,再在块内对比1次,共2次。是这样吗?
不是的。
对于30这个元素,由于它本身是包含在索引表中的,所以进行一次对比即可查找到。
但是对于27这个元素,由于它不是索引表的关键字,所以并不是对比一次就完成了的。而是需要完成若干次,直到low>high
时,才算确定了27所在的分块!
27具体需要对比几次,此处不再分析,自己按照折半查找的过程,考虑一下即可。
不难发现,对于索引表采用折半查找的例子,这么多元素,如果一个一个计算它们的对比次数,是过于繁杂的。因此考试一般不会考这样的情况。
一般只会考一下索引表为顺序查找下的ASL。
而且,以上均为查找成功的情况。
而查找失败的情况呢?
因为对于每个分块内,元素都是无序排放的,所以我们不能像之前的折半查找那样明确的表明,就是在某个指定的区间内,是查找失败的情况。
因此对于分块查找,查找失败的情况,会更加复杂。因此考试一般不考查找失败的情况,可以跳过这个问题。
3.特殊情形
刚才我们举的两个例子都是比较杂乱的。
此处,若长度为n的查找表被均匀地分为b块,每块s个元素。
设索引查找和块内查找的平均查找长度分别为LI、Ls,则分块查找的平均查找长度为
A
S
L
=
L
I
+
L
S
ASL = L_I + L_S
ASL=LI+LS
用顺序查找查索引表,则
L
I
=
1
+
2
+
.
.
.
+
b
b
=
b
+
1
2
,
L
S
=
1
+
2
+
.
.
.
+
s
s
=
s
+
1
2
L_I=\frac{1+2+...+b}{b}=\frac{b+1}{2},L_S=\frac{1+2+...+s}{s}=\frac{s+1}{2}
LI=b1+2+...+b=2b+1,LS=s1+2+...+s=2s+1
则
A
S
L
=
b
+
1
2
+
s
+
1
2
=
s
2
+
2
s
+
n
2
s
,
当
s
=
n
时
,
A
S
L
最
小
=
n
+
1
ASL=\frac{b+1}{2}+\frac{s+1}{2}=\frac{s^2+2s+n}{2s},当s=\sqrt n时,ASL_{最小}=\sqrt n+1
ASL=2b+1+2s+1=2ss2+2s+n,当s=n时,ASL最小=n+1
这个ASL相加之和为什么等于这个结果呢?因为上面我们说了,长度为n的查找表被均匀地分为b块,每块s个元素。所以n = s * b
,代入即可。
而ASL的最小值是什么?其实就是一个求极值的问题。求ASL的导数,令导数等于0即可。
由此,可以看出。
若n = 10000,也就是查找表中共有10000个元素。
则ASL最小值 = 101,即最优的分块策略为:将n个元素分成100块,每块有100个元素。
即对于这10000个元素来说,平均来看对每个元素只需要101次对比即可找到。
那相比于单纯的顺序查找来说,我们这样的分块查找,效率是提升了很多的。
(对于10000个元素,若采用顺序查找,则ASL = 5000)
用折半查找查索引表,则
L
I
=
⌈
l
o
g
2
(
b
+
1
)
⌉
,
L
S
=
1
+
2
+
.
.
.
+
s
s
=
s
+
1
2
L_I=\lceil log_2(b+1)\rceil,L_S=\frac{1+2+...+s}{s}=\frac{s+1}{2}
LI=⌈log2(b+1)⌉,LS=s1+2+...+s=2s+1
则
A
S
L
=
⌈
l
o
g
2
(
b
+
1
)
⌉
+
s
+
1
2
ASL=\lceil log_2(b+1)\rceil + \frac{s+1}{2}
ASL=⌈log2(b+1)⌉+2s+1
折半查找这个地方有个印象就可以,不是特别重要,也不再深入探讨。
(三)总结
分块查找
- 又称“索引顺序查找”,数据分块存储,块内无序、块间有序
- 算法思想
- 索引表中记录每个分块的最大关键字、分块的区间
- 先查索引表(顺序或折半)、再对分块内进行顺序查找
- ASL
- ASL = 查索引表的平均查找长度 + 查分块的平均查找长度
- 设n个记录,均匀分为b块,每块s个记录
- (※)顺序查找索引表,ASL = …;当s = 根号n时,ASL最小
- 折半查找索引表
- 易错点
- 对索引表进行折半查找时,若索引表中不包含目标关键字,则折半查找最终停在
low > high
,要在high所指分块中查找 - 而不是简单的认为,若大于/小于mid,就对比一次即可。而是要对比若干次,直到达到
low > high
才行,然后再看到底对比了多少次。
- 对索引表进行折半查找时,若索引表中不包含目标关键字,则折半查找最终停在
(四)拓展思考
若查找表是“动态查找表”,有没有更好的实现方式?——链式存储
对于之前的例子,我们的数据都是用顺序存储的方式进行存放的。
但是如果查找表中经常需要进行元素的增加、删除。
比如我要添加一个8
,那么我肯定不是在查找表的最后一位插入的。
如果我们要插入8,是要在第一个分块内插入8。
那么这会导致什么?
会导致,我们需要把其之后的全部元素,全部往后移一位。
所以,如果我们使用数组的方式来实现查找表的话,那么如果你是一个动态查找表,即需要进行插入、删除操作,那么你要维护块间有序的特性,就要付出很大的代价。
那么有没有更好的实现方式?
其实我们可以用链式存储。
索引表中的元素,链式地存储起来。
对应的各个分块中的数据元素,也链式地存储起来。
那么这样一来,你若要添加8这个元素,只需要在第一个分块的链表尾部插入8即可,如下
删除一个元素也是类似的。
甚至,如果你感觉某一个分块内的元素太多了,你也可以对这个分块的链表进行拆分。总之,可以根据现实遇到的需求,进行灵活的决定。(数据结构这门课不可能给你把所有能够遇到的情况全部罗列出来,它只是点明一种逻辑思想)
五、B树
主要学习B树的性质,以及手算方法。
一般不会考B树的代码书写。
(一)回顾:二叉查找树(BST)
二叉查找树也叫二叉排序树。
//二叉排序树结点
typedef struct BSTNode {
int key;
struct BSTNode *lchild, *rchild;
}BSTNode, *BSTree;
其意义在于,把搜索的范围不断的分为两个部分。(小于的,和大于的)
例如,我们原本的搜索范围为:
(-∞, +∞)
经过了根结点29后,搜索范围被划分为了:(-∞, 29),(29, +∞),到这两个范围之一中进行进一步查找。
问题来了:能不能把二叉查找树,拓展为m叉查找树?
(二)5叉查找树
//5叉排序树的结点定义
struct Node {
ElemType keys[4]; //最多4个关键字
struct Node *child[5]; //最多5个孩子
int num; //结点中有几个关键字
}
和二叉查找树其实是类似的。
只不过二叉查找树的每个结点将范围分割成了两个部分。
而多叉查找树,对每个结点,其将查找范围可能分为了多个部分。
例如:
根结点22将范围划分为了两个部分。
而,根结点的右孩子,包含两个关键字
36
和45
,因此,在该结点处,查找范围被划分为了:(22, 36),(36, 45),(45, +∞)。
也就是可能不只是将范围一分为二了,有可能多加了几个分割点。
因此对于5叉查找树,每个节点最少有1个关键字,2个分叉;最多4个关键字,5个分叉。
此外,结点中的关键字都是有序的,要么递增要么递减。
同时,失败结点的区间范围都是很明确的,因为结点中关键字是有序的。
如,最下面一层,从左到右的第三个节点,即包含了
13、15
的那个结点,其15右侧指向的失败结点的范围,就应该是(15, 22)
。如,最下面一层,从左到右的第四个结点,即包含了
40、42
的那个结点,其40左侧指向的失败结点的范围,就应该是(36, 40)
。
1.查找成功的例子
对于上面这个5叉查找树,若查找目标为9
,查找过程如下:
-
9 < 22,因此在22的左子树当中。
-
在22的左孩子结点处,依次扫描其中的关键字;
- 5<9,跳过,看下一个;
- 11>9。因此,9肯定在11的左子树中。
-
在11的左孩子结点处,依次扫描其中的关键字:
- 6<9,跳过,看下一个;
- 8<9,跳过,看下一个;
- 9 == 9。
-
查找成功。
此处,对于每个结点中关键字的遍历,我们使用的是顺序查找的方式。
但是,由于每个结点中的关键字都是有序的,因此,若必要的话,我们也可以使用折半查找。
2.查找失败的例子
对于上面这个5叉查找树,若查找目标为41
,查找过程如下:
- 41>22,因此在22的右子树中。
- 45>22,因此在45的左子树中。
- 42>41,因此在42的左子树中,而42的左子树是一个失败结点。
- 查找失败。
(三)如何保证查找效率
对于5叉查找树,我们只是规定了,每个结点最多只能有四个关键字、五个分叉。
如果我们在每个结点中,只保存一个关键字、两个分叉的话,那这个5叉查找树就退化成了二叉查找树。
那么,在这种情况下,由于每个结点中的关键字数量变少,所以树会变高(关键字总数相同的情况下,每个结点存放的关键字个数越少,树肯定就会越高),就要查更多层结点,效率低。
策略:m叉查找树中,规定除了根结点外,任何结点至少有⌈m/2⌉
个分叉,即至少含有⌈m/2⌉ - 1
个关键字。
例如,对于5叉排序树,规定除了根结点外,任何结点都至少有3个分叉,2个关键字。
这样就可以保证每个结点中,关键字个数不会太少,树的层数不会太高,查找效率也能够得到保证。
为什么是:除了根结点外?
实际上做不到。假设整棵树只有一个数据元素,那么根结点肯定只能有1个关键字、2个分叉。
接下来再看这样一棵树:
这棵树满足了每个结点关键字、分叉数量合理的特性。
但是你觉得这棵树优秀吗?显然不是。
结合我们在学习二叉查找树时的知识,其实不难想到,这棵树的问题在于,它是不平衡的。
一个结点,它的各个子树高度相差很大,这就是所谓的不平衡。
不够平衡,树会很高,要查很多层结点。
对于二叉排序树,我们的解决策略是,让左右子树高度之差不超过1。
我们把同样地思想,迁移到这里。
我们可不可以规定,对多叉查找树,令其所有子树,高度之差都不超过1?
仔细想想,这个条件,在二叉查找树中实现起来是比较方便的,但是在多叉查找树中,其实是很麻烦的。
我们可以直接实现一个简单粗暴的策略,如下。
策略:m叉查找树中,规定对于任何一个结点,其所有子树的高度都要相同。
如果一棵多叉查找树,能够同时保证这两条策略,那么这其实就是一棵B树。
可见,这棵5叉查找树:
- 所有结点都至少有
⌈5 / 2⌉ = 3
个分叉- 所有结点高度都相同
同时这会导致所有失败结点都是出现在最下面同一层的。
(四)B树
B树,又称多路平衡查找树,B树中所有结点的孩子个数的最大值称为B树的阶,通常用m表示。一棵m阶B数或为空树,或为满足如下特性的m叉树:
1)树中每个结点至多有m棵子树,即至多含有m-1个关键字。
2)若根结点不是终端结点,则至少有两棵子树。
3)除根结点外的所有非叶节点至少有⌈m / 2⌉
棵子树,即至少含有⌈m / 2⌉ - 1
个关键字。
4)所有的叶节点都出现在同一层次上,并且不带信息(可以视为外部结点或类似折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)
5)所有非叶节点的结构如下:
其中,Ki(i = 1, 2, …, n)为结点的关键字,且满足K1 < K2 < … < Kn;Pi(i = 0, 1, …, n)为指向子树根结点的指针,且指针P{i-1}所指子树中所有结点的关键字均小于Ki,Pi所指子树中所有结点的关键字均大于Ki,n(⌈m / 2⌉ -1 ≤ n ≤ m - 1
)为结点中关键字的个数。
-
最下面一层含有实际数据的一层,叫做终端结点。
-
最下面一层失败结点,叫做叶子结点。
我们再把以上信息给归纳一下:
m阶B树的核心特性:
1)根结点的子树数∈[2, m],关键字数∈[1, m-1]。
其他结点的子树数∈[ ⌈m / 2⌉, m ];关键字数∈[ ⌈m / 2⌉ - 1, m - 1 ]。
2)对任一结点,其所有子树高度相同。
3)关键字的值:子树0 < 关键字1 < 子树1 < 关键字2 <子树2 < … (类比二叉查找树的 左<中<右)
(五)B树的高度
注:大部分学校算B树的高度不包括叶子结点(失败结点)。
问题:含n个关键字的m阶B树,最小高度、最大高度是多少?
1.最小高度
最小高度——让每个结点尽可能的满,有m-1个关键字,m个分叉,则有
每个结点均有(m-1)个关键字。
第一层(根结点)有1个结点;
第二层有m个结点;
第三层有m²个结点;
……
n ≤ ( m − 1 ) ( 1 + m + m 2 + . . . + m h − 1 ) = m h − 1 n≤(m-1)(1+m+m^2+...+m^{h-1})=m^h-1 n≤(m−1)(1+m+m2+...+mh−1)=mh−1
因此,
h
≥
l
o
g
m
(
n
+
1
)
h≥log_m(n+1)
h≥logm(n+1)
2.最大高度
最大高度——让各层的分叉尽可能的少,即根结点只有2个分叉,其他结点只有⌈m/2⌉个分叉。
各层结点至少有:第一层 1、第二层 2、第三层 2⌈m/2⌉ … 第h层 2(⌈m/2⌉)^{h-2}
第h+1层共有叶子结点(失败结点) 2(⌈m/2⌉)^{h-1} 个。
n个关键字的B树必有n+1个叶子结点,则
n
+
1
≥
2
(
⌈
m
/
2
⌉
)
h
−
1
n+1≥2(\lceil m/2\rceil)^{h-1}
n+1≥2(⌈m/2⌉)h−1
即
h
≤
l
o
g
⌈
m
/
2
⌉
n
+
1
2
+
1
h≤log_{\lceil m/2\rceil}\frac{n+1}{2} + 1
h≤log⌈m/2⌉2n+1+1
对于n个关键字的B树必有n+1个叶子结点的说明:
类似于二叉排序树的失败结点一样。
是由于,共有n个关键字,所以共将(-∞,+∞)这个区间分割成了n+1个部分,也就对应了n+1种失败的情况。
对于最大高度,以上是课本中给出的方法。
其实也可以从另外一个角度考虑:
上面那个思路,是从最下层叶子结点的个数入手。
下面这个思路,是从每层关键字的最少关键字个数并求和入手。
我们将每层的最少关键字进行求和,那么。
既然h层的m阶B数,关键字个数至少有这么多,换句话说,也就是如果我的关键字个数少于这个值的话,就不对,或者说就不是h层了。因此。
即
h
≤
l
o
g
k
n
+
1
2
+
1
=
l
o
g
⌈
m
/
2
⌉
n
+
1
2
+
1
h≤log_k\frac{n+1}{2} + 1=log_{\lceil m/2\rceil}\frac{n+1}{2} + 1
h≤logk2n+1+1=log⌈m/2⌉2n+1+1
3.结论
问题:含n个关键字的m阶B树,最小高度、最大高度是多少?
l
o
g
m
(
n
+
1
)
≤
h
≤
l
o
g
⌈
m
/
2
⌉
n
+
1
2
+
1
log_m(n+1)≤h≤log_{\lceil m/2\rceil}\frac{n+1}{2} + 1
logm(n+1)≤h≤log⌈m/2⌉2n+1+1
这个公式能记下来最好。
记不下来,能推出来也没问题。
(六)总结
- m阶B树的核心特性:
1)根结点的子树数∈[2, m],关键字数∈[1, m-1]。
其他结点的子树数∈[ ⌈m / 2⌉, m ];关键字数∈[ ⌈m / 2⌉ - 1, m - 1 ]。
即,尽可能“满”
2)对任一结点,其所有子树高度相同。
即,尽可能“平衡”。(实际上是绝对平衡)
3)关键字的值:子树0 < 关键字1 < 子树1 < 关键字2 <子树2 < … (类比二叉查找树的 左<中<右)
-
含n个关键字的m叉b树,
l o g m ( n + 1 ) ≤ h ≤ l o g ⌈ m / 2 ⌉ n + 1 2 + 1 log_m(n+1)≤h≤log_{\lceil m/2\rceil}\frac{n+1}{2} + 1 logm(n+1)≤h≤log⌈m/2⌉2n+1+1 -
B树怎么查找?只要理解了就会查找,此处不再赘述。
六、B树的插入和删除
上一章主要讲的是B树的查找。
这里主要是B树的插入和删除操作。
(一)B树的插入
我们尝试着从0开始,建立一个B树。
5阶B树——结点关键字个数⌈m/2⌉-1 ≤ n ≤ m-1
,即:2 ≤ n ≤ 4
(注:此处省略失败结点)
对于根结点,我们按次序插入了4个结点(此时一个结点的关键字个数已经到达了上限)。
若要再插入一个数据元素80
,则不能向根结点中继续插入
而是要分裂。
我们需要把当前这个结点,分裂成两个结点。
怎么分裂的?如下。
在插入key后,若导致原结点关键字数超过上限,则从中间位置(⌈m/2⌉)将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新结点中,中间位置(⌈m/2⌉)的结点插入原结点的父结点。
接下来,如果我们再插入一个90
这个元素。
新元素一定是插入到最底层“终端结点”,用“查找”来确定插入位置。
注意,我们的新元素一定的插入到最底层的终端结点中,所以如果90插入到了以下位置,就是错的:
插入90
之后,我们再插入99
,如图
接下来,如果我们要再插入88
这一元素。则要插入到80和90之间的位置。
那到这一步,又导致它的关键字的个数超出了它的上限。
所以和刚才一样,我们需要找到它的中间元素88
,把这个元素提到父节点当中,然后把左右两部分分别放到两个不同的结点当中,如下。
接下来,依次插入83
、87
。
此时,如果要继续插入一个70
。不难看出,70
需要插到60和80之间的位置。
到了这里,又出现了结点关键字个数超过上限的情形。又要进行分裂了。
那么我们需要把它最中间的元素80
提到父节点中,然后把当前这个结点拆分成左右两个部分。
那么就出现一个问题,80放到父节点中,应该放到父节点中的什么位置呢?
显然,我们把80
放到父节点中,依然需要保证父节点中的元素是有序排放的。所以80应该放在49和88之间。
如果一个关键字,因为分裂,需要把它提到父节点当中,那么我们应该把这个关键字放到它所处的结点的这条指针,指针的右边这个位置,方可保证其仍然保持B树的特性,如图。
同理,
93应该放到这个结点上方指针对应的右边的位置处,也就是88的右边。
现在,继续插入元素,遇到以下情形。
此时,我们需要把73
这个元素,提到它的父节点中,也就是提到该结点所连指针的右侧位置,也就是49和80之间。
此时,又会导致,父节点当中的关键字个数超出了上限。
那么我们需要把父节点继续往上分裂。
在插入key后,若导致原结点关键字数超过上限,则从中间位置(⌈m/2⌉)将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新结点中,中间位置(⌈m/2⌉)的结点插入原结点的父节点。若此时导致其父节点的关键字个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进而导致B树高度增1。
※.概括
核心要求:
①对m阶B树——除根结点外,结点关键字个数⌈m/2⌉ - 1 ≤ n ≤ m-1
②子树0 < 关键字1 < 子树1 < 关键字2 < 子树2 < …
即,每个结点中关键字个数一定要保证是B树规定的范围。
此外,插入后,一定要保持,对于每个关键字,它左边一定比它小,它右边一定比它大。
新元素一定是插入到最底层“终端结点”,用“查找”来确定插入位置
在插入key后,若导致原结点关键字超过上限,则从中间位置(⌈m/2⌉)将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新结点中,中间位置(⌈m/2⌉)的结点插入原结点的父节点。若此时导致其父结点的关键字个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进而导致B树高度增1。
(二)B树的删除
1.引言
如果要删除60
这个元素,那么很简单。直接删掉即可。而且删除后,这个结点还有3个关键字,符合B树的要求,是合法的。
若被删除关键字在终端节点,则直接删除该关键字(要注意结点关键字个数是否低于下限
⌈m/2⌉ - 1
)
如果要删除80
这个元素。删除之后,80这个位置就空了。
我们可以找80
的直接前驱或直接后继,来顶替80的这个位置。
比如我们用80
的直接前驱77
来顶替它。
找直接前驱的方法也很简单,就是当前关键字左侧指针所指子树中“最右下”的元素。
若被删除关键字在非终端节点,则用直接前驱或直接后继来替代被删除的关键字。
直接前驱:当前关键字左侧指针所指子树中“最右下”的元素。
此时,我们如果删除77
这个元素,并且,我们用77
的直接后继来顶替它的位置。
77
的直接后继怎么找呢?也很简单,就是当前关键字右侧指针所指子树中“最左下”的元素。
直接后继:当前关键字右侧指针所指子树中”最左下“的元素。
将直接前驱或直接后继顶替到要删除的终端节点位置处之后,再将其从原来位置处删除。
因此,这个操作也相当于把,对非终端结点的删除操作,转换成了对终端节点的删除操作。那么就是直接删除即可。
也就是说,对非终端结点关键字的删除,必然可以转化为对终端节点的删除操作。
因为“最左下”或者“最右下”,也就是直接前驱或者直接后继,它必然是最下面一层的结点,也就是终端节点。
因此,接下来我们重点探讨对终端结点的删除。
2.对终端节点的删除
以上我们探讨的,对终端节点的删除,都很简单,都是直接删除的,因为它们删除之后,也都符合B树的要求,即没有低于每个结点关键字个数的下限。
下面我们来看一个删除后,低于下限的情况。
比如我们删除38
这个元素。
删除38
之后,这个结点里面的关键字个数,已经低于了下限。
该怎么处理呢?
此时又要划分为多种情况进行处理:
(1)兄弟够借
若被删除关键字所在结点删除前的关键字个数低于下限,且与此结点右(或左)兄弟节点的关键字个数还很宽裕,则需要调整该结点右(或左)兄弟结点及其双亲节点(父父子换位法)。
向右兄弟借
当前结点删除后,关键字个数不够了,而其右兄弟中的结点个数还绰绰有余,那么我们可以把右兄弟中的70
贡献给它。
直接放到这个位置可以吗?显然是不对的。因为其父节点49的左指针,所指的,肯定都要比49小。
所以如果直接把其右兄弟中的结点直接挪过来,显然是不正确的,将会不满足B树要求的特性。
那么该怎么做呢?
我们可以先把49
这个元素给拉下来,然后让70
这个元素去顶替49
原来的位置。
这样,就可以保证B树原有的特性。
说白了,当右兄弟很宽裕时,用当前结点的后继、后继的后继来填补空缺。
在此例子中,也就是,用当前结点(
25
所在的这个结点)的后继49
,后继的后继70
,来填补空缺。
上面是借右兄弟的例子,现在再看一个借左兄弟的例子。
向左兄弟借
比如,我们要删除90
这个元素。删除之后,当前结点关键字个数不足。而其右兄弟本身就不宽裕,因此要向左兄弟借。
当左兄弟很宽裕时,用当前结点的前驱、前驱的前驱来填补空缺。
92
的前驱是88
,92
的前驱的前驱是87
。
需要做的操作是,将88插入到92的前面,然后用87顶替88的位置。
总之,其本质在于,要永远保证:子树0 < 关键字1 < 子树1 < 关键字2 < 子树2 < …
(2)兄弟不够借
若要删除49
,会发现删除后关键字个数不足,且其右兄弟也不够借。
那怎么办呢?我们的策略是,让这两个结点进行一个合并的操作。
兄弟不够借。
若被删除关键字所在节点删除前的关键字个数低于下限,且此时与该结点相邻的左、右兄弟结点的关键字个数均 =
⌈m/2⌉ - 1
,则将关键字删除后与左(或右)兄弟结点及双亲结点中的关键字进行合并。
也就是,49
删除后,再将,含有25
的那一结点、含有71 72
的那一结点,以及父节点中的关键字70
,这三者进行合并。
但是,此时又出现一个问题,由于我们把它的父节点中关键字拿了一个下来,所以导致了它的父节点中关键字个数又发生了不足。
而且该结点的兄弟节点也属于不够借的状态。
所以我们也需要进行合并的操作,即,将含有73
的那一结点、含有87 93
的那一结点,以及它俩在父节点内夹住的那个关键字82
,这三者进行合并。
而现在,根结点中没有任何关键字,所以我们可以把根结点删除,并成立新的根结点,结果如下图。
概括
兄弟不够借。
若被删除关键字所在节点删除前的关键字个数低于下限,且此时与该结点相邻的左、右兄弟结点的关键字个数均 =
⌈m/2⌉ - 1
,则将关键字删除后与左(或右)兄弟结点及双亲结点中的关键字进行合并。在合并过程中,双亲节点中的关键字个数会减1。若其双亲结点是根结点且关键字个数减少至0(根结点关键字个数为1时,有2棵子树),则直接将根结点删除,合并后的新结点称为根;若双亲结点不是根结点,且关键字个数减少到
⌈m/2⌉ - 2
,则又要与它自己的兄弟节点进行调整或合并操作,并重复上述步骤,直至符合B树的要求为止。
(三)总结
B树
- 插入
- 删除
B树的插入
- 通过“查找”确定插入位置(一定是在终端结点)
- 若插入后结点关键字个数未超过上限,则无需做其他处理
- 若插入后关键字个数超过上限,则需要将当前结点的中间元素放到父节点中,当前结点分裂为两个部分;该操作会导致父节点关键字个数+1,若父节点关键字个数也超过了上限,则需要再向上分裂;根结点的分裂会导致B树高度+1。
B树的删除
- 非终端结点关键字
- 用其直接前驱或直接后继替代其位置,转化为对“终端节点”的删除
- 直接前驱:当前关键字左边指针所指子树中“最右下”的元素
- 直接后继:当前关键字右边指针所指子树中”最左下“的元素
- 终端结点关键字
- 删除后结点关键字个数未低于下限,无需任何处理
- 低于下限
- 右兄弟够借,则用当前结点的后继、后继的后继依次顶替空缺
- 左兄弟够借,则用当前结点的前驱、前驱的前驱依次顶替空缺
- 左(右)兄弟都不够借,则需要与父节点内的关键字、左(右)进行合并,合并后导致父节点关键字数量-1,可能需要继续合并。
核心要求:
①对m阶B树——除根结点外,结点关键字个数⌈m/2⌉ -1 ≤ n ≤ m-1
②子树0 < 关键字1 < 子树1 < 关键字2 < 子树2 < …
①每个结点的关键字是有上下限的。插入或删除操作使得结点的关键字个数超出了上下限的范围,就需要进行相应的调整。
②调整完了以后,一定也需要保证,B树的每个关键字,一定要大于它的左子树,同时一定要小于它的右子树。
注意,此处讲B树的所有操作,都是用5阶B树来讲的,而对于考试来说,不可能再出现比5阶更高阶的B树了,所以只需把此处讲的5阶B树的操作都理解到位,就可以了。
七、B+树
考研中对B+树的考察一般不会太深,都是一些概念性的东西。
观察上图,其实可以看出,它和分块查找有些相似之处。
(一)对比:分块查找
在分块查找中,我们会把数据分成一块一块,并在索引表当中保存每一块最大的元素。
(二)B+树
可以看到,每一层的数据元素都是分块的,而且每一块的最大元素,都对应地存放在它的上一层中。
一棵m阶的B+树需满足下列条件:
1)每个分支节点最多有m棵子树(孩子结点)。
2)非叶根结点至少有两棵子树,其他每个分支结点至少有⌈m/2⌉棵子树。
什么叫非叶根结点,如下。
为什么要这样规定呢?
可以理解为:要追求“绝对平衡”,即所有子树高度要相同。
为什么要保证每个分支结点至少有⌈m/2⌉棵子树?
和B树那里是类似的,是为了保证每个结点都不太空,来保证查找效率。
3)结点的子树个数与关键字个数相等。
这是B+树这里比较容易考察的,因为这是它和B树最大的区别。
B+树每个结点有几个关键字,就会有几个分支。例如有3个关键字,就会对应3个分支。
而B树,有2个结点,会有3个分支。
4)所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互链接起来。
例如,上面这棵B+树,若每个关键字代表某个学生的学号,那么当查找到叶子结点处,找到某个学号后,在那个关键字处会附有一个指向相应记录的指针,即指向某个学生数据项的具体记录。
此外,B+树的叶子结点这一层,是支持顺序查找的。
5)所有分支结点中仅包含它的各个子节点中关键字的最大值及指向其子节点的指针。
和分块查找是类似的原理。
(三)B+树的查找
如上图,若要查找9。
从根结点的第一个关键字开始检查,9比15小,于是向15的左子树找去,于是指针指向15的左子树的根结点的第一个关键字,即指向3。
指向3,3<9,则指针后移,指向9,此时,9==9,但是要注意的是,在B+树中,在分支节点处,若查找目标和关键字相匹配了,查找并没有真正结束,因为只有我们找到叶子结点的对应关键字之后,才能查找到其指向的记录信息,才能完成B+树的查找。
因此,指针指向9的左子树。依次遍历,最终找到9对应的记录。
15的左分支–>9的左分支–>6<7;8>7,于是判定7是不存在的,查找失败。
B+树中,无论查找成功与否,最终一定都要走到最下面一层结点
因为上面层次的分支结点的信息,并不能反应查找信息是否存在。
并且,只有在叶子结点找到某个关键字后,才可以找到这个关键字对应的实际的记录存放的位置。
对比:B树的查找
对于B树的查找,只要关键字匹配上就算查找成功,即指针可能停在任何一层。
对于B+树的查找,除了逐层的查找外,还可以直接进行顺序查找。
在叶子结点那一层进行顺序查找,从1开始向后依次遍历,查找到9。
(四)B+树 VS B树
我们可以看到,B+树有很多特点是和分块查找很类似的,而有很多特点也是和B树很类似的。考研中很容易将B+树和B树对比着来进行考察。
m阶B+树:
1)结点中的n个关键字对应n棵子树。
2)根结点的关键字数n∈[1, m]
。其他结点的关键字数n∈[⌈m/2⌉, m]
。
3)在B+树中,叶结点包含全部关键字,非叶结点中出现过的关键字也会出现在叶结点中。
4)在B+树中,叶结点包含信息,所有非叶结点仅起索引作用,非叶结点中的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址。
m阶B树:
1)结点中的n个关键字对应n+1棵子树。
2)根结点的关键字数n∈[1, m-1]
。其他结点的关键字数n∈[⌈m/2⌉-1, m-1]
。
其实特点(2)的上下限之所以不同,还是由特点(1)所导致的。
3)在B树中,各结点中包含的关键字是不重复的。
4)B树的结点中都包含了关键字对应的记录的存储地址。
一个涉及到操作系统相关内容的区别(数据结构这里不考,属于超纲的),也是B+树和B树最本质的区别:
这里面一个一个的结点,实际上是存储在计算机的磁盘当中的。
操作系统对磁盘的读写,一般是以磁盘块为单位。
一般来说,B+树/B树的一个个结点,就会存放在一个个不同的磁盘块当中。
此外,由于把数据读入内存中,计算机才能处理这些数据。
如:根结点
15 56
存放在块1
当中,32 42 56
存放在块7
当中,40 42以及其对应的记录
存放在块23
当中。那么,如果我们要查找
42
对应的记录,就会先把块1
读入内存中,之后,根据根结点中存放的指针信息来进行索引,可知如果要查询42,需要将块7
读入内存中,最后同理,将块23
读入内存中。最后,找到42
所指向的记录的指针,即可读出记录信息。
对于B树也是一样的,也是要进行一次次读取磁盘块的操作。
其实,计算机读磁盘的这个操作,时间开销是很大的,因为磁盘是一种慢速设备。
所以,每一次读取一个磁盘块,都需要花费很高的时间。因此,也就意味着,如果整棵B+树高度越高,就意味着查找过程当中读磁盘的次数会越多,就会导致查找速度更慢。
那怎么减少这个树的高度呢?——每个结点中存放的关键字个数越多,就意味着这棵树总的高度越低。
刚刚我们说了,这一个个的结点都是存放在磁盘块中的,而每一个磁盘块的大小其实是固定的。既然一个磁盘块的大小是固定的,我们要想让一个磁盘块包含尽可能多的关键字的信息。这就是B+树为什么要这样设计的原因。——非叶结点并不含有该关键字对应记录的存储地址,就会使这份数据需要花费的存储空间更少。
而B树当中,对每个结点的每个关键字,除了要保存这个关键字的信息以外,还要保存相应的记录的存储地址信息,就会导致一个磁盘块存放的关键字更少。
总之,在B+树中,非叶结点不含有该关键字对应记录的存储地址。这就可以使一个磁盘块可以包含更多个关键字,使得B+树的阶更大,树高更矮,读磁盘次数更少,查找更快。
实际上这也是B+树和B树最本质的区别。
典型应用:关系型数据库的“索引”,如MySQL。这些数据库的索引,实际上就是用B+树来实现的。
(五)总结
八、散列查找
(一)散列查找概念(Hash Table)
散列表(Hash Table),又称哈希表。是一种数据结构,特点是:数据元素的关键字与其存储地址直接相关。
就是数据元素本身的关键字,和它的存储地址之间有一个映射关系。
如何建立“关键字”与“存储地址”的关系?
通过“散列函数(哈希函数)”:Addr = H(key)
例:有一堆数据元素,关键字分别为{19, 14, 23, 1, 68, 20, 84, 27, 55, 11, 10, 79}
,散列函数H(key) = key%13
。
因此,任何一个关键字,都会被映射到0~12
这个区间内。
依次计算:
19%13 = 6
14%13 = 1
23%13 = 10
但是,当继续计算至:
1%13 = 1处时。
会发现,1
这数据,应该放在下标为1的位置处,但是放不进去。因为已经有14
这个数据占住了。
基于这个问题,我们引出两个概念:
- 若不同的关键字通过散列函数映射到同一个值,则称它们为“同义词”
- 通过散列函数确定的位置已经存放了其他元素,则称这种情况为“冲突”
接下来我们要寻找解决“冲突”的方法。
(二)处理冲突的方法——拉链法
例:有一堆数据元素,关键字分别为{19, 14, 23, 1, 68, 20, 84, 27, 55, 11, 10, 79}
,散列函数H(key) = key%13
。
用拉链法(又称链接法、链地址法)处理“冲突”:把所有“同义词”存储在一个链表中。
则上述所有关键字全部存入完毕后,结果如下所示。
将新元素插入到链表的链尾(链头也可以,无所谓)。
.
拉链法的小优化
对于拉链法,对于同义词,各个关键字的排序其实是乱序的。
而如果我们能够保持其中关键字是有序的,即增序 / 降序的话,会略微提高查找效率。
(三)如何进行散列查找
接下来看这样存储的数据元素如何进行查找。
基于上述例子中利用拉链法存储的散列表,如何进行查找。
例如查找27
这个数据。
首先,根据散列函数计算出这个关键字所对应的存放位置:27%13 = 1
那么27要存放的话,一定是存放在下标为1所对应的那个链表当中。于是在那个链表当中依次检查各个元素的值。对比次数为3次,即查找长度为3。
若查找目标为20
,则根据20%13 = 7
,去查找索引为7的链表,查找长度为1。
若查找目标为21
,则根据21%13 = 8
,去查找索引为8的链表,然而索引为8的地方为空,则查找失败,且查找长度为0。
查找长度——在查找运算中,需要对比关键字的次数称为查找长度。
下标为8的位置没有关键字,只是有个空指针而已,所以查找长度为0。
你查找成功的时候,也没有把判断指针的操作算到查找长度当中啊。比如27就是对比三次关键字,查找长度为3。所以此处查找长度为0是合理的计算方式。
若查找目标为66
,则根据66%13 = 1
,去查找索引为1的链表,对比4次,查找失败,即查找长度为4。
计算一下平均查找长度:
A
S
L
成
功
=
1
∗
6
+
2
∗
4
+
3
+
4
12
=
1.75
ASL_{成功}=\frac{1*6+2*4+3+4}{12}=1.75
ASL成功=121∗6+2∗4+3+4=1.75
查找成功时的平均查找长度。
因为哈希表中共有12个数据元素,每一个数据元素被查找的概率是相等的。
如果数据目标是“第一层”的话,就只需进行一次对比操作。
“其他层”同理。最终算出查找成功的平均查找长度。
而顺序查找,12个关键字需要平均进行6次对比。可见哈希查找比顺序查找要快得多。
A S L 成 功 = 1 + 2 + 3 + 4 + 1 + 2 + 1 + 2 + 1 + 1 + 2 + 1 12 = 1.75 ASL_{成功}=\frac{1+2+3+4+1+2+1+2+1+1+2+1}{12}=1.75 ASL成功=121+2+3+4+1+2+1+2+1+1+2+1=1.75
我们也可以从另一个思路来考虑查找成功的平均查找长度。
对于索引为1的链表:查找到
12
的查找长度为1,1
为2,27
为3,79
为4……
第二种计算思路和第一种有什么区别呢?
实际上,对于“1次”以后的查找,都是“冲突”的数据元素的查找,往后的数字越大,也就说明“冲突”的情况越严重,查找效率越低。
更理想的情况是这个样子。也就是所有关键字都没有同义词,这样对于所有元素都只需要查找1次。使得哈希查找在效率上得到提升。
当然了,如果我们坚持使用之前的那个哈希函数H(key)=key%13
的话,显然不会得到上图这样的理想效果。
而理论上来说,如果我们的哈希函数设计的足够好,在理论上就有可能达到这种最理想情况。
最理想情况:散列查找时间复杂度可达到O(1)。
接下来,我们的问题就是:如何设计冲突更少的散列函数?
这个问题先放一下。
刚才计算了查找成功的平均查找长度,那么如何计算查找失败的平均查找长度?
查找成功的平均查找长度,是对其中的关键字分别计算得到的。那么查找失败的平均查找长度该怎么算呢?查找失败的情况有很多很多。
实际上,可以这样考虑。不论我们要查找的数是多少,其在经过哈希函数H(key)=key%13
的映射后,只有可能映射到0~12
上。而映射到每个位置上的概率是相等的。
基于这样一种考虑,查找失败的平均查找长度计算如下:
A
S
L
失
败
=
0
+
4
+
0
+
2
+
0
+
0
+
2
+
1
+
0
+
0
+
2
+
1
+
0
13
=
0.92
(
装
填
因
子
)
ASL_{失败}=\frac{0+4+0+2+0+0+2+1+0+0+2+1+0}{13}=0.92(装填因子)
ASL失败=130+4+0+2+0+0+2+1+0+0+2+1+0=0.92(装填因子)
实际上可以发现,此处的分母
13
就是散列表长度;而分子各个数字加起来之和,就是表中数据元素的个数。
装填因子α = 表中记录数 / 散列表长度
如何理解装填因子这个参数呢?
其实就是描述散列表装的有多满。
装填因子越大,说明散列表装的越满。
可见,装填因子的大小会直接影响散列表的查找效率。
这是查找失败的情况。其实,查找成功的情况,也多多少少会和装填因子有一定的关联。因为,一个散列表装得越满,则说明发生“冲突”的可能性就越大。所以装填因子其实是一个很重要的参数。
到此,我们再来考虑:
如何设计冲突更少的散列函数?
(四)常见的散列函数
如何设计冲突更少的散列函数?
1.除留余数法
除留余数法 —— H(key) = key % p
散列表表长为m,取一个不大于m但最接近或等于m的质数p。
例:散列表表长13。
那么,取一个不大于13但最接近或等于13的质数。
其实就是可以取13。
散列函数H(key) = key%13。
例:散列表表长15,那么散列函数H(key) = key%13。
这会有个什么问题呢?——我们的表,表长是15,但是所有的关键字被13整除,取余之后只有可能被映射到
0~12
的范围,也就是13 14
两个位置被舍弃了。
会有个问题:为什么p一定要取质数?
不要忘记,我们设计散列函数的目标,就是让不同关键字的冲突尽可能地少。若p取质数的话,会让发生冲突的可能性更少。
设:可能出现的关键字 =
{1,2,3,4,5,6,7,8,9,10......}
散列表表长为8。
如果p取8。
如果p取不超过表长8的最大质数,即取7。
不是说取质数,冲突的情况会更少吗?
但是为什么看上面两个图,取7的时候反而冲突更多了呢?
问题出在哪里?
问题出在,我们所给的例子当中,可能出现的关键字是一系列连续的自然数。
接下来我们换一个例子。
设:可能出现的关键字 =
{2,4,6,8,10,12......}
,即可能出现的都是偶数。散列表表长为8。
若p取8。
若p取7。
可见,若p取8,将会导致每个关键字的散列地址集中在0,2,4,6
处。
而p取质数7,则各关键字会被均匀地映射到每一个位置。
即,用质数取模,分布更均匀,冲突更少。
具体怎么证明,可以看一下数论。
其原因,大致的意思,就是
一个合数,例如8,那么它会与
6 4 2
这些数,存在很多相似的特征。所以对一系列关键字进行取模操作时,如果关键字具备与之类似的特征(例如关键字都是偶数)的话,会引起较多冲突。而对于一个质数来说,它与这些数都是没有公因子的,因此没有相似的特征。可以保证对7取模后,这些关键字的分布是均匀的。
因此,如果关键字就是连续的自然数:
{1,2,3,4,5,6,7,8,9,10......}
的话,如果表长为8,那么对8取模会更好(因为不会浪费一个位置)。即,对于实际应用来讲,散列函数的设计要结合实际的关键字分布特点来考虑。但是对于答题的角度来讲,我们要做的就是,令p取最大的质数。
2.直接定址法
直接定址法 —— H(key) = key 或 H(key) = a*key + b
即,令关键字本身为存储位置,或者令关键字进行一个线性变化后作为其存储位置。
其中,a和b是常数。这种方法计算最简单,且不会产生冲突。它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间的浪费。
例:存储同一个班级的学生信息,班内学生学号为(1120112176—1120112205)。
H(key) = key - 1120112176。
3.数字分析法
数字分析法 —— 选取数码分布较为均匀的若干位作为散列地址
设关键字是r进制数(如十进制数),而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等;而在某些位上分布不均匀,只有某几种数码经常出现,此时可选取数码分布较为均匀的若干位作为散列地址。这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。
例:以“手机号码”作为关键字设计散列函数
138xxxx2875
138xxxx1682
138xxxx9125
… …
199xxxx1684
199xxxx1236
忽略中间四位不计,那么对于138开头的号码来说,只有后四位是不同的。
因此可以设计长度为10000的散列表,并且以手机号后四位作为散列地址。
4.平方取中法
平方取中法 —— 取关键字的平方值的中间几位作为散列地址
具体取多少位要视实际情况而定。这种方法得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀,适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数。
什么意思?
我们在对一个数做平方时,如上图右侧,做手算的步骤,是下面数字的每一个数位,分别对上面的数字做乘法,最后再求和。
而,我们会发现,对于上图左侧的一系列数字,其实头部和尾部均为
1
开头,0
结尾。因此,这些数字若做平方,则头部、尾部会有较多重复数字,而只有中间几位是变化的。
例:要存储整个学校的学生信息,以“身份证号”作为关键字设计散列函数。
身份证号码规则:
前1、2位数字表示:所在省份的代码;
第3、4位数字表示:所在城市的代码;
第5、6位数字表示:所在区县的代码;
第7-14位数字表示:出生年、月、日;
第15、16位数字表示:所在地的派出所的代码;
第17位数字表示性别:奇数表示男性,偶数表示女性;
第18位数字是校验码。
由于一个学校的学生,其出生年、月、日,通常都是较为接近的,如,都是1999年、2000年等。因此,将身份证号求平方,能够得到中间几位分布较为均匀的关键字。因为一个数字求平方,根本上讲,是由原数的各个数位得来的。
假设学生不超过十万人,则可以令身份证号平方取中间5位。可以令散列地址分布比较均匀。
但是,这种方法只是能够使得散列地址分布比较均匀,但不代表不会有冲突。
有没有可能绝对避免冲突呢?
当然可以,比如身份证号,我们散列表长度为1000000000000000000(18个0),则可以直接用身份证号作为散列地址,且不可能有冲突,查找时间复杂度为O(1)。其实也就是上面说的直接定址法。
但是这种做法,很显然是不切实际的。可以算一下需要使用多少内存空间。
但是通过这个想法,我们可以体会到:
散列查找是典型的“用空间换时间”的算法,只要散列函数设计的合理,则散列表越长,冲突的概率越低。
(五)处理冲突的方法——开放定址法
无论散列函数取得多么优秀,都是不可避免的会有“冲突”的情况发生。
之前我们讲过处理冲突的方法,拉链法。
此处再讲一个,开放定址法。
所谓开放定址法,是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。其数学递推公式为:
H
i
=
(
H
(
k
e
y
)
+
d
i
)
%
m
i
=
0
,
1
,
2
,
.
.
.
,
k
(
k
≤
m
−
1
)
H_i = (H(key) + d_i) \% m \\ i = 0,1,2,...,k(k≤m - 1)
Hi=(H(key)+di)%mi=0,1,2,...,k(k≤m−1)
m表示散列表表长;di为增量序列;i可理解为“第i次发生冲突”。
关于这个增量如何设计,有以下方法
- 线性探测法
- 平方探测法
- 伪随机序列法
1.线性探测法
(1)线性探测法
线性探测法 —— di = 0, 1, 2, 3, …, m-1;即发生冲突时,每次往后探测相邻的下一个单元是否为空。
例:有一堆数据元素,关键字分别为{19, 14, 23, 1, 68, 20, 84, 27, 55, 11, 10, 79}
,散列函数H(key) = key%13
。
当即将要存放关键字1
时。
先计算
H
(
k
e
y
)
=
1
%
13
=
1
H
0
=
(
1
+
d
0
)
%
16
=
1
H(key)=1\%13=1\\ H_0=(1+d_0)\%16=1
H(key)=1%13=1H0=(1+d0)%16=1
于是,发生冲突。于是。
H
1
=
(
1
+
d
1
)
%
16
=
2
H_1=(1+d_1)\%16=2
H1=(1+d1)%16=2
之后,发现2这个位置是空闲的,于是将关键字1
存入其中。
这个2就是,发生第1次冲突后重新计算,得到的哈希地址。
再往后,当即将要存放关键字84
时。
先计算
H
(
k
e
y
)
=
84
%
13
=
6
H
0
=
(
6
+
0
)
%
16
=
6
,
冲
突
H
1
=
(
6
+
1
)
%
16
=
7
,
冲
突
H
2
=
8
H(key)=84\%13=6\\ H_0=(6+0)\%16=6,冲突\\ H_1=(6+1)\%16=7,冲突\\ H_2=8
H(key)=84%13=6H0=(6+0)%16=6,冲突H1=(6+1)%16=7,冲突H2=8
将84
存入8处。
最终,所有元素全部存入,结果如下所示。
线性探测法,就是当我们用哈希函数算出一个关键字的存放地址之后,如果这个地址冲突,那么我们应该一步一步地往后探测这些存储单元。
接下来看一个问题。对于以上存储结果,如果我要再存入一个数据元素25
。
则,25
将会被放入13号位置当中。
这个例子,想表示
算哈希函数,是对13取模,13是不大于哈希表表长的最大质数,所以哈希函数算出的地址是
0~12
之间;而线性探测的时候,是对表长m取模,是可以算出
1~15
的范围。
(2)查找操作
如果要查找27
这个元素。
首先要算出哈希函数H(key) = 27%13 = 1
,而1号位的元素14
,与27
是不相等的,于是我们会线性探测,依次探测后续的位置。按照线性探测公式,最终找到H3 = 4
时,关键字为27
。27
的查找长度为4。
可见,开放定址法,在查找的时候,可能要与同义词作对比,也可能要与非同义词作对比。
如果要查找11
这个元素。
根据H(11) = 11%13 = 11
,而11号位的元素11
,没有冲突,直接可以查找成功。
如果要查找21
这个元素。
H(key)=21%13=8
,冲突;H1 = 9
,冲突;……;H5 = 13
,而13号位置没有存放任何元素,确定查找失败。21
的查找长度为6。
要强调的是,对最后一处空位置的判断,也要算作一次比较。
为什么拉链法,对于空指针的判断不算次数,而此处对于空位置的判断需要算作一次比较?
因为拉链法的空位置,是一个指针;而此处的空位置,是一个元素,和空指针是不一样的,所以在这个地方还是把它算作关键字的比较次数。
实际上,查找失败,就是从哈希地址开始,往后依次探测,直到探测为空停止。
越早遇到空位置,就越早确定查找失败。上图对21
的查找长度为3。
(3)删除操作
如果我们直接把1
给删掉。会引发一个问题:
当1
直接被我们拿掉之后,再查找27
,就出问题了。
注意:
采用“开放定址法”时,删除结点不能简单地将被删除结点的空间置为空,否则将截断在它之后填入散列表的同义词结点的查找路径,可以做一个“删除标记”,进行逻辑删除。
这样一来,当我们探测到“空位置”处,根据“删除标记”,就能够知道,还应该继续往后探测。
再看这样一个问题。
如,79
之前的元素都被删除了,且都标注了“删除标记”。
那我们需要从1处,一路往后走,找到79
。我们总共发生了8次冲突,对比了9次关键字,才找到。
所以,看起来很满,但实际上很空。有很多东西都已经被逻辑删除了。
所以这也是开放定址法的一个弊端。
(4)查找效率分析(ASL)
19%13 = 6(1次)
14%13 = 1(1次)
23%13 = 10(1次)
1%13 = 1(2次)
68%13 = 3(1次)
20%13 = 7(1次)
84%13 = 6(3次)
27%13 = 1(4次)
55%13 = 3(3次)
11%13 = 11(1次)
10%13 = 10(3次)
79%13 = 1(9次)
A S L 成 功 = 1 + 1 + 1 + 2 + 1 + 1 + 3 + 4 + 3 + 1 + 3 + 9 12 = 2.5 ASL_{成功}=\frac{1+1+1+2+1+1+3+4+3+1+3+9}{12}=2.5 ASL成功=121+1+1+2+1+1+3+4+3+1+3+9=2.5
再来看查找失败的情况。
查找失败的情况我们这样考虑:
初次探测的地址H0,只有可能在0~12
范围内。
那么,对于0号位置元素,对比1次即可确定查找失败,即查找长度为1。
而对于1号位置的元素,查找失败所需查找长度为13。其他位置的元素同理,得:
A
S
L
失
败
=
1
+
13
+
12
+
11
+
10
+
9
+
8
+
7
+
6
+
5
+
4
+
3
+
2
13
=
7
ASL_{失败}=\frac{1+13+12+11+10+9+8+7+6+5+4+3+2}{13}=7
ASL失败=131+13+12+11+10+9+8+7+6+5+4+3+2=7
可见,查找失败时的效率很低。
线性探测法很容易造成同义词、非同义词的“聚集(堆积)”现象,严重影响查找效率。
产生原因——冲突后再探测,一定是放在某个连续的位置
2.平方探测法
当 d i = 0 2 , 1 2 , − 1 2 , 2 2 , − 2 2 , . . . , k 2 , − k 2 时 , 称 为 平 方 探 测 法 , 又 称 二 次 探 测 法 。 其 中 k ≤ m 2 当d_i=0^2,1^2,-1^2,2^2,-2^2,...,k^2,-k^2时,称为平方探测法,又称二次探测法。其中k≤\frac{m}{2} 当di=02,12,−12,22,−22,...,k2,−k2时,称为平方探测法,又称二次探测法。其中k≤2m
d0 = 0
d1 = 1
d2 = -1
d3 = 4
d4 = -4
d5 = 9
d6 = -9
…
可以比较好地解决“聚集”的问题。
值得注意的是:当存放最后一个元素
84
的时候,计算出它需要存放到d6 = -9
的增量位置处。而从6往左数9个位置,没位置了怎么办?实际上,由于我们是取模运算,最终84
会存放到24的位置。也就是没位置了就从表尾接着数。
平方探测法:比起线性探测法更不易产生“聚集(堆积)”问题。
这是由增量序列的特性所决定的。
查找的过程,和线性探测法的原理是一样的。就是从H0、H1、H2…依次对比,只不过增量序列di不同而已。
此处提一个非重点的小问题:
平方探测法散列表长度m必须是一个可以表示成 4j + 3 的素数,才能探测到所有位置。
3.伪随机序列法
di是一个伪随机序列。如di = 0, 5, 24, 11, …
(六)处理冲突的方法——再散列法
再散列法(再哈希法):除了原始的散列函数 H(key) 之外,多准备几个散列函数,当散列函数冲突时,用下一个散列函数计算一个新地址,直到不冲突为止:
H
i
=
R
H
i
(
k
e
y
)
,
i
=
1
,
2
,
3
,
.
.
.
,
k
H_i = RH_i(key), i = 1,2,3,...,k
Hi=RHi(key),i=1,2,3,...,k
注。此处给出的定义,是严蔚敏《数据结构》教材上的定义。而王道数据结构书上的定义,与该定义有出入,可能存在一些问题。还是以此处为准。
(七)总结
散列查找
- 概念
- 散列表、散列函数H(key)、同义词、冲突
- 装填因子α = 表中记录个数 / 散列表表长
- 常见散列函数
- 目标是,对给定的关键字集合,应尽可能均匀地散列到各个地址,使冲突更少。
- 除留余数法:H(key) = key%p,p是不大于表长的质数
- 直接定址法:H(key) = key 或 H(key) = a*key + b
- 数字分析法:选取数码分布较为均匀的若干位作为散列地址
- 平方取中法:取关键字的平方值的中间几位作为散列地址
- 冲突的处理
- 拉链法(链地址法):同义词串成一个链表
- 开放定址法
- 原理都是 Hi = (H(key) + di) % m,只不过di序列的选取方式不同
- 线性探测法:di = 0, 1, 2, 3, …, m-1
- 平方探测法:di = 0², 1², -1², 2², -2², …
- 伪随机序列法:di = 一个伪随机序列
- 再散列法:准备多个散列函数,一个发生冲突了就用下一个
- 查找效率:取决于 散列函数、处理冲突的方法、装填因子α
实际上,对于冲突的处理,实际应用方面还是普遍采用拉链法,Java当中的HashMap就是用拉链法实现的。
而开放定址法由于坑比较多,实际操作用的并不多。但是由于是考研,坑多的地方容易出考题。而开放定址法中,线性探测法的坑是较多的,所以线性探测法出的考题比较多。