文章目录
- 一、查找的基本概念
- 二、顺序查找和折半查找
- 2.1顺序查找
- 2.3折半查找
- 2.3.1算法思想
- 2.3.2代码实现
- 2.3.3查找效率分析
- 2.3.4折半查找判定树的构造
- 2.3.5折半查找效率
- 2.3.6小结
- 2.4分块查找
- 三、树形查找
- 3.1二叉排序树
- 3.1.1二叉排序树定义
- 3.1.2查找操作
- 3.1.3插入操作
- 3.1.4二叉排序树的构造
- 3.1.5二叉排序树的删除
- 3.1.6查找效率分析
- 3.1.7小结
- 3.2平衡二叉树的插入
- 3.2.1引子
- 3.2.2LL
- 3.2.3RR
- 3.2.4LR
- 3.2.5RL
- 3.2.5小结
- 3.3平衡二叉树的删除
- 四、B树和B+树
- 4.1B树
- 4.2B树的插入删除
- 4.2.1插入
- 4.2.2删除
- 4.2.3小结
- 4.3B+树
- 4.3.1定义
- 4.3.2B+树的查找
- 4.3.3B+树和B树的对比
- 4.3.4小结
- 五、散列表
- 5.1散列表的基本概念
- 5.1.1散列表、散列函数
- 5.1.2冲突、同义词
- 5.1.3关于散列表,有待解决的问题
- 5.2散列函数的构造
- 5.2.1除留余数法
- 5.2.2直接定址法
- 5.2.3数字分析法
- 5.2.4平方取中法
- 5.2.5小结
- 5.3处理冲突的方法——拉链法
- 5.3.1插入
- 5.3.2查找
- 5.3.3删除
- 5.3.4小结
- 5.4处理冲突的方法——开放定址法
- 5.4.1基本原理
- 5.4.2线性探测法
- 5.4.3平方探测法
- 5.4.3双散列法
- 5.4.4伪随机序列法
- 5.4.5删除
一、查找的基本概念
ps:查找表可以是线性结构、树状结构、图状结构等等
评价一个查找算法的优劣:主要看算法的平均查找长度ASL
举个例子,我们现在有如下二叉排序树
如果你要查的是50,那么从根节点出发只需要对比一次关键字就可以了,所以第一项是1 * 1
如果你要查的是第二层的26或者66,你找26总共需要进行两轮对比,找66又需要两轮对比,所以查第二层元素共需要是2 * 2次对比
第三层同理,长度为3,共4个数据,所以3 * 4
第四层同理,长度为4,共1个数据,所以4 * 1
然后累和除8,这里8是指一共8个数据,平均到每个数据身上是1/8
我们之前还讨论过二叉排序树查找失败的情况,所以在评价一个查找算法的查找效率时,我们通常会分开考虑查找成功和查找失败两种情况下的平均查找长度
比如同样的二叉排序树,查找成功和查找失败的ASL是不同的:
ASL的数量级可以直接反映出你的查找算法的时间复杂度
ASL的计算也是考研中的重点,请务必掌握
二、顺序查找和折半查找
2.1顺序查找
顺序查找顾名思义,就是按照顺序一个个找,你可以从前往后,也可以从后往前。这个是最无脑的查找。
代码如下:
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号位置空出来,放你要找的那个东西
这种哨兵就是从后往前找,如果找到哨兵的下标,也就是这里的0,就说明查找失败了。
我们前面说过,当我们在评价一个查找算法时,我们通常是算它的平均查找长度ASL。而平均查找长度又分为查找成功和查找失败两种情况。
查找成功情况:
如果是带哨兵的实现方式,我们是从最后的位置开始往前扫描。
如果是找最后一个关键字,那你所需要对比关键字的次数就是1。
如果假设我们找任何一个关键字概率都是相同的,那么找最后一个关键字的概率就是1/n
查找长度为1 * (1/n)
如果是找倒数第二个关键字,那要对比关键字次数就是2
查找长度为2 * (1/n)
下面以此类推…
如果是查找失败,那就是把所有的n个元素对比完了,还要再和哨兵对比一下。
总之,不管是查找成功还是查找失败,时间复杂度都是O(n)
顺序查找的优化(对有序表)
如果要查的表本来就是有序的,那我们查找可以进一步优化
举个例子
你要在如下的顺序表中进行查找21的操作
其实不用遍历完所有元素,当你遍历到29发现还没找到21,就已经可以不用找了,因为后面全是比21大的元素。
顺序查找的优化(被查概率不相等情况)
如果是被查概率不同的,我们可以把被查找概率更大的元素放在查找相对靠前的位置
如果采取这种策略,可以减少查找成功的平均查找长度
但是对于查找失败的情况,我们只能从头扫到尾,来确定是不是查找失败。
所以,如果你实际运用中如果查找成功更多,用这种方法会更好一些。
2.3折半查找
2.3.1算法思想
折半查找又称为二分查找,它只适用于有序的顺序表
先看一个查找成功的例子:
比如现在要在下面的顺序表中找33
我们会先用两个指针low和high分别指向我们目前要搜索的区间范围
第一轮要检查的元素是low和high中间的一个元素,我们用指针mid来指向它
mid的计算方式也很简单,就是(low+high)/2
比如这里high=10,low=0,mid=(low+high)/2=5
所以我们第一轮要检查的元素就是29,对比mid指向的元素29和我们要查的33
发现29<33,如果33存在,那么肯定是在我们mid的右边
low=mid+1,
mid=(low+high)/2
第二轮
对比mid指向的元素37和要查找的元素33
33<37,如果33存在,那应该在mid左边
high=mid-1
mid=(low+high)/2
第三轮
对比mid指向的元素32和要查找的元素33
32<33
那么33肯定在mid右边
low=mid+1
mid=(low+high)/2
下一轮检查发现刚好mid指向值等于33,那么查找成功。
再看一个查找失败的例子:
还是刚才那个表,我们现在要查找一个不存在的元素12
刚开始还是一样的,low和high指向顺序表开头和结尾元素
mid=(low+high)/2
第一轮对比,12<29,那么12只会出现在mid左边
第二轮对比12<13,如果存在只会在mid左边
第三轮对比,7<12,如果存在,12在mid右边
第四轮对比,10<12,如果存在,12在mid右边
此时出现了low>high的情况,那么查找失败
2.3.2代码实现
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;
}
2.3.3查找效率分析
2.3.4折半查找判定树的构造
考研中还是比较喜欢喜欢考察折半查找判断树的构造,下面我们来进行介绍
2.3.5折半查找效率
2.3.6小结
ps:只能说大部分情况折半查找比顺序查找效率更高。
因为如果你要查的元素就是顺序表第一个元素,顺序查找是要更快的。
2.4分块查找
分块查找我们主要是介绍算法思想,考试中很少考代码题,大家会用手算模拟即可。
比如下面这个数组,看起来元素都是乱的
如果仔细观察,我们可以把这些元素分成一个个小区间
第一个区间:<=10
第二个区间:<=20
第三个区间:<=30
…
如果单独看元素是乱序的,但是如果我们把它们分成一个个小区间后,可以发现各个区间之间其实是有序的,我们可以给这个查找表建立上一级索引。
看上图,很好理解。索引表中保存的是每一个分块中最大的关键字,还有分块的存储区间。
比如说我们索引表中有个30,那就说明其中出现最大的关键字是30,然后这个分块的存储区间是6-8,也就是第三个分块下标起始到末尾是6到8
所以分块查找的特点:快内无序,快间有序
该索引表数据结构如下:
//索引表
typedef struct{
ElemType maxValue;//每个表项最大关键字
int low,high;//分块的区间范围
}Index;
//顺序表存储实际元素
ElemType List[100];
先来看一个查找成功的例子:
比如现在查22
先查找索引表,从索引表第一个元素依次往后找,
第一个元素10<22,继续往下
第二个元素20<22,继续往下
第三个元素30>=22,如果22存在,一定在此区间
接下来就从分块的起始位置,也就是6号下标往8号下标里找
我们在7号下标找到22
再来看一个查找失败的例子:
现在要查29,依次遍历索引表,确定如果29存在是在第三个分块内
然后我们从第三个分块开始查找,第一个27不满足
第二个22不满足
第三个30不满足
再往后,发现已经超出第三个分块范围了,就说明29不存在
刚才在查找索引表时,我们是按顺序查找的方式,也就是从索引表的第一个元素一个个往后找
但是由于索引表中保存的元素是有序的,并且我们索引表是用数组的方式,也就是顺序存储的方式来实现的。所以,针对索引表,我们可以采用折半查找,而对每个分块内的元素(乱序),只能顺序查找。
ps:分块查找又称为索引顺序查找,先索引表,然后顺序查找分块内元素嘛
三、树形查找
3.1二叉排序树
3.1.1二叉排序树定义
二叉树又称二叉查找树BST
二叉排序树的左子树结点值<根节点值<右子树值,如果用中序遍历(左根右),就可以得到一个递增的序列。
二叉排序树的左子树和右子树也是一棵二叉排序树
3.1.2查找操作
先看一个查找成功的例子:
假设我们现在要在一棵二叉排序树上找30
从19出发,19<30,如果30存在,则在19的右子树
50>30,如果30存在,则在50的左子树
26<30,如果30存在,则在26的右子树
成功找到30
再看一个查找失败的例子:
比如现在要查12
从19出发,如果12存在,则在19左子树
13>12,如果12存在,则在13左子树
11<12,如果12存在则在11右子树
但是我们发现,11没有右子树了,说明查找失败了
查找代码如下:
//二叉排序树结点
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;//key小于当前值,如果key存在,在左子树上
}
else{
T=T->rchild;//key大于当前值,如果key存在,在右子树上
}
}
return T;
}
上面的算法是用一个while循环,来找到目标结点(非递归)
下面的算法则是由于二叉排序树的递归特性,我们用递归来实现(递归)
//在二叉排序树中查找值为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);//在右子树查找
}
}
如果采用非递归
显然,如果采用非递归算法,我们只需要常数级的辅助空间。非递归实现空间复杂度为O(1)
但是如果用递归实现的话,一棵树有多高,它就有可能要往下递归几次。而每次递归都需要在函数调用栈中分配一片空间。递归实现空间复杂度为O(h)
3.1.3插入操作
对于插入操作,插入新结点后,我们也需要保证二叉排序树的一个特性,所以我们首先也需要用到查找操作的那种逻辑找到我们应该插入的具体位置。
比如我们现在要插入12这个数据
12<19,应该插到12左子树
12<13,应该插到13的左子树
12>11,应该插到11的右子树
需要注意,如果我们要插入的这个关键字本来就存在了,那么不该再插入值重复的结点了。另外,每次插入的新结点必定会成为一个叶子结点。
我们这里采用的是递归实现,所以这种算法的最坏空间复杂度为O(h),h为树的高度
显然,插入操作也可以用循环的方式来实现,也就是用非递归的方式实现,非递归算法空间复杂度会更低一些。这个大家自己去练习
3.1.4二叉排序树的构造
其实就是不断插入结点的过程,插入结点你调用刚才介绍的插入函数就行了
//按照str[]中的关键字序列建立二叉排序树
void Create_BST(BSTree &T,int str[],int n){
T=NULL;//初始时T为空树
int i=0;
while(i<n){//依次将每个关键字插入到二叉排序树中
BST_Insert(T,str[i]);
i++;
}
}
同样的数据,如果我们把插入顺序换一下,也会得到不同的二叉排序树
这也是一个常考的内容,就是给你一个二叉排序树序列,让你构造一个二叉排序树。
3.1.5二叉排序树的删除
也就是要删除某个指定值的结点,那第一步肯定是要找到那个要删除的结点。
接下来要分下面几种情况:
1.要删除的结点是叶子结点
删除叶子结点后依然可以保证二叉排序树的特性,你直接删就行了
2.要删除的结点只有左子树或者右子树
比如13这个结点,它只有左子树,那你把13删了之后,
把原先13的左孩子接到原先13的位置就行了。
3.要删除的结点有左子树,有右子树
比如50这个结点
你要删50,还得保证删除50后的二叉排序树还是一棵二叉排序树
法1:从要删除的结点的右子树中,找出一个最小的结点——右子树中最左边的结点
所以,如果要删50,我们可以把50右子树中的最小结点60换上来,如下图:
法2:从要删除的结点的左子树中,找出一个最大的结点——左子树中最右边的结点
所以,要删50,我们可以让50左子树中最大结点30换上来
3.1.6查找效率分析
3.1.7小结
3.2平衡二叉树的插入
3.2.1引子
我们在上小节说过,如果要让二叉排序树保持平衡,就可以保证这棵二叉排序树的查找效率可以达到O(Log2n)这个数量级
那我们下面要研究的就是,如何在插入一个新结点后保持二叉树的平衡?
如下图,我们插入67这个结点后,这个新插入结点的所有祖先的平衡因子都受到了影响:
要让新插入结点后的二叉树恢复平衡的方法:我们从新插入的结点往上找,找到第一个不平衡结点。在上图中就是70这个结点。
我们把以70为根的这棵子树称为最小的不平衡子树
为什么称为最小?因为你如果从70继续往上找,66为根的子树也是不平衡的,但是70为根的树是60为根的树的子树,所以70为根的子树是最小的不平衡子树。
所以,我们只要调整最小的不平衡子树就可以了。调整效果如下:
下面我们就是讨论如何调整最小的不平衡子树,让它恢复平衡
3.2.2LL
在A结点的左孩子的左子树中插入了一个新结点导致不平衡
我们用一些方形的框来抽象的表示各个部分子树,H表示子树的高度
(当前各个子树是平衡的)
现在在B结点的左子树插入了一个新结点,导致了A结点不平衡。其实就是因为B结点的左子树长高了。
原先A左子树高度为H+1,现在左子树长高1,那么左子树高度变为H+2
但是A右子树高度还是H,则A平衡因子变成了(H+2)-(H)=2,也就是A不平衡了
可能有同学会问:“你凭啥就认为A右子树高度是H,我认为A右子树高度为H+1,一开始A这个树不也是平衡的吗?”
解释如下:
如果A右子树高度为H+1,那么A原本平衡因子应该是0,你左边是H+1,右边也是H+1嘛。然后你
你要是设一开始A右子树高度是H+1。那么你A左子树左孩子高度+1,A左子树高度其实就变成了H+2。A的平衡因子是(H+2)-(H+1)=1,其实这还是平衡的。
但是我们讨论的是加了结点后不平衡的情况,所以这种情况不考虑。
有同学又要问了:“A右子树高度是H+1不可以,A右子树高度为H-1可以不”
这其实也是不可以的,因为你左子树高度是H+1,你右子树高度是H-1,那么一开始就是不平衡的。
我们要求的是一开始平衡,加了个结点之后不平衡。所以这种情况也不可以。
有同学又要问了:“那我一开始设B的右子树高度为H-1可以不?”
如果是这样的话,那么B一开始的平衡因子是(H)-(H-1)=1,
你在B左子树上高度+1,那么B左子树高度就变成了H+1,B右子树高度还是H-1,B的平衡因子就变成了(H+1)-(H-1)=2,那么B就成了最小不平衡子树了。
我们要探讨的是A是最小不平衡子树,所以B的右子树高度也只能是H
下面回到主线剧情,如何调整这个最小的不平衡子树
那调整后我们要得到的树需要满足:1.恢复平衡,2仍然是一棵二叉排序树
显然,B左子树的值<B<B右子树的值<A<A右子树的值
那我们具体做法,就是让B结点右上旋转,代替A成为根结点,
然后A结点要成为B的右子树的根节点,也就是A要成为B的右孩子
然后BL<B,所以只能把BL放B左边
BR原先在B右边的,但是你B右边已经挂了A了,所以BL不能挂B右边了
BL原先是在A左边,也就是BL<A,我们把BL放A左边
AR>A,然后以前的AR继续挂A右边就行了
这样我们就可以让加入结点后不平衡的的子树继续平衡,且保证二叉排序树的特性。
LL小结:在A左孩子的左子树插入一个新结点导致不平衡,我们采用A左孩子右旋一次
3.2.3RR
如果在A的右孩子的右子树加入一个结点导致不平衡
(我们这里也是假设了在进行加结点操作后,A是最小的不平衡子树,所以AL=BL=BR=H)
对于这种情况,我们采用的是左旋一次
也就是让B左上旋转,让A左下旋转
B变成根,A变成B左孩子
然后就是把剩余的AL,BL,BR挂上去
BR是大于B的,我们放B右边
AL是小于A的,我们放A左边即可
还剩一个BL,BL小于B但是大于A,我们就放A右边就行
RR小结:在A右孩子的右子树插入一个新结点导致不平衡,我们采用A右孩子左旋一次
LL和RR的代码思路
3.2.4LR
LR就是在A结点的左孩子的右子树插入一个结点导致A结点变成最小不平衡子树的根节点
为了方便讨论,我们需要把BR进行展开。我们假设B的右孩子是叫C的结点,C的左子树称为CL,右子树称为CR。由于B右子树整个高度是H,所以CL和CR高度我们可以假设H-1
那么现在C这个子树长高了,我们可以假设把新结点插入在CR,导致CR高度为H
你假设插在CL也是一样的,处理方法都一样
对于这种情况,我们要先让C左旋顶替B的位置;接下来再让C右旋顶替A的位置
如何把结点左旋/右旋,方法和前面讲LL和RR是一样的
首先是让C左旋替代B,B<C,那么B成为C的左孩子
然后由于BL<B,所以BL在B左边
CR>C,就放C右边
B<CL<C,就放B右边
所以,把C左旋后如下图,但是此时A仍然不平衡
接下来就是把C右旋替换A的位置
就是把C右孩子变成A左孩子,
A变成C右孩子
LR小结:在A左孩子的右子树插入一个新结点导致不平衡,我们采用A左孩子的右孩子左旋一次,再右旋一次
3.2.5RL
最后RL思路同理LR
RL小结:在A右孩子的左子树插入一个新结点导致不平衡,我们采用A右孩子的左孩子右旋一次,再左旋一次
3.2.5小结
3.3平衡二叉树的删除
平衡二叉树的第一个特性就是具有排序树的特性,也就是左<中<右
第二个特性就是:在二叉排序的基础上,要保证每个结点都是平衡的,也就是左右子树高度之差不超过1
在上一节介绍二叉排序树插入一个结点导致不平衡的四种情况如何解决。
该小节则是介绍二叉排序树删除一个结点导致不平衡的四种情况如何解决。
这四种情况和上节对应,依然是LL,RR,LR,RL
先来看一下删除的规则,具体怎么用我们下面会举例给大家详细讲解:
对于5.不平衡向上传导,如果出现这种情况那么题目给的树就一定非常复杂,并且会出现多种解决方案,解题过于繁琐,所以考试中百分之99是不会考的,我们这里只介绍一些较为简单的例子
例1:现在删除下面树的9
单纯删除结点操作和二叉排序树是一样的,由于9这个阶段是叶子结点,我们直接删除就可以了。
接下来要判断,删除这个元素之后,它上面的祖先结点有没有出现不平衡的现象。
所以下面要做的就是“一路向北”,一路向上看是否有不平衡子树出现,如果没有不平衡子树,那么就可以结束算法了。
删除9之后,对于13,平衡因子是-1,仍然平衡
对于25,平衡因子是1,仍然平衡
对于50,平衡因子是-1,仍然平衡
这就说明,刚才的删除操作并没有导致它的任何一个祖先出现不平衡现象,到此为止,删除操作结束。
例2:现在删除下面树的55
55这个结点是二叉排序树的叶子,我们可以直接删掉
接下来就是要看55的祖先有没有出现不平衡的现象
那么我们可以发现,55删了之后,75是出现了不平衡现象,平衡因子-2
由于产生了不平衡现象,我们就要进入第三步,找到最小不平衡子树下,个头最高的儿子、孙子
75有两个儿子,60高度为1,80高度为3,所以最高的儿子是80
再从最高的儿子往下找最高的孙子,77高度为1,90高度为2,所以最高的孙子是90
接下来进入第四步,根据孙子位置,调整平衡
显然,孙子是在爷爷RR的位置,也就是90在75RR的位置
当孙子在RR的位置,我们就是要让儿子左旋一次
左旋右旋在上节平衡二叉树讲过了,忘记了自己去回顾一下
下面就是让80左旋到75的位置
接下来第五步需要检查不平衡是否向上传导
考试基本不可能出现向上传导的情况,我们这里是为了介绍,防止大家有疑问。
什么叫检查不平衡向上传导?在调整平衡前,这个不平衡的子树高度是4
调整后原先那棵不平衡子树高度从4变成了3
而这棵子树高度出现变化,就有可能导致它上面的树也产生不平衡。这就是所谓的不平衡向上传导。
显然这里是没有出现不平衡向上传导的。
例3:现在删除下面树的32
首先根据二叉排序树的删除方法进行操作,32是叶子结点我们可以直接删除
删除32之后,可以发现44是出现了不平衡现象
找到了最小不平衡子树,接下来就是找最高的儿子和孙子
可以确定78是最高的儿子,50是最高的孙子
然后50是44的RL,那我们就让50右旋再左旋
发现没有出现不平衡向上传导,删除操作结束
(基本不可能考不平衡向上传导,如果你感兴趣可以自己去研究,会发现如果出现不平衡向上传导,那个树将非常非常复杂,并且答案有很多种。我这里就介绍考试会考的几个例子)
当然会有人好奇,出现不平衡向上传导的例子,就比如下面的树,要删除32
有时间,感兴趣自己研究
四、B树和B+树
4.1B树
考试中对B树的考察一般侧重考性质、插入、删除、查找操作,代码基本不会出现。
所以我们下面将重点介绍B树的性质还有手算方法。
先来回顾一下我们前面学的二叉查找树,也叫二叉排序树的特点。
如果我们要查的目标比当前结点值更小,我们会往左子树这边查找,如果比当前结点更小,就往右子树查找。
所以二叉查找树无非就是用一个关键字,把一个我们有可能搜索到的目标关键字范围分割成两份。
比如刚开始我们要搜索的范围是(-∞,∞),
那么根结点29就把(-∞,∞)划分为了(-∞,29)和(29,∞)
(-∞,29)就去左子树找,(29,∞)就去右子树找
问题来了:能否把二叉查找树拓展成m叉查找树?
举个例子:现在有图示的5叉查找树
其实我们虽然把它拓展成了5叉查找树,但是原理和2叉查找树一样的。
第一个根节点是22
如果当前要查找的元素比22小,就往左走
如果当前要查找的元素比22大,就往右走
假设我们要找的那个值比22更大,接下来就应该在22的右子树去找,即(29,∞)里面找
往下找发现当前结点又出现了两个关键字:36和45,
就相当于我们在这个区间内又插入了两个隔板。又把(29,∞)划分为3个区间
(29,36)、(36,45)、(45,∞)
综上,5叉排序树里面这些关键字还有关键字之间的指针,信息其实和二叉查找树是非常类似的。数据结构定义如下:
每个结点最多可以包含4个关键字,最多5个孩子,也就是5棵子树。然后用一个num变量记录当前结点一共多少个关键字。
像上图这种5叉查找树,在一个结点中,我们最少可以允许有1个关键字,2个分叉;最多允许4个关键字,5个分叉
一个关键字会把一个查找区间分割成两个部分
图中给出的紫色结点,其是就查找失败的情况,比如下面圈出的失败结点,就是对应的(15,22)这个范围
下面我们进行5叉查找树的查找成功的举例:
假设我们现在要查找9
根节点中第一个被保存的关键字是22
显然9要比22更小,所以下面要选22左边的路
到新的结点,我们会顺序扫描一个结点中的各个关键字
第一个关键字5要比9更小,往右检查下一个关键字
第二个关键字11又要比9大,所以9在11左边的路线上
到了新的结点,我们还是一样的从左往右依次扫描,然后扫描3次找到9
ps:刚才在查找每个结点中的数据时,是用的顺序查找。
但是由于每个结点中数据是有序排放的,所以在查找每个阶段数据时,你也可以用折半查找
下面我们进行5叉查找树的查找失败的举例:
比如我们要找41,现在从根节点出发
22要比41更小,我们指针右移,发现当前指针所指位置已经超出这个结点关键字个数了,所以如果41存在,我们应该往回找这个指针,然后指针往下走
来到下一层结点,第一个元素36<41,指针往右
第二个元素45>41,如果41存在,则在45左边的指针所指结点中
到新的结点,第一个元素40<41,指针右移
第二个元素42要比41更大,如果41存在,则在42左边指针指向的结点
但是你发现,如果沿着42左边指针往下走,居然是一个失败结点,那么就说明查找失败
ps:虽说是失败结点,其实就是一个NULL
刚才我们提出的5叉排序树,我们只是规定了每一个结点中最多有5个分叉。
那么如果我们每个结点只保留一个关键字,也就是每个结点只有2个分叉的情况。
如上图所示,这个5叉查找树就退化成了2叉查找树。
在这种情况下,由于每个结点的关键字数量变少,
所以,如果关键字总数相同,这个树会变成“细狗”,也就是又细又高的树。
而查找树越高,我们在进行查找时就需要更多层的结点,效率也会更低
那么如何保证5叉查找树的效率?
我们可以规定:在m叉查找树中,除了根节点,其他任何一个结点都至少有⌈m/2⌉个分叉
比如5叉查找树,m/2向上取整应该是⌈5/2⌉=3
所以,对于5叉查找树,我们可以规定每个阶段至少3个分叉,也就是2个关键字
这样就可以保证每个阶段中关键字个数不会太少,就可以保证这棵树不会变的很高,层数不会太多,相应的查找效率也可以得到保证
这里可能大家会有疑惑,为什么除了根结点之外呢?我们让根结点也有3个分叉不行?
想法不错,但是实际是做不到的。
比如现在5叉查找树,刚开始只有1个元素,1个关键字。
那么这种情况下,就只有一个根节点(一个关键字)
就一个关键字,你那里来能放2个关键字呢?
再来看另一个问题:下面这棵二叉树,你觉得它优秀不?
上面这个5叉查找树已经满足了我们刚才提出的特性:根节点除外,每个结点有3个或3个以上的分叉。
但是这棵树有一个问题:它不平衡
这种不平衡的特性,显然也会导致我们的5叉查找树长的很高。
和刚才一样,如果树长得太高,就会导致我们查找的过程中要查很多层的结点,从而导致效率降低。
我们可以规定:对于每个结点,它的左右子树高度都相同
如果能做到各个结点的子树都没有高度差,就可以保证我们的多叉查找树是平衡的,也就保证了它不会有太多层,从而保证查找效率。
如果能满足上面提到的两个策略,那么就是B树
我们通常会把失败结点称为叶子结点,而最下面一层含有实际数据的结点,我们称为终端结点。
注:由于平衡要求,我们是让树中所有结点子树高度都相同。那么就会导致,失败结点(叶子结点)一定会出现在最下层(同一层)
B树中所有结点孩子个数的最大值称为B数的阶,也就是这个B树你最多看到多少个分叉
如下图,最多是有5个分叉。
所以,所谓的5阶B树其实就是一个5叉查找树
然后,根节点如果不是终端结点,则至少两个子树。
这个特性就是保证每个结点都平衡,所以对于根节点来说,如果这个根节点不是终端结点,那么它必须是要有两棵子树,不然它自己不平衡啊。
还有一个特性,就是所有非叶子结点的结构如下:其实就是我们刚才给出的那个数据结构的定义。
如下图:
p0,p1,p2…pn指的是结点当中的一个个指针,总共有n+1个指针
而k1,k2,…kn指的是结点的关键字,总共是n个关键字
n记录实际关键字是几个
pi-1的所指结点的所有关键字<ki<pi的所指结点的所有关键字
下面是对B树特性的总结:
下面我们根据B树的特性来看一下B树有多高
注:大多数学校在计算B树高度时,都是不包括最下面的失败结点的,因为失败结点本质就是一个NULL
先来看一下最小高度是多少
如果要让树高最小,那么在关键字数量不变的情况下,我们应该尽可能让每一个结点都填满关键字。
那么对于m阶B树,每个结点最多有m-1个关键字,有m个分叉。
最上层的根节点只有一个(注意,我这里说的是结点数量是1,不是关键字数量是1),根结点有m个分叉。
由于第一层下来m个分叉,所以第二层有m个结点
第三层,又会由第二层m个结点下来m2个结点(每个结点m个分叉)
如果有h层,那么就应该是mh-1
也就是有h层,有(1+m+…+mh-1)个结点
而每个结点有m-1个关键字,所以共(m-1)(1+m+…+mh-1)个关键字
那么n个关键字的B树高度为h,那么n的范围肯定是小于等于我们刚才给出的数值
下面来看一下最大高度是多少
要让一棵树长的尽可能的高,就要让这颗数的分叉尽可能的少。
对于根节点最少2个分叉,对于其他结点则是最少⌈m/2⌉个分叉
对于m叉树来说:
在分叉最少的情况下,第一层只有1个根节点,2个分叉
第二层有2个结点,每个结点⌈m/2⌉个分叉,也就是2[m/2]个分叉
第三层有2[m/2]个结点,每个结点⌈m/2⌉个分叉,也就是2[m/2]2个分叉
…
第h层2[m/2]h-2个结点,每个结点⌈m/2⌉个分叉,也就是2[m/2]h-1个分叉
再往下推,叶子结点(失败结点)也就是h+1层,至少有2[m/2]h-1个结点
补充重要特性:对于n个关键字的B树,必然有n+1个叶子结点
解释:B树中n个关键字,相当于是在(-∞,+∞)这个区间内插入n个关键字,这n个关键字会把整个数值区间切分为n+1个部分。
这n+1个部分就对应了n+1种失败的情况。
所以,n个关键字的B树必有n+1个叶子结点,而m阶B树的叶子结点下限应该是2[m/2]h-1
n+1>=2[m/2]h-1
小贴士:关于为什么B树叫B树,B树这个数据结构的发明者也没有说明,个人觉得是balance,平衡的意思。
4.2B树的插入删除
4.2.1插入
下面我们从零开始建立一棵B树,
并且规定我们这个B树是5阶B树,也就是关键字个数最少不能少于2个,最多不能多于4个
我们插入第一个元素25,直接放入根节点中
插38,38>25,放25后面即可
插49,49>38,放38后面
插60,60>49,放49后面
到目前为止,根节点可以存放的结点个数达到上限
接下来如果继续往里面插入一个元素,比如80,此时就导致根节点中关键字的个数超过4
对于这种情况,需要把当前这个结点分裂成两个结点。
我们会以中间位置,也就是⌈m/2⌉ 位置的关键字,把原有的结点拆分为左右两部分,然后⌈m/2⌉ 位置的关键字提到父节点中。
该例子中也就是第三个元素49提到父节点位置
接下来插入90这个元素
注意:我们每次新插入的元素一定是插入到最底层的终端结点。
可以用上小节介绍的查找规则来确定我们应该插入到什么位置。
从根节点出发,90大于49,而49右边已经没有元素了,所以顺着49右边指针往下找
接下来检查新结点关键字,60<90,80<90,所以90应该插到80右边
所以,我们其实是通过一次查找来确定我们新元素应该插到什么位置。
再次强调:每次插入的新元素一定是插到最底层的终端结点中!
接下来再插99
接下来插88,我们用肉眼扫一下可以发现,88应该插在80和90之间
到这里又会导致当前这个结点关键字个数又超出上限了
处理方法和刚才一样,用⌈m/2⌉ 位置的关键字,这里是⌈5/2⌉ =3位置的关键字提到父节点,
然后把3位置的关键字左右两边元素分别放到其左右子树上。具体做法如下:
ps:这么做可以保证二叉树的特性,也就是一个阶段的左子树结点值<当前结点值<右子树结点值
再接下来插入83,插入87,没啥好说的,直接插终端结点
再接下来插入70,肉眼扫一下应该插80和83中间,那么又会溢出,该结点又需要分裂
把中间元素80提到父节点,两边元素放左右子树
而这里中间元素80>49,80<88,所以我们把80放49和88之间
如果一个关键字,它因为需要分裂而提到父节点中,我们需要把这个关键字放到它所属结点这条指针的右边
这样做可以保证我们的树仍然保持B树的特性
接下来再插入4个新元素92,93,94,99
那么又有结点需要分裂了
还是一样的办法,把中间的93提到父节点中88的右边
(把93放到指向当前结点这个指针对应点的右边,也就是88的右边)
然后93左右成为左子树和右子树
如果再往树里面插入73,74,75
那么结点又需要分裂了,我们需要把73提到指向该结点指针右边的位置
但是这里我们会发现,73提上去之后,根节点又不够用了
这种情况,我们就需要把这个父节点继续向上分裂,由于根节点上面已经没有父节点了,所以我们创一个新结点
这里根节点是可以只有一个关键字的,其他结点关键字个数n必须满足2<=n<=4
下面是B树插入的总结
4.2.2删除
比如我们现在要删B树的60
由于60是在终端结点中,我们直接删了,然后把60所在结点的其他关键字左移一下
需要注意的是,你删了一个结点之后,要保证这个结点关键字个数还是>=⌈m/2⌉-1
这里删完60还剩3个关键字,没有低于一个结点最少关键字个数,是合法的
再比如,我们现在要删80,80删了之后根节点就空了。
那我们可以找出80这个元素的直接前驱或者直接后继来顶替80的位置
如果用直接前驱来顶替
80的直接前驱就是80左子树中最大的,你就找左子树中最右边的元素,也就是77
上面的操作,就相当于我们把非终端结点的删除转换成了终端结点的删除。
如果用直接前驱来顶替
现在要删根节点的77,那么我们可以找77右子树中最小的来顶替77,也就是找右子树中最左边的元素82
前面我们探讨的情况都很简单,也就是我们删除一个终端结点关键字时,这个终端结点关键字数量还没有低于它的下限,那么我们下面来介绍一些低于下限的情况:
比如删38
你删了38之后,下图所指结点的关键字个数小于B树规定的最小值2了
那么我们需要分为多种情况来考虑
情况1:被删结点它的兄弟可以借
什么意思?当前结点关键字数量不够了,但是它的右兄弟关键字数量还够。
可能会有同学会想直接把右兄弟的一个关键字放到当前结点,但是这样是有问题的
你直接把70放过去,会导致70>49,而我们B树是要求左子树值<根值<右子树值
这样就矛盾了。
解决办法是先将49拉下来,然后70去顶替49的位置
上面的解决方案可以做一个小总结:如果右兄弟的手头宽裕,我们可以找到当前结点的后继、后继的后继来填补空缺
举个例子,当前这个结点,也就是25这个元素的后继是49
而25后继的后继,也就是49的后继应该是70,即49右边指针所连结点的第一个元素
所以,这就是用当前结点后继、后继的后继去顶替它们所需要顶替的位置
上面例子是借右兄弟的例子,下面我们介绍借左兄弟的例子
比如现在要删90这个关键字
而删完90之后,发现右兄弟已经不宽裕了,但是左兄弟还宽裕
具体做法:如果左兄弟的手头宽裕,我们可以找到当前结点的前驱、前驱的前驱来填补空缺
92的直接前驱是88,也就是顺着指向当前结点的指针左边的元素
而92前驱的前驱是87,也就是88的前驱,应该是88左子树最右边的元素
我们用88和87填补所需位置
上面都是介绍了左兄弟或者右兄弟宽裕的情况,下面我们介绍左兄弟和右兄弟都不宽裕的情况
情况2:被删结点它的兄弟不可以借
比如现在要删49
删完之后当前结点关键字已经不够了,但是右兄弟也不宽裕了
我们的策略是让当前结点和它的右兄弟合并
合并时,还需要把这两个结点中间的关键字也一起合并,这样才能满足B树特性
合并过程如下动图:
但是这里又出现问题了,由于我们从父节点拿走一个元素,父节点的关键字又不够了
所以我们接下来继续要合并
合并过程如下动图
而此时,根结点已经没有任何关键字了,删掉它
考试不会考5阶以上的B树插入删除,上面介绍的已经是比较难的例子,掌握好应对考试足矣
4.2.3小结
4.3B+树
对于B+树,考研不会考很深,基本都是概念的东西
细心的同学可能会发现,B+树和我们的分块查找比较类似
在分块查中,我们会把这些元素分成一个个块,在索引表中,我们会保存每个块中最大的关键字。
再回头看B+树,其实也是保存了每个块的最大关键字
然后每层索引往上,都是一样的规律
4.3.1定义
下面是B+树的定义
1.每个分支结点最多m棵子树
比如这里的4阶B+树,它最多就是有4个子树
2.非叶根节点至少有两棵子树,其他分支结点至少有⌈m/2⌉棵子树
什么叫非叶根节点?
首先要明确的是,我们在B+树中,把最下面一层称为叶子结点
下面三个例子中,第一个例子,只有一个根节点,它同时也是叶子结点。
而第三个例子,根节点并不是最下面一层的结点,也就是非叶子结点,它至少有2棵子树才能算是B+树
而第二个例子,根节点只有左子树没有右子树,不符合B+树的定义,它不是B+树
因为我们希望B+树高度尽可能低,所以我们会追求绝对平衡——所有子树高度相同
所以,我们会要求非叶根节点至少两棵子树
另外,我们还要求其他每个分支结点至少有⌈m/2⌉棵子树,这里m=4,也就是至少要有2棵子树
至于为啥这样,这个原因和B树是一样的,我们要保证每个结点关键字个数(结点个数)不要过少,因为如果每个结点子树数量太少,就会导致这棵B+树长得很高,这样查找效率就低了。
3.结点子树个数和关键字个数相等(重要)
这一点比较容易在选择题中进行考察,因为这也是B+树和B树比较大的一点区别
比如下面这个结点,对应3个关键字,每个关键字也对应1个分支
所以B+树当中一个结点里面,它含有多少个关键字,对应的它就有多少个分支,有多少个子树。
ps:B树中,如果一个结点有2个关键字,那它是对应3个分支
4.所有叶结点包含全部关键字及指向对应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互链接起来。
在B+树中,我们可以通过指针p,从第一个叶子结点开始,一个个往后遍历,把所有叶子结点都遍历完。
也就是说,B+树是支持顺序查找的
5.所有分支结点中仅包含它的各个子结点中关键字的最大值及指向其子节点的指针。
这个和分块查找原理很类似,这里不再赘述
4.3.2B+树的查找
先看一个查找成功的例子:
比如现在要查编号9的学生对应信息
从根节点出发,9<15,15表示的是它所指向的一整个分块中最大的关键字是15
所以如果9存在,一定是在15的分块中
3<9,指针后移
第二个正好是9,那么9是在当前关键字所指结点中
ps:如果在B+树查找中,我们在分支结点中找到我们所需关键字,其实整个查找并没有结束。
你得找到最下面一层叶子结点才能知道相关记录信息。
指针继续往下移,到叶子结点了
从左往右依次检查,就可以在这个叶子结点中找到9这个关键字
那么通过9,就可以找到9号学生对应的相关信息了
再看一个查找失败的例子:
比如我们要查找的关键字是7,
那么从根结点出发,7<15,应该往15所指结点找
3<7,7肯定不在3所指结点内,指针右移
7<9,如果7存在,肯定在9所指结点内
检查下一个结点,6<7,指针右移
下一个元素是8,8>7
注意!这里已经是最下面一层的叶子结点了,到这里没找到7就说明7不存在了。
B+树中无论是查找成功还是查找失败,都要找到最下面一层的叶子结点。
除了根节点往下找,还可以通过叶子结点那里保存的指针p进行顺序查找
4.3.3B+树和B树的对比
4.3.4小结
五、散列表
5.1散列表的基本概念
5.1.1散列表、散列函数
我们根据散列函数可以计算出一个数据元素在散列表中的存储地址。
比如这里散列函数是H(key)=key%13
那么如果你给我19,H(19)=19%13=6,我就可以立马知道如果19存在,则存放在6号位置
再比如你给我16,H(16)=16%13=3,我就可以立马知道如果16存在,则存放在3号位置
可以看到,在理想情况下,我们散列表查找一个元素时间复杂度只需要O(1)
5.1.2冲突、同义词
我们并不希望冲突频繁发生,冲突越少,散列表性能越高
5.1.3关于散列表,有待解决的问题
5.2散列函数的构造
散列函数的作用是把一个关键字映射到与之对应的存储地址上。
当我们设计一个散列函数时,应尽可能减少不同关键字之间发生冲突的情况。
该小节将介绍4种散列函数构造方式:
考试中最常考、现实中最常用的都是除留余数法
5.2.1除留余数法
5.2.2直接定址法
这种方法适用于关键字分布基本连续
5.2.3数字分析法
5.2.4平方取中法
5.2.5小结
5.3处理冲突的方法——拉链法
散列表通常不考代码,着重掌握手算分析方法
散列表中冲突这种现象是无法避免的,可能会有多个元素映射到同一个散列地址。
这些元素在插入时就会发生冲突
我们第一种解决冲突的方式就是拉链法
我们把这些同义词用一个链表连起来
5.3.1插入
现有如下的空散列表,长度13,散列函数H(key)=key%13,我们用拉链法解决冲突
5.3.2查找
5.3.3删除
5.3.4小结
5.4处理冲突的方法——开放定址法
5.4.1基本原理
散列表中冲突是不可避免的,我们可以用拉链法,也可以用开放定址法
举个例子,我们有散列函数H(key)=key%13,现在14已结占了1的位置,如果现在1再进来,想占据1的位置怎么办?
我们可以让给1找另一个空闲位置。
所谓开放定址法,就是一个散列地址,既对同义词开放,也对非同义词开放。
那么问题也随之而来,发生冲突时,我们要给新元素找另一个位置,那这个位置怎么确定?
假设我们现在原始地址发生冲突了,我们从原始地址往右偏移1位
如果还发生冲突,就去探索一下原始位置左边是否有冲突
如果还发生冲突,去原始地址右边两位看看有没有空位
如果还冲突,就探索原位置左边两位有没有空位。。。
也就是说,当发生冲突时,我们可以设计这样一个序列:0,1,-1,2,-2,3,-3…
来规定发生冲突时我们探测每个位置的顺序。
5.4.2线性探测法
对于散列表的插入,就是你先根据散列函数算出一个原始地址,然后根据那个探测法去探测原始地址周围的位置。
而对于散列表的查找,和插入基本一样,也就先算原始地址,然后根据对应探测法去探测。
但是对于查找有个需要注意的地方,就是如果你探测下来,探测到空位置还没探测到,说明原先就没有这个关键字。那么查找失败。
5.4.3平方探测法
5.4.3双散列法
对于双散列法,需要设计第二个散列函数,发生冲突时,需要根据关键字的值去确定第二个散列函数的计算结果。
不同关键字的探测序列也是不同的
5.4.4伪随机序列法
5.4.5删除
需要注意,删除元素时会有一个坑!以下是删除的错误示范!!!
比如我们现在用线性探测法,要删15
首先应该查找15
第一次定位15%13=2,但是2号位已经被占了,往右1位,找到15
然后删15
你这样做好像没啥问题,但是如果我们现在要你查找1怎么办?
由于你之前把3号位置清空了,那么你查1的时候经过3,系统会误认为没有1,这样就错了!
那该如何进行正确的删除呢?比如现在要删15
我们先找到15
找到15之后要删,但是这里的删,不是物理上的删,是逻辑上的删!
我们可以给15所在位置做一个标记,比如你设一个flag,把flag置为1,则表示已经删除
我们这里没有物理删除,而是逻辑删除,这样就可以保证线性探测法中间不会出现空档,也就避免了有数但是没找到的情况
需要注意的是,如果用的是开放定址法,你删了很多元素之后,会导致整个散列表看起来很满,但是很空(逻辑删除,物理上没删除)
这样其实是会导致查找效率低下的
比如我们要找的元素初始地址是0,但是你一个个往右发现都没有找到,最后才确定没有这个元素,这就很浪费时间了。
所以,如果散列表中有很多这种逻辑删除之后的元素,我们可以不定期的去整理散列表的数据
比如我们把元素1放到1号位置,其他位置逻辑删除的位置全部物理删除掉
ps:你插新元素的时候,也是可以插到逻辑删除的位置上的,就相当于是一个数据覆盖。