🌞欢迎来到图解数据结构的世界
🌈博客主页:卿云阁💌欢迎关注🎉点赞👍收藏⭐️留言📝
🌟本文由卿云阁原创!
📆首发时间:🌹2024年8月3日🌹
✉️希望可以和大家一起完成进阶之路!
🙏作者水平很有限,如果发现错误,请留言轰炸哦!万分感谢!
目录
顺序查找
从头到jiao进行查找
我们可以用for循环从0号位置开始依次往后进行查找,每次的循环数组下标不能越界(i<ST.TableLen)从同时还需要比较当前的元素和我们要查找的元素是否相等(ST.elem[i]!=key),直到i=8的时候不满足条件跳出for循环,然后返回i的值这是查找成功的情况,查找失败时i=ST.TableLen。
#include<stdio.h>
typedef struct
{
int *elem;
int TableLen;
}SSTable;
int Search_Seq(SSTable ST,int key)
{
int i;
for(i=0;i<ST.TableLen&&ST.elem[i]!=key;++i);
return i==ST.TableLen? -1:i;
}
我们还可以增加哨兵,把0号位置空出来,实际的数据是从1开始存放的,把要查找的关键字放到0好位置(ST.elem[0]=key),假设我们要找16这个元素,我们会让指针向前进行扫描(i=ST.TableLen),如果当前指向的值和我们目标不一样(ST.elem[i]!=key),让i向左移动(i--),一直移动到5这个位置找到16这个关键字,此时返回关键字对应的下标,这是查找成功的情况,返回0,有点在于不需要判断i的值是否越界,效率更高。
#include<stdio.h>
typedef struct
{
int *elem;
int TableLen;
}SSTable;
int Search_Seq(SSTable ST,int key)
{
ST.elem[0]=key;
for(i=ST.TableLen;ST.elem[i]!=key;--i);
return i;
}
查找效率
查找成功的情况:我们是从最后一个位置开始依次向后扫描的,假设我们要找的元素是37,只需要对比1次,找最后一个关键字的概率是1/n。
顺序查找的优化(对有序表)
如果我们此时的查找表本身就是有序的,比如此时我们要找到21这个元素,我们可以看到没有21这个元素,查找表中的元素是递增的,当我们查找到29这个元素的时候就已经可以知道查找失败了,下面就是这个查找表的查找判定树,21大于根节点7,所以会对比下一个关键字,21大于节点13,所以会对比下一个关键字,21大于节点19,所以会对比下一个关键字,21大于节点29,实际上我们关键字的值落在了(19,29)这样一个区间内,因此查找失败,显然如果这个查找表有n个元素的话,共有n+1中查找失败的情况,此时计算ASL,我们可以假设出现这n+1中情况都是相等的,所以如果关键字的值落在第一个区间只需要查找1次,最下面的两种情都是要况对比n次关键字。
用查找判定树分析ASL
我们把方形节点成为失败节点,圆形的节点称为成功节点。
顺序查找的优化(被查概率不相等)
我们可以把查找概率更大的元素放到更靠前的位置,这样可以使得查找成功的ASL可以进一步的缩短。
折半查找
算法思想
只适用于有序的顺序表(数组存储)
假设我们要找到33这个元素,首现我们会用low(low=0)和high(high=TableLe-1=10)表示我们的查找范围。第一轮我们要检查的元素是low和high中间的元素,我们用一个指针mid来计算,mid=(low+high)/2,此时mid=(10+0)/2=5。
我们第1个要检查的元素是29,33大于mid指向的元素,33只可能在右边的区域,然后把low指针指向mid指针右边的位置。
此时mid=(6+10)/2=8,就是要检查37这个元素,33小于37,33只可能在左边的区域。
接下来我们会让high指针指向7这个位置, mid=(6+7)/2=6,检查6这个元素,33大于32,在mid的右边区域,接下来让low=7,mid=(7+7)/2=7,刚好找到了33这个元素。
假设我们要找12这个元素
mid=(10+0)/2=5 12<29 high=mid-1=4
mid=(0+4)/2=2 12<13 high=mid-1=1
mid=(0+1)/2=0 12>7 low=mid+1=1
mid=(1+1)/2=1 12>10 low=mid+1=2
所以当low大于high的时候,查找失败。
代码实现
初始化:
low=0,high=L.TaleLent-1,mid
当(low<high)
检查中间值mid=(low+high)/2 ,和目标关键字是否相等
相等就返回mid的值
不相等,如果key的值小于mid的值,说明可能在左边的区域,让high=mid-1。
如果key的值大于mid的值,说明可能在右边的区域,让low=mid+1。
只有顺序表才能直接根据数组下标,找到中间的元素。
#include<stdio.h>
typedef struct
{
int *elem;
int TableLen;
}SSTable;
int Binary_Search(SSTable L,int 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(key<L.elem[mid])
high=mid-1;
else
low=mid+1;
}
return -1;
}
查找效率分析
如果我们查找的值刚好是29的话,只需要一轮循环。
第二轮循环我们可以找到的元素是13或者37
小于29的话,需要在左边进行查找,接下来要检查的元素是13。
大于29应该在右边进行查找,接下来要检查的元素是8。
查找成功最多只需要经过4轮查找,
折半查找判定树的构造
刚在给出的数据表中有11个数据元素,也就是有奇数个数据元素,去除mid所指向的数据元素之和还剩余偶数个数据元素,刚好可以均匀的分成两个部分,再下一级的分割也一样。
假设我们现在有10个数据元素,此时算得的mid的值等于4,此时左半部分比右半部分少一个元素。
如果查找表中有一个元素,显然查找判定树只有一个结点,如果有两个元素,判定树一定是在右子树。
但是折半查找不一定要比顺序查找要快,比如我们要找7这个元素,采用顺序查找的话,只需要对比1次,采用折半查找显然查找的次数更多。
mid计算方式的不同也会导致,查找判定树的不同。
分块查找
把这个查找表分成很多个小块,块内无序,块间有序。
然后给这个查找表建立上一级的索引,索引表中保存的是每一块中最大的关键字,还有分块的存储区间。
假设我们此时要查找的关键字是22,我们可以先查找索引表,从索引表的第一个元素依次往后找,第一个元素的值小于22,所以是在下一个分块,20<22,继续向后找,30>=22,所以如果22存在的话,一定是在30所指向的分块内,接下来从数组的6号元素开始查找,在7号位置查找成功。
假设我们要查找的目标关键字是29,我们依然是先查找索引表,29<=30,其可能在30所指向的分块内,接下来从数组的6号元素开始查找,在9号位置仍未查找成功,可认为查找失败。
分块查找的思想
- 在索引表查找对应的块(顺序或者折半)
- 在查找表查找具体的位置(顺序)
用折半查找查索引
假设我们的查找目标的30,根据折半查找的规则,low=0,high=4,mid=2,此时30<=30,查其指向的块,查找成功。
假设我们的查找目标的19,low=0,high=4,mid=2
19<30 high=mid-1=1 low=0 mid=0
19>10 high=1 low=mid+1=1 mid=1
19<20 high= mid-1=0 low=1 low>high 按照规则查找失败,但是我们发现查找目标19实际上是在low所指向的分块中,所以下面我们在low所指向的分块中进行查找。
(low>high 前一步是low=high=mid 此时有两种情况,mid<key low后移,此时low指向的元素一定大于mid,mid>key high后移,low指针不变)
接下来应该从2开始顺序查找,在3号位置查找成功。
假设我们的查找目标的54,最后的折半查找会停在low=4,high=5的位置,这种情况查找失败。
ASL
这里一共有14个元素。
当索引表查找27这个元素的时候,我们第一次对比发现,
key<mid,high=mid-1=1,low=0,mid=0
等等,需要执行很多轮。
(折半查找查找索引表不是很重要)
我们的查找表是用顺序存储的方式放的,如果这个查找表需要进行元素的增加和删除,比如现在我想增加一个元素的值8,我们要保证块间是有序的,我们要在第一个分块内插入8,会导致我们后面的元素全部往后移动,我们可以用链式存储的方式,要插入8这个元素,首先我们应该确定8应该放到那个分块,显然是第一个分块,只要把它连在第一个分块的后面可。
二叉排序树(BST)
’
定义:左子树结点值 < 根结点值 < 右子树结点值,不允许两个节点的关键字相同
进行中序遍历,可以得到一个递增的有序序列
查找操作:目标值小的往左找,目标值大的往右找
在二叉树中可以比较方便的实现查找操作是很方便的,比如我们要查找30这个结点,首先我们从根结点出发,30>19(T=T->rchild),30<59(key<T->key),向左走,(T=T->lchild),30>26(T=T->rchild),30=19(key=T->key),返回T指针指向的结点。
假设我们要查找12这个结点,12<19(key<T->key),向左走,12<13(key<T->key),向左走,12>11(key>T->key),向右走。此时T=NULL。
typedef struct BSTNode
{
int key;
struct BSTNode *lchild,*rchild;
}BSTNode,*BSTree;
BSTNode *BST_Search(BSTree T,int key)
{
while(T!=NULL&&T.key!=key)
{
if(key<T.key)
T=T->lchild;
else
T=T->rchild;
}
return T;
}
我们也可以用递归的方式来实现这个算法。
BSTNode *BSTSearch(BSTree T,int key)
{
if(T=NULL)
return NULL;
if(key=T.key)
return T;
if(key<T.key)
BSTSearch(T->lchild,key);
if(key>T.key)
BSTSearch(T->rchild,key);
}
插入操作:找到插入的位置修改父节点的指针。
下面看怎么插入一个结点,首先我们要找到我们要插入的具体位置,比如要插入关键字12,12<19,说明要插在19的左子树,12<13,说明要插在13的左子树,12>11,说明要插在11的右子树,我们要修改它的右孩子指针(要设置成引用类型),此时T=NULL,我们会申请一个结点。让这个结点的key等于12,左右孩子指向NULL。return 1表示插入成功,如果k==T->key,插入失败,每次插入的结点一定是叶子结点。
,如果k==T->key,插入失败,每次插入的结点一定是叶子结点。
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;
}
else if(k==T.key)
return 0;
else if(k<T.key)
return BST_Insert(T.lchild,k);
else
return BST_Insert(T.rchild,k);
}
怎么去构造一棵二叉树,就是不断增加新的结点的过程,首先插入50这个结点。
删除操作(删除某一个指定值的结点)
若被删除结点z是叶结点,则直接删除,不会破坏二叉排序树的性质。
若被删除结点z只有左子树或者只有右子树,比如13,删除13之后只要让它的左子树来替代原来的位置。
若被删除结点z即有左子树也有右子树,比如像50这个结点,我们删除这个结点之后,我们可以在其右子树中找到值最小的结点(按照中序遍历第一个被访问的结点)最左下的结点。让原来的位置变成60,再删除原来的位置即可,这个结点是左下的没有左子树,因此删除这个结点就变成了第二种情况。
也可以用直接前驱替代,找到左子树中最大的值来替代(最右下)。
查找效率
我们查找70这个关键字的时候要对比3次,所以查找长度就是3。我们要尽可能的让树高最小,才能保证查找效率最高。(平衡二叉树)
平衡二叉树的插入
结点的平衡因子只能是-1,0,1。
比如下图中当我们插入67这个节点的时候,查找路径上的所有结点的平衡因子都会收到影响,我们可以从插入结点往上找,找到第一个不平衡的结点,这个结点的子树就叫做最小不平衡子树。
只要调整最小不平衡子树就可以让其它节点恢复平衡。
如何调整不平衡子树呐?
在A结点的左子树上插入新的结点导致不平衡,我们用一些方型的框来表示一棵子树,下面的H表示这棵子树的高度,现在在B的左子树上插入一个新的结点,导致了A结点的不平衡(是因为B的左子树长高了),原来A的左子树的高度是H+1,现在变成了H+2,所以此时A的平衡因子变成了2,这里是假设A是最小不平衡子树。
现在我们来思考一个问题,这里我们为什么要假设所有子树的高度都是H呐?
先看AR,假设A的右子树高为H,但是如果为H+1,其实也是合理的,此时A的平衡因子是0,此时在BL上插入一个新的结点,A的平衡因子应该是H+2-(H+1)=1,所以不可能是H+1的情况,也不能为H-1(这样就会导致原来就不平衡了),再看B的右子树它有没有可能是H-1呐?此时当在BL上插入一个新的结点后,B的平衡因子是2,此时B就是离插入点最近的结点,B就变成了最小不平衡子树(我们设定的是A为最小不平衡子树),所以只要假设某一个子树的高度为H之和,其它子树的高度一定也为H,只有这样才能保证在LL插入时,A为最小不平衡子树的根结点。
我们的调整的目标一共有两个,第一个是让其保持平衡,第二个是保持二叉排序树的特性,具体的做法是我们要让B结点向(右上旋转),代替A成为根结点【B成为根结点,A成为B右子树的根结点】
代码思路:
假设我们用f指针指向A,用p指针指向B,假设A父节点的指针是gf,A有可能是gf->lchild或者rchild,B的右子树变成A的左子树(f->lchild=p->rchild),A结点变成B的右孩子(p->rchild=f),让原本指向A的结点指向B(g->rchild=p)。
如果此时B结点的右子树长高了,我们把B的右子树进行一个展开,假设B的右孩子原本是叫C的一个结点,C的左子树称为CL,C的右子树成为CR,CL,CR原来的高度是H-1,现在这棵子树长高了,我们可以假设插在了C右孩子导致CR的高度变成了H(也有可能插在BL结点,处理方式是一样的),先让C左旋,再右旋。
在这个例子中我们是插入了67这个结点,然后导致了70这个结点的不平衡,现在我们来解释一下为什么只要把最小不平衡子树调整成平衡,其它祖先结点都会恢复平衡?
之前一共介绍了四种类型,本来树的高度是H+2,插入新的结点之后树的高度变成了H+3,经过调整之后树的高度变成了H+2。
平衡二叉树的查找效率问题,主要问题是来自树的高度问题,我们查找任何一个关键字最多只需要对比H次,所以分析查找效率实际上就是分析这个平衡二叉树的高度问题,当高度为0的时候显然是一棵空树,n0=0,当h=1的时候,n=1,当h=2的时候,n最少=2,所以当我们知道结点为9的话,就能知道它的高度最大是4。
平衡二叉树的删除操作
删除一个结点,同样我们也要保证它是一棵排序树,还需要保证它是平衡的,删除一个结点也有4重情况,具体步骤可以划分成5步:(可以先掌握最简单的例子)
- 按照二叉排序树删除结点的方法,删除结点
- 找到最小不平衡子树,找不到就撒花
- 找到最小不平衡子树下,个头最高的儿子和孙子
- 根据孙子的位置调整平衡
- 如果不平衡向上传导继续2的操作
接下来,我们来看看具体该怎么做?
现在我们要从该二叉树重删除9这个元素,接下来判断它的祖先结点有没有不平衡的现象,此时没有出现不平衡的现象,删除成功。
假设我们现在要删除55这个结点,一路向北找到最小的不平衡子树,此时找到最小不平衡子树75,75有两个儿子60和80,60这个孩子身高为1,80这个孩子身高为3,所以找到身高最高的是80这个结点,这是最高的儿子,接下来,再找到90这个个头最高的孙子,
第3个例子我们要删除32这个结点
第4个例子我们也要删除32这个结点。出现了不平衡向上传导
从刚才调整的子树的根出发,一路向北,找到最下不平衡子树33
第5个例子我们要删除75这个结点,75这个结点有两棵子树,可以用它前驱结点(从左孩子出发,最右下,60)或者是后继结点可替代75,这个的替代我们采取了复制也就是把60这个数据复制到75这个结点处,接下来就转换成删除这个结点,对于该节点,它只有一棵子树,只要让它的左子树顶替它的位置就可以了(让55这个结点挂在最上面66的左指针上,然后释放下面的66)到这里我们就完成了第一步,从55开始向上找到第一个不平衡的子树66。
当然我们也可以用75的后继结点来顶替它的位置,从右孩子出发找到最左下的元素77,把77这个结点的值赋值到75的位置。
由于77这个结点,它是叶子结点所以可以直接删除,接下来我们可以看一下是否导致了不平衡,发现80这个结点出现了不平衡,实际上选85或者95作为最高的孙子都是可以的,这里我们先选择95这个结点,
选择85这个结点
B树
二叉查找树的本质就是:用一个关键字把我们的数据域分成两份,比如29就把数据域分成了负无穷到-29和29到正无穷,能不能变成m叉查找树?
如果当前查找的值比22小就往左走,比22大就往右走,右边的数据范围是22到正无穷,36和45的作用就相当于隔板一样,把数据域分成了3个区间。对于一棵5叉查找树来说,每个结点最多有4个关键字,5个孩子。结点内的关键字是有序的,每个结点中还可以使用折半查找。
比如现在我们要找41这个关键字,22<41,所以指针右移,此时发现找过该结点的关键字的数量,所以在下一层结点开始寻找,36<41,指针右移,45>41,进入下一层结点,40<41,指针右移,42>41,在42的左孩子处寻找,到达一个失败结点,查找失败,其实失败节点就是NULL。
假设每个结点只要两个分叉的话,此时树变高效率变低,所以我们采取的策略是对于一个5叉排序树,任何结点至少有3个分叉,2个关键字( 根节点除外,根节点做不到,根节点如果只有一个元素,就只能有两个分叉)。
不够平衡,树会很高,所以采取策略,所有子树的高度都要相同。
下图就是一个5阶B树
所有非叶子节点的结构如下:
含有n个关键字的m阶B树,最小高度和最大高度是多少?(不包括叶子节点)
B树的插入和删除
接下来我们从0开始建立一棵B树。对于5阶B树来说,关键字的个数[2-4],我们把25,38,49,60插入到根节点,当我们插入80的时候,关键字超过上限,会从中间位置分成两个部分,中间节点提到父节点中。
下面我们插入90这个元素,90>49,49的右边没有关键字了,接下来插入90,99,88。
插入99后,关键字是数量超过上限,开始分裂。
接下来插入83,87,70开始分裂,80放到父节点的那个位置?
它所属关键点的这条指针所对应点右边的位置
假设我们现在要删除60这个元素,60所在的位置是终端节点,直接删除,并且节点中关键字的个数满足要求。
我们再删除80这个关键字,此时根节点为空,我们可以找到80的直接前驱(左子树最右下)或者后继来替代80的位置,这里我们使用直接前驱77来替代,我们可以使用77的直接后继82来替代。
删除38这个元素,此时,节点中关键字的数量低于下限,它的右兄弟的结点的数量是够的,所以会让它的右兄弟结点贡献出一个关键字-70,但是直接放是不对的,因为49>70,所以这里我们可以把49这个元素拉下来,然后让70放到49原来的位置,所以当右孩子空余的时候我们会让当前结点的后继和后继的后继来填补空缺,25的后继是49,49的后继是70。
此时再删除90这个关键字。
接下来我们要删除49这个结点,它的兄弟的结点也不够了,此时我们可以让这两个结点合并,还要把这两个结点中间的结点70合并。
由于我们从父节点扣了一个关键字下来,导致该结点的数量低于最小值。此时我们继续和并。
B+树
结点子树的个数和关键字的个数相同,假设我们这棵B+树中存放的是每颗学生的信息,50,56这些关键字就相当于学号,如果我们想找到一个学生的信息,我们可以在这个叶子结点中找到该数据项(包含学号,和指向学生实际信息的指针)。每个学号就是一个关键字,都会出现在叶子结点。
假设我们要查找的元素是9,9<15,向左走,9>3,向左移动,指针下移,移动到下一层的结点,依次查找找到叶子结点中9这个关键字,就可以找到9这个关键字对应的详细信息。
假设我们要查找的元素是7,7<15,向左走,7>3,向左移动,7<9,指针下移,移动到下一层的结点,6<7,向左移动,8>7,查找失败。
上面介绍的两个例子都是从根结点开始向下查找, 下面我们直接依靠p指针顺序查找,
散列查找
散列表(关键字与存储地址直接相关)
1和14就是同义词,1这个元素的存放位置仍然是1,这个位置已经存放了14这个元素了,这种现象就叫做冲突。
新元素可以放在链表的链头或者链尾
假设我们要查找目标关键字是27,第一步是根据散列函数计算27存放的位置,27%13=1,所以27可能放在1位置链接的链表中,然后我们依次遍历这个链表,在第三个元素找到27,查找成功,查找长度是3。
根据散列函数计算21存放的位置,21%13=8,所以21可能放在8位置链接的链表中,但是这个链表是空的,所以查找失败,查找长度为0。
根据散列函数计算66存放的位置,66%13=1,所以66可能放在1位置链接的链表中,对比了所有关键字,发现查找失败。
不管所给的关键字的值是多少,经过散列函数映射后,只有可能(0-12),如果映射到0这个地址,显然查找失败的长度是0,如果映射到1这个地址,显然查找失败的长度是4。
假设散列表的表长是15的话,经过散列函数映射后,只有可能(0-12),实际上13,14这两个位置是被我们放弃的。
取质树可以保证关键字的冲突经可能的小,如果给出的关键字是一些连续的,我们发现使用质数7的冲突变大了。
如果我们把关键字全部变成偶数,各关键字的散 列地址集中在 0,2,4,6,⽤质数取模,分布更均匀,冲突更少。
对于我们的电话号码一共有13个数,开头的3位和其它位置的分布是不均匀的,但是后4位的分布较为均匀,所以我们可以让后面的四位作为我们的地址。
这里我们给出几个关键字,这几个关键字的数字分布不太均匀,所以我们可以求出他们的平方,然后取中间几位数字,取中间的原因是为了保证每个数字都是对应的,
开放地址法:数组中存放的就是一个数据,而不是链表, 空闲地址向同义词和非同义次开放,我们可以允许1放在3这个位置,关于增量的设置我们一共有3种方法。
首先看一下线性探测法,现在我们把前面3个元素存放进去了,然后我们开始存放1这个元素,通过计算1这个元素本来要存放在1这个位置,发生第0次冲突计算也是1这个位置,此时发生冲突,计算第一次冲突的位置为2,把1放进去,接着放入68和20,放84这个元素的时候发生了冲突,通过计算放到第二次发生冲突的位置。
假设除了上述元素外我们还需要存储25这个元素,25对13取模得到12,25本该存储在12的位置,现在发生了冲突,接下来的地址是13,所以25会放到13的位置(13要小于表长)。
现在我们来探讨查找操作,假设我们要查找27这个元素,首先算出其存放的位置应该是27,但是1这个位置是14,接下来对比2位置这个元素,查找27的过程种我们探测了4个位置,查找长度就是4,我们发现14和1与27是同义词,68和27不是同义词。
假设我们要查找11这个元素,查找长度是1。
假设我们要查找21这个元素,计算第0次冲突的位置为8,计算第1次冲突的位置为9,
计算第2次冲突的位置为10,计算第3次冲突的位置为11,计算第5次冲突的位置为13,确定查找失败,21的查找长度是6,空位置的判断也算作依次比较。
如果10号位置是空的话,查找长度就是3。
我们现在要删除,要删除1这个元素,如果我们直接删除它的话,此时当查找27这个元素的时候,碰到空位置会认为查找失败,所以当我们删除一个元素的时候可以设置一个标志位。
我们把1-8位置上的元素删除了,现在要查找79这个元素,需要对比9次关键字,此时实际上表是很空的,这也是它的弊端。
考虑查找失败时,初次探测的地址只有可能是0-12,如果在0号位置查找失败,对比1次,在1号位置查找失败,对比13次,效率低的原因是同义词和非同义词的聚集现象。
现在有一些元素对13取余都应该存放在6这个位置但是实际上只有第一个元素6可以被放进去,先看19这个元素,使用平方探测法,得到的结果是7,32这个元素,使用平方探测法,得到的结果是5等等。
散列表⻓度m必须是⼀个可以表示成4j + 3的素数,才能探测到所有位置