大部分内容基于中国大学MOOC的2021考研数据结构课程所做的笔记,该课属于付费课程(不过盗版网盘资源也不难找。。。)。后续又根据23年考研的大纲对内容做了一些调整,将二叉排序树和平衡二叉树的内容挪到了查找一章,并增加了并查集、平衡二叉树的删除、红黑树的内容。
排序一章的各种算法动态过程比较难以展现,所以阅读体验可能不是特别好。
西电的校内考试分机试和笔试。笔试占50分,机试2小时4道题占30分,做出2道满分,多做一道总分加5分。机试尽量把老师平时发的OJ题目都过一遍。笔试内容偏基础,但考的量比较大。
其他各章节的链接如下:
数据结构笔记(王道考研) 第一章:绪论
数据结构笔记(王道考研) 第二章:线性表
数据结构笔记(王道考研) 第三章:栈和队列
数据结构笔记(王道考研) 第四章:串
数据结构笔记(王道考研) 第五章:树和二叉树
数据结构笔记(王道考研) 第六章:图
数据结构笔记(王道考研) 第七章:查找
数据结构笔记(王道考研) 第八章:排序
其他各科笔记汇总
查找:在数据集合中寻找满足某种条件的数据元素的过程称为查找
查找表(查找结构):用于查找的数据集合称为查找表,它由同一类型的数据元素(或记录)组成
关键字:数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的
静态查找表只需进行操作1,仅关注查找速度即可。动态查找表则还要进行操作2,除了查找速度,也要关注插/删操作是否方便实现
查找长度——在查找运算中,需要对比关键字的次数称为查找长度
平均查找长度(ASL)——所有查找过程中进行关键字的比较次数的平均值。 A S L = ∑ i = 1 n P i C i ASL=\sum_{i=1}^{n}P_iC_i ASL=∑i=1nPiCi, P i P_i Pi为查找第 i i i个元素的概率, C i C_i Ci为查找第 i i i个元素的查找长度, n n n为数据元素个数。通常认为查找任何一个元素的概率都相同。 A S L ASL ASL的数量级反应了查找算法的时间复杂度。
之前在讲二叉排序树的时候就讲过二叉排序树的 A S L ASL ASL
评价一个查找算法的效率时,通常考虑查找成功/查找失败两种情况的 A S L ASL ASL,之前讲二叉排序树的时候也分类讨论过查找成功和查找失败的平均查找长度 A S L ASL ASL
顺序查找,又叫“线性查找”,通常用于线性表
算法思想:从头到脚挨个找(或者反过来也行)
以顺序表为例,查找表是单链表或者双链表当然也行,无非for循环和return语句的写法有一点改变而已
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;
}
课本当中给了另外一种很类似的写法。即增加了一个”哨兵“,把0号位置空出来,实际的数据从1这个位置开始存放。当我们要查找某一个关键字的时候,会把关键字放在0号位置,这就是所谓的哨兵
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
}
这种写法在每一轮for循环的时候,只需要判断当前指向的元素与要查找的关键字是否相等而无需判断是否越界,效率更高一点点
A S L 成功 = 1 + 2 + 3 + . . . + n n = n + 1 2 ASL_{成功}=\frac{1+2+3+...+n}{n}=\frac{n+1}{2} ASL成功=n1+2+3+...+n=2n+1
A S L 失败 = n + 1 ASL_{失败}=n+1 ASL失败=n+1
平均时间复杂度为 O ( n ) O(n) O(n)
若查找表中元素有序存放(递增/递减),则有些查找失败的情况在中途就能被终止,从而使 A S L 失败 ASL_{失败} ASL失败得到优化。下图就是一个查找失败的例子
若表中元素是递增的,则每一次对比时若等于则查找成功结束,若小于则查找失败,若大于则继续对比下一个。下以上表为例进行演示
从下图中可以看到有 n + 1 n+1 n+1个失败结点,即 n + 1 n+1 n+1种失败情况,如21落在 ( 19 , 29 ) (19,29) (19,29)区间内,是第4个失败结点,就要对比4次发现查找失败。
若假定出现这 n + 1 n+1 n+1个情况的概率都是相等的,则
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 n2+\frac{n}{n+1} ASL失败=n+11+2+3+...+n+n=2n+n+1n
分子上最后面加了两个 n n n,因为最下面的两种失败情况都需要把最前面的 n n n个元素全部对比一遍,这两种情况都要对比 n n n次关键字
学会仿造下图画查找判定树
可以把被查概率更大的放在靠前位置,这样在查找成功的情况下,平均查找长度能够进一步缩短。当然这样因为关键字被打乱顺序,可能会导致查找失败时平均查找长度增加。因此要具体情况具体分析
折半查找,又称“二分查找”,仅适用于有序的顺序表
顺序表其实就是说些元素得是用数组存放起来的,只有顺序表才拥有随机访问的特性,链表没有
现在假设要在一个按照关键字升序排列的顺序表中查找一个元素,关键字为 v a l u e value value。则
先用两个指针 l o w low low和 h i g h high high指向要搜索的区间范围两端,一个指向第一个元素,一个指向最后一个元素。然后不断减小搜索的范围,第一轮检查 l o w low low和 h i g h high high中间的一个元素,用指针 m i d mid mid指向。 m i d mid mid的计算公式是 m i d = ( l o w + h i g h ) / 2 mid=(low+high)/2 mid=(low+high)/2。若 v a l u e > m i d value>mid value>mid,则其只可能出现在 m i d mid mid右边的区域,就可以移动 l o w low low指向 m i d mid mid的后面一个元素,若 v a l u e < m i d value
注:以下代码基于升序排列的线性表,如果是降序排列会略有不同
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
}
以下面这个查找表为例
仿造上一节画出查找判定树
如何构造查找判定树在下面会介绍,这里直接给出结果
如果要查找的关键字落在上图紫色方框的区间内就会最终查找失败
A S L 成功 = ( 1 ∗ 1 + 2 ∗ 2 + 3 ∗ 4 + 4 ∗ 4 ) / 11 = 3 ASL_{成功}=(1*1+2*2+3*4+4*4)/11=3 ASL成功=(1∗1+2∗2+3∗4+4∗4)/11=3
A S L 失败 = ( 3 ∗ 4 + 4 ∗ 8 ) / 12 = 11 / 3 ASL_{失败}=(3*4+4*8)/12=11/3 ASL失败=(3∗4+4∗8)/12=11/3
仍然以上小节的查找表为例
如果当前 l o w low low和 h i g h high high之间有奇数个元素,则 m i d mid mid分隔后,左右两部分元素个数相等
再下一级的分割也是一样的,左右两部分都是奇数个。分别去除中间的元素之后刚好又可以把它们分为元素个数相等的左右两部分
再来看另一种情况,即有偶数个元素的例子
如果当前 l o w low low和 h i g h high high之间有偶数个元素,则 m i d mid mid分隔后,左半部分比右半部分少一个元素
继续进行上面的规则进行分隔直到得到最终结果
从上面的过程得到的两个结论:
1.如果当前 l o w low low和 h i g h high high之间有奇数个元素,则 m i d mid mid分隔后,左右两部分元素个数相等
2.如果当前 l o w low low和 h i g h high high之间有偶数个元素,则 m i d mid mid分隔后,左半部分比右半部分少一个元素
可以推知
折半查找的判定树中,若 m i d = [ ( l o w + h i g h ) / 2 ] mid=[(low+high)/2] mid=[(low+high)/2],则对于任何一个结点,必有:
右子树结点数-左子树结点数=0或1
由该结论不难若发现 m i d = [ ( l o w + h i g h ) / 2 ] mid=[(low+high)/2] mid=[(low+high)/2],则1个元素,2个元素,3个元素,…,n个元素的查找表对应的折半查找判定树的形状是可以确定的(注:这里暂不考虑失败结点)。且折半查找的判定树一定是平衡二叉树。折半查找的判定树中,只有最下面一层是不满的。因此,元素个数为n时树高 h = [ l o g 2 ( n + 1 ) ] h=[log_2(n+1)] h=[log2(n+1)](注:计算方法同“完全二叉树”)
树高直接反映了折半查找的时间复杂度,当然要注意到该树高不包含失败结点
此外还能观察到判定树结点关键字:左<中<右,满足二叉排序树的定义。失败结点:n+1个(等于成功结点的空链域数量)
不包含失败结点时,折半查找判定树树高 h = [ l o g 2 ( n + 1 ) ] h=[log_2(n+1)] h=[log2(n+1)]。可推知查找成功和查找的 A S L ASL ASL均 ≤ h \le h ≤h。因此折半查找的时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n)
注意这并不意味着折半查找一定比顺序查找快
拓展思考:
如果之前找 m i d mid mid时采用向上取整时,分析思路是不变的,只不过得到的有些结论刚好相反
1.如果当前 l o w low low和 h i g h high high之间有奇数个元素,则 m i d mid mid分隔后,左右两部分元素个数相等
2.如果当前 l o w low low和 h i g h high high之间有偶数个元素,则 m i d mid mid分隔后,左半部分比右半部分 多 一个元素
故折半查找的判定树中,若 m i d = [ ( l o w + h i g h ) / 2 ] mid=[(low+high)/2] mid=[(low+high)/2],则对于任何一个结点,必有:
左子树结点数-右子树结点数=0或1
给定一组元素,进行分块查找前将元素分块并保证块内无序,块间有序。对其建立索引表
该数据结构的代码定义如下
//索引表
typedef struct{
ElemType maxValue;
int low,high;
}Index;
//顺序表存储实际元素
ElemType List[100];
分块查找,又称索引顺序查找,算法过程如下:
1.在索引表中确定待查记录所属的分块(可顺序,可折半)
2.在块内顺序查找
若索引表中不包含目标关键字,则折半查找索引表最终停在 l o w > h i g h low>high low>high,要在 l o w low low所指分块中查找。
原因在于若折半查找失败,则最终 l o w > h i g h low>high low>high。但在这之前的一步肯定是 l o w = h i g h low=high low=high,这一步中 m i d , l o w , h i g h mid,low,high mid,low,high肯定指向的是同一个位置。此时可能有两种情况,一种是 m i d < k e y mid
当然 l o w low low最终可能超出索引表范围,此时说明查找失败
分析查找成功时的效率需要套公式 A S L = ∑ i = 1 n P i C ) ASL=\sum_{i=1}^{n}P_iC) ASL=∑i=1nPiC)。要分别老老实实算出所有元素关键字对比的次数,再分别乘以被查概率然后求和。查找失败的情况更复杂…一般不考
下面看一种可能考察的比较特别的情况
假设,长度为n的查找表的平均查找长度分别为 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
用顺序查找查索引表,则 L I = ( 1 + 2 + . . . + b ) b = b + 1 2 L_I=\frac{(1+2+...+b)}{b}=\frac{b+1}{2} LI=b(1+2+...+b)=2b+1, L S = 1 + 2 + . . . + s s = s + 1 2 L_S=\frac{1+2+...+s}{s}=\frac{s+1}{2} LS=s1+2+...+s=2s+1
则 A S L = b + 1 2 + s + 1 2 = s 2 + 2 s + n 2 s ASL=\frac{b+1}{2}+\frac{s+1}{2}=\frac{s^2+2s+n}{2s} ASL=2b+1+2s+1=2ss2+2s+n,当 s = n s=\sqrt n s=n时, A S L 最小 = n + 1 ASL_{最小}=\sqrt n+1 ASL最小=n+1
用折半查找查索引表,则 L I = [ l o g 2 ( b + 1 ) ] , L S = 1 + 2 + . . . + s s = s + 1 2 L_I=[log_2(b+1)],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=[log_2(b+1)]+\frac{s+1}{2} ASL=[log2(b+1)]+2s+1
拓展思考:
若要求查找表是“动态查找表”,可以像下图一样存储元素用链式存储而不是顺序存储
二叉排序树,又称二叉查找树(BST)。一棵二叉排序树或者是空二叉树,或者是具有如下性质的二叉树:
左子树结点值<根结点值<右子树结点值
对二叉排序树进行中序遍历,可以得到一个递增的有序序列。
二叉排序树可用于元素的有效组织,搜索
利用二叉排序树左子树结点值<根结点值<右子树结点值的特点实现查找操作
若树非空,目标值与根结点的值相比较:
若相等,则查找成功;
若小于根结点,则在左子树上查找,否则在右子树上查找。
查找成功,返回结点指针;查找失败返回NULL
//二叉排序树结点
typedef struct BSTNode{
int key;
struct BSTNode *lchild,*rchild;
}BSTNode,*BSTree
//在二叉排序树中查找值为key的结点
BSTNode* BST_Search(BSTree T,int key){
while(T!=NULL&&key!=T->key){ //若树空或等于根结点值,则结束循环
if(key<T->key){
T=T->lchild; //小于,则在左子树上查找
}else{
T=T->rchild //大于,则在右子树上查找
}
}
return T;
}
最坏空间复杂度为 O ( 1 ) O(1) O(1),也有如下递归实现的版本,最坏空间复杂度为 O ( h ) O(h) O(h),不如这个好
//在二叉排序树中查找值为key的结点(递归实现)
BSTNode *BSTSearch(BSTree T,int key){
if(T==NULL){
return NULL; //查找失败
}
if(key==T->key){
return T; //查找成功
}else if(key<T->key){
return BSTSearch(T->lchild,key); //在左子树中找
}else{
return BSTSearch(T->rchild,key); //在右子树中找
}
}
新插入的一定是叶子结点
若原二叉排序树为空,则直接插入结点;否则,若关键字k小于根结点值,则插入到左子树,若关键字k大于根结点值,则插入到右子树。
//在二叉排序树中插入关键字为k的新结点(递归实现)
int BST_Insert(BSTree &T,int k){
if(T==NULL){ //原树为空,新插入的结点为根结点
T=(BSTree)malloc(sizeof(BSTNode));
T->key=k;
T->lchild=T->rchild=NULL;
return 1; //返回1,插入成功
}else if(k==T->key){ //树中存在相同关键字的结点,插入失败
return 0;
}else if(k<T->key){ //插入到T的左子树
return BST_Insert(T->lchild,k);
}else{ //插入到T的右子树
return BST_Insert(T->rchild,k);
}
}
时间复杂度为 O ( h ) O(h) O(h),也有空间复杂度更低的用循环实现的非递归的实现方式
二叉树中不允许两个结点的值相等,如果我们要插入的关键字本来已经存在就不应该被插入
其实就是不断插入新结点的过程
现假如给定序列 s t r = { 50 , 66 , 60 , 26 , 21 , 30 , 70 , 68 } str=\{50,66,60,26,21,30,70,68\} str={50,66,60,26,21,30,70,68},建立BST
//按照str[]中的关键字序列建立二叉排序树
void Creat_BST(BSTree &T,int str[],int n){
T=NULL; //初始时T为空树
int i=0;
while(i<n){
BST_Insert(T,str[i]); //依次将每个关键字插入到二叉排序树中
i++;
}
}
不同的关键字序列可能得到同款二叉排序树,也可能得到不同款二叉排序树
先搜索找到目标结点
1.若被删除结点z是叶结点,则直接删除,不会破坏二叉排序树的性质
2.若结点z只有一棵左子树或右子树,则让z的子树成为z父结点的子树,替代z的位置
3.若结点z有左右两棵子树,则令z的直接后继(或直接前驱)替代z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一种或第二种情况
以下面这棵树为例,要删除50这个结点。
删除这个结点后不可能让其空着,需要找到其他某个结点来替代当前结点的位置,同时又要保持二叉排序树的特性。第一种方案是找z的直接后继,从当前删除结点的右子树中找到值最小的结点来替代当前结点,即z的右子树中最左下结点,该节点一定没有左子树。这其实就是找到它的右子树当中按照中序遍历第一个被访问的结点(因为之前提过对二叉排序树进行中序遍历,可以得到一个递增的有序序列)。
所以要删除50这个结点,可以让60来替代它,再删除原有的60结点。由于原来的60结点在最左下,肯定没有左子树,因此删除这个结点的操作就转变为了刚刚说的第二种情况(只有左子树或右子树的情况),把它删除后只要用它的右子树来替代即可,下面就是处理完后的结果
第二种方案是找z的直接前驱,从当前删除的结点的左子树当中寻找值最大的结点来替代这个结点,即z的左子树中最右下结点,该节点一定没有右子树。
所以要删除50这个结点,可以让30来替代它,再删除原有的60结点。由于原来的60结点没有在最右下,肯定没有右子树,因此删除这个结点的操作就转变为了刚刚说的第二种情况(只有左子树或右子树的情况),把它删除后只要用它的左子树来替代即可,下面就是处理完后的结果
查找长度:在查找运算中,需要对比关键字的次数称为查找长度。站在代码的角度,每一次关键字的对比其实就相当于一次循环或者是一层递归,因此查找长度直接反映了查找操作时间复杂度,时间复杂度的数量级应该和查找长度的数量级相同
仿造下面学会计算查找成功的平均查找长度ASL来度量整棵树的查找效率。
平均查找长度的数量级就是查找操作的时间复杂度,我们要追求查找操作的时间复杂度尽可能地低,因为插入和删除都需要建立在查找基础上
在一次查找操作当中,我们进行这些结点值的对比,对比的次数一定不会超过树的高度,所以查找操作的时间复杂度最坏也就和树的高度同等数量级。因此二叉排序树的查找效率很大程度上取决于这棵树的高度有多少,上面的例子也体现了结论
若树高 h h h,找到最下层的一个结点需要对比 h h h次。
最好情况: n n n个结点的二叉树最小高度为[ l o g 2 n ] + 1 log_2n]+1 log2n]+1。平均查找长度= O ( l o g 2 n ) O(log_2n) O(log2n)
最坏情况:每个结点只有一个分支,树高 h h h=结点数 n n n。平均查找长度 = O ( n ) =O(n) =O(n)
所以对于二叉排序树,应该让树的高度尽可能的接近我们所期待的最小值,这样才能使查找效率更高,更接近 l o g 2 n log_2n log2n数量级。下一小节会介绍怎么让一棵二叉排序树在构建的过程当中尽可能保持平衡,也就是任一结点的左子树和右子树高度之差不超过1。如果能保持平衡,就意味着这颗二叉树的高度或者说平均查找长度能够达到 l o g 2 n log_2n log2n数量级
仿造下面学会计算查找失败的平均查找长度ASL。查找失败则扫描指针只有可能停留在粉色方框标定的那几个位置
这块的重点是往平衡二叉树插入新结点后如何调整“不平衡”问题
平衡二叉树(Balanced Binary Tree)简称平衡树(AVL树):树上任一结点的左子树和右子树高度之差不超过1
结点的平衡因子=左子树高-右子树高。显然平衡二叉树结点的平衡因子的值只可能是-1,0,1。只要有任一结点的平衡因子绝对值大于1,就不是平衡二叉树
下图给出一个具体例子,标出了每个结点的平衡因子
相应可以在结点结构体内新增一个平衡因子参数
//平衡二叉树结点
typedef struct AVLNode{
int key; //数据域
int balance; //平衡因子
struct AVLNode *lchild,*rchild;
}AVLNode,*AVLTree;
上一小节的末尾提过让一棵二叉排序树保持平衡就能保证其查找效率达到 O ( l o g 2 n ) O(log_2n) O(log2n)数量级,因此我们着重关心在二叉排序树中插入新结点后如何保持平衡
在下面这个例子中插入67结点后,他查找路径上的所有结点都有可能受到影响。要让这些结点恢复平衡,就从插入点往回找到第一个不平衡结点(下图中为70结点),调整以该结点为根的子树(下图中该子树包含67,68,70三结点)。
该结点为根的子树又被叫做“最小不平衡子树”。每次调整的对象都是“最小不平衡子树”。在插入操作中,只要将最小不平衡子树调整平衡,则其他祖先结点都会恢复平衡
调整后的效果如下,具体怎么调整后面会讲解
分为四种情况
讨论在A结点的左孩子的左子树中插入新结点导致不平衡的情况
这里用方形的框来抽象地表示每一部分的子树,下面的H表示子树的高度,当然这些抽象的子树一开始都是平衡的。现在在A的左孩子(L),B结点的左子树(L)插入了一个新结点导致A结点不平衡。这是因为B结点的左子树长高高度变为H+1,A结点的左子树相应高度变为了H+2,导致A结点的平衡因子由1增至2,以A为根的子树失去平衡
注意这里假定了A是最小不平衡子树的根结点
因为这里讨论的是先假定插入后A是最小不平衡子树的根结点且插入新结点之后刚好导致不平衡的情况,所以要假定所有子树的高度都是H,尽管让AR高度为H+1和BR高度为H-1都能满足初始状态的树是平衡的
现在要调整最小不平衡子树,调整的目标是
1.恢复平衡
2.保持二叉排序树特性(二叉排序树的特性:左子树结点值<根结点值<右子树结点值,在上图反映为BL
调整需要进行LL平衡旋转(右单旋转)。即进行一次向右的旋转操作,将A的左孩子B向右上旋转代替A成为根结点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树
上面平衡二叉树的插入一小节中的例子即为一个LL型的具体例子
讨论在A结点的右孩子的右子树中插入新结点导致不平衡的情况
由于在结点A的右孩子(R)的右子树(R)上插入了新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡。调整需要进行RR平衡旋转(左单旋转)。即进行一次向左的旋转操作。将A的右孩子B向左上旋转代替A成为根结点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树
下面为一个RR型的具体处理例子
笔试考试手动调整完后记得检查是否符合左<根<右
实现右旋操作可以设置两个指针,f指针指向A结点,p指针指向B结点,gf为A的父结点,A为它的左孩子或右孩子。为了实现f向右下,p向左上旋转,可以
1.f->lchild=p->rchild //B的右子树变为A的左子树
2.p->rchild=f; //A结点变为B的右孩子
3.gf->lchild/rchild=p //让A的父结点原本指向A的孩子指针指向B
右旋操作实现后为
左旋操作也是类似的
实现f向左下旋转,p向左上旋转
1.f->rchild=p->lchild //B结点左孩子变为A的右孩子
2.p->lchild=f; //A结点变为B的左孩子
3.gf->lchild/rchild=p; //让A的父结点原本指向A的孩子指针指向B
讨论在A结点的左孩子的右子树中插入新结点导致不平衡的情况
由于在A的左孩子(L)的右子树(R)上插入新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡。调整需要进行LR平衡旋转(先左后右双旋转),即要进行两次旋转操作,先左旋后右旋转。先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置,然后再把该C结点向右上旋转提升到A结点的位置
上图是插入到了CR的位置,也有可能是插入到了CL的位置,这种情况处理的方式也是一样的
下面为一个LR型的具体处理例子
由于在A的右孩子(R)的左子树(L)上插入新结点,A的平衡因子由-1减到-2,导致以A为根的子树失去平衡。调整需要进行RL平衡旋转(先右后左双旋转),即进行两次旋转操作,先右旋转后左旋转。先将A结点的右孩子B的根结点C向右上旋转提升到B结点的位置,然后再把该C结点向左上旋转提升到A结点的位置
类似的无论新结点是插入CL还是CR导致了不平衡,处理的方式都是一样的
下面为一个RL的具体处理例子
以上总共有四种情况,为了便于记忆,可以总结出只有左孩子才能右上旋,只有右孩子才能左上旋,每一次旋转都能导致它和它的父结点父子关系互换的规律。在LR中处理A的左孩子的右孩子,它首先是一个右孩子,所以第一步只能让它左上旋替代A以前的左孩子,然后作为左孩子它只能右旋,RL也遵循相同的规律
插入操作导致“最小不平衡子树”高度+1,经过调整后高度恢复,所以在插入操作中只要将最小不平衡树调整平衡则其他祖先结点的平衡因子都会恢复
若树高为h,则最坏情况下,查找一个关键字最多需要对比h次,即查找操作的时间复杂度不可能超过 O ( h ) O(h) O(h)
平衡二叉树——树上任一结点的左子树和右子树的高度之差不超过1
假设以 n h n_h nh表示深度为h的平衡树中含有的最少结点数
则有 n 0 = 0 , n 1 = 1 , n 2 = 2 n_0=0,n_1=1,n_2=2 n0=0,n1=1,n2=2,并且有 n h = n h − 1 + n h − 2 + 1 n_h=n_{h-1}+n_{h-2}+1 nh=nh−1+nh−2+1
可以用数学证明含有 n n n个结点的平衡二叉树的最大深度为 O ( l o g 2 n ) O(log_2n) O(log2n),平衡二叉树的平均查找长度为 O ( l o g 2 n ) O(log_2n) O(log2n)
例4
例5
平衡二叉树 AVL:插入/删除 很容易破坏 ”平衡“ 特性,需要频繁调整树的形态。如:插入操作导致不平衡,则需要先计算平衡因子,找到不平衡子树(时间开销大),再进行 LL/RR/LR/RL 调整
红黑树 RBT:插入/删除 很多时候不会破坏 ”红黑“ 特性,无需频繁调整树的形态。即便需要调整,一般都可以在常数级时间内完成
平衡二叉树:适用于以查为主、很少插入/删除的场景
红黑树:适用于频繁插入、删除的场景,实用性更强
”左根右,根叶黑,不红红,黑路同“
红黑树是二叉排序树 ==>> 左子树结点值 ≤ \le ≤ 根结点值 ≤ \le ≤ 右子树结点值 ”左根右“
与普通BST相比,有什么要求 == >>
1.每个结点或是红色,或是黑色的
2.根节点是黑色的(根叶黑) ”根叶黑“
3.叶结点(外部结点、NULL结点、失败结点)均是黑色的
红黑树中的叶子结点并不是包含关键字的实际结点而是查找失败的结点,对应的是一个空指针
4.不存在两个相邻的红结点(即红结点的父节点和孩子结点均是黑色) ”不红红“
5.对每个结点,从该节点到任一叶结点的简单路径上,所含黑结点的数目相同 ”黑叶同“
struct RBnode { // 红黑树的结点定义
int key; // 关键字的值
RBnode* parent; // 父节点指针
RBnode* lchild; // 左孩子指针
RBnode* rchild; // 右孩子指针
int color; // 结点颜色,如:可用 0/1 表示 黑/红,也可使用枚举型enum表示颜色
};
结点的黑高 bh —— 从某结点出发(不含该结点)到达任一空叶结点的路径上黑结点总数
根节点黑高为 h h h的红黑树,内部结点数(关键字)至少有多少个?至多有多少个?
内部结点数最少的情况 —— 总共 h h h层黑结点的满树形态
如果不是满二叉树就会违反黑路同的特性
内部结点数最多的情况 —— h h h层黑结点,每一层黑结点下面都铺满一层红结点。共 2 h 2h 2h层的满树形态
故若根节点黑高为 h h h,内部结点数(关键字)最少有 2 h − 1 2^h-1 2h−1个,最多有 2 2 h − 1 2^{2h}-1 22h−1个
性质1:从根节点到叶结点的最长路径不大于最短路径的2倍,任一结点的左右子树高度之差不会超过2倍(由 ”不红红“ 和 ”黑路同“ 推得)
性质2:有 n n n个内部节点的红黑树高度 h ≤ 2 l o g 2 ( n + 1 ) h\le 2log_2(n+1) h≤2log2(n+1) —> 红黑树查找操作时间复杂度 = O ( l o g 2 n ) =O(log_2n) =O(log2n),查找效率与AVL树同等数量级
性质1证明:任何一条查找失败路径上黑结点数量都相同,而路径上不能连续出现两个红结点,即红结点只能穿插在各个黑结点中间
性质2证明:若红黑树总高度 = h =h =h,则根节点黑高 ≥ h / 2 \ge h/2 ≥h/2,因此内部结点数 n ≥ 2 h / 2 − 1 n\ge 2^{h/2}-1 n≥2h/2−1,由此推出 h ≤ 2 l o g 2 ( n + 1 ) h\le 2log_2(n+1) h≤2log2(n+1)
与BST、AVL相同,从根出发,左小右大,若查找到一个空叶节点,则查找失败
例:从一棵空的红黑树开始,插入:20, 10, 5, 30, 40, 57, 3, 2, 4, 35, 25, 18, 22, 23, 24, 19, 18
插入红结点不会增加黑路的长度 。所以新插入的结点如果是根结点就直接染黑,如果不是根结点就染红并只需要关注这个新结点的加入有没有破坏 ”不红红“ 的特性来决定是否要进行调整
插入20, 10, 5
插入30
插入40
插入57
插入22
插入23
插入18
当出现两个相同的关键字时,可以根据实际的应用场景决定是优先插入到左边还是优先插入到右边
1.红黑树删除操作的时间复杂度 = O ( l o g 2 n ) =O(log_2n) =O(log2n)
2.在红黑树中删除结点的处理方式和 “二叉排序树的删除” 一样
3.按2删除结点后,可能破坏 “红黑树特性”,此时需要调整结点颜色、位置,使其再次满足 “红黑树特性”。
将二叉查找树(BST)变为 m m m叉查找树
//5叉排序树的结点定义
struct Node {
ElemType keys[4]; //最多4个关键字
struct Node * child[5]; //最多5个孩子
int num; //结点中有几个关键字
}
为了保证查找效率,进一步:
1. m m m叉查找树中,规定除了根节点外,任何结点至少有 [ m / 2 ] [m/2] [m/2]个分叉,即至少含有 [ m / 2 ] − 1 [m/2]-1 [m/2]−1个关键字
eg:对于5叉排序树,规定除了根节点外,任何结点都至少有3个分叉,2个关键字
2. m m m叉查找树中,规定对于任何一个结点,其所有子树的高度都要相同。
B树,又称多路平衡查找树,B树中所有结点的孩子个数的最大值称为B树的阶,通常用 m m m表示。一棵 m m m阶B树或为空树,或为满足如下特性的 m m m叉树:
1.树中每个结点至多有 m m m棵子树,即至多含有 m − 1 m-1 m−1个关键字。
2.若根结点不是终端结点,则至少有两棵子树。
3.除根结点外的所有非叶结点至少有 [ m / 2 ] [m/2] [m/2](向上取整)棵子树,即至少含有 [ m / 2 ] − 1 [m/2]-1 [m/2]−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\lt K_2\lt ...\lt K_n K1<K2<...<Kn; P i ( i = 0 , 1 , . . . , n ) P_i(i=0,1,...,n) Pi(i=0,1,...,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 ] − 1 ≤ n ≤ m − 1 ) n([m/2]-1\le n \le m-1) n([m/2]−1≤n≤m−1)为结点中关键字的个数。
5.所有的叶结点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)。
m m m阶B树的核心特性:
1.根节点的子树数 ∈ [ 2 , m ] \in [2,m] ∈[2,m],关键字数 [ 1 , m − 1 ] [1,m-1] [1,m−1]。其它结点的子树数 ∈ [ [ m / 2 ] , m ] \in [[m/2],m] ∈[[m/2],m];关键字 ∈ [ [ m / 2 ] − 1 , m − 1 ] \in [[m/2]-1,m-1] ∈[[m/2]−1,m−1]
2.对任一结点,其所有子树高度都相同
3.关键字的值:子树0 < \lt <关键字1 < \lt <子树1 < \lt <关键字2 < \lt <子树2 < \lt < … (类比二叉查找树 左 < \lt <中 < \lt <右)
注:大部分学校算B树的高度不包括叶子结点(失败结点)
含 n n n个关键字的 m m m阶B树,最小高度、最大高度是多少?
log m ( n + 1 ) ≤ h ≤ l o g [ m / 2 ] n + 1 2 + 1 \log_m(n+1)\le h \le log_{[m/2]}\frac{n+1}{2}+1 logm(n+1)≤h≤log[m/2]2n+1+1
最小高度 —— 让每个结点尽可能的满,有 m − 1 m-1 m−1个关键字, m m m个分叉,则有 n ≤ ( m − 1 ) ( 1 + m + m 2 + m 3 + . . . + m h − 1 ) = m h − 1 n\le (m-1)(1+m+m^2+m^3+...+m^{h-1})=m^h-1 n≤(m−1)(1+m+m2+m3+...+mh−1)=mh−1,因此 h ≥ l o g m ( n + 1 ) h\ge log_m(n+1) h≥logm(n+1)
最大高度 —— 让各层的分叉尽可能的少,即根节点只有2个分叉,其他结点只有 [ m / 2 ] [m/2] [m/2]个分叉
各层结点至少有:第一层1、第二层2、第三层 2 [ m / 2 ] 2[m/2] 2[m/2] … 第 h h h层 2 ( [ m / 2 ] ) h − 2 2([m/2])^{h-2} 2([m/2])h−2,第 h + 1 h+1 h+1层共有叶子结点(失败结点) 2 ( [ m / 2 ] h − 1 ) 2([m/2]^{h-1}) 2([m/2]h−1)个
n n n个关键字将数域切分为 n + 1 n+1 n+1个区间,对应 n + 1 n+1 n+1种失败情况,这些失败情况都会体现在叶子结点当中,所以 n n n个关键字的B树必有 n + 1 n+1 n+1个叶子结点,则 n + 1 ≥ 2 ( [ m / 2 ] ) h − 1 n+1\ge 2([m/2])^{h-1} n+1≥2([m/2])h−1,即 h ≤ l o g [ m / 2 ] n + 1 2 + 1 h\le log_{[m/2]}\frac{n+1}{2}+1 h≤log[m/2]2n+1+1
或者从另外一个角度出发,让每个结点包含的关键字、分叉尽可能少。记 k = [ m / 2 ] k=[m/2] k=[m/2]
h h h层的 m m m阶B树至少包含关键字总数 1 + 2 ( k − 1 ) ( K 0 + k 1 + k 2 + . . . + k h − 2 ) = 1 + 2 ( k h − 1 − 1 ) 1+2(k-1)(K^0+k^1+k^2+...+k^{h-2})=1+2(k^{h-1}-1) 1+2(k−1)(K0+k1+k2+...+kh−2)=1+2(kh−1−1)
若关键字总数少于这个值,则高度一定小于 h h h,因此 n ≥ 1 + 2 ( k h − 1 − 1 ) n\ge 1+2(k^{h-1}-1) n≥1+2(kh−1−1)
得, h ≤ l o g k n + 1 2 + 1 = l o g [ m / 2 ] n + 1 2 + 1 h\le log_k\frac{n+1}{2}+1=log_{[m/2]}\frac{n+1}{2}+1 h≤logk2n+1+1=log[m/2]2n+1+1
新元素一定是插入到最底层 ”终端节点“,用 ”查找“ 来确定插入位置
在插入 k e y key key后,若导致原结点关键字数超过上限,则从中间位置( [ m / 2 ] [m/2] [m/2],向上取整)将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放在新结点中,中间位置( [ m / 2 ] [m/2] [m/2])的结点插入原结点的父结点中指向原结点的指针右边的位置。若此时导致其父结点的关键点个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进而导致B树高度增1
尝试建立一棵5阶B树,结点关键字个数 2 ≤ n ≤ 4 2\le n \le 4 2≤n≤4
插入80
插入90
插入70
插入75
对非终端结点关键字的删除,必然可以转化为对终端结点的删除操作。若被删除关键字在非终端节点,则用直接前驱或直接后继来替代被删除的关键字
直接前驱:当前关键字左侧指针所指子树中”最右下“的元素
直接前驱:当前关键字左侧指针所指子树中”最右下“的元素
若被删除关键字在终端节点,则直接删除该关键字(要注意节点关键字个数是否低于下限 [ m / 2 ] − 1 [m/2]-1 [m/2]−1)
否则兄弟够借。若被删除关键字所在结点删除前的关键字个数低于下限,且与此结点右(或左)兄弟结点的关键字个数还很宽裕,则需要调整该结点、右(或左)兄弟结点及其双亲结点(父子换位法)
说白了,当右兄弟很宽裕时,用当前结点的后继、后继的后继来填补空缺。当左兄弟很宽裕时,用当前结点的前驱、前驱的前驱来填补空缺
兄弟不够借。若被删除关键字所在结点删除前的关键字个数低于下限,且此时与该结点相邻的左、右兄弟结点的关键字个数均 = [ m / 2 ] − 1 =[m/2]-1 =[m/2]−1,则将关键字删除后与左(或右)兄弟结点及双亲结点中的关键字进行合并
在合并过程中,双亲结点中的关键字个数会减1。若其双亲结点是根结点且关键字个数减少至0(根结点关键字个数为1时,有2棵子树),则直接将根结点删除,合并后的新结点成为根;若双亲结点不是根结点。且关键字个数减少到 [ m / 2 ] − 2 [m/2]-2 [m/2]−2,则又要与它自己的兄弟结点进行调整或合并操作,并重复上述步骤,直至符合B树的要求为止
删除60
删除80
删除77
删除38
删除90
删除49
一棵 m m m阶的B+树需满足下列条件:
1.每个分支结点最多有 m m m棵子树(孩子结点)。
2.非叶根结点至少有两棵子树,其他每个分支结点至少有 [ m / 2 ] [m/2] [m/2]棵子树。
可以理解为:要追求 “绝对平衡”,即所有子树高度要相同。
3.结点的子树个数与关键字个数相等。
4.所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互链接起来支持顺序查找。
5.所有分支结点中仅包含它的各个子结点中关键字的最大值及指向其子结点的指针。
B+树同时支持多路查找和顺序查找
B树查找成功,可能停在任何一层。B+树中,如果只是在分支结点找到要查找的关键字,查找并没有结束,只有找到最下层的叶子结点后才可以找到某一个关键字实际对应的记录的存放位置。无论查找成功与否,最终一定都要走到最下面一层结点
m m m阶B+树:
1.结点中的 n n n个关键字对应 n n n棵子树
2.根节点的关键字数 n ∈ [ 1 , m ] n\in [1,m] n∈[1,m],其他结点的关键字数 n ∈ [ [ m / 2 ] , m ] n\in [[m/2], m] n∈[[m/2],m]
3.在B+树中,叶结点包含全部关键字,非叶结点中出现过的关键字也会出现在叶结点中
4.在B+树中,叶结点包含信息,所有非叶结点仅起索引作用,非叶结点中的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址。
m m m阶B树:
1.结点中的 n n n个关键字对应 n + 1 n+1 n+1棵子树
2.根节点的关键字数 n ∈ [ 1 , m − 1 ] n\in [1,m-1] n∈[1,m−1],其他结点的关键字数 n ∈ [ [ m / 2 ] − 1 , m − 1 ] n\in [[m/2]-1, m-1] n∈[[m/2]−1,m−1]
3.在B树中,各结点中包含的关键字是不重复的
4.B树的结点中都包含了关键字对应的记录的存储地址
操作系统对于磁盘的读写一般以磁盘块为单位,一般B+树和B树的不同结点就存放在不同磁盘块当中,对于B+树的查找就是反复将各个结点对应的磁盘块读入内存处理,最终得到要查找的关键字对应记录的存放位置
在B+树中,非叶结点不含有该关键字对应记录的存储地址。可以使一个磁盘块可以包含更多个关键字,使得B+树的阶更大,树高更矮,读磁盘次数更少,查找更快。典型应用:关系数据库的“索引”(如MySQL)
散列表( H a s h T a b l e Hash\quad Table HashTable),又称哈希表。是一种数据结构,特点是:数据元素的关键字与其存储地址直接相关。
而建立“关键字”与“存储地址”的联系要通过“散列函数(哈希函数)”: A d d r = H ( K e y ) Addr=H(Key) Addr=H(Key)
若不同的关键字通过散列函数映射到同一个值,则称它们为“同义词”
通过散列函数确定的位置已经存放了其他元素,则称这种情况为“冲突”
可以用拉链法(又称链接法,链地址法)处理“冲突”:把所有“同义词”存储在一个链表中
上述定义可能比较抽象,可以通过观察下面这个哈希表的具体例子来体会一下
例:有一堆数据元素,关键字分别为 19 , 14 , 23 , 1 , 68 , 20 , 84 , 27 , 55 , 11 , 10 , 79 {19,14,23,1,68,20,84,27,55,11,10,79} 19,14,23,1,68,20,84,27,55,11,10,79,散列函数 H ( k e y ) = k e y % 13 H(key)=key\%13 H(key)=key%13
以在上节给的哈希表的具体例子为例
若查找关键字27,先通过散列函数计算目标元素存储地址: A d d r = H ( K e y ) Addr=H(Key) Addr=H(Key)。 27 % 13 = 1 27\%13=1 27%13=1,故27这个元素如果存在的话肯定在1这个位置所链接的链表当中,接下来就在这个链表中依次检查各元素的值。找到第3个元素的时候查找成功,故该例中27的查找长度=3
若查找关键字21, 21 % 13 = 8 21\%13=8 21%13=8,而8所指的链表的头指针是空的,故21元素并不存在。此时查找长度为0
不过有的教材也会把“空指针”的判定算作一次比较,这样的话查找长度就是1而不是0了
计算查找成功时的平均查找长度需要按照公式算出各个元素的查找长度与查找概率的乘积然后求和。以上为例,不难得
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或者 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
由第二种计算方式可以发现“冲突”越多,查找效率越低。最理想的状态是所有的关键字都没有同义词,这种情况下对所有关键字的查找都只要对比一次,此时散列查找时间复杂度可到到 O ( 1 ) O(1) O(1)。
如何设计冲突更少的散列函数的问题先放一下,下一小节会讨论
计算查找失败时的平均查找长度时,假定查找失败的关键字映射到任何一个地址的概率都是相同的(如上例中总共可能映射到13个地址)。以上为例,不难得
A S L 失败 = 0 + 4 + 0 + 2 + 0 + 0 + 2 + 1 + 0 + 0 + 2 + 1 + 0 13 ASL_{失败}=\frac{0+4+0+2+0+0+2+1+0+0+2+1+0}{13} ASL失败=130+4+0+2+0+0+2+1+0+0+2+1+0
可以发现分子等于散列表中存储的数据元素总数,分母等于散列表的长度。由此可以引入概念
装填因子 α \alpha α=表中记录数/散列表长度
这个参数表示散列表装的有多满。参数越大,说明装的越满,同时发生冲突的可能性也越大,故装填因子会直接影响散列表的查找效率
补充:
"拉链法"在插入新元素时,保持关键字有序,可微微提高查找效率
散列查找是典型的“用空间换时间”的算法,只要散列函数设计的合理,则散列表越长,冲突的概率越低。故散列函数的设计目标是让不同关键字的冲突尽可能地少。而一般可以根据具体情况选用如下方案
H ( k e y ) = k e y % p H(key)=key\%p H(key)=key%p
散列表表长为m,取一个不大于m但接近或等于m的质数p
例:散列表表长15,散列函数 H ( k e y ) = k e y % 13 H(key)=key\%13 H(key)=key%13
用质数取模,分布更均匀,冲突更少(原因参见数论)。如下例
设:可能出现的关键字= { 2 , 4 , 6 , 8 , 10 , 12 } \{2,4,6,8,10,12\} {2,4,6,8,10,12}
散列函数的设计要结合实际的关键字分布特点来考虑,不要教条化。如下例中给出的关键字是一些连续的自然数,除留余数法没有优势。如果像上面一样有可能出现的关键字只能是偶数,除留余数法的效果就更好。这当中的具体原因见数论,这里不加深入解释
设:可能出现的关键字= { 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 , . . . . . . } \{1,2,3,4,5,6,7,8,9,10,......\} {1,2,3,4,5,6,7,8,9,10,......}
补充:
现实中什么时候有可能会出现关键字都是偶数或者偶数偏多的情况呢?
如中国人更喜欢双数,车牌号双数更多。这种情况下用除模取余法就会比较好
H ( k e y ) = k e y H(key)=key H(key)=key或 H ( k e y ) = a ∗ k e y + b H(key)=a*key+b H(key)=a∗key+b
其中,a和b是常数。这种方法计算最简单,且不会产生冲突。它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间的浪费
例:存储同一个班级的学生信息,班内学生学号为(1120112176~1120112205)。 H ( k e y ) = k e y H(key)=key H(key)=key-1120112176
设关键字是r进制数(如十进制数),而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等;而在某些位上分布不均匀,只有某几种数码经常出现,此时可选取数码分布较为均匀的若干位作为散列地址。这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数
例:以“手机号码”作为关键字设计散列函数。
电话号码一般有13个数,这13个数在各个位置上出现的频率一般是不相同的,很多人电话号码开头都是一样的。而电话号码的后四位分布一般比较均匀,没什么规律,因此这几个分布更均匀的数码位可以取出来作为散列地址
取关键字的平方值的中间几位作为散列地址
具体取多少位要视实际情况而定。这种方法得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀,适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数
思考:什么叫与关键字的每位都有关系?
例:要存储整个学校的学生信息,以“身份证号”作为关键字设计散列函数
身份证号码规则:
前1、2位数字表示:所在省份的代码;
第3、4位数字表示:所在城市的代码;
第5、6位数字表示:所在区县的代码;
第7-14位数字表示:出生年、月、日;
第15、16位数字表示:所在地的派出所的代码;
第17位数字表示性别:奇数表示男性,偶数表示女性;
第18位数字是校检码。
身份证号各个位置的数字分布一般是不均匀的。如对于同届来说出生年份一般是相同的,这种情况下就可以用平方取中法
上面几小节中处理冲突的方法都是拉链法,接下来介绍另一种处理冲突的方法——开放定址法。这种方法数组中存放的是一个个元素,而不是像拉链法一样会存放一个链表
例:有一堆数据元素,关键字分别为 19 , 14 , 23 , 1 , 68 , 20 , 84 , 27 , 55 , 11 , 10 , 79 {19,14,23,1,68,20,84,27,55,11,10,79} 19,14,23,1,68,20,84,27,55,11,10,79,散列函数 H ( k e y ) = k e y % 13 H(key)=key\%13 H(key)=key%13
所谓开放定址法,是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。其数学递推公式为:
H i = ( H ( k e y ) + d i ) % m H_i=(H(key)+d_i)\%m Hi=(H(key)+di)%m
i = 0 , 1 , 2 , . . . , k ( k ≤ m − 1 ) i=0,1,2,...,k(k\le m-1) i=0,1,2,...,k(k≤m−1), m m m表示散列表表长; d i d_i di为增量序列; i i i可理解为“第 i i i次发生冲突”。按照设计增量 d i d_i di的方法又可分为线性探测法,平方探测法,伪随机序列法
d i = 0 , 1 , 2 , 3 , . . . , m − 1 d_i=0,1,2,3,...,m-1 di=0,1,2,3,...,m−1;即发生冲突时,每次往后探测相邻的下一个单元是否为空
以上为例,现在要存入1
H ( k e y ) = 1 % 13 = 1 H 0 = ( 1 + d 0 ) % 16 = 1 H(key)=1\%13=1\quad H_0=(1+d_0)\%16=1 H(key)=1%13=1H0=(1+d0)%16=1,由于1位置已经存入元素,所以接下来探测 H 1 H_1 H1。 H 1 ( 1 + d 1 ) % 16 = 2 H_1(1+d_1)\%16=2 H1(1+d1)%16=2,2为发生冲突后重新计算得到的哈希地址,2为空,故元素1存入2位置
后面的元素都是类似的,用哈希函数算出一个关键字的存放地址之后,如果地址冲突就依次计算 H 1 , H − 2 , . . . , H m − 1 H_1,H-2,...,H_{m-1} H1,H−2,...,Hm−1,一步一步往后探测存储单元直到发现空位存入
注意公式中是对表长 m m m取模,故哈希函数值域是 [ 0 , 12 ] [0,12] [0,12],冲突处理函数值域则是 [ 0 , 15 ] [0,15] [0,15],如果要在下表中存入25,最终会存入位置13
以上为例
要查找27, H ( k e y ) = 27 % 13 = 1 H(key)=27\%13=1 H(key)=27%13=1(匹配失败) → H 1 = 2 \to H_1=2 →H1=2(匹配失败) → H 2 = 3 \to H_2=3 →H2=3(匹配失败) → H 3 = 4 \to H_3=4 →H3=4(4位置所存元素为27,查找成功)。故27的查找长度=4,这个过程中同义词1,14和非同义词68都需要被检查
要查找21, H ( k e y ) = 21 % 13 = 8 H(key)=21\%13=8 H(key)=21%13=8(匹配失败) → H 1 = 9 \to H_1=9 →H1=9(匹配失败)$ \to H_2=10 (匹配失败) (匹配失败) (匹配失败) \to H_3=11 (匹配失败) (匹配失败) (匹配失败) \to H_4=12 (匹配失败) (匹配失败) (匹配失败)\to H_5=13$(空位置),发现13为空位置,故查找失败,21的查找长度=6。
注意空位置的判断也要算作一次比较
通过这个例子可以看到,在查找失败的情况下我们从某一个位置出发,然后用线性探测法的规则一路往后扫,一直扫到第一个真正空缺的位置才能确认查找失败。故如果让很多的元素扎堆聚集在一块,查找效率可能会受到影响,如果这些元素之间有一些空缺,查找效率有可能会得到提升
越早遇到空位置,就可以越早确定查找失败
之前已经规定了碰到空位置,查找失败。所以删除时不能简单地把位置清空
注意:采用“开放定址法”时,删除结点不能简单地将被删结点地空间置为空,否则将截断在它之后填入散列表的同义词结点的查找路径,可以做“删除标记”,进行逻辑删除
这一注意点对后面的伪随机序列法和平方探测法都适用
由于只是逻辑删除,故当删除了很多结点后表会看起来很满,但实际上很空,这是开放定址法的一大弊端
先看查找成功时的平均查找长度,要老老实实套公式。以上为例
故 A S L 成功 = 1 + 1 + 1 + 2 + 4 + 1 + 1 + 3 + 3 + 1 + 3 + 9 12 = 2.5 ASL_{成功}=\frac{1+1+1+2+4+1+1+3+3+1+3+9}{12}=2.5 ASL成功=121+1+1+2+4+1+1+3+3+1+3+9=2.5
再看查找失败时的平均查找长度。由于初次探测的地址 H 0 H_0 H0只有可能在 [ 0 , 12 ] [0,12] [0,12],故查找失败的情况有13种。其中如果要找的关键字被映射到0位置,由于0位置为空,故只需要对比1次就能确定查找失败。如果关键字被映射到其他位置,查找失败时,根据线性探测法的规则就要把后续关键字全部对比一遍直到遇到空位置
故 A S L 失败 = 1 + 13 + 12 + 11 + . . . + 4 + 3 + 2 13 = 7 ASL_{失败}=\frac{1+13+12+11+...+4+3+2}{13}=7 ASL失败=131+13+12+11+...+4+3+2=7
这个算法的效率之所以不高,是因为线性探测法很容易造成同义词,非同义词的“聚集(堆积)”现象,严重影响查找效率
产生原因:冲突后再探测一定是放在某个连续的位置
要解决聚集的问题,可以用平方探测法
当 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\le m/2 k≤m/2
以下表为例,现在要塞入以下元素,所有的元素对13取余都是6
依据上述规则,按照19,32,45,58,71,84的顺序存入上表。下举一例
假设现在要存入元素58
代入哈希函数可知前面几次探测都是失败的,最终要发生四次冲突然后存入位置2
其他元素的存入也遵循相同的规律,不难得出最终结果
注意存取84时取模运算结果是-3,故存在位置24
可以看出平方探测法比起线性探测法不易产生“聚集(堆积)”问题。如果现在要查找71,查找长度为7
非重点小坑:散列表长度 m m m必须是一个可以表示成 4 j + 3 4j+3 4j+3的素数,才能探测到所有位置。
下面仅举一例说明,详细的证明见数论
d i d_i di是一个伪随机序列,如 d i = 0 , 5 , 24 , 11 , . . . d_i=0,5,24,11,... di=0,5,24,11,...
以下表为例,现在要塞入以下元素
计算后不难得出最终存取结果
除了开放定址法,还有一种处理冲突的方法——再散列法
再散列法(再哈希法):除了原始的散列函数 H ( k e y ) H(key) H(key)之外,多准备几个散列函数,当散列函数冲突时,用下一个散列函数计算一个新地址,直到不冲突为止:
H i = R H i ( K e y ) i = 1 , 2 , 3 , . . . , k H_i=RH_i(Key)\quad\quad\quad i=1,2,3,...,k Hi=RHi(Key)i=1,2,3,...,k
对再散列法的定义请以上为准,书上对再散列法的定义看看就行,有点问题
笔试考察线性探测法和平方探测法较多,但在实际应用中开放地址法用的其实并不多,还是拉链法的应用更广泛些。像 j a v a java java中的 H a s h S e t HashSet HashSet和 H a s h M a p HashMap HashMap实际上都是用拉链法实现的