数据结构-图、二叉树、B(+)树

线性数据结构主要用于存储相互独立的数据,但是如果数据间存在某些关系,比如常用的地图导航,从一个地区前往另一个地区需要途径很多条道路,那么途径的各条道路以及地点都是有关联的,道路的拥堵情况、地点间的距离等都会影响最终导航路线的确定。
导航例子十分吻合图这一数据结构的特征;另外类似于一、二菜单栏的设计,文件系统、内存管理系统的基础实现则是使用了另一种非线性数据结构-树,这一类数据大都存在父子从属关系,与树的特征更吻合,当然为了提高树的操作效率·,又进一步出现了B+树、红黑树等。这一篇会展开学习图与树这两种比较复杂的数据结构。

图的概念介绍可参考数据结构:图(Graph)这篇文章。
图的真正意义是如何把现实问题通过编程手段,将问题的特征转化为图的特征上面,图具有顶点、边、权重以及方向。试想这样一个例子:从A地到B地存在多条道路,但并不是每一条道路都可以到达B,另外各个道路之间是存在交叉的,某些道路是单行道,只能单向通行。假设两地之间目前一定可以找到一条道路通过,此时就需要使用图将所有道路、站点的信息存储起来,我们在出发前,只需要基于图中的信息,输入自己的出发地与目的地,就可以算出一条可以顺利到达目的地的路线,这其实就是一个简易的导航算法。
在这里插入图片描述
上图展示的路线图中包含如下几条信息:

  1. 一共存在A-G共计7个站点,对应图的定点
  2. 一共存在9条边,且路径是有向的
  3. 假设路径没有权重,即假设地图中各条道路的距离是一致的

提取完毕地图的数据信息后,如何将其存储到图中呢?这里选用邻接列表法:在邻接列表实现中,每一个顶点会存储一个从它这里开始的边的列表。比如,如上图所示顶点B有一条边到C、E和F,那么B的列表中会有3条边,上图的数据存储格式如下所示:

0(A): -> 1(B)
B (1): -> C(2) 、E (4)、 F (5)
C (2): -> E (4)
D (3): -> C (2)
E (4): -> B (1)、 D(3)
F (5): -> G (6)
G (6): ->

对应到实际的代码,最少要定义三种数据结构:边、顶点、最后是图。其中:

  1. 边应该实现为一种结构体链表结构,该链表所有边均是以某一顶点作为起始点,不同点则是终点各不相同
  2. 顶点应该实现为结构体,结构体中必须包含两种信息:1)该顶点的特有数据(本例中为顶点编号),2)第一步实现的链表的首元素指针,用以记录所有以该顶点为起点的边的组合
  3. 图实现为结构体,结构体为完整的一个图结构,必须包含顶点、边的数量,以及所有顶点结构体构成的结构体数组
// 邻接表中的边
typedef struct _ENode
{
    int ivex;                   // 该边所指向的终止顶点的位置
    struct _ENode *next_edge;   // 指向下一条边的指针
}ENode, *PENode;

// 邻接表中表的顶点
typedef struct _VNode
{
    char data;              // 顶点信息
    ENode *first_edge;      // 指向第一条依附该顶点的边
}VNode;

// 邻接表
typedef struct _LGraph
{
    int vexnum;             // 图的顶点的数目
    int edgnum;             // 图的边的数目
    VNode vexs[MAX];        // 每个顶点VNode为数组一个元素,所有顶点组成vexs数组
}LGraph;

定义好数据结构,下面看如何将图片中的信息初始化到该数据结构中:

/*
 * 返回某顶点在顶点结构体数组中的位置
 */
static int get_position(LGraph g, char ch)
{
    int i;
    for(i=0; i<g.vexnum; i++)
        if(g.vexs[i].data==ch)
            return i;
    return -1;
}

/*
 * 将某一边结构体链接到某一边结构体链表的末尾
 */
static void link_last(ENode *list, ENode *node)
{
    ENode *p = list;

    while(p->next_edge)
        p = p->next_edge;
    p->next_edge = node;
}

/*
 *演示所用,hard code将图片信息转存到图结构中,实际应用是不能这样
 */
LGraph* create_example_lgraph()
{
    char c1, c2;
    //输入顶点
    char vexs[] = {'A', 'B', 'C', 'D', 'E', 'F', 'G'};
    //二维数组模拟边信息
    char edges[][2] = {
        {'A', 'B'}, 
        {'B', 'C'}, 
        {'B', 'E'}, 
        {'B', 'F'}, 
        {'C', 'E'}, 
        {'D', 'C'}, 
        {'E', 'B'}, 
        {'E', 'D'}, 
        {'F', 'G'}}; 
    int vlen = LENGTH(vexs);
    int elen = LENGTH(edges);
    int i, p1, p2;
    ENode *node1, *node2;
    LGraph* pG;


    if ((pG=(LGraph*)malloc(sizeof(LGraph))) == NULL )
        return NULL;
    memset(pG, 0, sizeof(LGraph));

    // 初始化"顶点数"和"边数"
    pG->vexnum = vlen;
    pG->edgnum = elen;
    // 填充"邻接表"的顶点
    for(i=0; i<pG->vexnum; i++)
    {
        pG->vexs[i].data = vexs[i];
        pG->vexs[i].first_edge = NULL;
    }
    // 填充"邻接表"的边
    for(i=0; i<pG->edgnum; i++)
    {
        // 读取边的起始顶点和结束顶点
        c1 = edges[i][0];
        c2 = edges[i][1];
        //获取某边起始顶点在图中的数组下标
        p1 = get_position(*pG, c1);
        //获取某边结束顶点在图中的数组下标
        p2 = get_position(*pG, c2);
        //初始化node1
        node1 = (ENode*)malloc(sizeof(ENode));
        node1->ivex = p2;
        
        //node1是以某一顶点为起始顶点的所有边组成的链表的首元素
        if(pG->vexs[p1].first_edge == NULL)
            pG->vexs[p1].first_edge = node1;
        //node1不是链表的首元素,则将node1链接到"p1所在链表的末尾"
        else
            link_last(pG->vexs[p1].first_edge, node1);
    }

    return pG;
}

好了,图信息已填充完毕,首先打印出所有信息查看,其实就是遍历所有顶点,并将以各个顶点为起始点的边全部打印出来

void print_lgraph(LGraph G)
{
    int i,j;
    ENode *node;

    printf("List Graph:\n");
    for (i = 0; i < G.vexnum; i++)
    {
        printf("%d(%c): -> ", i, G.vexs[i].data);
        node = G.vexs[i].first_edge;
        while (node != NULL)
        {
            printf("%d(%c) ", node->ivex, G.vexs[node->ivex].data);
            node = node->next_edge;
        }
        printf("\n");
    }
}

打印如下:

List Graph:
0(A): -> 1(B) 
1(B): -> 2(C) 4(E) 5(F) 
2(C): -> 4(E) 
3(D): -> 2(C) 
4(E): -> 1(B) 3(D) 
5(F): -> 6(G) 
6(G): -> 

如何使用图数据结构是关键,实质是构建图的目的是利用图算法(深度、广度优先搜索法)去搜索满足某些条件的数据。例如地图路径问题,需要利用深度优先算法,查找起始顶点到结束顶点的所有路径,下面代码的注释很详细:

//标记哪些顶点已经被访问过,大小100不太合适,需要基于顶点数量来定
int visited[100];
//深度优先遍历查找两个顶点之间的所有路径
void Path_DFS(LGraph G, int i, int j)
{
	/*
     B[1]->C[2]->E[4]
	 B[1]->E[4]
     */
	static int n = 0;
	//存放沿某一path经过的顶点,大小100不太合适,需要基于最大深度来定
	static char vertex[100];	
    //打标记,表明某个顶点访问过了
	visited[i] = 1;
	//n代表某一条path上已经访问到第几个顶点了
    n++;
    //保存此次递归经过的顶点名称    
	vertex[n] = G.vexs[i].data;       
    //根据顶点找到依附它的第一条边,继续找到下一个顶点
    //注意:下一个顶点可能有多个,因为可能有多条边依附该顶点,所以对于每一个分支都要使用递归进行深度遍历
	ENode *p = G.vexs[i].first_edge;
	while(p)
	{
		if(visited[p->ivex] == 0)
			//若该边有后续顶点可以到达,则以该边的后续顶点作为起始顶点继续递归向后查找
			Path_DFS(G, p->ivex, j);
		p = p->next_edge;
	}
	//若某次递归到达了终止顶点,则打印出来
	if(i == j)
	{
		for(int m = 1; m <= n; m++)
		{
			if(m < n)
				printf("%d(%c)->",get_position(G, vertex[m]), vertex[m]);
			else
				printf("%d(%c)",get_position(G, vertex[m]), vertex[m]); 
		}
		printf("\n");
	}
        
	//无论这次递归是否到达终止顶点,都回退当前遍历过的顶点,一步步回到分叉顶点,再次遍历另一个分支
	//清标记,证明对于某个顶点的访问已结束,现在回退为未访问状态
	visited[i] = 0;
	n--;       
}

void SimplePath(LGraph G, int i, int j)
{
	int m;
	//int on[100];
	for(m = 1; m <= G.vexnum; m++)
		visited[m] = 0;
	Path_DFS(G, i, j);
}

打印如下:

input vertex start:
1
input vertex end:
4

1(B)->2(C)->4(E)
1(B)->4(E)

图数据结构的优势在于查找具有关联关系的一系列数据,核心功能是查找。例程中对于数据的插入、删除很不友好,基本都是写死的,实际使用时也需要改善。
图实现源码

树结构不同于图结构,他是链表的演变物。链表通常是插入、删除速度快,但是查找元素只能遍历,效率很低。如何让链表变得像有序数组那样可以使用二分思想去查找,是树结构的设计初衷。比如linux内核的进程管理模块中,对于进程的创建、销毁、查找等操作都有与之对应的红黑树结构的记录,原因就在于进程PCB的插入、查找都很快快。树分为几大典型类型,最基础的类型为二叉查找树,但是二叉树存在退化为链表的风险,于是后续又出现了考量平衡后改进的AVL树、红黑树与B(B+)树,其中AVL树的平衡最为严格,实现平衡的算法复杂且低效,所以通常不会在实际应用中广泛推广。
多种树结构描述

二叉查找树

二叉查找树(BST:Binary Search Tree)是一种特殊的二叉树,它改善了二叉树节点查找的效率。二叉查找树有以下性质:

(1)若左子树不空,则左子树上所有节点的值均小于它的根节点的值
(2)若右子树不空,则右子树上所有节点的值均大于它的根节点的值
(3)左、右子树也分别为二叉排序树
(4)没有键值相等的节点
二叉树查找树的特性即它的要求,也正是上述四个要求,才使得查找树的结构是按照数据value大小来依次排列的。这样一来,二叉查找树既有链表的快速增删优势也具有了有序数组快速查找的优势,是一种性能均衡的数据结构。
在这里插入图片描述
二叉树数据组织依然是基于链表,但是每个节点都可以分叉连接两个子节点,按照二叉查找树的规则,可以很容易实现节点的插入、删除、查找等操作。
例如插入操作:

/* 添加新节点 */
BSTree *AddNewNode(BSTree *cur, int NewData)
{
    if (cur == NULL)
    {
        if ((cur = (BSTree *)malloc(sizeof(BSTree))) == NULL)    //创建新节点
        {
            printf("内存不足");
            exit(0);
        }
        cur->data = NewData;
        cur->left = NULL;
        cur->right = NULL;

        return cur;
    }
    if (NewData > cur->data)
    {
        cur->right = AddNewNode(cur->right, NewData);
    }
    else if (NewData < cur->data)
    {
        cur->left = AddNewNode(cur->left, NewData);
    }
    else if (NewData == cur->data)
    {
        printf("不允许插入重复值\n");
        exit(0);
    }

    return cur;
}

传入树的根节与待插入数据,首先动态创建新的节点,再利用递归方法找到新节点的存放位置,将其连接到已有节点的左、右子节点即可。下面是随机插入10个数据的过程以及最终生成的二叉查找树结构

注:由于插入只是通过递归比较方式,树的结构与数据输入顺序存在极大的相关性,最差情况下(输入为从小到大的数列)会出现树一直左偏情况,此时已经退化为链表,后面的红黑树则可避免这种树的不平衡问题。

输入新插入数据
5
输入新插入数据
2
输入新插入数据
3
输入新插入数据
1
输入新插入数据
4
输入新插入数据
6
输入新插入数据
11
输入新插入数据
32
输入新插入数据
56
输入新插入数据
99
data = 88 level = 1
data = 5 level = 2
data = 2 level = 3
data = 1 level = 4
data = 3 level = 4
data = 4 level = 5
data = 6 level = 3
data = 11 level = 4
data = 32 level = 5
data = 56 level = 6
data = 99 level = 2

在这里插入图片描述
删除操作相对复杂,因为要考虑删除节点后续节点的连接,可分为三种情况

1. 待删除节点无子节点
这种情况下可直接删除
2. 待删除节点有一个子节点
根据查找树的特性1)、2)可以确定,待删除节点的子节点与待删除节点的父节点的大小关系是已知的,因此可将待删除节点的左/右子树赋值给待删除节点的父节点的左/右子树
3. 待删除节点有两个子节点
此时需要先明确后继节点的概念,后继节点:一个节点的后继节点是指,这个节点在中序遍历序列中的下一个节点。什么又是中序遍历呢,中序遍历:在二叉树中,中序遍历首先遍历左子树,然后访问根结点,最后遍历右子树。例如如下两种情形:

按照中序列遍历,遍历顺序是:3->5->6->7->9,因此后继节点9为7的子节点,同时后继节点为待删除节点子节点的必备条件是:该后继节点一定为待删除节点的右边子树,且后继节点一定没有左子树(这两条是由中序遍历推到得出)在这里插入图片描述
通过上面的分析可以首先确定,若后继节点为待删除节点的子节点,那么该后继节点一定没有左子树(由中序遍历规则可知),而且后继节点本身一定大于待删除节点的左子树(查找树特征决定),另外后继节点的假如有右子树,那么它一定大于待删除节点。基于以上三个结论,可确定,该情况下,可以直接用后继节点替代待删除节点。

以下两种情况, 按照中序列遍历,遍历顺序是:3->5->6->7->8(->9)->10,因此后继节点8不为7的子节点。在这里插入图片描述在这里插入图片描述
此时后继节点可定也没有左子树(由中序遍历可知),右边子树则可能存在,因此删除第一步同样是后继节点替换待删除节点。无右子树的后继节点无需做其它操作;有右节点的后继节点则可将其右子树连接到其父节点的左子树即可,因此分别结果为:

在这里插入图片描述
在这里插入图片描述
参考源码:rstree完整源码

红黑树

查找树虽然实现了查找与插入的性能均衡,但是极可能出现树结构不平衡问题(与数据插入顺序有关)。因此需要一种手段,在数据插入时尽可能保证树的平衡,这就是红黑树实现的目的。
实现树的平衡从直观上来看就是保持树的矮胖形状,曾经出现过一种2-3树的结构。参考:2-3树实现二叉树的平衡
2-3树在插入数据时并不急于向下增加树的层数,而是会临时生成3叉树(两个父节点、三个分叉)甚至4叉树,然后根据一些规则,向上融合(2叉树变3叉树、3叉树变4叉树)、分解(一层4叉树边为两层2叉树),直到变为只有二叉树节点的树,由于4叉树可以向上直接分解成一颗平衡二叉树,所以最终整颗树的结构会非常矮胖,如下图所示:
在这里插入图片描述
但是,将这种直白的表述写成代码实现起来并不方便,因为要处理的情况太多。这样需要维护两种不同类型的节点,将链接和其他信息从一个节点复制到另一个节点,将节点从一种类型转换为另一种类型等等,因此红黑树出现,它使用两个技巧:变色与旋转模拟2-3树的插入过程(其实直接将分解这一步去掉了)。首先列出红黑树所谓的规则:

  1. 每个结点要么是红的要么是黑的。
  2. 根结点是黑的。
  3. 每个叶结点(叶结点即指树尾端NIL指针或NULL结点)都是黑的。
  4. 如果一个结点是红的,那么它的两个儿子都是黑的。
  5. 对于任意结点而言,其到叶结点树尾端NIL指针的每条路径都包含相同数目的黑结点。
    其实前四条均是因为模拟2-3树才做出的规定,看下图:
    在这里插入图片描述

上图左侧为标准的红黑树,将红色节点与其左子树放在同一层后,得到右上角结构。
去掉null节点,合并每一层并列的节点后则变为了为分解的2-3树。
我们也能猜测,红黑树进行查找时,针对红节点与其左子树一定是并列而非递归处理的,因为虽然两者结构上不在一层,但是必须将其按照3叉树来处理,否则红节点就起不到平衡树的作用了。
再来看红黑树的五条规则与2-3树的对应关系:

  1. 每个结点要么是红的要么是黑的 。

废话

  1. 根结点是黑的。

2-3树向上分解,根节点一定不能是3叉树,因此红黑树也不能是红色

  1. 每个叶结点(叶结点即指树尾端NIL指针或NULL结点)都是黑的。

叶子节点不可能存在左子树,因此不可能为3叉树节点,自然也不可能是红色

  1. 如果一个结点是红的,那么它的两个儿子都是黑的。

如果儿子是红色,就会出现4叉树,自然不可能为红色,其实就是说不存在两个红色节点相连。

  1. 对于任意结点而言,其到叶结点树尾端NIL指针的每条路径都包含相同数目的黑结点**。

由性质4可推出

可以看出,通过红色节点的控制(插入时的变色操作)模拟3叉树的融合操作,可以将树结构尽量压扁。 至于红黑树的变色规则也就没必要去深究了,其本质仍然是2-3树的融合原则。
对于自旋(左旋、右旋)有两种场景使用:
一. 需要旋转操作时,肯定一边子树的结点多了或少了,需要租或借给另一边。插入显然是多的情况,那么把多的结点租给另一边子树就可以了
二.仅仅变色无法满足规则时,需结合自旋完成插入
具体变色与旋转时机参考: 红黑树的变色与旋转
红黑树实现较复杂,不过可参考内核源码学习:移植使用内核rbtree源码
总结:
红黑树效率高,十分适合完全内存操作场景,即树节点直接存储有效数据,程序一次性将树读入内存,后续的增、删、查均为内存操作。

B树

红黑树已经很好的平衡了树的查找与插入的效率问题,但是要对红黑树进行操作,需要一次性将整棵树放进内存,否则无法完成插入与查找。但是在数据库实现中,存储的数据量极大,如果直接使用红黑树存储,由于树的每个节点只存放一个有效数据,当查找数据时,首先需要在二叉树上查找,此时节点中并无存储数据,需去磁盘上读取索引对应的实际数据。每遍历一个节点,就要进行一次磁盘读取,最坏情况下树有多高,就需要进行几次IO,严重降低树的查找性能。那么何不设计一种树结构,让其每个节点顺序存储多个数值,以此来减少IO的读写次数呢?

B树是二叉搜索树的一般化,因为节点可以有两个以上的子节点。与其他自平衡二进制搜索树不同,B树非常适合读取和写入相对较大的数据块(如光盘)的存储系统。它通常用于数据库和文件系统。B树也是一颗自平衡树,但是它的平衡原理并不是红黑树那样自旋转与变色,而是类似于2-3树的合并、分解。当然,B树的分叉是多于3的,一般与一次读写磁盘的单位大小有关,通常一个节点所能存储的索引数量为页大小。比如一次硬盘I/O读取一页数据为4K,B树节点内的一个数据用4字节表示页索引,那么一个节点一般选则存放1000个元素(去除指向子节点指针所需的元素),根据磁盘的读写规则,一次IO读写会一次性读取一页,那么我把1000个数据放在在同一页中,这样一次磁盘读取就能查询1000条数据。
针对B(B+)树的定义以及操作规则可参考:B树、B+树详解

可以看出,B树并不十分刻意控制树的分支数量,在二叉查找树或者红黑树中,限制分支为2的目的是将二分查找法充分利用到树节点的查找上,这样可以减少查找某一数据的遍历次数,提高效率。但是B树的应用场景是数据库查找,与磁盘读写的耗时相比,多做几次内存数据遍历判断造成的性能影响则可忽略不计。另外B树的多个元素实际上是顺序表也就是按照数组来组织的,删除元素时,会涉及到元素依次移动的情况,降低删除效率,同样的与读写IO相比,仍可忽略不计。可以看出,B树并不是一种更“先进的”数据结构,而是在实际使用时情况之下的折中而已。

B树定义

B树是一种平衡的多分树,通常我们说m阶的B树,它必须满足如下条件:

  1. 每个节点最多只有m个子节点。
    指一个节点的向下分叉数量小于阶数
  2. 每个非叶子节点(除了根)具有至少⌈ m/2⌉子节点。
    指一个节点的向下分叉数量不小于阶数的一半
  3. 如果根不是叶节点,则根至少有两个子节点。
    根节点分叉不小于2而不是阶数的一半
  4. 具有k个子节点的非叶节点包含k -1个键。
    一个节点的分叉数量永远比该节点存放的key多一个,实际编程中存放key的数组0位置不放任何key即可
  5. 所有叶子都出现在同一水平,没有任何信息(高度一致)。
  • 定义中的m即节点中的最大分支数量,4阶B树,表示最大分支为4个。
  • 定义也规定了每个节点拥有子节点数目限制与所含元素数量限制(不能太多也不能太少,太多要分裂、太少则要合并),节点分为根节点、内部节点与叶子节点,那么子节点数目限制与所含元素数量限制就要针对这三种节点类型分别给出:
节点类型子节点数量M限制节点内元素数量K限制
根节点2<= M <=m1<= K <=m-1
内部节点(m/2)<= M <=m(m/2)-1<= K <=m-1
叶子节点0(m/2)-1<= K <=m-1
在这里插入图片描述

搞清楚定义,就可以去具体实现B树的插入、删除等常用操作了,其中插入数据时,可能出现节点内元素数量过少,此时需要做节点的合并;删除数据时,可能出现节点内元素数量过多,此时需要做节点的分裂,最坏情况下可能导致树变高。
定义是一定要指明阶数,最大最小元素数;树节点对应的结构体包含元素以及指向子节点指针(实际场景时,节点只有在访问到时才会malloc,访问完毕需free,两个成员也需实时从磁盘读取):

const int m=4;                      //设定B树的阶数 
const int Max=m-1;                  //结点的最大关键字数量 
const int Min=(m-1)/2;              //结点的最小关键字数量 
typedef int KeyType;                //KeyType为关键字类型

typedef struct node{                //B树和B树结点类型 
    int keynum;                     //结点关键字个数
    KeyType key[MAXM];              //关键字数组,key[0]不使用 
    struct node *parent;            //双亲结点指针
    struct node *ptr[MAXM];         //孩子结点指针数组 
}BTNode,*BTree;

typedef struct{                     //B树查找结果类型 
    BTNode *pt;                     //指向找到的结点
    int i;                          //在结点中的关键字位置; 
    int tag;                        //查找成功与否标志
}Result;

B树查找

在B树查找某一元素按照如下过程进行:

  1. 按照元素值大小,中序遍历方式查找节点(每个节点的最小元素是按照中序关系组织的),若该元素值小于正在查询节点的第一个存储元素,此时跳过步骤2,直接遍历左子树节点
  2. 在该节点中顺序遍历查找具体元素位置,若该元素值大于正在查询节点的最后一个存储元素,则跳过步骤3,直接遍历右子树子节点
  3. 顺序遍历节点中所有元素,若匹配,则查找结束;否则由于顺序遍历是从最小开始,如果找不到,那么待查寻元素只可能位于该节点中第一次大于待查元素的的元素左子树中,因此同样需继续遍历左子树
  4. 以此对子树进行中序遍历,直到查找成功或者直到叶子节点也为查找到,则查找失败。
int SearchBTNode(BTNode *p,KeyType k){
//在结点p中查找关键字k的插入位置i 
    int i=0;
    for(i=0;i<p->keynum&&p->key[i+1]<=k;i++);
        return i;
}

Result SearchBTree(BTree t,KeyType k){
/*在树t上查找关键字k,返回结果(pt,i,tag)。若查找成功,则特征值
tag=1,关键字k是指针pt所指结点中第i个关键字;否则特征值tag=0,
关键字k的插入位置为pt结点的第i个*/
    BTNode *p=t,*q=NULL;                            //初始化结点p和结点q,p指向待查结点,q指向p的双亲
    int found_tag=0;                                //设定查找成功与否标志 
    int i=0;
    Result r;                                       //设定返回的查找结果 

    while(p!=NULL&&found_tag==0){                   //中序遍历整颗树
        i=SearchBTNode(p,k);                        //在结点p中查找关键字k,找不到的话:
                                                    //  1:该节点所有key都小于k,则i等于的key的最大下标
                                                    //  2: 该节点所有key都大于k,则i等于=0
        if(i>0&&p->key[i]==k)                       //找到待查关键字(key[0]中不存放元素)
            found_tag=1;                            //查找成功 
        else{                                       //本次遍历查找失败 ,由树结构以及SearchBTNode返回值可知:
                                                    //  1. i =0,那么比k只可能存在当前搜索节点的第一个key的的左子节点 p->ptr[0]
            										//  2.  i >0, 那么比k只可能存在当前搜索节点的key[i]左子节点 p->ptr[i]

            q=p;
            p=p->ptr[i];
        }
    }
    if(found_tag==1){                               //遍历完树后,找到key,则查找成功
        r.pt=p;
        r.i=i;
        r.tag=1;
    }
    else{                                           //否则找失败
        r.pt=q;
        r.i=i;
        r.tag=0;
    }
    return r;                                       //返回关键字k的位置(或插入位置)

B树插入

B树插入包含如下过程:

  1. 首先搜索插入元素,是否存在,不存在时,找到合适的节点中的元素位置(该位置左子树小于插入元素,右子树大于插入元素),执行步骤2

  2. 将已定位的元素位置开始依次向后移动一个位置,将待插入元素放到空出的位置,并在插入的对应位置插入一个空的右子树

  3. 若插入后,该节点元素总数为超过阶数-1,则插入结束,否则执行步骤4

  4. 节点元素数量过大,则需要分裂当前节点,具体做法是:新分配一个节点,将过大节点的后半部分的元素以及子节点指针放入q,后半部分继续留在过大节点中;新节点的父节点同样指向过大节点的父节点,同时将原P节点目前最后一个元素移动到父节点,是P为新父元素的左子树,q为新父元素的右子树如下图所示;若此时父节点不过大,则插入结束,否则继续向上分裂;如果被分裂的节点是根节点,且否则执行步骤5
    在这里插入图片描述
    在这里插入图片描述

  5. 根节点不允许有两个,所以如果上一步分裂了根节点,那么就需要提取部分元素,向上生成一个新的根节点,然后指向分裂出的两个节点。

void NewRoot(BTNode *&t,KeyType k,BTNode *p,BTNode *q)
{
//生成新的根结点t,原p和q为子树指针
    t=(BTNode *)malloc(sizeof(BTNode));             //分配空间 
    t->keynum=1;
    t->ptr[0]=p;
    t->ptr[1]=q;
    t->key[1]=k;
    if(p!=NULL)                                     //调整结点p和结点q的双亲指针 
        p->parent=t;
    if(q!=NULL)
        q->parent=t;
    t->parent=NULL;
}

void InsertBTNode(BTNode *p,int i,KeyType k,BTNode *q)
{
//将关键字k和结点q分别插入到p->key[i+1]和p->ptr[i+1]中
    int j;
    for(j=p->keynum;j>i;j--)
    {                       //整体后移空出一个位置
        p->key[j+1]=p->key[j];
        p->ptr[j+1]=p->ptr[j];
    }
    p->key[i+1]=k;
    p->ptr[i+1]=q;
    if(q!=NULL)
        q->parent=p;
    p->keynum++;
}

void SplitBTNode(BTNode *&p,BTNode *&q)
{
//将结点p分裂成两个结点,前一半保留,后一半移入结点q,
    int i;
    int s=(m+1)/2;
    q=(BTNode *)malloc(sizeof(BTNode));             //给结点q分配空间

    q->ptr[0]=p->ptr[s];                            //结点q的第一个元素的左子树是被分裂节点中间元素的右子树
    for(i=s+1;i<=m;i++)
    {                         	//后一半移入结点q
        q->key[i-s]=p->key[i];
        q->ptr[i-s]=p->ptr[i];
    }
    q->keynum=p->keynum-s;
    q->parent=p->parent;
    for(i=0;i<=p->keynum-s;i++)                     //修改双亲指针 ,将原来指向被分裂节点的子树全部改为指向新生成的节点
        if(q->ptr[i]!=NULL)
            q->ptr[i]->parent=q;
    p->keynum=s-1;                                  //结点p的前一半保留,修改结点p的keynum
}

void InsertBTree(BTree &t,int i,KeyType k,BTNode *p)
{
/*在树t上结点q的key[i]与key[i+1]之间插入关键字k。若引起
结点过大,则沿双亲链进行必要的结点分裂调整,使t仍是B树*/
    BTNode *q;
    int finish_tag,newroot_tag,s;                   //设定需要新结点标志和插入完成标志 
    KeyType x;
    if(p==NULL)                                     //t是空树
        NewRoot(t,k,NULL,NULL);                     //生成仅含关键字k的根结点t
    else
    {
        x=k;
        q=NULL;
        finish_tag=0;
        newroot_tag=0;
        while(finish_tag==0&&newroot_tag==0)
        {
            InsertBTNode(p,i,x,q);                  //将关键字x和结点q分别插入到p->key[i+1]和p->ptr[i+1]
            if (p->keynum<=Max)
                finish_tag=1;                       //插入完成
            else
            {
                s=(m+1)/2;
                SplitBTNode(p,q);                   //分裂结点
                x=p->key[s];						//子节点分裂后变为两个,因此需要将子节点的一个元素上移到父节点中,否则新分裂的节点并不会融入到B树中
                if(p->parent){                      //查找上移元素x在父节点的插入位置
                    p=p->parent;
                    i=SearchBTNode(p, x);           //找到插入位置后,下次循环将其插入父节点(原p作左子树,新分裂出的节点作左子树),若父节点也过大,则需要再次分离父节点
                }
                else                                //没找到x,需要新结点
                    newroot_tag=1;
            }
        }
        if(newroot_tag==1)                          //根结点已分裂为结点p和q 
            NewRoot(t,x,p,q);                       //生成新根结点t,p和q为子树指针
    }
}
B树删除

B树删除时,又可能导致节点元素数量过小,此时需要合并。根据其兄弟节点元素数量,可以借元素来增加本元素的数量,也可以与兄弟元素合并使节点元素数量达标。另外要注意一点,就算被删除的元素位于非叶子节点,但是实际删除时会使用子节点元素去覆盖待删除元素,子节点本身的元素则会继续遍历子节点来覆盖,直到到达叶子节点才会进行真正的删除,这样做的目的是降低树的分支结构的改变。
删除叶子节点中的元素后,如果元素数目过小需要调整树结构,这涉及到向左借用、向右借用以及合并,先来说明这三个操作的实现方式

调整

到底是借用还是合并需要分情况选择,因此首先需要一个决策函数

  1. 当删除当前叶子节点最左侧key,如果右兄弟key足够,则将父节点的第i+1个key放到当前叶子节点最后,并将右叶子节点第一个元素的key补充到父节点i+1处, 否则将当前叶子节点加父节点i处key与左节点融合,删除右叶子节点
  2. 当删除当前叶子节点最右侧key,如果左兄弟key足够,则将父节点的第i个key放到当前叶子节点最后,并将左叶子节点的key补充到父节点i处,否则将当前叶子节点加父节点i处key与左节点融合,删除右叶子节点
  3. 当删除当前叶子节点非两端位置key,如果左兄弟key足够,则将父节点的第i+1个key放到右叶子节点最后,并将左叶子节点的key补充到父节点i+1处;如果右兄弟key足够,则将父节点的第i+1个key放到左叶子节点最后,并将右叶子节点的key补充到父节点i+1处, 否则将当前叶子节点加父节点i处key与左节点融合,删除当前叶子节点

左借(向左移动)
删除元素13时,p->ptr[2]元素数目为0,而右兄弟节点元素数目为2,可以进行左借
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
右借(向右移动)
删除元素26时,p->ptr[3]元素数目为0,而左兄弟节点元素数目为2,可以进行右借
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

结合

将双亲结点的关键字p->key[i]插入到左结点aq最后的key位置 ,将右子树第一个ptr存放内容放到左节点最后一个存放ptr位置,将右结点q中的所有关键字插入到左结点aq,删除节点q。

void MoveLeft(BTNode *p,int i){
/*将双亲结点p中的第i个关键字移入左结点aq中,
将右结点q中的第一个关键字移入双亲结点p中*/
    int j;
    BTNode *aq=p->ptr[i-1];
    BTNode *q=p->ptr[i];

    aq->keynum++;                                   //把双亲结点p中的关键字移动到左兄弟aq中
    aq->key[aq->keynum]=p->key[i];
    aq->ptr[aq->keynum]=p->ptr[i]->ptr[0];

    p->key[i]=q->key[1];                            //把右兄弟q中的关键字移动到双亲节点p中
    q->ptr[0]=q->ptr[1];
    q->keynum--;

    for(j=1;j<=q->keynum;j++){                     //将右兄弟q中所有关键字向前移动一位
        q->key[j]=q->key[j+1];
        q->ptr[j]=q->ptr[j+1];
    }
}

void MoveRight(BTNode *p,int i){
/*将双亲结点p中的第i关键字移入右结点q中
将左结点aq中的最后一个关键字移入双亲结点p中*/
    int j;
    BTNode *q=p->ptr[i];
    BTNode *aq=p->ptr[i-1];

    for(j=q->keynum;j>0;j--){                       //将右兄弟q中所有关键字向后移动一位
        q->key[j+1]=q->key[j];
        q->ptr[j+1]=q->ptr[j];
    }

    q->ptr[1]=q->ptr[0];                            //
    q->key[1]=p->key[i];                            //从双亲结点p移动关键字到右兄弟q中
    q->keynum++;

    p->key[i]=aq->key[aq->keynum];                  //将左兄弟aq中最后一个关键字移动到双亲结点p中
    p->ptr[i]->ptr[0]=aq->ptr[aq->keynum];
    aq->keynum--;
}

void Combine(BTNode *p,int i){
/*将双亲结点p的i处关键字、右结点q合并入左结点aq,
并调整双亲结点p中的剩余关键字的位置*/
    int j;
    BTNode *q=p->ptr[i];
    BTNode *aq=p->ptr[i-1];

    aq->keynum++;                                  //将双亲结点的关键字p->key[i]插入到左结点aq最后的key位置
    aq->key[aq->keynum]=p->key[i];
    aq->ptr[aq->keynum]=q->ptr[0];                 //将右子树第一个ptr存放内容放到左节点最后一个存放ptr位置

    for(j=1;j<=q->keynum;j++){                     //将右结点q中的所有关键字插入到左结点aq 
        aq->keynum++;
        aq->key[aq->keynum]=q->key[j];
        aq->ptr[aq->keynum]=q->ptr[j];
    }

    for(j=i;j<p->keynum;j++){                       //将双亲结点p中的p->key[i]后的所有关键字向前移动一位 
        p->key[j]=p->key[j+1];
        p->ptr[j]=p->ptr[j+1];
    }
    p->keynum--;                                    //修改双亲结点p的keynum值 
    free(q);                                        //释放空右结点q的空间
}

void AdjustBTree(BTNode *p,int i){
//删除结点ptr->[i]中的第i个关键字后,调整B树
    if(i==0)                                        //删除的是最左边关键字
        if(p->ptr[1]->keynum>Min)                   //右兄弟可以借
            MoveLeft(p,1);
        else                                        //右兄弟不够借 
            Combine(p,1);
    else if(i==p->keynum)                           //删除的是最右边关键字
        if(p->ptr[i-1]->keynum>Min)                 //左兄弟可以借 
            MoveRight(p,i);
        else                                        //左兄弟不够借 
            Combine(p,i);
    else if(p->ptr[i-1]->keynum>Min)                //删除关键字在中部且左结点够借 
        MoveRight(p,i);
    else if(p->ptr[i+1]->keynum>Min)                //删除关键字在中部且右结点够借 
        MoveLeft(p,i+1);
    else                                            //删除关键字在中部且左右结点都不够借
        Combine(p,i);
}

删除

删除首先要考虑树中是否有该节点以及删除后根节点是否无key,根节点无key则可以将原根删除,即使降低树的高度,因此删除的上层结构为

void BTreeDelete(BTree &t,KeyType k){
//构建删除框架,执行删除操作  
    BTNode *p;
    int a=BTNodeDelete(t,k);                        //删除关键字k 
    if(a==0)                                        //查找失败 
        printf("   element %d not in BTree\n",k);
    else if(t->keynum==0){                          //原根节点可能被调整后无key,需要释放,降低树的高度
        p=t;
        t=t->ptr[0];
        free(p);
    }
}

真正执行删除动作的是BTNodeDelete函数,该函数在会递归进行,包含以下几种case

  1. 第一次或者连续递归多次后找到要删除的key位于叶子节点,执行路径是5(5可能递归多次)->4->6
  2. 第一次或者连续递归多次后找到要删除的key位于非叶子节点,执行路径是5(5可能递归多次)->1->2->3 (1、2、3可能循环多次)->4->6

首先看寻找替补元素的函数,前面提到,假如删除中间节点的元素,做法是使用其子节点的合适元素去覆盖,覆盖后子节点的元素则继续使用下一层子节点覆盖,直到到达叶子节点,再将最后的叶子节点元素删除,可以调用如下函数完成这个动作:

void Substitution(BTNode *p,int i){
//查找被删关键字p->key[i](在非叶子结点中)的替代叶子结点(右子树中值最小的关键字,即右子树的最小叶子节点的第一个key)
    BTNode *q;
    for(q=p->ptr[i];q->ptr[0]!=NULL;q=q->ptr[0]);
        p->key[i]=q->key[1];                            //复制关键字值
}

int BTNodeDelete(BTNode *p,KeyType k)
{
    int i;
    int found_tag;                                  //查找标志 
    if(p==NULL)
        return 0;
    else{
        found_tag=FindBTNode(p,k,i);                //返回查找结果
        printf("found_tag %d\n",found_tag);
        //在当前节点找到key
        if(found_tag==1)
        {                  //查找成功 
           if(p->ptr[i-1]!=NULL) //1.删除的是非叶子结点
           {    
                Substitution(p,i); //2.寻找相邻关键字(沿着右子树的第一个子树一直向下找,直到叶子节点) ,将该节点内的key复制到待删除节点中
                BTNodeDelete(p->ptr[i],p->key[i]);  //3.递归执行删除操作,此时是要删除被复制的叶子节点的key ,
            }
            else
                Remove(p,i);      //4.从叶子结点p中位置i处删除关键字
        }
        else
           BTNodeDelete(p->ptr[i],k);    //5.沿孩子结点递归查找并删除关键字k
        if(p->ptr[i]!=NULL)              //6.删除叶子节点的key后,由于该节点key数量可能过少,需要做结构调整
            if(p->ptr[i]->keynum<Min)    //删除后关键字个数小于MIN
                AdjustBTree(p,i);        //调整B树
        return found_tag;
    }
}

例如下图的B树,我们要删除元素6,其过程为:
在这里插入图片描述

  1. 第一次调用BTNodeDelete函数时遍历根节点,因此FindBTNode返回值不为1,但是i设置为1,代表可查找第二层第二个子节点,于是继续递归调用BTNodeDelete
  2. 6位于该节点中,所以本次递归时FindBTNode返回值为1, i = 0;此时进入if(p->ptr[i-1]!=NULL)分支,并调用Substitution查找到第三层的第三个节点中的key=5可以用来覆盖6,此时再次递归调用BTNodeDelete去删除第三颗树的元素5
    在这里插入图片描述
  3. FindBTNode返回值为1,且(p->ptr[i-1]=NULL,因此走步骤4,真正执行删除
    在这里插入图片描述
  4. 最后一次BTNodeDelete递归返回,进入上一次BTNodeDelete递归调用,此时走到 if(p->ptr[i]!=NULL)成立,元素5删除后,数量小于1,需进行调整,删除节点的右节点元素数量足够,可执行左借
    在这里插入图片描述
    参考:nullzx博客

B+树

B树各个元素实际存储的是索引+数据,因此实际使用时,元素大小一般不会为几个字节,而是几百上千字节,因此元素数量并不能特别大。而B+树设计成元素只包含索引,真正的有效数据全部位于叶子节点,这样可以保证树的高度不会因为存储数据尺寸过大而变高,B+树有如下特点:

  1. 有k个子树的中间节点包含有k个元素(国外喜欢定义为k-1个元素,两者都对),每个元素不保存数据,只用来索引,所有数据都保存在叶子节点。

  2. 所有的叶子结点中包含了全部元素的信息,及指向含这些元素记录的指针,且叶子结点本身依关键字的大小自小而大顺序链接。

  3. 所有的中间节点元素都同时存在于子节点,在子节点元素中是最大(或最小)元素。

在这里插入图片描述
B+树的显著特点在图中体现的十分明显,例如:
每个元素都对应一个子树;
所有子节点元素总空间内包含父节点的所有元素,这样可保证所有叶子节点的元素总空间包含整颗树所有元素;
根节点15是本节点的最大值,也是其各层右子树的最大值;
各中间节点的元素只存储索引,实际数据均在叶子节点通过指针指明;
各叶子节点水平顺序串联成为链表

与B树的差异与共同点:

差异点:

  1. 当单元素查找时,B+树最终都是到达叶子节点,因此每次查找的耗时是稳定的;而B树每次会遍历到不同节点停止
  2. 当进行多元素的范围查找时,B树针对每个元素都需要中序遍历,相当于执行多次单元素查找,而B+树则优雅许多,它只需要中序便利到查找中的最小元素,然后从该元素开始遍历叶子节点所在的链表即可
  3. B+树元素只存储索引值,并且所有非叶子节点的元素均会在叶子节点组成的链表中存放,所以B+树可以容纳更多的元素,使树更加矮胖,即执行更少的IO操作

共同点:

  1. 根结点至少有两个子女。
  2. 每个中间节点都至少包含ceil(m / 2)个孩子,最多有m个孩子。
  3. 每一个叶子节点都包含k-1个元素,其中 m/2 <= k <= m。
  4. 所有的叶子结点都位于同一层。
B+树的基本操作:

B+树的基本操作包括:查询、插入、删除以及叶子节点的横向链表遍历,实现方法与B树类似,规则参考:B+树及插入和删除操作详解,以下为自行实现的部分代码:

结构体定义:

#define M (4)
#define LIMIT_M_2 (M % 2 ? (M + 1)/2 : M/2)
 
typedef struct BPlusNode *pBPlusTree,*pBPlusNode;
typedef int KeyType;
typedef unsigned char Data;
struct BPlusNode{
    int KeyNum;
    KeyType Key[M + 2]; //0不存放数据
    pBPlusNode Children[M + 2]; 0不存放数据
    pBPlusNode Parent;  //垂直父节点
    pBPlusNode Next;    //叶子节点水平链表下一个
    Data StData[M + 2];//叶子节点使用该区域存放卫星数据
};

struct SearchResult{
    int staus; //是否找到某索引
    pBPlusNode NodePtr;
    pBPlusNode NodeParentPtr;
    pBPlusNode Next;    //叶子节点水平链表下一个
    pBPlusNode Pre;     //叶子节点水平链表上一个
    int KeyIndex; //找到时表示索引所在Key数组的位置,否则代表大于待查找索引值得上一个Key数组位置
};

查询:

/* 查询B+树中是否含有某个key ,无论找到与否,最后一定返回叶子节点地址*/
SearchResult SearchKey(pBPlusTree T, KeyType KeyValue)
{
    int index = 0;
    pBPlusNode TempNode = T;
    pBPlusNode ParentNode =NULL;
    SearchResult Res;

    Res.KeyIndex = 1;
    Res.NodePtr  = TempNode;
    Res.NodeParentPtr = NULL;
    Res.staus = 0;

    if(TempNode->Children[1] == NULL)
    {
        for(index = 1; index <= TempNode->KeyNum; index++) //顺序遍历该节点的所有key
        {
            if( TempNode->Key[index ] >= KeyValue) 
            {
                if( TempNode->Key[index] == KeyValue)
                    Res.staus = 1;
                else
                    Res.staus = 0;
                goto OK;
            }
            else //当前key小于查询key,继续for循环
            {
                Res.staus = 0;
                continue;
            }
        }
OK:
        Res.KeyIndex = index ;
        Res.NodePtr  = TempNode;
        Res.NodeParentPtr = ParentNode;
        printf("[%s] ,Only seach root node index = %d, KeyValue:%d, Res.staus:%d\n", __FUNCTION__, index, KeyValue, Res.staus);
        return Res;
    }

    while(1) //  一直遍历到叶子节点
    {
        printf("[%s]search node key:%d\n", __FUNCTION__, KeyValue);
        ParentNode = TempNode->Parent == NULL? TempNode:(TempNode->Parent);

        if(TempNode->Key[1] > KeyValue) //遍历的当前节点第一个key大于查询key,直接向下查找第一个子节点
        {
            printf("[%s]search next most left node key:%d\n", __FUNCTION__,KeyValue);
            Res.KeyIndex = 1;
            Res.NodePtr  = TempNode;

            if(TempNode->Children[1] != NULL)
            {
                ParentNode = TempNode;
                Res.NodeParentPtr = ParentNode;
                TempNode = TempNode->Children[1];
            }
            else
            {
                return Res;
            }
        }
        else if(TempNode->Key[TempNode->KeyNum] < KeyValue) //遍历的当前节点最后一个key小于等于查询key,若有子节点,直接向下查找后节点
        {
            printf("[%s]search next most right node key:%d \n", __FUNCTION__, KeyValue);

            Res.KeyIndex = TempNode->KeyNum + 1;
            Res.NodePtr  = TempNode;

            if(TempNode->Children[TempNode->KeyNum] != NULL)
            {
                ParentNode = TempNode;
                Res.NodeParentPtr = ParentNode;
                TempNode = TempNode->Children[TempNode->KeyNum];
            }
            else
            {
                return Res;
            }
        }
        else //遍历的当前节点中间某一个key大于查询key,需顺序查找
        {
            printf("[%s]search middle or leaf node all, key %d  cur node:%p\n", __FUNCTION__, KeyValue, TempNode);
            for(index = 1; index <= TempNode->KeyNum; index++) //顺序遍历该节点的所有key
            {
                if( TempNode->Key[index] < KeyValue) //当前key小于查询key,继续for循环
                    continue;
                else if(TempNode->Children[index] != NULL)//当前key大于等于查询key且有后续叶子节点,跳出for循环,继续执行外层while
                {
                    printf("[%s]search this middle or leaf node all key %d,cur node:%p\n", __FUNCTION__, KeyValue, TempNode);

                    Res.KeyIndex = index;
                    Res.NodePtr  = TempNode;
                    Res.NodeParentPtr = ParentNode;
                    if( TempNode->Key[index] == KeyValue) //当前key等于查询key,查询成功,但是不返回,需在叶子节点也找到该key
                    {
                        Res.staus = 1;
                        printf("[%s]search success key %d in middle node,keep search, cur node:%p\n", __FUNCTION__,KeyValue, TempNode);
                        break;
                    }
                    else
                    {
                        
                        Res.staus = 0; //当前key不等于查询key,查询失败,遍历下一级叶子节点
                        printf("[%s]search this middle node fail , goto next node, key:%d, cur node:%p\n", __FUNCTION__, KeyValue, TempNode);
                        break;
                    }
                }
                else //当前key大于等于查询key且无后续叶子节点
                {
                    Res.KeyIndex = index ;
                    Res.NodePtr  = TempNode;
                    Res.NodeParentPtr = ParentNode;
                    if( TempNode->Key[index] == KeyValue) //当前key等于查询key,查询成功,返回对应的叶子节点地址与key索引
                        Res.staus = 1;
                    else
                        Res.staus = 0; //当前key不等于查询key,查询失败,返回查询key可插入的叶子节点地址与key索引

                    printf("[%s]search status:%d key:%d in leaf node, parent:%p\n", __FUNCTION__, Res.staus,KeyValue , ParentNode);
                    return Res;
                }
            }
            TempNode = TempNode->Children[index];
        }
    }
    return Res;
}
}

插入:

/* 插入key,最后一定在叶子节点插入*/
pBPlusTree InsertKey(pBPlusTree T, KeyType KeyValue, Data data)
{

   SearchResult Res;
   Res = SearchKey( T, KeyValue);
   if(Res.staus == 1)
   {
       printf("key:%d in tree already\n", KeyValue);
       return T;
   }
   else
   {
       printf("[%s]insert key:%d, data:%c, position:%d\n", __FUNCTION__,KeyValue, data , Res.KeyIndex);
       pBPlusNode CurNode = Res.NodePtr;
       //在节点key数组的中间插入,需后移一个空间
       if(Res.KeyIndex <= Res.NodePtr->KeyNum)
       {
           for(int index = CurNode->KeyNum; index >= Res.KeyIndex; index--)
           {
               CurNode->Key[index + 1] = CurNode->Key[index];
               CurNode->Children[index + 1] = CurNode->Children[index];
               CurNode->StData[index + 1] = CurNode->StData[index];
           }
       }

       CurNode->Key[Res.KeyIndex] = KeyValue;
       CurNode->Children[Res.KeyIndex] = NULL;
       CurNode->StData[Res.KeyIndex] = data;
       CurNode->Parent = Res.NodeParentPtr;
       CurNode->KeyNum++;

       //叶子节点新插入的key过大,更新所有父节点最大key
       if(T->Key[T->KeyNum] < KeyValue && CurNode->Parent != NULL)
       {
           printf("[%s]KeyValue:%d is big update all parent key\n", __FUNCTION__,KeyValue);
           T->Key[T->KeyNum] = KeyValue;
           pBPlusNode Node = T;
           while (Node->Parent != NULL)
           {
               Node = Node->Parent;
               printf("[%s]Key:%d change to %d\n",\
                      __FUNCTION__, Node->Key[Node->KeyNum], KeyValue);
               Node->Key[Node->KeyNum] = KeyValue;
           }
       }

       if(Res.NodePtr->KeyNum <= M)
           return T;
       else
       { 
           T = SpliteNode(T,CurNode);
           return T;
       }
   }
}

插入时用到的分裂节点:

/* 分裂node,从叶子节点开始,分裂后若父节点超过M,则继续向上分别,知道所有节点元素均小于等于M */
/*步骤: 
      1、将待分裂节点后半部分保留,前半部分放入新创建node,
      2、将新node最后的key复制到父节点合适位置
      3、父节点的新key对应的子节点指针指向该新节点
      4、判断是否需要增高树,即原根节点分裂后,向上生成新的根节点
*/
pBPlusTree SpliteNode(pBPlusTree T, pBPlusNode Node)
{
    pBPlusNode newNode;
    pBPlusNode parentNode;
    pBPlusNode newRootNode;
    unsigned char isNewRoot = 0;
    /* 新分裂结点 */
    newNode = MallocNewNode();
    //新根节点先与T一致
    newRootNode = T;
    //若待分裂节点为根节点,需重新生成根节点
    if(Node->Parent == NULL)
    {
        newRootNode = MallocNewNode();
        Node->Parent = newRootNode;
        //新的根节点,只包含待分裂节点与新分裂出的节点,因此key也只有两个
        newRootNode->Key[1] = Node->Key[LIMIT_M_2];
        newRootNode->Key[2] = Node->Key[Node->KeyNum];
        newRootNode->Children[1] = newNode;
        newRootNode->Children[2] = Node;
        newRootNode->KeyNum = 2;
        parentNode = newRootNode;
        isNewRoot = 1;
        printf("create new root node\n");
    }
    else
    {
        parentNode = Node->Parent;
        printf("no need create new root node, keep parent node as :%p\n",parentNode);
    }

    //将原节点前半部分key移入新建node,并清空原节点前半部分key相关数据
    for( unsigned int index = 1; index <= (LIMIT_M_2 ) ; index++)
    {
        newNode->Key[index ] = Node->Key[index ];
        newNode->Children[index] = Node->Children[index ];
        newNode->StData[index] = Node->StData[index];
        newNode->KeyNum++;
    }
    //将新节点的子节点的父节点更新为自己
    for(unsigned int index4 = 1; index4 <= newNode->KeyNum; index4++)
    {
        if(newNode->Children[index4] != NULL)
        {
            newNode->Children[index4]->Parent = newNode;
        }
    }

    newNode->Parent = parentNode;
    newNode->Next  = Node;

    //取巧,横向指针未设计prev指针,所以分裂新节点后,要依赖父节点转接找到左兄弟节点,并将其next指向新分裂节点
    for(unsigned int chd = 1; chd <= newNode->Parent->KeyNum; chd++)
    {
        if((newNode->Parent->Children[chd] == Node) && (chd != 1))
        {
            newNode->Parent->Children[chd -1 ]->Next = newNode;
            break;
        //printf(" %p next: %p prev: %p\n",newNode, newNode->Next, newNode->Parent->Children[chd]);
        }
    }


    //将原节点后半部分key移入前半部分,并清空后半部分数据
    for(unsigned int index1 = 1 ; index1 <= (unsigned int)((Node->KeyNum) - LIMIT_M_2); index1++)
    {
        Node->Key[index1 ] = Node->Key[index1 + LIMIT_M_2];
        Node->Children[index1] = Node->Children[index1 + LIMIT_M_2];
        Node->StData[index1] = Node->StData[index1 + LIMIT_M_2];

     }
    for(unsigned int index2 = LIMIT_M_2 +2 ; index2 <= Node->KeyNum; index2++)
    {
        Node->Key[index2 ] = Unavailable;
        Node->Children[index2] = NULL;
        Node->StData[index2] = Unavailable;
     }
    //原节点减去LIMIT_M_2个key
    Node->KeyNum = Node->KeyNum - LIMIT_M_2;
    Node->Parent = parentNode;

    //原节点的父节点(新生成的父节点除外)key从某处后移,容纳新节点的最后一个key放入
    if(!isNewRoot)
    {
        for(int index1 = 1; index1 <= (parentNode->KeyNum); index1++) //顺序遍历该节点的所有key
        {
            if( (parentNode->Key[index1]) < (newNode->Key[LIMIT_M_2]))
                continue;
            else
            {
                for(int subindex = M; subindex >= index1; subindex--)
                {
                    parentNode->Key[subindex + 1] = parentNode->Key[subindex ];
                    parentNode->Children[subindex + 1] = parentNode->Children[subindex];
                }
                parentNode->Key[index1 ] = newNode->Key[(LIMIT_M_2 )];
                parentNode->Children[index1 ] = newNode;
                parentNode->KeyNum++;
                parentNode->Next =NULL;
                break;
            }
        }
    }
    
    //判断是否需递归分裂父节点
    if(parentNode->KeyNum > M)
        //递归分裂,注意不生成新根节点时,newRootNode = T,否则新根节点由最深递归函数生成
        newRootNode = SpliteNode(newRootNode ,parentNode);
    //newRootNode可能为下一层递归生成的新的节点,也可能保持为本次递归传入的T
    return newRootNode;

}

删除节点:

/* 删除key,首先在叶子节点插入,如删除叶子节点中的最大值,则需要以此更新父节点、父父节点...的key*/
/* 步骤:
        1、查找待删除key所在树节点,查找失败直接返回
        2、删除key(注意剩余key位置移动),若删除最大值,则需要依次更新父节点、父父节点...的key
        3、判断删除key后,该节点key数量是否小于LIMIT_M_2,不小于则删除结束,否则执行步骤4
        4、若满足一下三种情况之一,则借用节点(注意同步更新父节点对应key),否则执行步骤5
            4.1 若待删除key所在节点为最左侧节点,且右兄弟节点可借,则借兄弟的第一个key,删除结束,否则执行步骤5
            4.2 若待删除key所在节点为最右侧节点,且左兄弟节点可借,则借兄弟的最后一个key,删除结束,否则执行步骤5
            4.3 若待删除key所在节点为中间位置节点,则依次判断右兄弟节点、左兄弟节点是否可借,删除结束,否则执行步骤5
        5、若满足一下三种情况之一,则合并节点(合并后,父节点的key可能小于LIMIT_M_2,此时递归执行以上步骤)
            5.1 若待删除key所在节点为最左侧节点,且右兄弟节点可合并(合并后key数量不大于M),则与右兄弟节点合并,删除结束
            5.2 若待删除key所在节点为最右侧节点,且左兄弟节点可合并,则与左兄弟节点合并,删除结束
            5.3 若待删除key所在节点为中间位置节点,则依次判断右兄弟节点、左兄弟节点是否可合并,删除结束
*/
pBPlusTree DeleteKey(pBPlusTree T, KeyType KeyValue)
{
    SearchResult Res;
    Res = SearchKey( T, KeyValue);
    //1、查找待删除key所在树节点,查找失败直接返回
    if(Res.staus == 0)
    {
        printf("key:%d not in tree , delete fail!\n", KeyValue);
        return 0;
    }

    printf("[%s]delete key: %d position:%d\n", __FUNCTION__, KeyValue , Res.KeyIndex);
    pBPlusNode CurNode = Res.NodePtr;

    //2、删除key(注意剩余key位置移动),若删除最大值,则需要依次更新父节点、父父节点...的key
    //若删除key在数组的中间,将后续key依次前移一个空间
    if(Res.KeyIndex != Res.NodePtr->KeyNum)
    {
        for(int index = Res.KeyIndex; index < CurNode->KeyNum; index++)
        {
            CurNode->Key[index ] = CurNode->Key[index + 1];
            if(CurNode->Children[index] != NULL)
            {
                CurNode->Children[index ] = CurNode->Children[index + 1];
                CurNode->StData[index] = CurNode->StData[index + 1];
            }
        }
        CurNode->Key[ CurNode->KeyNum] = -1;
        CurNode->KeyNum--;
    }
    //若删除key在数组的末尾(最大值),需要更新父节点为当前节点的倒数第二个key
    else
    {
        __updateMaxKeyValue(CurNode, CurNode->Key[CurNode->KeyNum], CurNode->Key[CurNode->KeyNum -1]);
        CurNode->Key[ CurNode->KeyNum] = -1;
        CurNode->KeyNum--;
    }

    //是否需对树调整
    pBPlusTree T1 =__TreeModifyMethod(CurNode);;
    return T1;

}

删除节点Key不足时的决策函数:

//删除key,是借用还是组合的决策函数
pBPlusTree __TreeModifyMethod(pBPlusTree T)
{
    pBPlusNode CurNode = T;
    unsigned int pos = 1;
    pBPlusNode NewRoot =T;


    if(CurNode->Parent == NULL && CurNode->KeyNum < 2)
    {
        //根节点key数量至少为两个,否则直接删除该根节点,其唯一的子节点作为新根节点

        NewRoot = CurNode->Children[1];
        NewRoot->Parent = NULL;
        printf("[%s][%d] NEW ROOT %p key:%d num:%d\n",__FUNCTION__,__LINE__,NewRoot,NewRoot->Key[1],NewRoot->KeyNum);
        if(NewRoot->Children[1] != NULL)
        {
            for(unsigned int g = 1; g <= NewRoot->KeyNum; g++)
            {
                //这里不清楚孩子节点的父节点为啥不是当前NewRoot,懒得查了,直接更新一遍

                    NewRoot->Children[g]->Parent = NewRoot;
                printf("[%s][%d] child[%d] %p key:%d num:%d\n",__FUNCTION__,__LINE__,g, NewRoot->Children[g],NewRoot->Children[g]->Key[1],NewRoot->Children[g]->KeyNum);
            }
        }
        printf("[%s][%d] delete node:%p key[1]:%d\n",__FUNCTION__,__LINE__, CurNode,CurNode->Key[1]);

        free(CurNode);
        CurNode = NULL;

        return NewRoot;
    }
    else
    {
        //返回之前的root node
        while( NewRoot->Parent != NULL)
        {
            NewRoot = NewRoot->Parent;
        }

    }
    //printf("[%d]CurNode->Parent:%p, CurNode->KeyNum:%d\n",__LINE__,CurNode->Parent,CurNode->KeyNum);

    //3、判断删除key后,该节点key数量是否小于LIMIT_M_2(根节点除外),不小于则删除结束,否则执行步骤4与步骤5
    if(CurNode->Parent != NULL && CurNode->KeyNum < LIMIT_M_2)
    {
        //先找到当前节点在父节点的索引
        for(pos; pos <= T->Parent->KeyNum; pos++)
        {
            if(T->Parent->Children[pos] == T)
                break;
        }

        //4、若满足一下三种情况之一,则借用节点(注意同步更新父节点对应key),否则执行步骤5
        //4.1 若待删除key所在节点为最左侧节点
        if(CurNode->Parent->Children[1] == CurNode)
        {
            //且右兄弟节点可借,则借兄弟的第一个key,删除结束,否则执行步骤5
            if(CurNode->Parent->Children[2]->KeyNum > LIMIT_M_2)
            {
                //借右到左
                __MoveRightToLeft(CurNode);
                return NewRoot;
            }
            //5.1 且右兄弟节点可合并(合并后key数量不大于M),则与右兄弟节点合并,删除结束
            else
            {
                //与右合并
                return __CombineWithRight(CurNode);;
            }
        }
        //4.2 若待删除key所在节点为最右侧节点
        else if(CurNode->Parent->Children[CurNode->Parent->KeyNum] == CurNode)
        {
            //且左兄弟节点可借,则借兄弟的最后一个key,否则执行步骤5
            if(CurNode->Parent->Children[CurNode->Parent->KeyNum - 1 ]->KeyNum > LIMIT_M_2)
            {
                //借左到右
                __MoveLeftToRight(CurNode);
                return NewRoot;
            }
            //5.2 且左兄弟节点合并,则与左兄弟节点合并,删除结束
            else
            {
                //与左合并
                 printf("[%s][%d] combine with left node\n",__FUNCTION__,__LINE__);
                return __CombineWithLeft(CurNode);
            }

        }
        //4.3 若待删除key所在节点为中间位置节点
        else
        {
            //printf("[%d]\n",__LINE__);

            //右兄弟节点可借,删除结束
            if(CurNode->Parent->Children[pos + 1]->KeyNum > LIMIT_M_2)
            {
                //借右到左
                //printf("[%d]\n",__LINE__);
                __MoveRightToLeft(CurNode);
                return NewRoot;
            }
            //左兄弟节点可借,删除结束
            else if(CurNode->Parent->Children[pos - 1]->KeyNum > LIMIT_M_2)
            {
                //借左到右
                //printf("[%d]\n",__LINE__);
                __MoveLeftToRight(CurNode);
               return NewRoot;
            }
            //5.3 右节点不可借,但可以合并
            else if(CurNode->Parent->Children[pos + 1]->KeyNum + CurNode->KeyNum <=  M)
            {
                //与右合并
                printf("[%s][%d] combine with right node\n",__FUNCTION__,__LINE__);
                return __CombineWithRight(CurNode);
            }

            //5.3 左节点不可借当可合并
            else if(CurNode->Parent->Children[pos - 1]->KeyNum + CurNode->KeyNum <=  M)
            {
                //与左合并
                printf("[%s][%d] combine with left node\n",__FUNCTION__,__LINE__);
                return __CombineWithLeft(CurNode);
            }
        }
    }
    else
    {
        return NewRoot;
    }
}

结合、借用实现函数:

//当前key数量不够的node与其左兄弟合并
pBPlusTree __CombineWithLeft(pBPlusTree T)
{
    //  [4        65         70 ]
    //[3 4 ]   [5 65 ]   [67 70 ]
    unsigned int index = 1;

    unsigned int pos = 1;
    pBPlusTree NewRoot = T;
    //先找到当前节点在父节点的索引
    for(pos; pos <= T->Parent->KeyNum; pos++)
    {
        if(T->Parent->Children[pos] == T)
            break;
    }
    printf("[%s][%d] get parent index of child: %d total num %d\n",__FUNCTION__,__LINE__,pos,T->Parent->KeyNum);


    unsigned int offset = T->Parent->Children[pos-1]->KeyNum + 1;

    //当前节点key依次放入左节点key数组后面位置
    for(index = offset; index < offset + T->KeyNum; index++)
    {
        T->Parent->Children[pos-1]->Key[index] = T->Key[index - offset + 1];
        T->Parent->Children[pos-1]->Children[index] = T->Children[index - offset + 1];
        T->Parent->Children[pos-1]->KeyNum += T->KeyNum;
    }

    //当前节点的父节点也需要更新key
    for(index = pos; index < T->Parent->KeyNum; index++)
    {
        T->Parent->Key[index] = T->Parent->Key[index + 1];
        T->Parent->Children[index] = T->Parent->Children[index + 1];
        printf("[%s][%d]parent key[%d] %d to key[%d] %d\n",__FUNCTION__,__LINE__,index,T->Parent->Key[index],index,T->Parent->Key[index+1]);
    }
    T->Parent->Key[T->Parent->KeyNum] = -1;
    T->Parent->Children[T->Parent->KeyNum] = NULL;
    T->Parent->KeyNum--;

    //父节点更新后数量不足,需继续对父节点进行借用或组合调整
    if( T->Parent->KeyNum < LIMIT_M_2)
    {
        printf("[%s][%d]parent keynum %d not enough\n",__FUNCTION__,__LINE__,T->Parent->KeyNum);
        NewRoot = __TreeModifyMethod(T->Parent);
    }
    else
    {
        while( NewRoot->Parent != NULL)
        {
            NewRoot = NewRoot->Parent;
        }

    }
    printf("[%s][%d] delete node:%p key[1]:%d, root:%p \n",__FUNCTION__,__LINE__, T,T->Key[1],NewRoot);
    free(T);
    T = NULL;
    return NewRoot;

}

//当前key数量不够的node与其右兄弟合并
pBPlusTree __CombineWithRight(pBPlusTree T)
{
    unsigned int index = 1;
    unsigned int offset = T->KeyNum;
    unsigned int pos = 1;
    pBPlusTree NewRoot = T;
    //先找到当前节点在父节点的索引
    for(pos; pos <= T->Parent->KeyNum; pos++)
    {
        if(T->Parent->Children[pos] == T)
            break;
    }
    printf("[%s][%d] get parent index of child: %d total num %d\n",__FUNCTION__,__LINE__,pos,T->Parent->KeyNum);
    //右节点key向后移动offset位置
    for(index = T->Parent->Children[pos+1]->KeyNum; index >= 1; index--)
    {
        T->Parent->Children[pos+1]->Key[index + offset] = T->Parent->Children[pos+1]->Key[index];
        T->Parent->Children[pos+1]->Children[index + offset] = T->Parent->Children[pos+1]->Children[index];
    }

    //当前节点key依次放入右节点key数组
    for(index = 1; index <= T->KeyNum; index++)
    {
        T->Parent->Children[pos+1]->Key[index] = T->Key[index];
        T->Parent->Children[pos+1]->Children[index] = T->Children[index];
    }
    T->Parent->Children[pos+1]->KeyNum += T->KeyNum;

    //当前节点的父节点也需要更新key
    for(index = pos; index < T->Parent->KeyNum; index++)
    {
        T->Parent->Key[index] = T->Parent->Key[index + 1];
        T->Parent->Children[index] = T->Parent->Children[index + 1];
        printf("[%s][%d]parent key[%d] %d to key[%d] %d\n",__FUNCTION__,__LINE__,index,T->Parent->Key[index],index,T->Parent->Key[index+1]);
    }
    T->Parent->Key[T->Parent->KeyNum] = -1;
    T->Parent->Children[T->Parent->KeyNum] = NULL;
    T->Parent->KeyNum--;

    //父节点更新后数量不足,需继续对父节点进行借用或组合调整
    if( T->Parent->KeyNum < LIMIT_M_2)
    {
        printf("[%s][%d]parent keynum %d not enough\n",__FUNCTION__,__LINE__,T->Parent->KeyNum);
        NewRoot = __TreeModifyMethod(T->Parent);
    }
    else
    {
        while( NewRoot->Parent != NULL)
        {
            NewRoot = NewRoot->Parent;
        }
    }
    printf("[%s][%d] delete node:%p key[1]:%d\n",__FUNCTION__,__LINE__, T,T->Key[1]);
    free(T);
    T = NULL;
    return NewRoot;

}

//左兄弟节点的最后一个key移动到当前节点的第一个key位置
void __MoveLeftToRight(pBPlusTree T)
{
    unsigned int index = 1;
    for(index; index <= T->Parent->KeyNum; index++)
    {
        if(T->Parent->Children[index] == T)
            break;
    }


    //更新父节点key为当前节点的左兄弟节点的倒数第二个key
    T->Parent->Key[index -1] = T->Parent->Children[index-1]->Key[T->Parent->Children[index-1]->KeyNum -1];


    //当前节点key与Children后移一位
    for(unsigned int index1= T->KeyNum; index1 > 0 ; index1--)
    {
        T->Key[index1 + 1 ] = T->Key[index1];
        T->Children[index1 + 1 ] = T->Children[index1];
    }

    //空出的位置放置左兄弟节点的最后一个key与Children
    T->KeyNum++;
    T->Key[1] = T->Parent->Children[index-1]->Key[T->Parent->Children[index-1]->KeyNum];
    if(T->Children[1] != NULL)
        T->Children[1] = T->Parent->Children[index-1]->Children[T->Parent->Children[index-1]->KeyNum];

    //左兄弟节点的keyNum -1
    T->Parent->Children[index-1]->KeyNum--;

}

//右兄弟节点的第一个key移动到当前节点的最后一个key位置
void __MoveRightToLeft(pBPlusTree T)
{
    unsigned int index = 1;
    for(index; index <= T->Parent->KeyNum; index++)
    {
        if(T->Parent->Children[index] == T)
            break;
    }

    //更新父节点key为当前节点的右兄弟节点的第一个key
    T->Parent->Key[index] = T->Parent->Children[index+1]->Key[1];
    //增加当前节点的最后一个key为右兄弟节点的第一个key
    T->Key[T->KeyNum + 1] = T->Parent->Children[index+1]->Key[1];
    //增加当前节点的最后一个Children为右兄弟节点的第一个Children
    T->Children[T->KeyNum + 1] = T->Parent->Children[index+1]->Children[1];
    T->KeyNum++;

    //右兄弟节点key与Children前移一位
    T->Parent->Children[index+1]->KeyNum--;

    for(unsigned int index1= 1; index1 <= T->Parent->Children[index + 1]->KeyNum; index1++)
    {
        T->Parent->Children[index+1]->Key[index1] = T->Parent->Children[index+1]->Key[index1 + 1];
        T->Parent->Children[index+1]->Children[index1] = T->Parent->Children[index+1]->Children[index1 + 1];
    }
}

叶子节点横向遍历函数:

/* 基于叶子节点的横向链表,遍历叶子节点*/
void TraversalLeaf(pBPlusTree T)
{
    pBPlusNode TempNode = T;
    unsigned int index = 1;
    while(TempNode->Children[1] != NULL)
    {
        TempNode = TempNode->Children[1];
    }
    while( TempNode != NULL)
    {
        printf("[%s]Traversal Leaf,leaf: %p\n", __FUNCTION__,TempNode);
        for(index =1; index <=TempNode->KeyNum; index++)
        {
            printf("    [%s] key[%d] = %d\n", __FUNCTION__, index, TempNode->Key[index]);
        }
        TempNode = TempNode->Next;
    }
}

实验结果:

   TraversalLeaf(T);

//中序遍历整颗树并打印
    PrintAllTree(T);
    /* 原始树
    [4 72 ]
    [2 4 ] [66 68 72 ]
    [1 2 ] [3 4 ] [5 65 66 ] [67 68 ] [69 70 71 72 ]
    */


// 测试删除
    T = DeleteKey(T, 72); //测试 删除最key,则需要依次更新父节点、父父节点.的key
    PrintAllTree(T);
    /* 删除72后
    [4 71 ]
    [2 4 ] [66 68 71 ]
    [1 2 ] [3 4 ] [5 65 66 ] [67 68 ] [69 70 71 ]
    */

    T = DeleteKey(T, 68); //测试 向右借key
    PrintAllTree(T);
    /* 删除68后
    [4 71 ]
    [2 4 ] [66 69 71 ]
    [1 2 ] [3 4 ] [5 65 66 ] [67 69 ] [70 71 ]
    */

    T = DeleteKey(T, 69); //测试 向左借key
    PrintAllTree(T);

    /* 删除69后
    [4 71 ]
    [2 4 ] [65 67 71 ]
    [1 2 ] [3 4 ] [5 65 ] [66 67 ] [70 71 ]
    */

    T = DeleteKey(T, 66); //测试 向右合并节点
    PrintAllTree(T);
    /* 删除66后
    [4 71 ]
    [2 4 ] [65 71 ]
    [1 2 ] [3 4 ] [5 65 ] [67 70 71 ]
    */

    T = DeleteKey(T, 2); //测试 向右合并节点后,若父节点key不足,再递归父节点向右合并,不能合并则释放原根节点,树层数降低
    PrintAllTree(T);
    /* 删除2后
    [4 65 71 ]
    [1 3 4 ] [5 65 ] [67 70 71 ]
    */

    T = DeleteKey(T, 71);
    PrintAllTree(T);
    /* 删除71后
    [4 65 70 ]
    [1 3 4 ] [5 65 ] [67 70 ]
    */
    
    T = DeleteKey(T, 1);
    PrintAllTree(T);
    /* 删除1后
    [4 65 70 ]
    [3 4 ] [5 65 ] [67 70 ]
    */
    T = DeleteKey(T, 70); //测试 向左合并节点
    PrintAllTree(T);

    /* 删除70后
    [4 65 ]
    [3 4 ] [5 65 67 ]
    */
    T = DeleteKey(T, 67);
    PrintAllTree(T);
    /* 删除67后
    [4 65 ]
    [3 4 ] [5 65 ]
    */

    T = DeleteKey(T, 5); //测试 向左合并节点,若父节点key不足,再递归父节点向右合并,不能合并则释放原根节点,树层数降低
    PrintAllTree(T);
    /* 删除5后
    [3 4 65 ]
    */
    PrintAllTree(T);

完整工程

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值