07查找
7.1 查找基本概念
- 查找:在数据集合中寻找满足某种条件的数据元素的过程称为查找
- 查找表(查找结构):用于查找的数据集合称为查找表,它由同一类型的数据元素(或记录)组成
- 关键字:数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的
- 评价查找算法效率指标
- 查找长度:在查找运算中,需要对比关键字的次数称为查找⻓度
- 平均查找长度,ASL,Average Search Length:所有查找过程中进行关键字的比较次数的平均值
- ASL=\(\displaystyle \sum^{n}_{i=1}P_iC_i\),其中n表示数据元素个数,\(P_i\)表示查找第i个元素的概率,\(C_i\)表示查找第i个元素的查找长度
- 通常认为查找任何一个元素的概率是相同的
- 评价一个查找算法效率是通常考虑查找成功/失败时的ASL,ASL的数量级反应了查找算法时间复杂度
7.2 顺序查找
又名线性查找,通常用于线性表。算法思路:
- 从头到尾(序号从小到大)遍历所有元素进行查找
- 若查找成功返回元素下标,查找失败返回-1
顺序查找还可以使用哨兵用于存放查询结果下标,一般为数组的0号元素
- 相比普通顺序查找,数组a[n+1]需要存放n+1个元素,若查找成功a[0]即为查询结果,查找失败a[0]=0
- 查找顺序可从第n+1个元素到第1号元素,如果均未找到下标返回0
顺序查找时间复杂度为O(n)
- 顺序查找\[ASL_{成功}=\frac{1+2+3+...+n}{n}=(n+1)/2 \]\[ASL_{失败}=n+1 \]
7.2.1 有序优化
如果线性表本身有序,若查找元素k有a[i]<k<a[i+1],说明在该有序表中无法查找到该元素,直接结束查找
- 因此该种顺序查找共存在n+1中失败情况,$$ASL_{失败}=\frac{1+2+3+...+n+n}{n+1}=\frac{n}{2}+\frac{n}{n+1}$$
- 其中当a[n]<k和a[n-1]<k<a[n]两种情况均需要遍历整个表,因此都需要查找n次
结合这一优化方式,算法的ASL可通过有序节点链构成的查找判定树进行分析
- 该树包含n个查找成功节点以及n+1个查找失败节点
- 一个成功节点的查找长度=该节点在查找判定树的所在层数
- 一个失败节点的查找长度=该节点的父节点在查找判定树的所在层数
- 默认情况下,失败情况或成功情况都等概率发生
7.2.2 概率优化
如果线性表每个节点的被查找概率不等,可按照概率从大到小排列,被查找概率大的靠前放置,从而减小\(ASL_{成功}\),但是对于\(ASL_{失败}\)仍然为n+1
7.3 折半查找
又称二分查找,只用于有序的顺序表。算法思路:
- 假设顺序表升序,初值设置low=1,high=n
- 每次mid=⌊(low+high)/2⌋,向下取整,查找值k与a[mid]进行比较。如果k>a[mid]说明值偏小,low=mid+1;如果k<a[mid]说明值偏大,high=mid-1
- 重复上述过程,如果找到直接结束循环,mid即为查找元素的下标。直到low>high,结束循环,说明未找到
折半查找必须基于元素可以随机访问的特性,因此链表无法进行折半查找
7.3.1 折半查找判定树
- 如果采用mid=⌊(low+high)/2⌋,向下取整,则有
- 如果当前low与high之间共有奇数个元素,则mid分割后左右两部分元素个数相等
- 如果当前low与high之间共有偶数个元素,则mid分割后左部分元素比有部分少一个元素
- 折半查找判定树中,若采用mid=⌊(low+high)/2⌋,向下取整,则对于任何一个节点,暂不考虑失败节点,其
右子树节点数-左子树节点数=0或1- 折半查找判定树一定是平衡二叉排序树,因此对于n个节点顺序表,其折半查找判定树的树高h=\(\lceil log_2(n+1)\rceil\),向上取整。但该树高不包含失败节点
- 折半查找判定树具有n个成功查找节点,n+1个失败查找节点
- \(ASL_{成功}\)≤h,\(ASL_{失败}\)≤h,折半查找时间复杂度为O(\(log_2n\))
相同节点数的折半查找判定树与完全二叉树的高度相同
7.4 分块查找
又称索引顺序查找,将顺序表中的元素进行分块存放,每个分块中元素的值均在某个范围内,呈现块内无序,块间有序的特点。由索引表去保存每个分块内最大元素值以及所在区块的下标范围,算法思路:
- 在索引表中定位元素的所在分块,由于索引表时有序的,该定位可使用折半查找
- 在所在块内进行顺序查找
如果使用折半查找在索引表中进行定位,假设\(n_0\)为索引表长,可能会出现三种情况
- 所查找的值正好是索引中的值,则直接到所在区块进行顺序查找即可
- 所查找的值并未存放在索引表中,那么必然有low > high且low = high + 1
- 若low < \(n_0\),即查找值为超出顺序表中最大值,则到low所指的块中查找
- 若low ≥ \(n_0\),则直接说明查找失败
7.4.1 分块查找判定树
对于分块查找而言,每个元素的被查找概率都是相同的,查找次数由索引表查找与块内顺序查找两部分决定
假设每个长度为n的查找表被均匀分为j块,每块有k个元素,即n = jk。设索引表查找和块内查找的平均查找长度分别为\(L_1,L_2\),则分块查找ASL = \(L_1\) + \(L_2\)
- 用顺序查找查索引表,则\[L_1 = \frac{1+2+3+...+j}{j} = \frac{1+j}{2} \]\[L_2 = \frac{1+2+3+...+k}{k} = \frac{1+k}{2} \]\[ASL = \frac{1+j}{2} + \frac{1+k}{2} = \frac{j^2+2j+n}{2s} \]其中,当j = \(\sqrt{n}\)时,\(ASL_{最小}\) = \(\sqrt{n}\) + 1
- 用折半查找查索引表,则\[L_1 = \lceil log_2(j+1) \rceil \]\[L_2 = \frac{1+2+3+...+k}{k} = \frac{1+k}{2} \]\[ASL = \lceil log_2(j+1) \rceil + \frac{1+k}{2} \]
7.5 二叉排序树
二叉排序树,又称二叉查找树,BST,Binary Search Tree。一棵二叉树或者空二叉树,满足如下性质:
- 左子树上所有结点的关键字均小于根结点的关键字
- 右子树上所有结点的关键字均大于根结点的关键字
- 左子树和右子树又各是一棵二叉排序树
二叉排序树的左子树节点值<根节点值<右子树节点值,若进行中序遍历可以得到一个递增的有序序列
7.5.1 查找
算法思路:
- 若树非空,目标值与根节点值比较
- 若相等则查找成功
- 若小于根节点值,向其左子树进行查找,否则向右子树进行查找
- 查找成功,返回节点指针;查找失败,返回NULL
typedef struct BSNode{
ElemType data;
struct BNode *lchild,*rchild;
}BSTNode,*BSTree;
BSTNode *SearchBST(BSTree T,ElemType key){
//递归实现
if(!T) return NULL;
else{
if(T->data==key) return T;
else if(T->data>key) return SearchBST(T->lchild,key);
else return SearchBST(T->rchild,key);
}
/*
//非递归实现
while(T && key!=T->data){
if(T->data>key) T=T->lchild;
else T=T->rchild;
}
return T;
*/
}
两种算法实现方法时间复杂度均为O(\(log_2n\)),递归实现空间复杂度为O(h),h为二叉排序树的高度;而非递归实现空间复杂度为O(1)
7.5.2 插入
算法思路:
- 若原二叉排序树为空,则直接插入结点
- 否则,若关键字k小于根结点值,则插入到左子树;若关键字k大于根结点值,则插入到右子树
typedef struct BSNode{
ElemType data;
struct BNode *lchild,*rchild;
}BSTNode,*BSTree;
void InsertBST(BSTree &T,ElemType key){
//递归实现
if(!T){
T=(BSTNode *)calloc(1,sizeof(BSTNode));
T->data=key;
}
else if(T->data > key)
InsertBST(T->lchild,key);
else i(T->data < key)
InsertBST(T->rchild,key);
/*
//非递归实现
if(!T){
T=(BSTNode *)calloc(1,sizeof(BSTNode));
T->data=key;
}
else{
BSTNode *p=T;
while(key!=p->data){
BSTNode *tmp=p;
if(p->data>key) p=p->lchild;
else p=p->rchild;
if(!p){
tmp->lchild=(BSTNode *)calloc(1,sizeof(BSTNode));
tmp->lchild->data=key;
break;
}
}
}
*/
}
递归实现空间复杂度为O(h),h为二叉排序树的高度;而非递归实现空间复杂度为O(1)
7.5.3 二叉排序树构造
二叉排序树根据插入的次序不同,构造的树结构也不同
typedef struct BSNode{
ElemType data;
struct BNode *lchild,*rchild;
}BSTNode,*BSTree;
void InsertBST(BSTree &T,ElemType key); //二叉排序树插入
void CreateBST(BSTree &T,ElemType array[],int n){
T=NULL;
for(int i=0;i<n;i++) InsertBST(T,array[i]);
}
7.5.4 删除
删除二叉排序中的节点,算法思路:
- 先查找目标节点
- 若该节点为叶子节点,即没有左右子,则直接删除
- 若该节点只有一棵左子树或一棵右子树,则子树树根替代原先节点的位置
- 若节点度为2,则让该节点的直接后继(或直接前驱)替代该节点,然后从BST中删除该直接后继(或直接前驱),转变为前两种情况
- 直接后继为该节点右子树中最小的节点
- 直接前驱为该节点左子树中最大的节点
typedef struct BSNode{
ElemType data;
struct BNode *lchild,*rchild;
}BSTNode,*BSTree;
BSTNode *FindNextNodeBST(BSTree T,BSTNode *p){
BSTNode *node=T;
while(node->lchild){
p=node;
node=node->lchild;
}
return node;
}
BSTNode *FindPreNodeBST(BSTree T,BSTNode *p){
BSTNode *node=T;
while(node->rchild){
p=node;
node=node->rchild;
}
return node;
}
void RemoveBST(BSTree &T,ElemType key){
//非递归实现
if(T){
BSTNode *node=T,*p;
while(node && key!=node->data){
p=node;
if(node->data>key) node=node->lchild;
else node=node->rchild;
}
if(node){
if(!node->rchild && !node->lchild){
if(p->lchild==node){
p->lchild=NULL;
free(node);
}
else{
p->rchild=NULL;
free(node);
}
}
else if(node->rchild && node->lchild){
//直接前驱替换
BSTNode *tmp=FindPreNodeBST(node->lchild,p);
node->data=tmp->data;
free(tmp);
p->rchild=NULL;
/*
//直接后继替换
BSTNode *tmp=FindNextNodeBST(node->rchild,p);
node->data=tmp->data;
free(tmp);
p->lchild=NULL;
*/
}
else if(node->rchild){
if(p->lchild==node){
p->lchild=node->rchild;
free(node);
}
else{
p->rchild=node->rchild;
free(node);
}
}
else{
if(p->lchild==node){
p->lchild=node->lchild;
free(node);
}
else{
p->rchild=node->lchild;
free(node);
}
}
}
}
}
7.5.5 查找效率分析
二叉排序树的查找成功/失败效率和树的高度有关,最好情况二叉排序树高度为\(\lfloor log_2n \rfloor+1\),即\(ASL_{最好}\) = O(\(log_2n\));最坏情况树高为n,即\(ASL_{最坏}\) = O(n)
二叉排序树的查找性能与折半查找相比
- 如果该二叉排序树平衡,则查找效率相同
- 如果该二叉排序树不平衡,则查找效率不如折半查找
7.6 平衡二叉树
平衡二叉树简称平衡树,Blanced Binary Tree,也可叫AVL树,满足以下性质
- 树上任意节点的左子树和右子树的高度之差不超过1
- 节点的平衡因子=左子树高度-右子树高度。因此平衡二叉树上的节点平衡因子只可能是0、-1、1
- 对于平衡的二叉排序树,其查找效率可以保持在O(\(log_2n\))
- 数据结构中同时存放height和balance属性,可以让节点在O(1)时间内获取某个子树的高度以及平衡因子
typedef struct AVLNode{
ElemType data;
int balance;
int height;
struct AVLNode *lchild,rchild;
}AVLNode,*AVL;
7.6.1 插入
由于新插入的节点可能对查找路径上的节点平衡因子造成影响,所以关键是找到第一个不平衡节点进行调整,故每次调整的对象都是最小不平衡子树
调整最小不平衡子树E,有如下四种情况
- LL:在E的左子的左子树中插入,导致不平衡
- RR:在E的右子的右子树中插入,导致不平衡
- 如果由于这两种情况导致不平衡,则一定有根节点A的平衡因子为1,子树中被插入新节点的节点B的平衡因子为0
- 同时一定有如下情况,假设根节点A未插入新节点一侧的子树高度为h,那么为了保持上述情况,B节点的左右子树高度也为h
- 当作为子树E左子根节点B的左子树插入新节点时,其高度变为H+1,B节点平衡因子+1,A节点平衡因子也+1,A节点平衡因子为2,出现不平衡;作为子树E右子节点B的右子树插入新节点同理
- LR:在E的左子的右子树中插入,导致不平衡
- RL:在E的右子的左子树中插入,导致不平衡
- 新增的节点最小不平衡子树的中间节点。且由于插入新节点后发生不平衡,节点B的平衡因子一定为1或-1,因而导致节点B一定有非空孩子节点C
- 假设这两种情况如果和LL、RR一样的处理方式,假定孩子节点B所在子树高度为h,直接单旋会导致原根节点A所在子树高度变为h+2,而B节点另一个子树高度仍为h,最小不平衡子树仍然不平衡
- 考虑上述的情况在于调整后,不平衡树中高度最高的最小子树并没有调整到单独的一侧,从而导致失衡。因此采用先从最底层旋转,再向上旋转到根的方式,即先将孙子节点C与根儿子节点B进行单旋互换,过程同LL、RR;然后再和根节点进行单旋互换
对于LL和RR情况的调整
- 新增的节点处于最小不平衡子树的最大或最小子树P,因此所在子树不需要调整位置
- 记子树P的父节点B的另一个子树为Q,节点B替换子树E的根,原根节点A与子树P构成节点B的左右子,子树Q与节点A原子树构成节点A的左右子
- LL对应右单旋,RR对应左单旋。节点B和节点A的平衡因子均变为0
- 调整后最小不平衡树高度-1,并向上传递更新祖先节点平衡因子
对于LR和RL情况的调整
- 假定孙子节点C插入新节点的子树P高度为h,另一个子树P的高度则为h-1,则有
- 若孙子节点C的平衡因子为-1
- 第一次单旋
- 如果节点C在最小不平衡树根节点A的右子树中(RL),儿子节点B继承节点C的右子作为节点C的右子,且下次单旋不需要调整,由于节点C平衡因子为-1,节点B的平衡因子确定为0
- 如果节点C在根节点A的左子树中(LR),节点B继承节点C的左子作为节点C的左子,由于节点C平衡因子为-1,节点B的平衡因子确定为1
- 第二次单旋
- 如果节点C在根节点A的右子树中(RL),节点A继承节点C的左子作为整棵树的左子树,此时左子树高为h+1,右子树高度也为h+1,因而节点C的平衡因子为0。由于节点C第二次单旋前平衡因子为-1,节点A平衡因子变为1
- 如果节点C在根节点A的左子树中(LR),节点A继承节点C的右子作为整棵树的右子树,节点C的平衡因子也为0。由于节点C第二次单旋前平衡因子为0,节点A平衡因子也变为0
- 第一次单旋
- 若孙子节点C的平衡因子为1
- 本质上和前一种情况相同,由于节点C的平衡因子为1,调整后节点A、B、C的平衡因子有所区别
- 如果节点C在根节点A的右子树中(RL),最终节点A的平衡因子为0,节点B的平衡因子为-1,节点C的平衡因子为0
- 如果节点C在根节点A的右子树中(LR),最终节点A的平衡因子为-1,节点B的平衡因子为0,节点C的平衡因子为0
- 本质上和前一种情况相同,由于节点C的平衡因子为1,调整后节点A、B、C的平衡因子有所区别
综上所述,新增一个节点,各节点平衡因子变化如下
- RR,A=0,B=0,C=0
- LL,A=0,B=0,C=0
- LR
- 若C=-1,则A=0,B=1,C=0
- 若C=1,则A=-1,B=0,C=0
- RL
- 若C=-1,则A=1,B=0,C=0
- 若C=1,则A=0,B=-1,C=0
算法如下:
typedef struct AVLNode{
ElemType data;
int balance;
int height;
struct AVLNode *lchild,*rchild;
}AVLNode,*AVL;
void UpdateBalanceAndHeight(AVLNode *T){
T->balance=(T->lchild?T->lchild->height:0)-(T->rchild?T->rchild->height:0);
T->height=1+((T->balance>0)?(T->lchild?T->lchild->height:0):(T->rchild?T->rchild->height:0));
}
void AdjustAVL(AVL &T){
if(T && ( T->balance==2 || T->balance==-2)){
//LL,右单旋转
if(T->lchild && T->lchild->balance == 1){
//节点A为原根节点,节点B为A的左子,节点C为B的左子
AVLNode *A=T,*B=T->lchild,*C=T->lchild->lchild;
//左子抬升成AVL根
T=B;
//原根作为左子的右子,原根继承左子的右子
A->lchild=B->rchild;
B->rchild=A;
//更新平衡因子和高度
updateBalanceAndHeight(A);
updateBalanceAndHeight(C);
updateBalanceAndHeight(B);
}
//RR,左单旋转
else if(T->rchild && T->rchild->balance == -1){
//节点A为原根节点,节点B为A的右子,节点C为B的右子
AVLNode *A=T,*B=T->rchild,*C=T->rchild->rchild;
//右子抬升成AVL根
T=B;
//原根作为右子的左子,原根继承右子的左子
A->rchild=B->lchild;
B->lchild=A;
//更新平衡因子和高度
updateBalanceAndHeight(A);
updateBalanceAndHeight(C);
updateBalanceAndHeight(B);
}
//LR,先左后右双旋转
else if(T->lchild && T->lchild->balance == -1){
//节点A为原根节点,节点B为A的左子,节点C为B的右子
AVLNode *A=T,*B=T->lchild,*C=T->lchild->rchild;
//孙子左单旋
A->lchild=C;
B->rchild=C->lchild;
C->lchild=B;
//儿子右单旋
T=C;
A->lchild=C->rchild;
C->rchild=A;
//更新平衡因子和高度
updateBalanceAndHeight(B);
updateBalanceAndHeight(A);
updateBalanceAndHeight(C);
}
//RL,先右后左双旋转
else if(T->rchild && T->rchild->balance == 1){
//节点A为原根节点,节点B为A的右子,节点C为B的左子
AVLNode *A=T,*B=T->rchild,*C=T->rchild->lchild;
//孙子右单旋
A->rchild=C;
B->lchild=C->rchild;
C->rchild=B;
//儿子右单旋
T=C;
A->rchild=C->lchild;
C->lchild=A;
//更新平衡因子和高度
updateBalanceAndHeight(B);
updateBalanceAndHeight(A);
updateBalanceAndHeight(C);
}
}
}
void InsertAVL(AVL &T,ElemType key){
if(!T){
T=(AVLNode *) calloc(1,sizeof(AVLNode));
T->data=key;
T->height=1;
T->balance=0;
}
else if(T->data-key){
if(T->data>key) InsertAVL(T->lchild,key);
else if(T->data<key) InsertAVL(T->rchild,key);
UpdateBalanceAndHeight(T);
AdjustAVL(T);
}
}
7.6.2 删除
算法思路:
- 删除查找节点,方法同二叉排序树删除,如果未找到直接结束
- 若该节点为叶子节点,即没有左右子,则直接删除
- 若该节点只有一棵左子树或一棵右子树,则子树树根替代原先节点的位置
- 若节点度为2,则让该节点的直接后继(或直接前驱)替代该节点,然后从BST中删除该直接后继(或直接前驱),转变为前两种情况
- 直接后继为该节点右子树中最小的节点
- 直接前驱为该节点左子树中最大的节点
- 可从被删除节点向上查找祖先节点的平衡因子,确定是否存在最小不平衡子树,若不存在直接结束,反之找到最小不平衡子树中儿子辈、孙子辈中平衡因子为1、-1的两个节点,判断需要调整的类型(LL、RR、LR、RL)
- 如果最小不平衡子树向上传导,则重复调整最小不平衡子树,直至整个树平衡
删除节点和新增节点的初始状态不同,删除一个节点,对树的高度影响不确定
typedef struct AVLNode{
ElemType data;
int balance;
int height;
struct AVLNode *lchild,*rchild;
}AVLNode,*AVL;
void AdjustAVL(AVL &T); //调整AVL平衡
void UpdateBalanceAndHeight(AVLNode *T); //更新节点高度和平衡因子
void RemoveAVL(AVL &T,ElemType key){
if(T){
if(T->data>key) RemoveAVL(T->lchild,key);
else if(T->data<key) RemoveAVL(T->rchild,key);
else{
if(!T->rchild || !T->lchild){
AVLNode *tmp=T->rchild?T->rchild:T->lchild;
if(!tmp){
tmp=T;
T=NULL;
}
else {
T->data=tmp->data;
if(T->rchild) T->rchild=NULL;
else T->lchild=NULL;
}
free(tmp);
}
else{
//直接前驱替换
AVLNode *tmp=T->lchild;
while(tmp->rchild) tmp=tmp->rchild;
T->data=tmp->data;
RemoveAVL(T->lchild,tmp->data);
/*
//直接后继替换
AVLNode *tmp=T->rchild;
while(tmp->lchild) tmp=tmp->lchild;
T->data=tmp->data;
RemoveAVL(T->rchild,tmp);
*/
}
}
if(T){
UpdateBalanceAndHeight(T);
AdjustAVL(T);
}
}
}
7.6.3 查找效率分析
含n个节点的平衡二叉(排序)树最大深度为O(\(log_2n\)),因此平均搜索长度为O(\(log_2n\))。由于数据结构中新增了balance、height两个属性,实际上每次调整平衡因子、以及计算平衡因子在O(1)时间内可以完成,但是每次操作都需要经过很多次运算,该次数和查找长度有关。因而平衡二叉树插入、删除操作时间复杂度可视为O(\(log_2n\))
注:AVL树本身并没有强调元素之间的前后顺序,在代码实现上为了方便编写和测试,因此一般实现的是平衡二叉排序树的增删
无序的AVL树的查找、插入、删除节点操作均需要遍历整颗树,效率上有所损失,无法达到O(\(log_2n\))
7.7 红黑树
平衡二叉排序树在普通二叉排序树的基础上,优化了树的高度从而提高查找效率。但是由于插入、删除节点很容易造成AVL树失衡,需要计算节点的平衡因子以及调整树的结构造成时间浪费,而红黑树在进行插入、删除节点操作很多情况下并不会破坏树本身的特性,尽管需要调整树结构仅需O(1)时间内可完成
平衡二叉树跟适合以查为主,插入、删除比较少的应用环境,红黑树适合频繁插入、删除的场景
红黑树,RBT,Red-Black Tree
- 红黑树是二叉排序树,并满足一下条件
- 红黑树每个节点为红色或黑色
- 根节点颜色为黑色
- 叶节点均为黑色,这里的叶节点也叫外部节点、NULL节点、失败节点。原先树中具有数值的叶节点不一定是黑的
- 不存在两个相邻的红色节点,即红色节点的父节点和孩子节点均为黑色
- 对每个节点,从该节点到任一叶节点的简单路径上,所含黑结点的数目相同
此处的节点专指内部节点,不包括外部节点
typedef struct RBNode{
ElemType data; //数据域
bool color; //节点颜色,false表示黑,true表示红色
int blankHeight; //节点黑高,默认为0
struct RBNode *parent; //指针域,父节点指针
struct RBNode *lchild,*rchild; //指针域,左右子指针
}*RBT,RBNode;
节点的黑高BH:从某节点出发(不包含该节点)到达任意空叶节点(外部节点)的路径上黑节点的数量
7.7.1 红黑树性质
- 从根节点到叶节点的最长路径不大于最短路径的长度的两倍,因此任意节点左、右子树的高度之差不会相差两倍
- 任何一条查找失败路径上的黑节点数量都相同,而路径上不能连续出现两个红节点,即红节点只能穿插在各个黑节点中间
- 最长路径则为每个黑节点中间均穿插一个红节点,最短路径则是全为黑节点
- 某节点的黑高为h时,内部节点数量至少为\(2^h\)-1个
- 如果要求内部节点最少的情况,只可能是满二叉树,且包括外部节点所有节点全为黑节点,即内部节点构成高度为h-1的满二叉树
- 有n个内部节点的红黑树高度h ≤ 2\(log_2\)(n+1)
- 若红黑树总高度为h,则根节点的黑高 ≥ h/2,因此内部节点数n ≥ \(2^{h/2}\)-1,由此得出
7.7.2 查找
BHT由于是二叉排序树,因此查找节点和BST、AVL是相同的,若找到外部节点则说明查找失败
7.7.3 插入
算法思路
- 查找,确定插入位置,同BST插入
- 如果新节点是根,染黑色;如果非根,则染红色
- 插入新节点后,如果整颗BHT的结构没有破坏,则直接结束
- 检查新节点和他的父节点是不是均为红色
- 若插入后BHT结构发生变化,需要调整
- 插入新节点后,如果整颗BHT的结构没有破坏,则直接结束
- 调整:查看新节点的叔叔节点的颜色,即新节点的父节点的兄弟节点的颜色决定
- 如果叔叔节点为黑色,旋转在染色,旋转同AVL
- LL:右单旋,父节点与爷节点互换,父、爷节点均染色
- RR:左单旋,父节点与爷节点互换,父、爷节点均染色
- LR:左、右双旋,子节点与爷节点互换,子、父节点均染色
- RL:右、左双旋,子节点与爷节点互换,子、父节点均染色
- 如果叔叔节点为红色
- 叔、父、爷节点染色,爷节点视为刚刚新插入节点,从第二步开始重新判断
- 如果叔叔节点为黑色,旋转在染色,旋转同AVL
扩展:
- 如果关键字为升序,依次插入2\(^n\)-1个节点到空红黑树中,最终的红黑树不一定呈现满二叉树,一般会偏右
- 如果关键词为降序,依次插入2\(^n\)-1个节点到空红黑树中,最终的红黑树不一定呈现满二叉树,一般会偏左
7.7.4 删除
红黑树删除操作的时间复杂度为O(\(log_2n\)),在红黑树中删除节点的操作和BST删除节点一样。由于删除节点操作可能会破坏红黑树的性质,因此需要对节点位置、颜色进行调整
7.8 B树
m叉查找树,又名多路平衡查找树,因此B树也是一棵AVL树,简称B树: 每个节点最多可以存放m-1个关键词,m个指针
- 每个节点最少1个关键词,2个分叉;最多m-1个关键词,m个分叉
- 节点内关键词有序
- 规定m叉查找树中,除了根节点外,任何节点至少有\(\lceil m/2 \rceil\)个分叉,即至少有\(\lceil m/2 \rceil\)-1个关键词
- 如果每个节点内关键字太少,会导致树变高,需要查找更多层节点造成查找效率降低
- 根节点无法满足这个条件,当树内元素小于m/2个时,根节点只能存放这么多,因此根节点的关键字数∈[2,m],子树数∈[1,m-1]
- 规定m叉查找树中,任何一个节点其所有子树高度均相同
- 结合AVL性质,避免了失去平衡导致树的高度过高
- 所有叶节点都出现在同一层次,且不带信息,其实是空指针,表示查找失败
- 终端节点位于叶节点上一层,是深度最深的关键字节点
- B树的树高不包含失败节点
struct Node{
ElemType data[m-1]; //最多存放m-1个关键字
struct Node *child[m]; //最多m个孩子
int num; //节点有多少个关键词
};
7.8.1 B树性质
- 对于n个关键字的m阶B树,不论m为何值,叶节点/失败节点数量为n+1
- B树结构是为了加快对n个关键字查找效率,因此本质上B树的查找失败就是对n个顺序结构的数据查找失败的次数,即n+1个失败情况,故B树的叶节点共n+1个
- 由于B树是链式结构,无法支持顺序查找
- 含n个关键字的m阶B树,最小高度\(h_{min}\) ≥ \(log_m\)(n+1)
- 计算B树高度一般不包括外部节点
- 最小高度考虑让每个节点的关键字个数达到最多,每个节点最多有m个子树,m-1个关键字,即最小高度为n个关键字的满m叉树的高度
- 对于高度为h,m阶B树关键字的个数n ≤ (m-1)(1 + m + m\(^2\) + m\(^3\) + ... + m\(^{h-1}\)) = m\(^h\) - 1
- 含n个关键字的m阶B树,最大高度为\(h_{max}\) ≤ \(log_{\lceil m/2\rceil}\frac{n+1}{2} +1\)
- 最大高度考虑让每个节点的分叉尽可能少。根节点最少2个分叉,其他节点至少\(\lceil m/2 \rceil\)
- 第一层至少1个节点,第二层至少2个节点,第三层至少2\(\lceil m/2 \rceil\)个节点,第h层至少2\(\lceil m/2 \rceil^{h-2}\)个节点
- 第h+1层共2\(\lceil m/2 \rceil^{h-1}\)个节点
- n个关键字的B树必有n+1个失败节点/外部节点,则有n+1 ≥ 2\(\lceil m/2 \rceil^{h-1}\)
- 对于高度为h的m阶B树
- 每个节点至多有m个子树,除根以外的非叶子节点至少有2棵子树
- 那么整棵B树所包含的节点数(包括失败节点)
- 最少节点数情况为,高度为h的满m-1叉树
- 最多节点数情况为,高度为h的满m叉树
7.8.2 插入
- 考虑只有根节点状态
- 忽略失败节点,每个节点按照顺序依次插入,直至节点中填满m-1个元素
- 当插入第m个元素时,m个元素进行排序,并选择中间位置节点mid = \(\lceil m/2 \rceil\),将其中关键词分为两部分
- mid左半边关键词放在原节点,右半边放入新的节点,mid节点作为两个节点的父节点,且父节点的第一、二指针分别指向左半边、右半边节点
- 当B树有多个节点时,每次新插入的元素都是插入到最底层的终端节点,通过查找的方式确定插入位置
- 由于要保证任意节点的子树高度相同,因此在根节点插满后,才会增加整颗B树的高度
- 假设某孩子节点插入第m个元素,依旧是找到中间位置节点mid,将关键词再分为两部分
- mid左半边节点留在原节点中,右半边节点放入新的节点
- mid节点插入到父节点上
- 由于原节点所有元素都在父节点有大小关系,假设父节点指向该节点的下标为i,则mid节点插入到父节点关键字集合的第i位置,后置节点后移
- 指针i依旧指向原节点,后续指针全部后移,指针i+1指向新节点
- 由于每次只右根节点满了才会调整整棵树高度,同时一分为二,左右子树的高度是相同的。同时所有子树插满后继续插入只会向兄弟方向延展,即整颗B树越来越胖,而不是直接变高
查找节点的过程不一定使用顺序查找,由于本身节点内关键字是有序的,可以使用适用于有序序列的查找算法加快效率
7.8.3 删除
- 考虑删除的元素为非终端节点中的元素,则直接找到该元素的直接前驱或直接后继元素进行替换,从而转换为对终端节点中的元素进行删除
- 直接前驱:该关键字的左侧指针所指子树中的最右下的元素
- 直接后继:该关键字的右侧指针所指子树中的最左下的元素
- 考虑删除的元素为终端节点的元素
- 若删除元素后,所在的终端节点仍旧满足每个节点中至少有\(\lceil m/2 \rceil\)-1个关键字,则删除后直接结束
- 若删除元素后,终端节点元素数量小于下限
- 如果左右相邻兄弟元素够用,即兄弟节点中至少有\(\lceil m/2 \rceil\)个关键字,则将该关键字的与父节点、兄弟节点进行位置互换
- 左兄弟够用,用当前关键字的前驱、前驱的前驱来填补空缺
- 有兄弟够用,用当前关键词的后继、后继的后继来填补空缺
- 如果左右相邻兄弟元素不够用,即兄弟节点中均只有\(\lceil m/2 \rceil\)-1个关键字,则删除其一兄弟节点,并将兄弟节点元素、父节点元素进行合并
- 如果由于删除影响会向上传递,可能导致根节点中删除了元素后为空,则直接将合并后的节点作为根节点,原根节点直接删除,同时树高度-1
- 如果左右相邻兄弟元素够用,即兄弟节点中至少有\(\lceil m/2 \rceil\)个关键字,则将该关键字的与父节点、兄弟节点进行位置互换
7.9 B+树
一棵m阶B+树,需要满足以下条件:
- 每个分支节点最多有m棵子树
- 非叶根节点至少有两棵子树,其他每个分支节点至少有\(\lceil m/2 \rceil\)棵子树
- 其中B+树中,最底层关键字节点为叶子节点,上面层中的节点为分支节点
- B+树和B树类似,追求所有子树高度相同的平衡状态
- 节点的子树个数和关键字个数相等
- 所有叶节点包含全部关键字以及指向相应记录的指针,叶节点中关键字有序,并且相邻叶节点之间有序,并且用指针相链
- 因此B+树支持顺序查找,在顺序存储的基础上新增了分块查找的树形索引表
- 所有分支节点中仅包含他的各个子节点中关键字的最大值,以及指向子节点的指针
B+树查找过程
- B+树的查找过程类似于分块查找,从根节点出发,节点内有序,一直查到所需节点。在B+树种,无论查找成功与失败,最终都会从树的根找到叶子节点
- 由于B+树叶子节点是有序相连的,可以进行顺序查找。但由于节点之间是分离的且节点数量不确定,无法进行折半等查找方式
7.9.1 B+树与B树对比
- B+树中节点的n个关键字对应n棵子树,而B树节点n个关键字对应n+1棵子树
- B+树和B树对节点中的关键字数量均有最小下限且均相同,但B+树每个节点最多可拥有m个关键字
- B+树中包含了全部的关键字,非叶节点中出现的关键词在叶子节点中也会出现;而B树所有节点只会出现一次
- B树和B+树均可以用于磁盘的文件索引
- B+树也节点中包含全部记录信息,所有非叶节点仅有索引作用且不含有记录实际存储地址;而B树中每个节点中的关键字均保存了相应的记录信息
- 因此B+树更加适合作为操作系统磁盘索引表的数据结构,一个磁盘块中可以包含更多的关键字,使B+树阶更大,树更加矮,从而读写磁盘次数更少
- 分支节点不需要存储记录信息,只用于作为索引所占用的空间小,B+树阶数越大,从而指向磁盘空间越大
- 关系型数据库MySQL的索引也是通过B+树进行实现
7.10 散列表
散列表,又叫哈希表,Hash Table,可以根据元素的关键字计算它在散列表中的存储地址
每个散列表均有散列函数,也叫哈希函数,散列函数建立了关键字->存储地址的映射关系
因此,理想状态下,在散列表中查找一个元素的时间复杂度为O(1)
其他概念
- 冲突(碰撞):在散列表中插入一个数据元素,需要根据关键字的值确定存储地址,若该地址已经存储了其他元素,则发生冲突(碰撞)
- 同义词:若不同关键词通过散列函数映射到同一个存储地址,则称他们为同义词
7.10.1 散列函数
通过构造更合适的散列函数,让各个关键字尽可能映射到不同的存储位置,从而减少冲突
散列函数应满足的条件:
- 定义域必须涵盖所有可能出现的关键字
- 值域不能超出散列表的地址范围
- 尽可能减少冲突,且计算出来的地址尽可能分布在整个地址空间
- 散列函数应尽量简单,能够快速计算出任意一个关键字对应的散列地址
除留余数法
H(key) = key % p
散列表长为m,取一个不大于m但最接近或等于m的质数p
质数,又名素数,除了1和该整数本身外,不能被其他自然数不能整除
- 由于取余运算会被公因子影响,质数的公因子足够少,对质数进行取余可以使散列分布更均匀,从而减小冲突
使用场景:比较通用,只要关键字是整数即可
直接定址法
H(key) = key 或 H(key) = a*key + b
其中a和b是常数,该定址不会产生冲突,但可能造成空间浪费
使用场景:关键字分布基本连续
数字分析法
选取数码分布较为均匀的若干位作为散列地址
- 设关键字是r进制数,而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等
- 而在某些位上分布不均匀,只有某几种数码经常出现,此时可选区数码分布较为均匀的若干位作为散列地址
使用场景:关键字集合已知,且关键字的某几个数码分布均匀
例如手机号后几位可作为关键字设计散列函数
平方取中法
取关键字的平方值的中间几位作为散列地址
- 具体取多少位视情况而定,该方法得到的散列地址与关键字的每一位都有关系,因此散列地址分布比较均匀
使用场景:关键字每位取值都不够均匀
例如信息为985,平方后是
985*5=4925
985*8=7880
985*9=8865
970225
三个结果错位相加,其中结果的低3、4位的"0"、"2"参与了所有位数码的计算。因此尽管原始信息各位的取值不够均匀,也可以将散列后地址分布变得均匀
7.10.2 解决散列冲突
两种主流的解决方案
- 拉链法:又称链接法、链地址法,把所有同义词存储在一个链表中
- 开放定址法:如果发生冲突,就给新元素找另一个空闲得位置
- 一个散列地址,既对同义词开放,又对非同义词开放
除了上述解决方案外,散列表的查找效率还受到装填因子影响
- 装填因子α:定义一个表的装满程度
- α = 表中记录数n / 散列表长度m
- 散列表的平均查找长度依赖于散列表的装填因子α,不直接依赖于n和m
- 直观上看,α越大,表示装填的记录越满,发生冲突的可能性越大;反之发生冲突的可能性越小
拉链法
散列表长度为m,可以将散列表视为m个链表的头指针顺序结构
- 插入
- 插入过程
- 散列函数计算新元素散列地址
- 将新元素插入散列地址对应的链表,可用头插或者尾插
- 优化插入过程
- 由于插入过程并未要求有序,因此在查找时间复杂度为O(\(L_i\)),\(L_i\)表示散列下标为\(i\)的链表长度
- 如果插入链表时就保证链表有序,查找效率可以略微提升
- 插入过程
- 查找
- 查找过程
- 散列函数计算查找元素散列地址
- 依次比较整个散列中的列表元素,若链表仅有表头表示查找失败;若找到直接返回,否则查找失败
- 查找长度:在查找运算中需要对比关键字的次数。一般默认空指针对比不计入查找长度
- 因此查找成功的查找长度至少为1,而查找失败的查找长度可能为0
- 查找过程
- 删除
- 删除过程
- 散列函数计算删除元素散列地址
- 查找删除元素,若找不到直接结束,反之链表删除该节点
- 删除过程
开放定址法
确定一个探测的顺序,从初始散列地址出发,依次寻找下一个空闲位置
\(H_i = (H(key)+d_i)\ \%\ m\)
其中\(d_i\)表示第i次发生冲突时,下一个探测地址与初始散列地址的相对偏移量
构造探测序列\(d_i\),其中0 ≤ \(d_i\) ≤ m-1,偏移量一定从0开始
- 线性探测法
- 又叫探测序列、增量序列,\(d_i=0,1,2,3,...,m-1\)
- 平方探测法
- \(d_i=0^2,1^2,-1^2,2^2,...,k^2,-k^2\),其中k ≤ m/2
- 双散列法
- \(d_i=i * hash_2(key)\),其中\(hash_2(key)\)为另一个散列函数
- 伪随机数序列法
- \(d_i\)是一个伪随机序列,\(d_i=0,5,3,11,-1,7\)
开放定址法查找、新增
- 插入的探测过程类似于其他数据结构的查找操作,若探测到空单元则填入;若探测到非空单元则进行下一次探测
- 而开放定址法的查找过程和探测过程正好相反,持续探测直至找到目标关键词则查找成功,若探测到空单元说明查找失败
开放地址法删除
- 删除操作的一般过程是先查找元素,然后删除对应位置,但是由于冲突避免的原因,会导致同义词探测序列\(d_i\)中某个位置的值为空,查找过程会判定为查找失败
- 因此散列表中的元素删除不能直接删除,否则会截断之后探测路径上的元素,可通过删除标记进行逻辑删除
- 但是由于做了逻辑删除,每次探测序列会被拉的很长,降低查找、插入效率
- 可以不定期整理散列表内的数据,如同义词往探测序列之前方向移动、新元素直接插在逻辑删除的元素位置
开放地址法的探测覆盖率
- 线性探测法:理想状态下,若散列表长为m,则最多发生m-1次冲突即可探测完整的散列表,即只要散列表有非满,一定可以成功插入
- 平方探测法:至少可以探测到散列表中一般的位置,即尽管散列表有非满,也不一定能成功插入
- 若散列表长为m,m可以表示为4j+3的一个素数,j为自然数,则拼房探测法可以探测到所有位置
- 双散列法:与第二个散列函数有关,如果\(hash_2(key)\)计算得到的值和散列表长m互质,就能保证双散列可以探测所有单元
- 伪随机数序列法:完全取决于伪随机序列设计是否合理
7.11 可视化演示
https://www.cs.usfca.edu/~galles/visualization/Algorithms.html
本文来自博客园,作者:GK_Jerry,转载请注明原文链接:https://www.cnblogs.com/GKJerry/articles/18288432

浙公网安备 33010602011771号