目录
1 查找表
查找表是同一类型的数据元素构成的集合。如下每个学生的信息记录合在一起构成了查找表。
查找就是根据给定的某个值,在查找表中确定也给其关键字等于给定值的数据元素。
关键字:用来标识某个数据元素的某个数据项的值。
比如给定姓名叫陈红,查找确定查找表中姓名这个数据项的值为陈红的数据元素。
主关键字:可以唯一标识一个记录的关键字。比如上表中准考证号。
查找表分类
静态查找表
仅作查询(检索)操作的查找表。
动态查找表
作插入和删除操作的查找表。
有时候在查找的时候需要将不在查找表中的元素插入到查找表中,或者将查询到的元素从查找表中删除。
查找算法评价指标
平均查找长度ASL(average search length)
注意这里的定义是查找成功时的平均查找长度
查找算法研究内容
查找的方法强依赖于查找表的结构,查找的研究其实就是研究各种查找表的组织结构,以及在特定结构上的查找方法。
2 线性表的查找
顺序查找(线性查找)
元素与顺序表数据结构定义
应用范围:顺序表或线性链表表示的静态查找表。
我们定义数据元素类型如下:
typedef struct{
KeyType key; //关键字域
// 其它域暂不定义
}
顺序表类型定义:
typedef struct{
ElemType *R; // 顺序表起始地址,比如用vector来实现R
int length; // 顺序表元素个数
}SSTable;
一个顺序表实例如下:
注意顺序表第一个位置没有放元素
顺序查找的几种实现
顺序查找函数的几种实现形式如下:
// 第一种,表长作为循环结束条件
int Search_Seq(SSTable ST, KeyType key){
for(int i = ST.length; i >= 1; --i){
if(ST.R[i].key == key) return i;
}
return 0; // 如果没有在循环中找到与给定字相等的关键字,返回0
}
// 第二种,关键字与给定值匹配作为循环结束条件
int Search_Seq(SSTable ST, KeyType key){
for(int i = ST.length; ST.R[i].key != key; --i){
if(i <= 0) break;
}
if(i > 0) return i;
else return 0;
}
// 第三种,关键字与表长同时作为循环结束条件
int Search_Seq(SSTable ST, KeyType key){
for(int i = ST.length; ST.R[i].key != key && i > 0; --i);
if(i > 0) return i;
else return 0;
}
需要注意的是上边三种方法中,在循环体内我们都要进行两次比较,一次比较关键字是否匹配,一次比较索引i是否已经到0号位置,同时我们注意到在数组表索引为0的位置是没有放元素的,因此我们可以通过在这个位置放置哨兵(即把待比较的值放到这个位置),这样我们就不用进行i的比较,当遍历到哨兵位置的时候,关键字一定是匹配的(因为哨兵位置放的就是待比较的值),但是这个时候输出0我们可以知道,这不是真正的查找成功,而是已经到了哨兵位置。
通过防止哨兵,我们把循环体中的两次比较变成了一次比较,当ST.length较大时,此改进可使查找时间几乎减少一半(ASL不变)。
// 放置哨兵的方法
int Search_Seq(SSTable ST, KeyType key){
ST.R[0].key = key;
for(int i = ST.length; ST.R[i].key != key; --i);
return i;
}
顺序查找效率分析
时间效率
如图,如果给定值是第11个元素,则比较一次,查找次数跟匹配关键字的位置强相关。
表中各项查找概率相等,则查找成功时的平均查找长度ASL为:
ASL = (1 + 2 + ... + n) / n = (n + 1) / 2
因此时间复杂度为 O(n) = O(ASL)
空间效率
O(1),这个没啥好说的,又没有用辅助数组。
优化手段
(1)当记录的查找概率不相等,即某些记录可能被查找的次数多,有些记录被查找的次数少,这个时候如何提高查找效率?
更改查找表,按查找概率排序。查找概率高的就放在比较开始的位置,查找效率低的就放在比较结束的位置。
(2)记录的查找概率未知时怎么提高查找效率?
按查找概率动态调整记录顺序。
在数据元素结构体中增加一个查找频度成员,如下:
typedef struct{
KeyType key; //关键字域
// 其它域暂不定义
int fre; // 查找频度,关键字每匹配成功一次就加1
}
某个记录的关键字域每匹配成功一次,就将其频度域加1,记录在表中按频度排序。
顺序查找特点
优点:算法简单,顺序存储方式和链式存储方式均适用。
缺点:ASL太长,时间效率低
二分查找
每次将待查找记录所在区间缩小一半。
但是要求静态查找表有序,即关键字域是有序的。然后通过每次求索引中点来将查找区间缩小一半。
二分查找非递归实现
主要是三个指针low,high,mid分别指向待查元素所在区间的上界,下界和中点。key为要查找的值。
int Search_Bin(SSTable ST, KeyType key){
int low = 1, high = ST.length, mid;
while(low <= high){
mid = (low + high) / 2;
if(ST.R[mid].key == key) return mid;
else if(key < ST.R[mid].key) high = mid - 1;
else low = mid + 1;
}
return 0;
}
二分查找递归实现
int Search_Bin(SSTable ST, keyType key, int low, int high){
if(low > high) return 0;
mid = (low + high) / 2;
if(key == ST.elem[mid].key) return mid;
else if(key < ST.elem[mid].key)
Search_Bin(); // 递归查找
else Search_Bin(); // 递归查找
}
二分查找效率分析
如下案例:如果要查找的是6号元素,那么比较一次就够了,如果是3号或9号则需要比较2次,同理可以标出要查找的元素所在位置与查找次数之间的关系。
二分查找判定树如下图:
如果我们要查找的是6号元素,则一次就找到了,因为把它放在第一层
如果要查找的是3号元素,那么需要先与6号结点比较,再与3号结点比较,比较两次,因此放在第二层,同理查找9号结点也是两次。
这样按照查找某个结点需要的比较次数来确定结点所在层数形成的树就是二分查找的决策树。
这样,对于查找成功的情况,其比较次数就是查找路径的结点数,即该匹配结点所在的树的层数。
对于查找失败的情况(即表中没有该查找数据),最后一次区间划分的时候low会大于high,因此查找失败情况的比较次数也是小于等于判定树的高度。
因为二叉树的高度不大于 [log2n] + 1 (中括号表示下取整),因此最坏情况下的比较次数也不超多这个数。
所以二分查找的时间复杂度为O(logN),即我们通过构造二分查找的判定树,得到最坏情况下的比较次数,即为判定树的深度,通过二叉树深度的已有结论而推到出了二分查找最坏情况下的时间复杂度。
平均查找长度推导如下:
查找成功时的平均查找长度计算例子
二分查找特点
优点:查找效率高
缺点:只能用于有序表,且只适用于顺序存储结构,不适用于链式存储结构。对于顺序存储结构,找区间中点直接把两端序号求平均就行,而对于链式存储结构,要找区间中点需要一个一个结点遍历,有这个功夫直接用顺序查找就好了。
如果要插入删除元素的话,要移动很多元素。
分块查找(索引顺序查找)
分块查找具体流程
本质做法是将数据分块,缩小查找范围,即原本是在所有记录中找,现在第一步我们先确定待查找元素在哪一块,然后确定查找范围就是这一块,其它块就不用考虑了。
为了实现可以确定数据在某一块而不在其它块,就要求每个块内的数据范围不重合,即分块有序,要求一个块的最小值要大于另一个块的最大值。
具体应用中,就是将数据组织为分段有序的形式,可以用顺序存储也可以用链式存储。
然后建立一个索引表,记录每个块的最大值和最大值所在地址的映射。
查找过程分为两步:
第一步,确定待查找数据所在块,因为数据分块有序,所以索引表一定是有序的,可以使用二分查找来确定块。
第二步,块内查找,如果数据无序,可以使用顺序查找。
分块查找性能分析
分块查找主要分为两步,一步是索引表的查找,一步是块内查找。
对于索引表的查找用二分查找,索引表中数据的个数等于查找表中总数除以每个块内部的个数。即下图中n/s,因此对于索引表的查找其平均查找长度为log(n/s + 1)。
对于块内查找,一般使用顺序查找,因此平均查找长度是 (s + 1)/ 2。
加一起即为分块查找的平均查找长度。
分块查找的平均查找长度在二分查找和顺序查找之间,比较接近二分查找。
分块查找优缺点
线性表上的查找方法比较
3 树表的查找
树表查找概念
主要有两点,
一,目的是为了应用于插入删除很频繁的场景,即动态查找场景。
二、动态查找场景,一般要借助几种树来实现方便的查找与插入删除。
二叉排序树BST
又称为二叉搜索树,二叉查找树。
二叉排序树定义
二叉排序树或者是空树,或者是满足如下性质的二叉树:
(1)若其左子树非空,则左子树上所有结点的值都小于根节点的值;
(2)若其右子树非空,则右子树上所有结点的值都大于等于根节点的值;
(3)其左右子树本身又各是一棵二叉排序树。
二叉排序树性质
中序遍历非空的二叉排序树得到的是一个升序序列。
二叉排序树上的查找
若查找的关键字等于根节点,则查找成功。
否则:
若小于根节点,查其左子树;
若大于根节点,查其右子树;
在左右子树上递归进行此操作直到查找成功。
由上边描述可知二叉排序树上的查找是一个递归过程
具体例子可看leetcode 700 700. 二叉搜索树中的搜索 - 力扣(LeetCode) (leetcode-cn.com)
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
TreeNode* searchBST(TreeNode* root, int val) {
if(root == nullptr) return root;
if(val == root->val) return root;
else if(val < root->val) return searchBST(root->left, val);
else return searchBST(root->right, val);
}
};
二叉排序树查找性能分析
二叉排序树其实也是一种二分查找的思想,每次比较都可以缩小待查找空间,只是二分查找的时候是把待查找空间缩小一半,而二叉排序树的查找并不能确定是把待查空间缩小一半,因为左右子树可能非空不平衡,可以一下缩小了很多,也可能一点都没缩小(例如左子树为空,待查关键字大于根节点)。
二叉排序树的最坏情况,即最多比较次数也是树的深度。
可见对于含有同样记录的一个查找表,其二叉排序树的组织形式不同,查找效率会相差非常大。
比如下边例子:
可以看到,当二叉排序树的形态比较均衡的时候,其深度与二分查找中的判定树相同,时间效率也是对数阶。但是当二叉排序树的形态极端不平衡的时候,其查找效率就等同于顺序查找,是线性阶。
故,为了提升二叉排序树的查找效率,就要尽量让二叉树的形态均衡,即做平衡化,下边也会提高平衡二叉树的问题。
二叉排序树的插入
我们每插入一个元素,一定是生成一个新的叶子结点。二叉排序树的插入操作可以总结如下:
若二叉排序树为空,则插入结点作为根节点。
若二叉排序树不为空,继续在其左右子树上查找:
如果树中已有该元素,则不再插入;
如果树中没有:
继续往下查找,直到某个结点的左子树或者右子树为空为止,则插入结点应该为该结点的左孩子或右孩子。
从以上步骤也可以看出,每次插入一个元素到二叉排序树中,都要先在该树上进行查找,即查找操作是插入操作的基础。
二叉排序树的生成
给定一个无序序列,通过不断的插入过程(依次插入每一个结点)可以构造出一个二叉排序树,这个二叉排序树就相当于是一个有序序列,毕竟只要中序遍历就能得到有序序列。
因为我们每次插入的结点都是作为一个叶子结点插入的,也就无需移动二叉排序树中的其它结点。相当于在有序序列上插入记录而无需移动其它记录。因此二叉排序树适用于动态查找表的场景。
但是,我们给定的无序序列中数据顺序不同,生成的二叉排序树的形态也是不同的!不同的二叉排序树,也就对应了不同的查找效率。第二棵树实际上相当于退化成了链表。
二叉排序树的删除
二叉排序树的删除要比插入操作更复杂。因为插入的时候插入结点一定是叶子节点,但是删除的时候任何结点都有可能。因此删除一个结点的时候不能把以该结点为根的子树都删除,只能删除掉这一个结点,即删除一个结点之后,要把以其为根的其它结点重新链接到二叉树上,还应保证删除结点后的二叉树仍然是一个二叉排序树,并且最好还能减少二叉排序树的高度那就再好不过了。
二叉排序树的删除问题具体可分为三种情况:
(1)被删除的结点是叶子结点,即既没有左子树也没有右子树。
此时可以直接删除该结点。不会对其它结点造成影响,就跟插入的时候一样。
(2)被删除的结点只有左子树或者只有右子树,则用其左子树或者右子树替换它。
即双亲结点的相应指针域的值改为“指向被删除结点的左子树或右子树”。
其实就是将30结点的右指针替换成40结点的左指针。
(3)被删除的结点既有左子树,也有右子树。
这种就不好办了,我们需要用以该被删除结点为根节点的子树的中序序列中,找到紧邻该被删除结点的那个结点,可以是中序序列中的前驱,也可以是中序序列中的后继,然后用该前驱或者后继来替代被删除的这个结点。这个前驱或后继要从原先的位置删除。
如下边这个例子,虽然用删除以后用前驱替换还是用后继替换均可,但是后继替换可以减少二叉排序树的高度,因此还是用后继替换比较好。
平衡二叉树
也被称作AVL树。
有些资料上说平衡二叉树首先是二叉排序树,但根据笔者调查,还有人说平衡只是二叉树的一种状态,即对于树上每一个结点,其左右子树高度之差不超过1。只是对于BST来说,平衡状态更有利于查找,因此AVL可以是BST,也可以不是BST,并没有要求一定要有序,总而言之,平衡二叉树是不是二叉排序树是一个比较模棱两可的概念,当然,笔者比较赞同平衡树与二叉树是两个概念。
在上一小节二叉排序树中我们直到,树的形态会严重影响查找效率,树比较平衡时二叉排序树上的查找效率接近二分查找,而树极端不平衡时查找效率则退化成了顺序查找。因此平衡二叉树对于树表的查找十分重要。
平衡二叉树定义
一棵平衡二叉树要么是一棵空树,要么是具有以下性质的二叉树:
左子树与右子树的高度之差的绝对值小于等于1;
左子树与右子树也是平衡二叉树。
为了方便,定义一个平衡因子的概念:
某结点的平衡因子 = 该结点左子树的高度 - 该结点右子树的高度。
根据平衡二叉树的定义,平衡二叉树上所有结点的平衡因子只能是 -1 , 0 或者1。
对于一棵有n个结点的AVL树,其高度保持在O(logN)数量级,平均查找长度ASL也保持在
O(logN)数量级。
平衡二叉树问题可以参考leetcode 110 110. 平衡二叉树 - 力扣(LeetCode) (leetcode-cn.com)
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
bool isBalanced(TreeNode* root) {
if(root == nullptr || !root->left && !root->right) return true;
else return isBalanced(root->left) && isBalanced(root->right) && abs(height(root->left) - height(root->right)) <= 1;
}
int height(TreeNode* root){
if(root == nullptr) return 0;
else return max(height(root->left), height(root->right)) + 1; // 这里加1别忘了,因为还有个根结点
}
};
失衡二叉排序树的分析与调整
当我们在一个平衡二叉排序树上插入一个结点,很有可能导致二叉树失衡,即出现平衡因子绝对值大于1的结点。这个时候就必须重新调整树的结构,使之恢复平衡。调整方法主要分为左旋和右旋,注意点是左旋和右旋要看好旋转点,左旋和右旋都是根据旋转点进行的操作。
失衡二叉排序树共有四种类型:
因为A结点失衡了,所以主要目的就是把A换下去。
以下动图copy自图示讲解AVL平衡二叉树的左旋和右旋_江城的博客-CSDN博客_二叉树左旋右旋
平衡化之右旋操作
解决LL型失衡问题。
失衡结点的左孩子上位,失衡结点变成左孩子的右孩子,如果左孩子原本就有右孩子,那么原先的右孩子变成失衡节点的左孩子。
如下图例子,3上位,5退位,4变成5的左孩子。
平衡化之左旋操作
解决RR型失衡问题
其实就是失衡结点的右孩子上位,然后失衡结点变成右孩子的左孩子,如果失衡结点的右孩子原先就有左孩子,则原先的这个左孩子变成失衡结点的右孩子。
平衡化之先左旋再右旋
解决LR型失衡问题
结合这两张图来看,1号就是A结点,2号就是B结点,LR型失衡,即在B的右子树上插入导致了A结点的失衡。
我们的解决方案虽然叫先左旋再右旋,但是实际针对的结点是不同的,先左旋是以B为根结点进行的左旋,再右旋是以A为根节点进行的右旋。
平衡化之先右旋再左旋
解决RL型失衡问题
以上两张图对应着看,同理,先右旋是以B为根节点进行的右旋,再左旋是以A为根节点进行的左旋。
B-树
注意这个叫B树,下边的那个叫B加树,这里 - 并不是一个减号,而是一个横线。以下简称B树。
B-树概念
B树又称为平衡多路查找树,其与AVL的区别在于B树是多叉树,而AVL是二叉树。B树中每个结点的最多分支数(即该节点有多少个子节点)就称为B树的阶数。值得注意的是B树中的每个结点通常也会存储很多的值(即很多的关键字),且每个结点存储的值都是升序排列的,且结点内的值要求互不相等。
一棵M阶B树有以下6个特点:
(1)每个结点中存放的多个值按升序排列,左小右大。
(2)根结点的子节点个数为 【2,M】,即根结点最少有两个子节点,最多M个是肯定的啦。
(3)除根结点外的非叶子结点的子节点个数为 【ceil(M/2), M】,ceil()表示向上取整,也就是对于一棵5阶B树来说,它的非根非叶子结点最少有3个子结点,最多有5个子节点。
(4)每个非叶子结点存储的值的个数等于子节点个数减1,注意这些结点内的值要升序排列。
(5)B树的叶子结点都位于同一层,一般都是NULL?有种说法是叶子结点是查找失败的结点,即叶子结点中没有关键字,即叶子结点是不携带信息的,最后一层携带信息(存储关键字)的结点被称为终端结点(感觉这一块说法不一,有空再搞)。
下面先看一下B数的具体实现,顺便引出B树的第6个性质:
如下如所示,事实上B树中的每个结点都存储了一个相同长度的数组,当我们人为定义一个B树的阶数以后,该数组的长度就确定了,即阶数减去1,比如下图中就是一个5阶的B树,其每个结点中是一个长度为4的数组,数组中存的元素个数即为对应结点存储的关键字个数,并且在数组中是升序存储的,且该结点的子节点数是结点中存储的关键字个数加1。
B树中的结点结构体示意如下:
可以看成每个结点中含有两个数组,第一个数组中存放的是结点存储的关键字个数与具体的关键字,第一个位置存放的是关键字的个数,后边位置是升序存储的关键字。这里没有考虑关键字个数与B树阶数的问题,实际上数组的长度就等于B树的阶数,数组中存放的关键字数目应该是小于等于阶数减1的。第二个数组中存放的是指向子节点的指针。这里就可以引出B树的第6个性质:
(6)pi指针指向的子节点中的所有关键字要大于Keyi 小于Keyi+1。比如p1指针指向的子节点的所有关键字都大于Key1小于Key2。
特点6非常重要,对于查找、插入、删除都有很大意义。当我们拿到一个待查找元素的时候,就把它与当前结点中的值比较,如果大于Keyi小于Keyi+1,那么就到pi指向的子节点中继续查找,所以我们在查找的时候通过将待查找值与Keyi和Keyi+1比较,就可以确定下一层要到哪一个结点中去查找。
B树的关键字查找操作
查找成功案例:
如上图,B树是5阶B树,且最后一层的结点没有画出来,我们可以认为最后一层的结点都赋值为NULL,访问到最后一层的时候就说明查找失败了。
我们要查找的是44
第一步,与根节点中的30比较,发现大于30,因为根节点中只有30这一个元素,下一步直接到根节点的右结点中去找。
第二步,待查询结点中有39,45两个关键字,将44与39比较,发现大于39,再与45比较,发现小于45,因为39和45两个关键字可以确定一个指针(B树特点6),这个指针指向的元素都是大于39小于45的,因此下一步到这个指针指向的结点中去寻找。
第三步,待查询结点中有40,42,44三个关键字,依次比较刚好能找到44,查找成功。
需要注意的是上述步骤中我们在进行结点内查询的时候都是用的顺序查找,也可以使用二分查找来提升查找效率,毕竟我们的关键字是升序存储在数组中的,使用二分查找非常合适。
查找失败案例:
如图,待查找元素是35。
第一步,与30比较,大于30,下一步到右孩子结点中查找。
第二步,待查找结点存储有关键字39,45,与39比较,发现小于39,则到p0指针指向的结点中去查找。
第三步,待查找结点存储有关键字33,34,与33比较发现大于33,于是与34比较,发现大于34,由于34是当前结点中最后一个关键字,因此继续查找p2指针指向的结点,发现p2指针为NULL,则返回查找失败。
B树的关键字插入&B树创建
创建过程就是从空树开始不断地把结点插入的过程。
注意掌握B树创建的整体思路------不断在终端层插入结点,然后向上裂变形成B树。
用一个例子来演示一下:
因为B树的阶数是5阶,所以每个结点中存储关键字的数目最多是4个。
第一步,插入前4个关键字,得到结果如下:
第二步,插入第五个关键字:
因为5阶B树最多只能存储4个关键字,当插入第5个关键字之后,需要进行拆分,需要注意的是,我们在插入每一个关键字的时候都要注意保持结点内关键字的有序性。
拆分的方法是让中间的关键字拆分出去成为更高一层结点中的关键字,
以此中间关键字为界限,可以把拆分前结点的关键字序列分成左右两部分,存储在不同结点中,
如果中间关键字在更高一层中的编号为i,则在更高一层会产生一个新的指针pi指向右部分,即大于中间关键字的部分,同时把更高一层原先的最右边的指针pi-1(原先是NULL)指向左部分,即小于中间关键字的部分。如下:
第三步,要插入的关键字是4,指向查找过程,4小于6,因此查看6的左孩子,然后再左结点内部数组中查找,最终找到4的插入位置。
接下来插入8,经过查找发现应该插入到7和11的中间。
接下来插入关键字13,应该在11的后边
这几步的插入都没有超过每个结点能储存的关键字的个数,但是插入13后,这个结点的容量已经到头了,因为5阶M树最多插入4个数,如果再插入就又要出错了。
第四步,插入关键字10
当还是按照前边插入步骤,10插入到对应结点之后,超过了结点数组长度,所以需要执行拆分操作,注意拆分的时候是将当前结点的中间关键字合并到更高一层的结点中去。如下图所示,10合并到根结点中,并将根结点中对应的指针指向拆分后的左右两部分。
接下来插入关键字5
插入关键字17
插入关键字9
插入关键字16
第五步,插入关键字20
此时又有结点数组满了,需要进行拆分,中间结点合并到更高一层去,左右两部分变成两个结点。
第六步,插入关键字3
又满了,因此需要拆分,把中间结点3合并到更高一级的结点中去。
接下来插入关键字12
插入关键字14
插入关键字18
插入关键字19
第七步,插入最后一个关键字15
因为满了,需要拆分,把13合并到上一层
13合并到上一层以后,上一层也超过数组长度了,因此需要再次拆分,把10放到更上一层,即插入一个关键字引起了两次拆分操作,最终就得到了如下所示的B树。可以检验一下我们上边提到了B树6个特点,比如对于5阶B树,非根非叶结点的子节点个数应该不小于3个,即存储关键字不少于2个。
B树的关键字删除操作
对于上一小节中我们创建的B树,现在来删除一些关键字试试。
删除关键字8,16,15,4
需要注意的是,在我们删除关键字的时候,要保证删除之后的树仍然是B树。
第一次,删除关键字8
因为关键字8所在的结点有三个关键字,已知5阶B树的非根非叶结点的子节点数大于等于3,关键字数大于等于2,因此删除8以后还有两个子节点,仍然是B数,因此直接删即可。
第二次,删除关键字16
在二叉排序树的删除中,如果要删除的结点没有子节点,那么直接删除,如有左子树或者右子树,那直接让原先指向删除结点的指针指向其左孩子或者右孩子即可,如果既有左子树又有右子树,那么就从二叉排序树中序遍历序列中找到删除结点的前驱或者后继来取代删除结点即可,注意其排序序列的前驱或后继在二叉排序树中与删除结点并不一定直接相连。
二叉排序树一个结点只储存一个关键字,而B树一个结点可以储存多个关键字。
当删除关键字16的时候,我们发现,16所在的结点只有两个关键字(13,16),如果直接删除的话就剩下一个关键字了,而5阶B树非根非叶子结点至少要有2个关键字,因此我们需要进行类型二叉排序树的删除操作,从关键字16对应的左分支中找到一个最大关键字替换掉16或者右分支中找到一个最小关键字替换掉16。具体到这个例子,因为16对应左分支只有两个关键字,如果拿其中一个去替换16那么就剩下一个了,显然不行。因此我们把右分支中最小关键字17拿去替换16。
注意上边说的左分支和右分支千万不要简单理解成直接相连的左孩子结点和右孩子结点,比如我们要删除10,那么替代10的不是6或13,而是更下一层的9或11。
第三次,删除关键字15
15虽然在终端结点上,但是是不可以直接删除的,因为15所在的结点的关键字数目已经达到了下限。
解决方法是向其同一层的兄弟结点借关键字的方法,因为15所在结点的左边兄弟只有11,12两个结点,因此显然是没办法借的,因此只能向其右边结点借关键字,即借18。方法分为两步。
第一步:将15的父节点中最右边的关键字17下来到15所在结点上
第二步:让右结点中最左边关键字上去到父结点关键字17的位置上
这两步完成以后,就可以直接删除关键字15了,反正删除之后该结点上还有两个关键字。
第四次,删除关键字4
如下图我们看到,4所在结点的关键字数目已经达到了下限,其左右兄弟结点的关键字数目也都已经达到了下限。
这个时候我们可以直接把关键字4删除,然后将该结点中剩下的最后一个关键字5与其左右兄弟结点中任意一个合并即可。我们让其和右结点合并。即:
第一步,删除4
第二步,让父结点中6下来,帮忙合并两个子结点
第三步,我们发现这个时候3所在结点又不满足下限了,因此与其右兄弟结点合并,兄弟结点合并需要父结点中关键字的帮助,因此10下来帮助两个结点合并。
2-3树&2-3-4树
其实就是B树的实例,23树是3阶B树,234树是4阶B树。
如下为一个234树案例,但是需要注意的是234树是一个满树,即对于一个含有2个关键字的234树来说,其分支数一定得是3个,否则就非法。
需要注意的是,一个红黑树一定等价于一个234树,一个234树一定对应多个红黑树。
B+树
是B树的变形
B+树定义
B+树特点:
(1)具有n个关键字的结点含有n个分支,即一个关键字就对应一个子节点。注意,在B树中,含有n个关键字的结点是有n+1个分支的。
(2)除根节点以外的每个结点中关键字个数n的取值范围为 【ceil(m/2),m】,m是B+树的阶数,即结点的子节点个数最多有m个。在根节点中【2,m】,即要求根节点中至少有两个关键字,其实这点与B树中一样,因为B树中根节点分支数【2,m】,而在B树中的结点,关键字数目是比分支数目少1的,因此B树中根节点关键字数目最少是1,而B+树中的结点,关键字数目是等于分支数的。这里我们描述的关键字的取值范围,其实也可以认为就是B+数的子结点数目的取值范围。
(3)在B+树中叶子结点包含信息,注意B+树中的叶子结点是图中记录上边的那一行,即指针p串起来的那一行,B+树与B树中叶子结点的定义不同,B树中叶子结点是查找失败的结点,B+树中的叶子结点类似B树中的终端结点。注意B+树中的叶子结点包含了所有的关键字,上边非叶子结点中的关键字也都能在叶子结点中找到,这跟B树不同,B树中不同结点中关键字不重复。叶子结点中每个关键字都引出一个指针指向一个记录。
(4)B+树中的所有非叶子结点仅仅起到一个索引的作用,即每个索引项只含有对应子树中的最大关键字以及指向该子树的指针,不含有该关键字对应记录的存储地址。而在B树中,每个结点对应的关键字就是真正的关键字,每个关键字都对应了一个指向记录的指针,虽然我们在上一小节的时候并没有提这个指针的事。B+树有点类似于前边提到的分块查找,非叶子结点的索引就类似于分块查找的索引列表,存储了对应子块中的最大关键字,且索引表是有序的。
(5)在B+树中有一个指针sqt指向关键字最小的叶子结点,然后所有的叶子结点链接成了一个线性链表,通过该链表,可以实现关键字的顺序查找。而B树中是没有该链表的。因此B+树可以进行两种查找,一种是从根节点指针root开始进行类似二分查找的查找方式,一种是利用sqt指针指向的链表进行顺序查找。
PS:在搜索B+树相关的资料的时候,发现B+树还有另外一种定义,如下图所示。我们掌握一种就行了,基本思想还是差不多的。
B+树中的查找
如上图中例子,我们要查找关键字71
由于B+树中每个关键字都是对应分支中关键字的最大值,所以如果待查找值大于当前关键字,那么它一定不在当前关键字对应的分支中。
第一步,与关键字50比较,发现 71 > 50,因此关键字71一定不在50对应的分支中。
第二步,与96比较,71 < 96,所以可能在96对应的分支中,之所以说可能,是因为可能不存在。如果存在,一定在96对应的分支中。
第三步,到96的分支中,与71比较,发现找到了,查找成功。
B+树中的插入&删除
B+树中的插入都是将关键字插入到叶子结点上,然后相应地改变或不用改变非叶子结点。
具体操作不再赘述。
插入:
插入操作全部在叶子结点上进行,根据叶子结点的不同情况判断会不会改变内部索引结点。
如果拆入待插关键字之后结点中关键字数量小于等于阶数M,直接插入即可。
如果插入之后结点中关键字数量大于阶数M,则进行分裂,每个结点包含ceil(m/2)个结点,并改变其双亲结点中的对应关键字(要增加一个)。如果改变后双亲结点关键字个数小于等于M,则插入完成,如果双亲结点中关键字数目大于M,则双亲结点也需要进行分裂。
插入过程中始终保持双亲结点中关键字是其对应分支结点中关键字的最大值。
删除:
如果删除该关键字不会破坏B+树本身的性质,则直接删除。
如果删除操作导致结点中最大值改变,则相应地改变父节点中的索引值。
如果删除后导致结点中关键字个数不足,有两种方案,一种是向兄弟结点去借,但是注意同步更新索引值。如果兄弟结点也不够用,那就进行兄弟结点的合并。
B*树
B+树的变体,B+树中叶子结点间有指针串成一条链表,B*树则是在B+树的非根非叶子结点间再增加兄弟结点之间的指针。B*树相对于B+树的优点主要在于分裂的时候分配新结点的概率更低,而且定义了非叶子结点的关键字个数至少为 2/3M ,而B+树是1/2M,因此B*树的空间使用率也更高。
红黑树
平衡二叉排序树虽然查找效率可以达到跟二分查找比肩的水平,但是生成过程要经过大量的左旋和右旋操作,并且插入删除过程中也可能需要大量的左旋与右旋操作,未免太过复杂。
红黑树有必要重点掌握。
终结B站没人能讲清楚红黑树的历史,不服等你来踢馆!-【码炫课堂收费课节选之-红黑树源码解析及手写红黑树】_哔哩哔哩_bilibili
红黑树引入
因为BST在树是倾斜的时候,最坏的情况下即所有结点都在一条斜线上,这个时候BST上的查找效率会退化成链表上的顺序查找,因此要引入平衡二叉树,最常见的平衡二叉树是AVL树(高度平衡的二叉树,每个结点的左右子树高度差不超过1)和红黑树。
为什么有了AVL树还需要红黑树呢?
因为AVL树严格要求每个结点的左右子树高度差不超过1,导致每次插入或删除结点几乎都需要通过左旋或右旋来进行调整,显然,在插入、删除很频繁的场景中,AVL树需要频繁的左旋右旋等调整操作,这会使得其性能大打折扣。
于是有了红黑树,红黑树可以认为是一种不太严格的平衡树,能保证O(lgN)时间的查找效率。但是在插入删除等操作的时候,不会像AVL树那样,频繁破坏规则,因此也就不需要频繁调整。
如果单论查找效率的话,AVL树比红黑树更快。
解决BST退化成链表情况------AVL
解决AVL在频繁插入删除性能差的情况------红黑树
红黑树定义
红黑树是一种结点带有颜色属性的二叉查找树,它有五大性质:
(1)结点是黑色或红色。
(2)根结点一定是黑色。
(3)所有叶子结点都是黑色(叶子结点指的是不包含关键字信息的NULL结点)。
(4)每个红色结点必须有两个黑色的子结点,这也意味着从每个叶子到根的所有路径上都不会有连续的红色结点。
(5)黑色平衡:从任一结点到其下边的每个叶子的路径都包含相同数目的黑色结点,红黑树的平衡实际上就是黑色结点的平衡。
2-3-4树与红黑树的等价性
如上图定义,2-3-4树中只有三种结点,即2结点,3结点,4结点。因为2-3-4树本质上就是4阶B树,由上文可知,B树中的叶子结点是查找失败的结点,因此图中2-3-4树实际上没有画出叶子结点,即终端结点下一层都是NULL的结点。
红黑树来源于2-3-4树,因为2-3-4树结点元素数不确定,在有些编程语言中实现起来不方便,因此一搬用与其等价的红黑树来实现它。
一个2-3-4树一定对应多个红黑树,一个红黑树对应一个2-3-4树。
2-3-4树中一共有2结点,3结点,4结点,还有裂变状态的结点,相当于一共四种类型的结点,这四种结点都可以对应到红黑树上。
以图中的典型2-3-4树举例,演示这4种等价关系:
2结点就等价于一个黑色结点:
因此结点5换到红黑树中就是一个黑色结点
3结点在红黑树中一定对应上黑下红两个结点,因此有两种形态:
比如对于结点79,它就有有两种形态,7上9下,或7下9上,但一定是上黑下红
分别称为右倾状态和左倾状态(红色结点在右边还是左边)
正是2-3-4树中的每个3结点都对应红黑树中两种形态,因此一棵2-3-4树可以对应多棵红黑树。
4结点在红黑树中中间黑两边红:
比如上图中10 11 12这个4结点
裂变状态等价关系:
这个需要稍微推导一下
还是对于10 11 12这个4结点,如果树中再插入一个13,则要发生裂变
对应的红黑树中如下,需要注意红黑树中新增结点都是红色结点:
现在要插入一个新结点13:
这个时候加入13后要裂变:
又因为红色结点的相邻结点一定是黑色结点,因此要进行变色调整:
如果裂变之后的高层结点(图中的11)是根节点的话,要变成黑色的,因为根节点永远都是黑色的。
由以上四种等价关系,就可以把图示2-3-4树转化成一个红黑树,当然可以转化成不同的多棵红黑树,因为3结点有两种形态啊:
利用2-3-4树与红黑树等价性推导红黑树五大性质
第一条性质,结点是黑色或红色:
这个不言自明。
第二条性质,根节点一定是黑色:
分情况讨论,把红黑树映射回2-3-4树
第一种情况,如果根节点是一个2结点,如下图所示,由2-3-4树中2结点与红黑树结点的对应关系我们指导,2结点就等价于一个黑色结点。
第二种情况,根节点是一个3结点,因为2-3-4的3结点对应到红黑树中有左倾右倾两种形态,但是不管是左倾还是右倾,都是上黑下红,因此根节点一定是黑色的。
第三种情况,根节点是一个4结点,因为4结点对应到红黑树中一定是上边一个黑,下边两个红,所以根节点一定是黑色。
综合以上三种情况,可以反推出红黑树的第二条性质,即根节点一定是黑色。
第三条性质,所有叶子结点都是黑色:
叶子结点即查找失败的结点,可以与第四条性质一起看,如果终端结点是红色的,而红色结点必须有两个黑色的子结点,那么在这个红色结点下挂两个NULL的空结点就行了。
第四条性质,每个红色结点必须有两个黑色的子结点:
待记录。
第五条性质,从任一结点到其每个叶子的所有简单路径都包含相同数目的黑色结点:
这个推导比较有意思,因为2-3-4树是满的,其每个叶子结点到根节点的路径上经过相同的层,即经过相同的结点树。而2-3-4树中就三种类型的结点,即2结点,3结点,4结点,而由上一小节的映射关系我们指导,2结点对应到红黑树中就是一个黑色结点,3结点对应到红黑树中是一黑一红,4结点对应到红黑树中是1黑两红,因此2-3-4树中的每个结点中都含有一个黑色结点,如果2-3-4树叶子结点到根节点经过的结点树相同,那么对应到红黑树中,经过的黑色结点树一定相同,因为2-3-4树中的每个结点都对应了一个黑色结点呀。
红黑树核心操作
变色:结点的颜色红变黑,或者黑变红。
左旋:与AVL树中的左旋操作一样
右旋:与AVL树中的右旋操作一样
红黑树的插入操作
对比2-3-4树来理解。
首先明确一点,所有新增的结点一定是在最底层完成新增的,如果某个结点中关键字个数超过限制则会往上一层进行裂变。
对比2-3-4树,分情况讨论红黑树插入结点:
第一种情况,新增结点是2-3-4树中第一个结点:
在2-3-4树中,新增结点是第一个结点时,不需要合并或裂变等操作,直接插入即可。
在对应的红黑树中,如果新增结点是红黑树中第一个结点,则分成了两步:
第一步,任何新增结点都是以红色结点的形式插入的,因此插入了一个红色结点:
第二步,插入之后发现,这个结点是根节点,由红黑树五大性质,根节点必须是黑色结点,因此要发生一次变色:
即2-3-4树中直接插入,但是对应红黑树中有一个变色过程,因为红黑树要始终保证满足五大性质。
第二种情况,2-3-4树中新增一个结点,与一个2结点合并:
在2-3-4树中,直接合并即可
合并之后变成3结点,在对应的红黑树中,有左倾右倾两种形态:
也就意味着,如果我们插入一个红色结点3,而现在有的是一个黑色结点2,那么有两种情况,
一种是直接把3作为2的右孩子插入即可:
一种是把2作为3的左孩子插入:
反正基本上都是直接插入,如果父节点是红色的话,那可能要将父节点进行变色,一般都保持插入结点是红色,这样可以避免破坏黑色平衡,除非是上边第一种情况,插入结点是第一个结点,那么将插入结点变黑色。
第三种情况,2-3-4树中新增一个结点,与一个3结点合并:
在2-3-4树中,直接合并得到4结点即可
而在红黑树中,因为3结点对应到红黑树中有两种形态,因此新增一个红色结点的时候也要根据这两种形态分类讨论:
(1)右倾形态插入4
因为3结点在红黑树中都是上黑下红,当我们插入一个4的时候必须作为3的右孩子插入,而3是红色的,这个时候又来了一个红色,连续两个红不满足红黑树条件,因此需要调整。其实就是先以2为支点左旋,然后2与3变色,再将4插入。
调整完如下:
(2)左倾形态插入1
同理也需要调整,以3为支点右旋,然后2,3结点变色,再将1插入。
(3)右倾形态插入1
满足红黑树条件,不需要调整
(4)左倾形态插入4
不需要调整
注意这里还有2种情况没有讨论,即对应左倾右倾两种形态加入结点2.5的时候。
比如下图这种,先以2为支点左旋,然后就转化成了上边左倾状态插入1那种形式,在以3为结点右旋并将2.5,3变色即可。另一种情况需要先右旋后左旋的同理。
第四种情况,新增一个结点,与一个4结点合并:
在234树中,这种情况会导致裂变
对应到红黑树中,就是原本有一个上黑下边两个红的结构:
然后我们要往这里插入一个新的结点,当然结点插入位置有很多,可以是4的左右孩子,也可以是2的左右孩子。比如下图中插入一个5,这个时候5应该是4的右孩子,但是由于红黑树中不能有连续的红色,所以要进行变色调整,把上黑下两红变成上红下两黑,然后再插入红5。
裂变情况下对应红黑树中的插入都是只需要变颜色就行了,没有其它左旋右旋调整,通过上边几种情况也可以看到,只有插入到一个2结点对应的情况才会引发左旋右旋等调整操作,其它情况一般都是变色。
综合以上四种情况的推导可以得到红黑树的插入规则:
以下全部以左子树上的插入为例。
注意我们插入的结点都是红色结点,因为如果插入黑色的话很有可能会破坏黑色平衡。
(1)如果插入的是第一个结点即根节点,直接插入,将红色插入结点变黑色即可。对应上边第一种情况。
(2)如果插入位置的父节点是黑色,那么直接插入即可,不需要变色。对应上边第二种情况。
(3)如果父节点和叔叔结点都是红色(此时爷爷结点一定黑色,对应4结点),则将父节点和叔叔结点变黑色,爷爷变红色(如果爷爷是根节点,则再变成黑色)。爷爷结点需要递归操作,即把爷爷结点当作新插入的结点,再次检验其双亲,看是否满足红黑树条件。对应上边第四种情况。
(4)如果父节点是红色,没有叔叔结点或者叔叔结点黑色(NULL结点),则此时相当于3结点对应的左倾形态,此时要插入到父节点的左孩子上的话,需要先以爷爷结点为支点右旋,旋转之后爷爷变红色,父亲变黑色,然后插入到父亲左孩子位置。其实就是对应于上边讲的第三种情况的左倾形态插入1。
红黑树的删除操作
因为红黑树的本质就是带了红黑两种颜色的二叉排序树,所以要先明确一下二叉排序树的删除规则:
(1)删除叶子结点,直接删除即可。
(2)删除的结点有一个子结点,那么用子结点替代
(3)如果删除的结点有2个子结点,此时需要找到前驱结点或后继结点来替代。
注意把红黑树转化成234树的时候找到每个红色结点依附的黑色结点即可,因为红色结点只会出现在3结点或4结点中,且都依附于一个黑色结点。
红黑树的删除分为3种情况:
情况一:待删除结点对应到234树中是3结点和4结点,此时自己能搞定。
比如删除0,直接删除即可。
删除1,则0取代1,并将0染黑。
再比如9,直接删除即可。
对于10,则用9或11取代10,并将9或11染黑。
如果要删除6,则将其后继7替代6,7.5替代7,然后将7.5染黑。
如果要删除8,则用9替代8即可。如果8是黑的,则9替代8以后还需要染黑。
情况二:待删除结点对应到234树中是2结点,兄弟结点(或许叫兄弟子树更合适,因为兄弟可能是2个或3个结点)对应到234树中是3结点或4结点,这个时候需要删除后父亲下来兄弟上去。
比如结点5,在234树中是一个2结点,其对应到234树中的兄弟结点是7和7.5,删除5以后,要让6下来替代5,然后7上去替代6。为了平衡,注意7.5要染黑。
情况三:待删除结点对应到234树中是2结点,兄弟结点对应到234树中也是2结点(兄弟有难同当,递归自损):
比如上图中,如果要删除的结点是35,因为35对应的是234树中的2结点,其兄弟56也是2结点,这个时候如果删除35,为了黑色平衡,56会主动自损,变成红色,然后50变成黑色。这时候整棵树仍是黑色平衡的。
但是如果说他们的父亲50本来就是黑色的呢?
这个时候删除35,然后让兄弟56变红,虽然结点50是黑色平衡的,但是结点60则黑色不平衡了,因为60的左子树会少一个黑色结点。
所以这个时候要递归自损,即在60的右子树上也找一个结点变红。
如果以60为根节点的这棵树只是整个红黑树的一个小子树,则要不断递归自损下去。
键树
Trie树,又称为字典树、前缀树、单词查找树。
Trie树有3个基本性质:
(1)根结点不包含字符,除根节点外的每一个结点都包含一个字符。
(2)从根节点到某一结点,路径上经过的字符连接起来,为该结点对应的字符串。
(3)每个结点的所有子结点,包含的字符都不想同(废话,相同就合并成同一个结点了)。
键树的插入
对于{"cha", "chao", "che", "la"},其中$代表结束符。其实就是将单词的每个字母逐一插入trie树,如果字母已经存在,就共享该字母,然后查看下一个字母,否则就创建一个新结点存储该字母。
Trie树的查询
分两种情况:
(1)单词中每个字母都查找到了。这种情况要看最后一个字母对应的结点中有没有设置结束符标志位。如果没有设置,则查找失败,不存在该字符串,否则查找成功。
(2)单词中某些字母没有找到,查找失败。
leetcode208 实现前缀树
208. 实现 Trie (前缀树) - 力扣(LeetCode) (leetcode-cn.com)
class Trie {
private:
bool isEnd; // 标记该结点是不是一个插入字符串的结尾字符
Trie *next[26]; // Trie类型的指针数组,如果索引为i的地方不是nullptr,说明该结点的子结点中包含第i-1个字母
public:
/** Initialize your data structure here. */
Trie() {
isEnd = false; // 默认当前结点不是一个插入字符串的结尾
memset(next, 0, sizeof(next)); // 默认当前结点没有子结点
}
/** Inserts a word into the trie. */
void insert(string word) {
Trie *node = this; // node指针指向当前结点,用node来帮助插入新字符串
for(auto &w : word){
if(node->next[w - 'a'] == nullptr){
node->next[w - 'a'] = new Trie();
}
node = node->next[w - 'a'];
}
node->isEnd = true; // 在插入字符串对应的最后一个结点中做标记。
}
/** Returns if the word is in the trie. */
bool search(string word) {
Trie *node = this;
for(auto &w : word){
node = node->next[w - 'a'];
if(node == nullptr) return false; // word上的字符搜索过程中断了
}
return node->isEnd; // 如果word上的字符搜索完成了,那就看最后一个字符的标志位是否为true了。
}
/** Returns if there is any word in the trie that starts with the given prefix. */
bool startsWith(string prefix) {
Trie *node = this;
for(auto &p : prefix){
node = node->next[p - 'a'];
if(node == nullptr) return false;
}
return true; // 只是判断前缀的话,不用看标志位,只要把所有字符搜索完成就行
}
};
/**
* Your Trie object will be instantiated and called as such:
* Trie* obj = new Trie();
* obj->insert(word);
* bool param_2 = obj->search(word);
* bool param_3 = obj->startsWith(prefix);
*/
这是一个前缀树的经典实现
在这个前缀树结点定义中有成员变量isEnd,用来判断当前结点是否是一个插入字符串的结尾。
还有一个指针数组成员next[26],如果next某个位置上的结点指针不是nullptr,就说明当前结点有一个这个位置上的子结点。也就是说,在前缀树结点定义中,并没有定义其包含的是哪个字母,结点对应的是哪一个字母不是由结点本身决定的,而是由其父节点决定的,其父节点的next数组中哪个位置上的指针指向当前结点,当前结点就对应了哪个字母。从这个实现也可以看出,根节点不包含字符,因为根节点没有父亲结点啊。
在查找的时候,是从根节点开始,看其要查找的字母对应到next数组的某个位置上的指针是否为nullptr,如果是nullptr,就说明没有这个字母对应的子结点,否则就有这个字母对应的子结点,然后再查找待查字符串的下一个字母在子结点的next数组中有没有对应位置的非nullptr指针。
leetcode 720, 692也可以用前缀树解决。
后缀树
算了,这个有空再看。
4 哈希表上的查找
也叫散列表。
基本思想
记录的关键字与存储位置之间有映射关系
即用hash函数能将关键字直接映射到存储位置
例子:
比如我们有一块连续存储区域编上号,然后用一个哈希函数将记录的关键字映射到记录所在的第k号地址。这样在找这个记录的时候,直接用哈希函数就能在O(1)时间找到。例子中散列函数即为H(k) = k,图中的从0号到39号的表即为构造出的散列表。
这个例子中的哈希表优点是查询效率高,缺点是空间效率低。
所以利用哈希表进行查找的步骤为:
第一步,构造哈希表:选取一个哈希函数,用该函数按关键字计算元素的存储位置,并按此存放,这个步骤也叫散列存储;
第二部,哈希表中查找:由同一个哈希函数对给定关键字k计算地址,将该地址单元中的关键字与k比较确定是否查找成功。
散列表上的冲突:如果不同的关键字通过散列函数映射到了同一个地址,我们称为发生了冲突。
使用哈希表要解决的两个问题
(1)构造好的散列函数,有两点要求
a. 散列函数要尽量简单,提高计算素的
b. 所选函数对所有关键字计算出的地址,应该在散列空间中均匀分布,减少空间浪费。
构建散列函数需要考虑计算散列函数所需时间,关键字的长度,散列表的大小,关键字的分布情况,查找频率等。
(2)制定一个好的解决冲突的方案
查找时,如果从散列函数计算出的地址中找到的关键字与查询关键字不同,则根绝冲突解决的规则,有规律地查询其它相关单元。
对于(1),主要用的一般是除留余数法。
直接定址法:
优点:以关键字的某个线性函数为散列地址,不会产生冲突。
缺点:要占用连续地址空间,空间效率低。
除留余数法:
其实就是对所有关键字都取余。问题在于怎么找到合适的p?
p的选取,如果哈希表长为m,取p<=m且p为质数。如下例子中表长为7那就直接选p为7了。
对于(2),如果散列函数产生冲突,一般有四种处理冲突的方法,开地址法,链地址法,再次哈希法,建立公共溢出区。比如用除留余数法的话,就很有可能会产生冲突。
开地址法:
如果构建哈希表时,如果发生冲突,就有规律地去找下一个空的散列地址,然后把元素存入。
查找下一个地址的冲突有多种。比如线性探测法,当前地址发生冲突时,就将当前地址加1,再对表厂取余,看看处理后的地址是否冲突,不冲突就存入。还有二次探测法,伪随机探测法等。
链地址法:
基本思想:具有相同散列地址的记录链接成一个链表。如果有m个散列地址,那么就设m个单链表,然后用一个大小为m的数组将m个单链表的表头指针存储起来。
链地址法建立散列表步骤:
(1)对关键字k计算散列函数值(得到散列地址),若该地址对应的链表为空,则将该元素插入链表,否则执行(2)。
(2)若该地址对应链表不为空,则利用链表前插法或后插法将该元素插入链表。
链地址法优点:
非同义词不冲突,无聚集现象。
链表上结点空间动态申请,更适用于表厂不确定的情况。
再次哈希法:
当哈希值冲突时采用备用的哈希函数再次计算哈希值,关键在于多准备几个不同的哈希函数。
建立公共溢出区:
基本思想是将哈希表分为基本表和溢出表,凡是和基本表发生冲突的元素,一律填入溢出表。
散列表查找效率与几点结论
装填因子的概念:
哈希表的经典例题
(1)当然是梦想开始的地方------两数之和。
1. 两数之和 - 力扣(LeetCode) (leetcode-cn.com)
定义一个哈希map,数组值为键(即计算哈希时的关键字),数组索引为值。
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> m;
int len = nums.size();
for(int i = 0; i < len; ++i){
if(m.find(target - nums[i]) != m.end()){
return {i, m[target - nums[i]]};
}
m[nums[i]] = i;
}
return {};
}
};
(2)leetcode 217 是否存在重复元素
217. 存在重复元素 - 力扣(LeetCode) (leetcode-cn.com)
class Solution {
public:
bool containsDuplicate(vector<int>& nums) {
unordered_set<int> s;
for(auto &n : nums){
if(s.find(n) != s.end()) return true;
s.insert(n);
}
return false;
}
};
(3)leetcode 594 最长和谐子序列
594. 最长和谐子序列 - 力扣(LeetCode) (leetcode-cn.com)
class Solution {
public:
int findLHS(vector<int>& nums) {
unordered_map<int, int> hash_table;
for(auto &n : nums){
hash_table[n]++; // 键是数组元素,值是该元素出现次数
}
int result = 0;
for(auto &m : hash_table){
if(hash_table.find(m.first + 1) != hash_table.end()){
result = max(m.second + hash_table[m.first + 1], result);
}
}
return result;
}
};
(4)leetcode 128 最长连续序列
128. 最长连续序列 - 力扣(LeetCode) (leetcode-cn.com)
class Solution {
public:
int longestConsecutive(vector<int>& nums) {
unordered_set<int> s;
for(auto &n : nums){
s.insert(n);
}
int ans; // 临时存储长度
int temp; // 临时存储数组元素
int res; // 最终结果
for(auto &n : s){
temp = n;
ans = 1;
if(s.find(--temp) == s.end()){ // 没有上一个元素的时候才开始计数
temp++;
while(s.find(++temp) != s.end()){
++ans;
}
res = max(res, ans);
}
}
return res;
}
};
5 跳表
学会跳表只需要3分钟 | 让你的链表支持二分查找_哔哩哔哩_bilibili
跳表基本概念
简而言之,结合链表与二分的思想,就得到了跳表。
为什么需要跳表呢?因为链表相比数组来说,虽然删除和插入都更简单,直接改一下指针即可,但是链表上的查找却很复杂。当数组有序的时候,我们可以通过二分法将时间复杂度降到O(logN),但是对于链表来说,即使是有序的,我们也只能一个一个老老实实遍历查找,跟无序链表一样是线性时间复杂度。
为了能利用上链表有序这个性质,人们用空间换时间的策略,给链表加多级索引,就形成了跳表。
比如对于如下一个链表,如果要查找18,则需要顺序比较8次,
给链表加一级索引,其实就是再引入一个链表,其包含的链表是原链表的真子集。比如再加一级链表,如下图,则:
第一次与1比较,18 > 1;
第二次与5比较,18 > 5;
第三次与10比较,18 > 10;
第四次与15比较, 18 > 15;
这个时候18与15已经非常接近了,因此我们到跳表的下一层继续遍历,
则再进行一次比较,就能找到18。
一共比较了5次。
如果给链表再增加一级索引,则只需要比较3次了。
跳表的创建
我们可以看到,跳表的第一层存储了所有的结点,一般我们假设或者倾向于让第二层结点数是第一层的一半,然后第三层是第二层的一半。
在实际实现的时候,
比如对于1,10,20这三个结点,实际上是拥有三个有效指针域,分别代表第一层链表的指针,第二层链表的指针,第三层链表的指针。
而对于5,15这两个结点,则含有两个有效指针域,分别属于第一层链表,和第二层链表。
跳表的插入&删除
插入的时候不但要在第一层插入数据,还需要更新索引。不然就可能导致两个索引之间有大量的数据。
因为我们希望上层结点数是下层结点数的二分之一,但是严格要求二分之一且隔一个添加一个实现起来过于麻烦,所以我们通过抛硬币的方式,来决定这个新加结点是否应该添加到上一层成为索引结点。即添加到第一层的概率是二分之一,添加到第二层的概率是四分之一,,,,,,以此类推。
删除比较简单,只需要找到这个结点,然后删除掉这个结点,当然也就删除掉了其所有的指针域,相当于在各个层上都删除了这个结点。
跳表效率
查找,插入,删除时间复杂度都是O(logN)
空间复杂度O(N)
6 基础查找算法总结
首先是顺序查找,或者叫线性查找,其在数组或者链表上都适用,但是查找时间复杂度是O(N),其实就是查找算法的底线了,就是一个一个遍历比较嘛,因此效率太差。
然后二分查找,其每次比较都将待查找区间缩小一半,成功将查找复杂度降到了对数级,但是其只能用于数组上,对于链表无法适用,基于顺序表的方法插入删除需要移动大量元素,也很不方便,二分查找还要求数组元素有序。
接下来是分块查找,其解决了二分查找要求有序的问题,分块查找相当于建立了一个索引数组,索引数组中每个元素代表原始数据中很多元素,即把原始数据分块了,因此在索引表上可以用二分查找,在块内则使用顺序查找,但是分块查找索引表只能用数组,原始数据倒是顺序存储和链式存储均可。
为了解决链式存储查找效率低的问题,结合链表与二分的思想,出现了跳表,其通过给原始链表加特定层的高层索引链表,将时间复杂度降到了对数级别。
因为链表的查找效率低,数组则插入删除麻烦,此时也出现了基于数表的查找方法。
二叉排序树BST,其通过约定二叉树每个结点的左子树元素小于根,右子树元素大于根,再最好的情况下能让查找时间复杂度达到对数级,但是如果二叉树形态是一个斜二叉树,则实际上二叉树已经退化成了链表,此时查找时间复杂度又成了线性阶。
因为越矮胖的二叉排序树查找效率越高,因此出现了平衡二叉排序树AVL,其可以保证二叉排序树永远处于平衡状态,因此查找效率始终是对数阶,但是每次插入删除都要严格保持树的平衡状态,插入删除频繁的时候需要大量的左旋右旋等调整操作,维护起来十分麻烦。因此提出了红黑树,红黑树的平衡只是黑色平衡,其查找效率接近AVL,但是插入删除的时候需要更少的调整操作。
哈希表上的查找效率可以达到常数阶。可以将红黑树与哈希表这两个常见数据结构进行比较。
红黑树是一个特殊的二叉排序树,要求元素有序,占用内存更少,查找时间复杂度是对数阶。可以实现动态扩展,但是数据量大的时候维持红黑树状态的时间成本会变大。
哈希表不要求元素有序,只关注元素是否在表中即可。查找复杂度常数阶。但是哈希表的大小需要事先定义,存储哈希表需要更多的内存。
因此如果要求元素有序,内存占用小,可扩展性强而且数据量较少则可以使用红黑树。
如果内存比较大,只关注数据是否存在,数据量很大,则使用哈希表。
对于B树(平衡的多路查找树),B+树,B*树,需要结合数据库和文件系统来理解。现在只叙述其主要区别,B树中所有关键字在整棵树中出现且只出现一次,即叶子(这里指的是上文中的终端结点)结点也是存储关键字的。B+树在B树基础上把所有叶子结点串成了一个链表,并且所有关键字都在叶子结点中出现,非叶子结点只是索引作用。B*树在B+树基础上把非叶子结点也串成了链表,将结点的最低利用率从1/2提高到2/3。B+树方便遍历,只需要沿着叶子结点链表遍历即可,而B树必须用中序遍历的方法。B+树支持范围查找,B树不支持。
此外还有前缀树,其优点是对于字符串查找的场景,其可以最大限度减少没必要的字符比较,因此查询效率可以超过哈希表。核心思想是空间换时间,利用字符串的公共前缀来降低查询时间的开销。但是前缀树的内存消耗也非常大,看上文中leetcode208的实现。