7.1.1 查找基本概念
7.2.1 顺序查找
顺序查找意思就是从头到尾(反之亦然)挨个查找,适合于顺序表、链表,表中元素有序无序都无所谓。
顺序查找的实现:老师讲的是不带哨兵版,书上是带哨兵版。
typedef struct SSTable {
ElemType* elem;//动态数组基址
int Tablen;//表的长度
};
//顺序查找
int Search_Seq(SSTable ST, ElemType key) {
int i;
for (i = 0; i < ST.Tablen&&key!=ST.elem[i]; i++) {
//查找成功返回下标,失败返回-1
return i = ST.Tablen ? -1 : i;
}
}
带哨兵版本:
typedef struct SSTable {
ElemType* elem;//动态数组基址
int Tablen;//表的长度
};
//顺序查找
int Search_Seq(SSTable ST, ElemType key) {
int i;
ST.elem[0] = key;//0号位置存哨兵
for (i = 0; key!=ST.elem[i]; i--) {//从后往前找
//查找成功返回下标,失败返回-1
return i = ST.Tablen ? -1 : i;
}
}
有哨兵存在只是不用判定数组是否越界,但对实际运行效率而言并没有多快。
查找效率分析我们看ASL,并且分查找成功和失败两种情况去讨论。
查找成功:ASL=(1+2+3+...+n)/n=(n+1)/2;查找失败:ASL=n+1;
无论成功还是失败,数量级都是O(n)。
接下来我们看是否可以优化:如果一个顺序表是有序的,表中数据元素要么递增要么递减,
通过构造查找树来分析问题是我们解决这一章问题的关键,几乎每一种查找方式都需要分析其构成的树。
对于顺序查找(有序)判定树,成功结点的查找次数=自身所在层数,失败结点查找次数=其父结点所在层数。
这样做又产生了另一个问题,查找成功概率是增加了,但是破坏了原有的有序结构,查找失败的ASL大大增加了。
7.2.2 折半查找
折半查找又称为二分查找,是我们本章中最重要的一种查找方法,需要掌握具体代码实现,并且要会手动模拟。
折半查找仅适用于一开始就有序(下文假定为升序)的顺序表(顺序表拥有随机访问的特性,链表没有)。定义两个指针low high,low指向开始,high指向最后,定义mid=[(low+high)/2],如果待查找元素值>mid对应的元素值,那么low指针移动,low=mid+1;反之high=mid-1;重复上面这个过程,直到mid对应元素值=待查找元素值。
如果查找失败,此时low>high.
typedef struct SSTable {
ElemType* elem;//动态数组基址
int Tablen;//表的长度
};
//折半查找
int Binary_Search(SSTable L, ELemType key) {
int low = 0; int high = L.Tablen - 1;
int mid = 0;
while (low <= high) {
mid = (low + high) / 2;
if (L.elem[mid] > key)
high = mid - 1;
else
low = mid + 1;
}
return -1;
}
折半查找树的构造: 我们的mid是low和high相加向下取整,
故我们得出了一个重要结论,在向下取整的情况下,右子树的结点数-左子树结点数=0/1;
折半查找的查找效率:树高h=[log2n],(该树高不包含失败结点)时间复杂度log2n.
如果要查找失败,我们考虑二叉树的定义,每个二叉树有n+1个空链域,这些就是可能查找失败的结点。
7.2.3 分块查找
分块查找并不要求我们掌握具体的算法代码,只需要手动模拟出算法过程,了解算法思想就行了。
分块查找的思想就是把数据分区间分段存储,并且保留每个区间最大值作为索引表的关键字。
//索引表
typedef struct {
ElemType maxvalue;
int low, high;
}Index;
//顺序表存储实际元素
ElemType List[100];
分块查找,又称索引顺序查找,算法顺序如下:
(1):在索引表中确定待查元素所属的分块(可用顺序查找或者折半查找)
(2):在块内顺序查找。
在索引表中确定元素所属分块,顺序查找很好分析,我们这里单独分析一下折半查找。
利用折半查找找索引表,找到low=high时,假设此时还不相等,那么由于索引表中是最大关键字,所以目前两个指针指向的位置,一定是大于我们要查找元素的。故low再移动一次,此时指向的就是我们待查元素的区间。
分块查找的ASL很复杂,考试只考一种最特殊的情况,也就是,
如果我们查找表是动态查找表,那么有没有更好的实现方式呢?比如要插入删除几个元素?
答案是我们可以用链式存储。数据结构是为了让我们更好地具体分析问题的,不应该被课本束缚,链式存储更方便删除插入元素。
7.3.1 二叉排序树
二叉排序树,又称二叉查找树BST(binary search tree),关键是左<根<右,二叉排序树通过中序遍历,一定可以得到一个递增序列。
//二叉排序树结点
typedef struct BSTNode {
int key;
struct BSTNode* lchild, * rchild;
}*BSTree;
//在二叉排序树查找值为key的结点
BSTNode* BST_Search(BSTree T, int key) {
while (T != nullptr && key != T->key) {
if (key < T->key) {
T = T->lchild;
}
else {
T = T->rchild;
}
}
return T;
}
//递归实现二叉排序树的查找
BSTNode* BSTSearch(BSTree T, int key) {
if (T = nullptr)
return nullptr;
if (key = T->key)
return T;
else if (key < T->key)
BSTSearch(T->lchild, key);
else
BSTSearch(T->rchild,key);
}
如果我们用循环写,最坏时间复杂度O(1),递归写最坏时间复杂度O(h)
二叉排序树的插入:
若二叉树为空则直接插入,若关键字小于根节点值则插入到左子树,反之插入到右子树。
int BST_Insert(BSTree& T, int k) {
if (T == nullptr) {
T = (BSTree)malloc(sizeof(BSTNode));//原树为空,插入结点为根结点
T->key = k;
T->lchild = nullptr;
T->rchild = nullptr;
return 1;//插入成功
}
else if (T->key == k) {//树中存在相同关键字结点,插入失败
return 0;
}
else if (k < T->key) {
return BST_Insert(T->lchild, k);
}
else {
return BST_Insert(T->rchild, k);
}
}
二叉排序树的构造:
不同的关键字序列可能得到同款二叉排序树,也可以得到不同的二叉排序树。
//按照str[]的关键字序列构造二叉排序树
void Create_BST(BSTree& T, int str[], int n) {
T = NULL;
int i = 0;
while (i < n) {
BST_Insert(T, str[i]);
i++;
}
}
二叉排序树的删除:
1.被删除结点为叶子结点,我们直接删除
2.被删除节点只有左子树或者右子树,则用子树代替其位置
3.被删除节点有左子树和右子树,则令Z的直接后继(或者直接前驱)代替z,然后从二叉排序树中删去这个直接后继(或直接前驱)这样就转换成了第一种情况。
后继:z的右子树最左下结点。
查找效率分析:
若树高h,找到最下层的结点需要对比h次,查找失败的结点一定是叶子结点的下一层,最好情况平均查找长度O(log2h),最坏情况O(h)。
7.3.2 平衡二叉树
Balanced Binary Tree 简称平衡树(AVL),树上任意一个结点的左子树和右子树高度之差不超过1.
结点的平衡因子=左子树高-右子树高。平衡二叉树结点的平衡因子只可能是-1,0,1
只要有任一结点平衡因子绝对值大于1 ,就一定不是平衡二叉树。
//平衡二叉树结点
typedef struct ALVNode {
int key;
int balance;
struct ALVNode* lchild, * rchild;
}ALVTree;
平衡二叉树的插入:
平衡二叉树的插入比较复杂,我们需要分情况一步步讨论清楚。
这一点是我们处理问题的关键,我们每次处理的都是最小不平衡子树。
在插入操作中,只要将最小不平衡子树调整好,那么其它所有祖先都能调整好。
调整最小不平衡子树分四种情况,插入地方为LL,LR,RR,RL
LL
为什么要假设所有树高都是H?
AR来说,如果高度为H+1,那么就不是不平衡了,二叉树仍然稳定。如果高度为H-1,那么现在已经产生不平衡了。
BL来说,如果高度为H+1,二叉树已经不稳定不平衡了。如果高度为H-1,那我们插入结点之后,插入结点之后仍然是平衡二叉树,不影响。
因此我们采取以下方法使二叉树恢复平衡:将LL右单旋转,将A的左孩子B右上旋转代替A成为根结点,A结点右下旋转成为B的右子树的根结点。
RR
左单旋转,A的右孩子左上旋转成为根结点,A成为B的左子树的根结点。
代码思路:
//实现f右下旋转,p右上旋转
f->lchild = p->rchild;
p->rchild = f;
gf->lchild/rchild = p;
//实现f左下旋转,p左上旋转
f->rchild = p->lchild;
p->lchild = f;
gf->lchild / rchild = p;
LR
LR插入到左子树的右子树上,这里为了便于分析,我们把左子树的右子树展开,至于实际插入到C结点具体哪个分支无所谓,后续我们会发现处理是一样的。
现在这个图,最小不平衡子树应该是 A,也就是根结点。我们先将C子树左旋,再将其右旋即可。
RL
处理同上。我们找到最小不平衡子树之后,将该子树先右旋再左旋即可。
为什么我们每次处理的都是最小不平衡子树呢?因为插入操作使得最小不平衡子树高度+1,一切不平衡都是从这里产生的。只要它恢复平衡了,其它祖先结点都能平衡。
本小节很重要!一定要多做习题!最好是给定一个序列,自己从头到尾构建一遍平衡二叉树!必须要完成,要又快有准,写完之后还要检查一遍是否满足左<根<右。
7.3.3 红黑树的定义和性质
平衡二叉树:适用于以查为主、很少插入/删除的场景
红黑树: 适用于频繁插入、删除的场景,实用性更强 。
性质1: 从根节点到叶结点的最长路径不大于最短路径的2倍
性质2:有n个内部节点的红黑树高度 h <2log(n + 1)。
因此,红黑树查找的时间复杂度为O(log2n)
7.3.4 红黑树的插入
红黑树的插入删除操作比较复杂,考试不太可能考到,我们了解下插入操作就可以了。最主要的其实应该是红黑树的定义和性质。
红黑树首先是二叉排序树,在进行插入操作的时候,我们首先要找到在哪里插入,然后分情况讨论。
考试不太可能考到红黑树手动插入删除,我们会判断是否还满足红黑树定义即可。
因此最少应该有2^h-1个。
7.4.1 B树
我们学过了二叉排序树,接着我们研究一下5叉排序树。
我们思考一下,如何保证五叉查找树的效率呢?首先每个结点里面关键字不能太少,如果太少,那么树就会很高很高,查找效率就会降低。
策略:m叉查找树中除了根节点外任何结点至少有[m/2]个分叉,即至少含有[m/2] -1个关键字。
接着我们再想,如果不平衡呢?树只有左子树,那么高度还会很高,查找效率还是很低。
策略: m叉查找树中,规定对于任何一个结点,其所有子树的高度要相同。
因此,我们引入了B树的概念。
m阶B树的核心特性:
课本上给出了另一种最大高度的求法:
7.4.2 B树的插入和删除
B树的插入和删除操作比较复杂了,但不如红黑树复杂,因此我们需要掌握。我们学校期末考试就考过从头开始插入一棵B树。
新元素一定是插入到最底层“终端节点”,用“查找”来确定插入位置。
B树的插入真的不难,还是很好理解的!
下面我们研究B树的删除。
直接前驱:当前关键字左侧指针所指子树中“最右下”的元素
直接后继:当前关键字右侧指针所指子树中“最左下”的元素
对非终端结点关键字的删除,必然可以转化为对终端结点的删除操作。
最后我们总结下B树的删除操作。
7.4.3 B+树
B+树和B树非常类似,我们要对比着学习。先学完B树再学B+树会发现很多相似地方。
所有分支节点中只包含其指向序列中关键字的最大值以及指向该序列的指针。
B+树中,我们如果想要查找一个数字,比如7,那么我们一定要走到最下层才能找到具体位置。首先分支节点只有最大值,7可能不在里面,其次找到了也只能说明有7,7这个关键字的具体位置还在叶子结点上。
为什么我们有了B树之后,还要引入B+树的概念?
在B+树中,非叶结点不含有该关键字对应记录的存储地址。可以使一个磁盘块可以包含更多个关键字,使得B+树的阶更大,树高更矮,读磁盘次数更少,查找更快。
最典型的应用就是MySQL关系型数据库的索引,底层应用就是B+树。
7.5.1 散列查找(上)
这里介绍的哈希表只是适用于考研数据结构的。如果想要更底层看哈希表的应用,欢迎看我另外的博客,比如代码随想录或者左程云算法通关教程。
直白来讲,数组就是一张哈希表。
哈希表能解决什么问题呢,一般哈希表都是用来快速判断一个元素是否出现集合里。
例如要查询一个名字是否在这所学校里。
要枚举的话时间复杂度是O(n),但如果使用哈希表的话, 只需要O(1)就可以做到。
我们不去过多研究hash表应用,主要研究怎么解决同义词和冲突的问题。
如上图所示,不同关键字通过哈希函数映射到了同一个位置,发生了冲突,我们怎么办?
拉链法
我们把产生冲突的关键字拉成一个链表。这样就可以避免冲突。但是又有新的问题,我用哈希表查找的时间复杂度应该是常数级别,现在链表查找无法做到O(1)啊。所以拉链法要合适的哈希表长度,这样才能缓解链表查找消耗的时间。
21这个关键字没有和8位置的关键字比较,空指针的比较我们不算查找次数。
接着我们对上图计算一下ASL:(1*6+2*4+3*1+4*1)/12=1.75
ASL失败=(0*7+4+2+2+1+2+1)/13=0.92(因为查找失败有13种可能,但是成功只有12个关键字,所以一个除以12,一个除以13)
可以看出查找效率还是比较高的!我们定义装填因子a=表中记录数/散列表长度。装填因子会直接影响散列表的查找效率。
常见的散列函数
设计目标一一让不同关键字的冲突尽可能地少。
散列查找是典型的“用空间换时间”的算法,只要散列函数设计的合理,则散列表越长,冲突的概率越低。
7.5.2 散列查找(下)
这一小节我们继续研究解决冲突的办法。哈希表考的不算难,主要在于能否正确理解冲突和关键字,并且计算出ASL。
开放定址法
这句话很难理解?意思就是这个地方没空发生冲突了,我就想办法找到一个没冲突的地方存关键字就好了。上面的话不用理解。
因此使用线性探测法,同义词和非同义词都会被检测到。
再比如上图中,我们要查找关键字21,21%13=8,从下标为8开始查找,一共查找六次,发现失败(这里和数组空元素比较也算一次)
ASL成功=(1+1+1+2+4+1+1+3+3+1+3+9)/12=2.5;这个ASL已经很高了。我建议在计算的时候把每一个元素的查找长度都列出来。
线性探测法很容易造成同义词、非同义词的“聚集 (堆积)”现象,严重影响查找效率
产生原因——冲突后再探测一定是放在某个连续的位置。
ASL失败=(1+13+12+11+10+9+8+7+6+5+4+3+2)/13=7
非重点小坑:散列表长度m必须是一个可以表示成4i+3的素数才能探测到所有位置。
再散列法
再散列法 (再哈希法):除了原始的散列函数 H(key)之外,多准备几个散列函数,当散列函数冲突时,用下一个散列函数计算一个新地址,直到不冲突为止。