目录
注
查找的基本概念
线性表的查找
顺序查找
折半查找
分块查找
树表的查找
二叉排序树
平衡二叉树
平衡二叉树的定义
平衡二叉树的平衡调整方式
平衡二叉树的实现
B-树
B-树的定义
B-树的示意性实现
B+树
注
本笔记参考:《数据结构(C语言版)(第2版)》
在实际应用中,我们会发现,查找运算是十分常见的。而为了提高查找效率,就出现了对于查找算法的研究。
查找的基本概念
同样地,为了方便说明,存在一些关于查找的概念和术语:
术语 | 概念 | 补注 |
---|---|---|
查找表 | 是由同一类型的数据元素(或记录)构成的集合 | 查找表十分灵活,可以由其他的数据结构来实现 |
关键字 | 是数据元素中某个数据项的值,用于标识一个数据元素 | 主关键字:可以唯一标识一个记录 |
次关键字:用以识别若干记录 | ||
查找 | 根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素 | 若查找失败,返回一个“空”记录/“空”指针 |
动态查找表 | ① 若在查找的同时能对表进行修改操作,则称相应的表为动态查找表 ② 反之,是静态查找表 | 修改操作就是插入、删除等 |
静态查找表 | ||
平均查找长度 (ASL) | 对于含有n个记录的表,查找成功时的平均查找长度为 | :查找表中第i个记录的概率; :找到目标记录时,已经进行过比较的关键字个数。 平均查找长度是衡量查找算法性能的一个指标。 |
线性表的查找
顺序查找
该类查找的查找过程是:从表的一端开始,遍历线性表。遍历中若发现与关键字相等的值,查找成功;反之,查找失败。
接下来展示的是顺序表的顺序查找。
数据元素及顺序表的定义:
#define KeyType int
#define InfoType char
//---数据元素的定义---
typedef struct
{
KeyType key; //关键字域
InfoType otherinfo; //其他域
}ElemType;
//---顺序表的定义---
typedef struct
{
ElemType* R; //指向存储空间的基地址
int length; //当前顺序表的长度
}SSTable;
注:下方展示的算法中,元素是从 ST.R[1] 开始向后存放的。(顺序表的查找可在之前的笔记看到更详细的解释)
【参考代码】
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;
}
在上述算法中,程序在每一次循环前都要检查是否到达表的边界,这个监测过程是可以被省略的,为此,可以将 ST.R[0] 设为岗哨:
int Search_Seq_sentry(SSTable ST, KeyType key)
{
ST.R[0].key = key;
int i = 0;
for (i = ST.length; ST.R[i].key != key; i--);
return i;
}
【算法分析】
尽管改动很小,但在实践中,若像上述表示的那样通过使用“岗哨”改进算法,在ST.length ≥ 1000时,可以使每次查找所需的平均时间减少近一半。
而显而易见的,该算法的时间复杂度是O(n)。
该算法的优点:
- 算法简单;
- 对表结构无要求。
其缺点也很明显:
- 平均查找长度较大;
- 查找效率较低,不适合大规模查找。
折半查找
||| 折半查找,即效率较高的二分查找。
不同于顺序查找,该算法对表结构存在要求:
- 是顺序存储结构;
- 表中元素要按照关键字有序排列。
折半查找的查找过程:从表的中间记录开始查找,若没找到目标,则将表分为两个子表,选择与给定值所在区间匹配的那个子表再次进行查找。(折半查找每一次都会缩小一半的查找范围)
【参考代码:以递增有序的表为例】
int Search_Bin(SSTable ST, KeyType key)
{
int low = 1; //low和high分别是当前选定区间的两个端点
int high = ST.length;
while (low <= high)
{
int mid = (low + high) / 2;
if (key == ST.R[mid].key) //若找到待查元素
return mid;
else if (key < ST.R[mid].key) //在前一个子表进行查找
high = mid - 1;
else //在后一个子表中进行查找
low = mid + 1;
}
return 0; //待查元素不存在
}
注:当low = high时,可能还有剩余的结点未处理,故循环的判断条件是 low <= high 。
顺便,上述函数也可以写成递归:
【算法分析】
根据折半查找的过程,可以构建一棵折半查找的判定树,其中:
- 结点值是表中的记录序号;
- 当前查找区间的中间位置就是根。
由该树可知,折半查找的查找次数不会超过树的深度。其中,判定树的形态只与表中记录的个数有关,若判定树有n个结点,其深度将会是[log₂n] + 1。因此,折半查找在查找成功时,至多进行了[log₂n] + 1次的比较。(ps:查找失败时进行比较的关键字个数也不会超过[log₂n] + 1个)
计算折半查找的平均查找长度,当有序表的长度n足够大时,可以得到近似结果:
因此,折半查找的时间复杂度为O(log₂n)。
折半查找的优点:
- 比较次数小,查找效率高。
其缺点也提到过了:
- 对表结构有高要求(只能使用顺序存储的有序表);
- 在上述表结构中,查找、增删都会费时。
综上所述,折半查找不适合数据元素经常变动的线性表。
分块查找
分块查找,即索引顺序查找。该方法的性能介于顺序查找和折半查找之间(实际上是顺序查找和折半查找的简单合成)。对于该方法而言,需要建立一个“索引表”,例如:
||| “分块有序”:指后一个子表中,所有记录的关键字均大于其前一个子表中的最大关键字。
由此,分块查找的查找过程分为两步:
- 先确定待查记录所在的块(即子表);
- 再在块中进行顺序查找。
由于索引表有序,所以在进行块的查找时可以使用顺序查找或者折半查找。
假设:
- 表的长度是n;
- 将表均匀分为b块;
- 每块含有s个记录;
- 表中每个记录的查找概率相等。
有如下结论:
① 若用顺序查找确定所在块,则分块查找的平均查找长度为:
在上述公式中,平均查找长度同时与n和s有关。显然,当s的取值为n开方时,平均查找长度最小。但是该结果仍远不如折半查找。
② 若用折半查找确定所在块,则分块查找的平均查找长度为:
分块查找同样尤其优缺点,它的优点是:
- 增删元素更为方便(由于块内无序,仅需找到对应的块即可);
- 可以适应一些需要 快速查找 和 经常动态变化 的表。
与之相对的,缺点也存在着:
- 索引表要求更大的存储空间。
树表的查找
在之前的介绍中,若使用线性表作为查找表的组织形式,其中折半查找效率较高。就是如此,折半查找本身也存在着诸多限制。因此,线性表的查找更适用于静态查找表。为了更高效地进行动态查找,就需要依靠特殊的二叉树作为查找表的组织形式,它们被称为树表。
二叉排序树
二叉排序树又称二叉查找树,其在查找和排序上都大有用处。二叉排序树形如:
对于一个二叉排序树而言:
- 可能是空树或者是具有下方所述性质的二叉树:
- 若它的左子树不为空,则左子树上所有结点的值均小于它的根结点的值;
- 若它的右子树不为空,则右子树上所有结点的值均大于它的根结点的值;
- 它的左、右子树也是二叉排序树。
由上述规则可知,二叉排序树是递归定义的。
重要性质:中序遍历一棵二叉排序树时,可以得到一个结点值递增的有序序列。
若中序遍历上述二叉树,可得:
3, 12, 24, 37, 45, 53, 61, 78, 90, 100
同样地,二叉排序树的操作需要根据关键字域进行,故需要有每个结点的数据域的类型定义:
typedef struct {
KeyType key; //关键字域
InfoType otherinfo; //其他数据项
}ElemType;
typedef struct BSTNode
{
ElemType data; //结点的数据域(包含关键字和其他数据)
struct BSTNode* lchild, * rchild; //左右孩子指针
}BSTNode, * BSTree;
【参考代码:二叉排序树的查找】
BSTree SearchBST(BSTree T, KeyType key)
{
if ((!T) || key == T->data.key) //查找结束
return T;
else if (key < T->data.key)
return SearchBST(T->lchild, key); //在左子树中继续查找
else
return SearchBST(T->rchild, key); //在右子树中继续查找
}
【算法分析】
根据算法的描述可知,在二叉排序树上进行关键字的搜索,实际上就是在走一条从根结点到目标结点的路径。和折半查找类似,该算法中与给定值比较的关键字个数不会超过树的深度。
但是,不同于折半查找,含有n个结点的二叉排序树是不唯一的。例如:
在上图中,两棵树由于其形态的不同,导致了其平均查找长度的不同:
- 当插入的关键字有序排列时,二叉排序树就变为了一棵单支树,此时树的深度为n,平均查找长度为(n + 1) / 2。显然,这种情况是最差的;
- 与之相对,最好的情况应该是将二叉排序树构造为类似于折半查找的判定树的形态,此时平均查找长度与 log₂n 成正比。
相比于折半查找,二叉排序树有其优点:
- 在维护表的有序性上,二叉排序树仅需修改指针即可完成结点的增删。
- 相比于折半查找,对于需要频繁查找和增删的表,使用二叉排序树更加方便。
-------
【参考代码:二叉排序树的插入】
void InsertBST(BSTree& T, ElemType e)
{
if (!T) //找到需要进行插入操作的位置,递归结束
{
BSTree S = new BSTNode; //生成新结点
S->data = e; //设置新结点的数据域
S->lchild = S->rchild = NULL; //新结点是叶子结点
T = S; //将新结点链接到预定位置
}
else if (e.key < T->data.key) //将新结点插入到左子树
InsertBST(T->lchild, e);
else if(e.key > T->data.key) //将新结点插入到右子树
InsertBST(T->rchild, e);
}
注:上述算法的插入仅在树中不存在对应结点时进行插入操作。
以之前提到过的二叉排序树为例:
【算法分析】
该算法的基本过程依旧是查找,因此时间复杂度也和查找一样,是O(log₂n)。
-------
【参考代码:二叉排序树的创建】
void CreateBST(BSTree &T)
{//依次读入一个关键字为key的结点,将结点插入
T = NULL;
ElemType e = {0, 0};
cin >> e.key;
while (e.key != ENDFLAG) //ENDFLAG是自定义常量,作为输入的结束标志
{
InsertBST(T, e);
cin >> e.key;
}
}
【算法分析】
假设有n个结点,就需要进行n次插入操作,而每次插入操作的时间复杂度为O(log₂n)。因此二叉排序树的创建算法的时间复杂度为O(nlog₂n)。
例如,输入无序数列:45, 24, 53, 45, 12, 24, 90,可得到如下所示的二叉树:
可见,上述的无序数列在构造二叉排序树时被处理成了一个有序数列。也就是说,构造树的过程也就是对无序序列进行排序的过程。同时,上述操作也说明,在二叉排序树上进行插入操作,无需移动其他记录。
------
【参考代码:二叉排序树的删除】
注意,在删除二叉排序树的结点时,可能出现下面的三种情况(设被删除结点为 P ,p的双亲结点是 F):
- P是叶子结点,无左、右子树(可直接删除)。
- P有且仅有左子树或右子树(需要将子树链接到 F 上)。
- P同时拥有左、右子树,此时有两种处理方式:
- 删除结点P,①将其左子树链接到 F 上,②将其右子树链接到 P的直接前驱 上;
- 令 P的直接前驱(或直接后继)代替p,再删除 直接前驱(或直接后继),这也是下方代码使用的方法。
void DeleteBST(BSTree& T, KeyType key)
{//从二叉排序树中删除关键字是key的结点
BSTree p = T; //指向被删除的结点位置
BSTree f = NULL; //指向*p的双亲结点
//---搜索关键字为key的结点*p---
while (p)
{
if (p->data.key == key) //若找到目标
break;
f = p; //*f是*p的双亲结点
if (p->data.key > key) //搜索左子树
p = p->lchild;
else //搜索右子树
p = p->rchild;
}
if (!p) //若不存在目标结点
return;
/*--- 考虑目标结点可能所处的三种情况:
*p左右子树均不为空、左子树不为空、右子树不为空(若子树均不存在,该步骤跳过)---*/
BSTree q = p; //存储p所在位置,以备处理
if ((p->lchild) && (p->rchild))
{
BSTree s = p->lchild;
while (s->rchild) //寻找*p的直接前驱
{
q = s;
s = s->rchild;
}
p->data = s->data; //注:此时s可能有两种状态:存在左子树、无子树
if (q != p) //若在进入循环前,s本就是p的直接前驱
q->rchild = s->lchild;
else //否则
q->lchild = s->lchild;
delete s;
return;
}
else if (!p->rchild) //若p的右子树不存在
p = p->lchild;
else if (!p->lchild) //若p的左子树不存在
p = p->rchild;
//将p所指的子树链接到其双亲所在位置
if (!f) //若被删除的结点是根结点
T = p;
else if (q == f->lchild) //选取链接位置
f->lchild = p;
else
f->rchild = p;
delete q;
}
【算法分析】
类似于插入,二叉排序树的删除也是建立在查找的过程之上的,所以时间复杂度仍是O(log₂n)。
平衡二叉树
平衡二叉树的定义
由之前的讨论可以得到这样一个结论,树的高度越小,查找速度越快。因此,希望树的高度越矮越好。
平衡二叉树,又称AVL树。该类树或者为空树,或者有如下特征:
- 左子树和右子树的深度之差的绝对值不超过1;
- 左、右子树均为平衡二叉树。
||| 平衡因子(简称BF)的定义:二叉树上某一结点的平衡因子就是该结点的左子树和右子树的深度之差。认为平衡因子只可能是-1、0、或1 。
因为平衡二叉树的任一结点的平衡因子 ≤ 1,因此其深度和log₂n是同数量级的。故查找平衡二叉树的时间复杂度为O(log₂n)。
平衡二叉树的平衡调整方式
- 在插入结点时,按照二叉排序树的方式处理;
- 若插入的结点破坏了平衡二叉树的特性,就进行调整。
调整前,需要有以下特征的祖先结点:
1. 离当前插入结点最近;
2. 平衡因子绝对值大于1。
对以该祖先结点为根的子树(即最小不平衡子树)进行局部调整。
【局部调整的例子】
通过上述这种调整平衡的例子,可以发现,在不同的最小不平衡子树中进行调整的方式是不同的。假设最小不平衡子树的根结点是A,则可以归纳出下列四种情况:
1. LL型
【LL型的调整实例】
2. RR型
【RR型的调整实例】
3. LR型
【LR型的调整实例】
4. RL型
【RL型的调整实例】
平衡二叉树的实现
类似于二叉排序树,为了计算相对高度,为每个结点增加一个数据域height:
typedef struct BBSTNode
{
ElemType data; //结点的数据域(包含关键字和其他数据)
int height; //相对高度
struct BBSTNode* lchild, * rchild; //左右孩子指针
}BBSTNode, * BBSTree;
为了创建平衡二叉树,就需要对二叉树进行旋转,其中:
【参考代码:二叉树的左旋函数】
void RoateLeft(BBSTree& T)
{
if (!T)
return;
BBSTree p = T->rchild; //暂存T的右子树的根结点
T->rchild = p->rchild; //连接新的右子树
if (p->lchild) //处理p的左孩子
p->rchild = p->lchild; //将p的左子树右旋
p->lchild = T->lchild; //将p插入T的左子树,完成左旋
T->lchild = p;
ElemType e = p->data; //交换数据域
p->data = T->data;
T->data = e;
}
右旋函数和左旋函数高度相似,故不重复展示。
【参考代码:平衡二叉树的插入】
平衡二叉树的插入算法可以参考二叉排序树的实现,同样是进行比较再插入。不同之处在于,平衡二叉树需要在插入结点的同时进行相对高度的计算,并且对于平衡因子异常的结点进行处理。假设往平衡二叉树BBST上插入数据元素e:
- 若e可以插入到BBST的左子树上,则当插入之后的 左子树的深度 +1 时:
- 若BBST的根结点的平衡因子 = -1:将根结点的平衡因子更改为0;
- 若BBST的根结点的平衡因子 = 0:将根结点的平衡因子更改为1;
- 若BBST的根结点的平衡因子 = 1:
- 若BBST的左子树根结点的平衡因子 = 1(参考:LL型):① 进行单向右旋平衡处理,② 将根结点及其右子树根结点的平衡因子更改为0;
- 若BBST的左子树根结点的平衡因子 = -1(参考:LR型):①对结点B及其右子树进行逆时针旋转;② 对当前的最小不平衡子树进行一次顺时针旋转;③ 分别修改根结点和其左、右子树的平衡因子。
- 若e可以插入到BBST的右子树上,处理与上述相似,不再赘述。
void InsertBBST(BBSTree& T, ElemType e)
{
if (!T) //找到需要进行插入操作的位置,递归结束
{
BBSTree S = new BBSTNode; //生成新结点
S->data = e;
S->height = 0; //叶子结点的相对高度为0
S->lchild = S->rchild = NULL;
T = S; //将新结点链接到预定位置
}
else if (e.key < T->data.key) //将新结点插入到左子树
{
InsertBBST(T->lchild, e);
if (T->height == -1 || T->height == 0) //处理左子树上可能出现的情况
T->height++;
else if (T->height == 1) //可能需要进行旋转的情况
{
if (T->lchild->height == 1) //LL型
{
RoateRight(T);
T->height = 0; //调整相对高度的数值
T->rchild->height = 0; //改变根结点及右子树的相对高度数值
}
else if (T->lchild->height = -1) //LR型
{
RoateLeft(T->lchild); //先对左子树进行一次左旋
RoateRight(T); //再进行右旋
CalculateHeight(T->lchild); //计算相对高度
CalculateHeight(T->rchild);
CalculateHeight(T);
}
}
}
else if (e.key > T->data.key) //将新结点插入到右子树
{
InsertBBST(T->rchild, e);
if (T->height == 1 || T->height == 0) //处理右子树上可能出现的情况
T->height--;
else if (T->height == -1)
{
if (T->rchild->height == -1) //RR型
{
RoateLeft(T); //进行左旋
T->height = 0; //调整根结点及左子树的相对高度
T->lchild->height = 0;
}
else if (T->rchild->height == 1) //RL型
{
RoateRight(T->rchild); //先对右孩子进行一次右旋
RoateLeft(T); //在对根结点进行一次左旋
CalculateHeight(T->lchild); //计算相对高度
CalculateHeight(T->rchild);
CalculateHeight(T);
}
}
}
}
计算相对高度的函数CalculateHeight可以参考:
void CalculateHeight(BBSTree T)
{//该函数在使用前,T已经是一棵平衡二叉树
if (T->lchild && !T->rchild) //若左子树存在且右子树不存在
T->height = 1;
else if (!T->lchild && T->rchild) //若右子树存在且左子树不存在
T->height = -1;
else //剩余情况
T->height = 0;
}
上述计算相对高度的函数实现是依赖于T的结构。若T不是平衡二叉树,则无法使用上述函数。
B-树
上述的方法适用于存储在计算机内存中较小的文件,统称为内查算法。在外存中进行查找时,这种算法需要反复进行内、外存的交换,会浪费时间。为此,就出现了适用于外查找的平衡多叉树,即B-树。
B-树的定义
一棵m阶的B-树或为空树,或为满足以下条件的m叉树:
- 树中每个结点至多有 m 棵子树;
- 若根结点不是叶子结点,则至少有两棵子树;
- 除根结点之外的所有非终端结点至少有 m/2 棵子树;
- 所有叶子结点都出现在同一层次上,并且不带信息,通称为失败结点(注意:在这里的失败结点其实并不存在,即指向该结点的指针值为NULL);
- 所有非终端结点最多有 m-1 个关键字,结点结构如下:
上图所示是一个4阶的B-树,其结点中的关键字个数最多就是3。
在具体实现时,通常会为B-树结点的存储结构增加一个parent指针,指向结点的双亲结点:
不同于之前学到过的数据结构,作为适用于外存的B-树需要涉及到外存的存取。由于笔者能力有限,此处没有写出外存的处理方式。在下文的结点类型定义中仅用 Recode* recptr[m+1] 象征在外存中的处理过程。
#define m 3 //定义B-树的阶
typedef struct BTNode
{
int keynum; //结点中关键字的个数,与结点的大小关联
struct BTNode* parent; //指向结点的双亲结点
KeyType K[m + 1]; //关键字向量,0号单元不使用
struct BTNode* ptr[m + 1]; //指向子树的指针
Record* recptr[m + 1]; //象征B-树在外存中的处理(记录指针向量)
}BTNode, * BTree;
typedef struct
{
BTNode* pt; //指向查找到的结点
int i; //在结点中的关键字的序号(1...m)
int tag; //1:查找成功 0:查找失败
}Result; //B-树的查找结果的类型
B-树的示意性实现
1. B-树的查找
【部分参考代码:B-树的查找】
在m阶的B-树上查找关键字key,返回结果为(pt, i, tag):
- 若查找成功,则特征值为1,指针pt指向当前结点中的第 i 个关键字(该关键字等于key);
- 若查找失败,则特征值为0,此时指针pt指向当前结点中第 i 个和第 i + 1 个关键字(等于key的关键字应该插入pt所指位置)。
Result SearchBTree(BTree T, KeyType key)
{
//---初始化---
BTree p = T; //p指向待查的结点
BTree q = NULL; //q指向p的双亲结点
bool found = false;
int i = 0;
//---搜索---
while (p && !found)
{
i = Search(p, key); //在p->key[1]到p->key[keynum]中查找i,有:p->key[i] <= i < p->key[i + 1]
if (i > 0 && p->K[i] == key) //若找到待查找关键字
found = true;
else
{
q = p; //每次查找都是从根结点开始,沿着某一路径往下进行的
if (key > p->K[i]) //分两条路走
p = p->ptr[i];
else
p = p->ptr[i - 1];
}
}
//---返回---
if (found) //查找成功
return { p, i, 1 };
else //查找失败,返回K的可插入位置的信息
return { q, i, 0 };
}
其中的Search函数可参考(此处为顺序查找。若结点较大,也可以使用折半查找):
int Search(BTree T, KeyType key)
{
if (!T)
return 0;
int i = 1;
while (i < T->keynum)
{
if (T->K[i] <= key && key <= T->K[i + 1])
break;
i++;
}
return i;
}
【算法分析】
一般而言,B-树的查找操作分为两部分:
- 在B-树种查找结点(在磁盘上进行,上述算法中没有体现);
- 在结点中找关键字(在内存中进行)。
换言之,B-树的查找就是① 需要先在磁盘中找到指针p所指结点,将信息读入内存(Recode* recptr[m+1]);② 再利用内存中的查找算法进行关键字key的查找。由于在磁盘中的查找更加耗时,因此,在磁盘上的查找次数(即待查关键字所在结点在B-树上的层次数),是该查找算法效率的关键。
设一深度为h+1的m阶B-树,该树具有N个关键字:
根据B-数的定义,在第一层上至少有1个结点;第二层至少2个结点;除根之外,每一个非终端结点至少有 m/2 棵子树,故第三层至少有 个结点;……。由此可知
- 第h+1层(叶子结点所在层次)的最少结点数为;
- 叶子结点(失败结点)的数量为N+1。
可推出公式:
将上述公式转换:
故,在含有N个关键字的B-树上进行查找时,路径上涉及到的结点数不会超过。
2. B-树的插入
B-树是动态查找树,其生成过程是由空树开始,在查找的过程中逐步插入关键字得到整棵树的。B-树的不同之处在于其每个结点能容纳的关键字个数都有规定:
因此,B-树的关键字在插入时,不是往B-树中新增一个叶子结点,而是现在最底层的非终端结点中添加一个关键字,再分情况处理:
- 若当前结点未满(关键字个数 ≤ m - 1):插入完成;
- 若当前结点已满:“分裂”当前结点,将此结点在同一层次分为两个结点。
一般地,结点的“分裂”方式如下:
- 以中间关键字为界,把结点一分为二成为两个结点;
- 把中间关键字向上插入到双亲结点上,若双亲结点已满,采用同样的方式“分裂”双亲结点。
若一直“分解”到根结点,则B-树的高度加1。
【例子】
【部分参考代码:B-树的插入】
要求:
- 在m阶B-树T的结点*q的key[i]和key[i+1]之间插入关键字K;
- 若插入导致结点过大,则沿双亲链进行必要的“分裂”和调整,使T仍是一棵m阶B-树。
和之前一样,B-树的操作中涉及到外存的那部分没有出现在以下代码中。
Status InsertBTree(BTree& T, KeyType K, BTree q, int i)
{
KeyType x = K; //x表示新插入的关键字
BTree ap = NULL; //初始化
BTree aq = q; //暂存q中的数据
bool finished = false;
while (q && !finished)
{
Insert(q, i, x, ap); //将x和ap分别插入到q->key[i + 1]和q->ptr[i + 1]中
if (q->keynum < m) //若结点未满,插入完毕
finished = true;
else //结点已满,分裂结点
{
int s = RoundUp(m); //s是一个向上取整的数
Split(q, s, ap); //将q->K[s+1]到q->K[m],q->ptr[s]到q->ptr[m],q->recptr[s+1]到q->recptr[m]移入到新结点*ap中
x = q->K[s]; //记录将要向上插入的结点
aq = q; //aq存储q的上一个位置
q = q->parent; //指向双亲结点
if (q)
i = Search(q, x);
}
}
if (!finished) //若①T是空树,②或者根结点已经分裂成*q和*ap,需要新的根结点
NewRoot(T, aq, x, ap); //生成含有信息(q, x, ap)的新的根结点*T,q和ap是其子树的指针
return true;
}
在上述代码中出现的函数可以参考:
【向上取整 - RoundUp】
int RoundUp(int x)
{
if (x % 2)
return x / 2 + 1;
else
return x / 2;
}
【关键字的插入 - Insert】
void Insert(BTree q, int i, KeyType x, BTree ap)
{
if (x < q->K[i])
i -= 1;
for (int j = m; j > i + 1; j--) //移动结点内的数据,为插入腾出空间
{
q->K[j] = q->K[j - 1];
q->ptr[j] = q->ptr[j - 1];
}
q->K[i + 1] = x;
if (!ap || q->K[i] > ap->K[1]) {
q->ptr[i] = ap;
q->ptr[i + 1] = NULL;
}
else {
ap->parent = q;
q->ptr[i + 1] = ap;
}
q->keynum++;
}
【移动结点中的数据至新结点中 - Split】
void Split(BTree q, int s, BTree& ap)
{
ap = new BTNode;
ap->ptr[0] = q->ptr[s]; //先存储q->ptr[s],对齐接下来的循环
ap->recptr[0] = q->recptr[s];
if (q->ptr[s]) //注意:需要改变parent域
q->ptr[s]->parent = ap;
for (int i = 1; i + s <= m; i++) //统一进行存储操作
{
ap->K[i] = q->K[s + i];
ap->recptr[i] = q->recptr[s + i];
ap->ptr[i] = q->ptr[s + i];
if (q->ptr[s + i])
q->ptr[s + i]->parent = ap;
}
ap->keynum = m - s; //更改关键字数目
q->keynum = s - 1; //相当于从结点*q中删除了已经被分出去的关键字
}
【生成新的根结点 - NewRoot】
void NewRoot(BTree& T, BTree q, KeyType x, BTree ap)
{
T = new BTNode;
for (int i = 0; i < m + 1; i++) //先初始化
{
T->K[i] = 0;
T->ptr[i] = NULL;
T->recptr[i] = NULL;
T->parent = NULL;
}
T->K[1] = x;
T->ptr[0] = q;
if (q)
q->parent = T;
T->ptr[1] = ap;
if (ap)
ap->parent = T;
T->parent = NULL;
T->keynum = 1;
}
3. B-树的删除
m阶B-树的删除操作是指:
- 在B-树的某个结点中删除指定的关键字(记录)及其邻近的一个指针:
- 删除记录后:
- 关键字个数 ≥ :记录删除完毕;
- 关键字个数 < :进行“合并”结点的操作。
- 删除记录邻近的一个指针时:
- 进行删除操作的结点是一个位于最下层的非终端结点:删除指针不会影响其他结点,可直接删除指针;
- 进行删除操作的结点不是一个位于最下层的非终端结点:将即将被删除的记录用其右(左)边邻近指针指向的子树中的关键字最小(大)的记录替代(该记录必定位于最下层的非终端结点中)。例如,在下图中删去45: (注:该方法对任一非终端结点有效)
- 删除记录后:
【例子:在最下层中删除非终端结点的关键字】
1. 若被删关键字所在结点中的关键字数目 >
此时,只需删除该关键字Kₓ及其相应指针Pₓ,例如:
------
2. 若① 被删关键字所在结点p中的关键字数目 = ;② 与该结点相邻的左兄弟(或右兄弟)结点q中的关键字数目 > :
- 将q中最大(或最小)的关键字上移到其双亲结点中;
- 将双亲结点中紧靠q上移关键字的关键字下移到p中。
例如,现在要删除下图中结点*f中的50:
则其删除过程如下:
------
3. 被删关键字所在结点p及其兄弟结点q(由指针Pₓ指向)中的关键字数目均 = :
- 删去p中需要删除的关键字;
- 将 ①p中剩余信息(关键字、指针)及 ②其双亲结点中的关键字Kₓ 一起,合并到q中。
例如:删除下图中结点*f的53:
则其删除过程如下:
若在删除结点后,双亲结点的关键字数目少于,则以此类推。例如,删除下图中*d中的37:
则其删除过程如下:
【参考代码:B-树的删除】
要求:
- 删除m阶B-树T的结点*q;
- 若删除破坏了B-树的结构,则沿双亲链进行必要的调整,使T仍是一棵m阶B-树。
无外存部分的算法。
Status DeleteBT(BTree& T, BTree q, int i) {
if (!q)
return false;
if (q[i].ptr) //若q不是终端结点
Change(q, i); //将q与终端结点进行交换
int s = RoundUp(m) - 1; //用以判断的界限
KeyType x = 0;
int finished = 0;
if (q->keynum > s || !q->parent)
Delete(q, i, x); //删除第i个关键字
else {
while (!finished && q) { //若q存在,且关键字长度 <= s
BTree p = q->parent; //p是q的双亲结点
BTree ap = NULL; //使用ap承接返回指针
int j = 0;
if (FindKey(q, ap, j)) {//查找q的一个左/右兄弟结点上最大或最小的关键字
Delete(ap, j, x); //若找到可使用结点,将对应关键字提取出来
j = Search(p, x); //返回提取的关键字在双亲结点中插入的位置j
KeyType tmp = p->K[j]; //上移操作开始(进行关键字的互换)
p->K[j] = x; //x存储下移关键字
x = tmp; //上移关键字到位
q->K[i] = x; //删除关键字
finished = 1; //删除完毕,程序结束
}
else { //兄弟结点不符合要求(对应情况三)
Delete(q, i, x); //删除目标关键字
i = -1; //在删除关键字后,i不可使用,防止误删
Move(q, ap); //将q中剩余信息移动到ap中。移动后,q中还会剩余一个指针
int j = Search(p, x); //寻找可插入位置
Delete(p, j, x); //将可使用关键字从p中取出
j = Search(ap, x); //在兄弟结点中寻找插入位置
Insert(ap, j, x, q->ptr[0]); //将q的未被移动的指针移动到ap中
BTree aq = q;
q = q->parent; //q移动至双亲结点的位置
delete aq;
if (!q || !q->parent) //若双亲结点不存在
finished = 1;
}
}
if (q && q->keynum <= 0)
delete q;
}
return true;
}
在上述函数中同样存在一些辅助函数,它们的写法可参考如下代码:
【交换终端结点与非终端结点 - Change】
Status Change(BTree& q, int& i) {
//寻找适合的终端结点
BTree p = q->ptr[i];
if (p) {
while (p->ptr[0]) //若p所在结点并非最适合的结点
p = p->ptr[0];
q->K[i] = p->K[1]; //将q的数据域置为p->K[0]
i = 1;
q = p; //指针q指向终端结点
return true;
}
return false; //若q是终端结点,返回false
}
【删除关键字 - Delete】
void Delete(BTree q, int i, KeyType& x) {
if (i < 0) //若所选删除对象超出范围,则不进行删除
return;
x = q->K[i]; //使用x保存关键字
int s = 0; //对删除指针的两种情况进行划分(x < q->K[i] / x >= q->K[i])
if (x < q->K[i]) //删除指针是其左指针
s = -1;
else //删除指针是其右指针
s = 0;
for (int k = i; k < m; k++) //通过移动数据的方式进行删除
{
q->K[k] = q->K[k + 1];
q->ptr[k + s] = q->ptr[k + s + 1];
q->recptr[k + s] = q->recptr[k + s + 1];
}
q->keynum--;
if (s == -1) { //当删除左指针时,需要多进行一次移位
q->ptr[m - 1] = q->ptr[m];
q->recptr[m - 1] = q->recptr[m];
}
}
【寻找可替代的关键字 - FindKey】
int FindKey(BTree q, BTree& ap, int& j)
{
if (!q->parent)//若无兄弟结点(根结点)
return 0;
BTree p = q->parent; //p是q的双亲结点
int i = 0;
for (i = 0; p->ptr[i] != q; i++); //寻找p中q对应的指针下标
int s = RoundUp(m) - 1;
if (i > 0) { //若p不是q的第一个指针
ap = p->ptr[i - 1]; //ap是p->K[i]的左指针
if (ap->keynum > s) { //若关键字数量 > s
j = ap->keynum; //j取最靠近q的数字
return 1;
}
}
if (i < p->keynum) { //指针的取值不能超过keynum的范围
if (p->ptr[i + 1]->keynum > s) {
ap = p->ptr[i + 1]; //其余情况
j = 1;
return 1;
}
}
return 0; //若其兄弟结点关键字数目均小于s,返回0
}
【移动剩余信息 - Move】
void Move(BTree q, BTree ap)
{
if (!q || !ap)
return;
int j = 0;
for (int i = q->keynum; i > 0; i--) //将q中剩余信息(关键字和指针)移动到ap中
{
j = Search(ap, q->K[i]);
Insert(ap, j, q->K[i], q->ptr[i]);
q->keynum--;
}
}
B+树
B+树是B-树的一种变形,更适合用于文件索引。
B-树和B+树的差异
- 一个结点,若有n棵子树,则含有n个关键字;
- 叶子结点包含所有关键字的信息(和指针),且叶子结点本身按照关键字的大小排序;
- 所有非终端结点都可以看做是索引,结点中只存储其子树中最大(或最小)的关键字。
【例如】