【C语言】B 树全代码实现与超超详细思路解读(五天的呕心沥血,绝对原创每个细节,详解查找、插入与删除)

C语言-B树(B-树)的完整实现 B树的定义 B树是一种平衡的多路查找树。 一颗m阶B树,或为空树,或为满足下列特性的m叉树。 (1)树中每个结点最多含有m棵子树; (2)若根结点不是叶子结点,则至少有两颗子树; (3)除根之外的所有非终端结点至少有[m/2]; (4)每个非终端结点中包含信息:(n,A0,K1,A1,K2,A2,…,Kn,An)。其中 ①Ki(1≤i≤n)为关键字,且关键字按升序排序。 ②指针Ai( 阅读详情
AI摘要

本文详细介绍了B树的实现过程,包括查询、插入和删除操作。在查询操作中,通过Result结构体获取结点和位置信息;插入操作通过查找、分裂和调整树形完成;删除操作涉及替换、借记录和树形调整。文章强调了双亲和孩子之间的双向绑定关系在操作中的重要性,并分享了在实现过程中遇到的挑战和解决思路。


—— 前言 ——

期末到了,数据结构老师安排了最后的实验作业
本着深入理解 B 树,本澈一步一步按照理论思路撸了一遍代码
过程非常曲折,各种 bug 层出不穷,debug 算是安排的明明白白
其中参照了不少其他人的代码,最终手撕了这份 B 树代码,已经最大程度地保证了无 bug 出现

整篇博客写完之后,我认为这是我目前写的最投入感情的一次了
嘛虽然可能大家看不出来,但在这几天的自己一点点摸索,而不是通过学校老师或者 B 站网课老师直接输入知识,到最后往回看还是蛮感动的,也祝屏幕前的你好运~

▲ 在这里还是需要特别声明一下

本人来自 广东工业大学
() 如果你也是广工一员而且需要完成本次实验作业,要注意不能直接拷贝代码哦(思路也是噢,本人会写进实验报告的)
本人代码思路与网上和课本上的都有明显的区别,被辨识出来雷同就不好了(重点在于了解实现思路与代码思路以及避雷)

不过,在本篇博客中,除了代码上有非常详细的注释,我还将自己在实现 B 树过程中的很多思路、想法以及踩到的坑记录在里边
相信聪明的你读完整篇博客后,自己手撕一份 B 树代码肯定是没问题的~
(想自己手撕的,可以只看代码前的思路解读以及代码段中间的黄色框框注意点,直接看代码容易限制思路)
当然,如果是单纯想学习的,那么请随意~

希望本篇分享能帮到正努力向前的你,oo,o!
● 彩蛋预告:

在调整树形那一部分,有一份 B 树本身留给大家的礼物,这是我在写博客期间惊喜发现的
希望大家都能坚持用心看到那里,相信在收到这份礼物后,你可能会对数据结构有新的想法哦

B 树全代码实现与思路解读

接下来就正式进入主题啦(嘛,可能前面废话有点多)

① 结构体与宏定义

需要提前定义以下常量,方便后续的使用

/* B 树阶数 */
#define M 4

/* B 树关键最大最小值以及中间值 */
#define MAX_KEY (M-1)
#define MIN_KEY ((M-1)/2)
#define MID_KEY ((M+1)/2)
注意点1:
对于三个 KEY 值的解释
MAX_KEY 应该很好理解,M 接 B 树就是该树最多有 M 棵子树,那 M-1 个关键字自然就会留出 M 个子树的位置啦
MIN_KEY 的话,按照定义应该是 M/2 向上取整为子树数量,但是整型除法默认是向下取整呀,所以我们需要调整成 (M+1)/2,这样相当于被 2 除的向上取整了,然后对应的关键字数量自然就是 (M+1)/2 - 1 即 (M-1)/2 啦
MID_KEY 作为分裂时需要移动到上层的记录的位置,注意这个值和前面的最大最小值有明显的区别哦,前边的表示数量,而该值表示的是“位置”。右应为 KEY 的第一个位置不存值,位置应该从 1 开始数,所以本来中间位置应该是 M/2 的,在第一个位置不使用之后,自然就变成了 (M+1)/2 啦

然后准备以下 3 个结构体

/* 记录类型 */
typedef struct Rcd {
	int key;		// 记录的键
	int data;		// 记录的值
} Rcd, *Record;

/* B 树结构体 */
typedef struct BTNode {
	int keyNum;					// 结点中关键字个数,即结点的大小
	KeyType key[M + 1];			// 关键字数组,0 号单元未用
	Record rcd[M + 1];			// 记录指针数组,0 号单元未用
	struct BTNode* parent;		// 指向双亲结点
	struct BTNode* child[M + 1];// 子树指针数组,0 号有使用
} BTNode, *BTree;

/* 结果类型 */
typedef struct Result {
	int i;		// 在结点中的关键字位序
	int tag;	// 是否找到了
	BTree data;	// 找到的数据
} Result;

然后是所有的接口

/* 通用辅助接口 */
Record getRecord(int key, int data);			// 获得一个记录值
Result getResult(int i, int tag, BTree data);	// 获得一个结果值

/* B 树接口 */
Status InitBTree(BTree& tree, Record data);		// 初始化 B 树
BTree MakeBTree(const int treeData[][2], int num);		// 构建 B 树 
void TraverseBTree(BTree tree);					// 打印 B 树
Result SearchBTree(BTree tree, KeyType key);	// 对 B 树执行查找操作
Status InsertBTree(BTree& tree, Record data);	// 对 B 树执行插入操作
Status DeleteBTree(BTree tree, KeyType key);	// 对 B 树执行删除操作
Status UpdateBTree(BTree tree, KeyType key, Record data);	// 将 B 树中关键字为 key 的结点记录更换成新的记录

/* B 树辅助接口 */
int Search(BTree node, KeyType key);	// 寻找 key 在 node 所在结点中的位置
void InsertBTNode(BTree& node, int i, Record rcd, BTree newNode);	// 将新记录 rcd 插入到结点 node 的第 i 个位置,同时将新子节点 newNode 作为后继孩子
Status SplitBTNode(BTree& node, BTree& newNode);	// 将 node 结点从中间分裂成两部分,前半部分留在原位,后半部分进入 newNode 并指向原结点的双亲
Status newRoot(BTree& root, Record rcd, BTree child1, BTree child2);	// 生成一个新根
int CountKeyNum(BTree tree);	// 计算出整棵树上记录条数的总和
void Successor(BTree& node, int& i);	// 找到前驱结点,并进行关键字的替换
Status InsertRecord(BTree& node, int i, Record rcd);	// 将 rcd 插入到指定结点的第 i 个位置
Status RemoveRecord(BTree& node, int i);	// 将指定结点中第 i 个记录移除
void RestoreBTree(BTree& node, int pi);		// 针对某个结点调整一颗 B 树
void CombineBTNode(BTree& left, BTree& right);	// 将右结点合并到左结点,结果放在左结点 left 中,右结点 right 将被释放
void DeleteRoot(BTree& root);	// 删除一个空根
void PrintBTree(BTree tree, int isLast[], int nowNum);	// 递归打印树形
void printBranch(int isLast[], int nowNum);	// 打印树枝

② 查询操作

对于 B 树的查询,是往后插入和删除的根基,重点在于理解与用好结果集 Result

Result 的三个值,tag 表示是否查询得到,data 表示找到的整一个 B 树结点
而重点在于 i 值,该值有两种情况,且无论找没找着,它都非常有用
① 如果找到了,i 值就是当前记录值所在位置(从 1 开始算)
② 如果没找到,i 值就是所查询的结点本应该在的位置,该位置是孩子 child 的位置(从 0 开始算),对于后面的插入和删除都有很重要的作用

与之对应一个很重要的函数就是 Search 函数(如下),它实现了 Result 中 i 值的逻辑

/* 寻找 key 在 node 所在结点中的位置 */
int Search(BTree node, KeyType key) {
	// 判空
	if (node == NULL) return 1;

	int i = 1;
	while (i <= node->keyNum && key > node->key[i]) i++;
	return i;
}

这里需要好好理解 i 的含义,i 从 1开始,遍历所有关键字,直到碰到不比提供的 key 小的关键字,便停下
此时有可能是相等(找到了),也可能是小于(没找到,此时 i 正是 key 原本该在的位置),返回该值,查询代码如下:

/* 对 B 树执行查找操作 */
Result SearchBTree(BTree tree, KeyType key) {
	// 当前正在遍历的结点(指向传进来的树),以及其双亲结点(用于结果返回)
	BTree now = tree, parent = NULL;

	int i = 0, found = 0;
	while (now != NULL && found == 0) {
		// 找到 key 在当前结点的位置
		i = Search(now, key);

		// 判断该位置是否为所需的值
		if (i <= now->keyNum && key == now->key[i]) found = 1;
		// 没找到的话,继续往下找
		else {
			parent = now;
			now = now->child[i - 1];
		}
	}

	// 找到的话,返回目标结点以及数据所在结点的位置
	if (found == 1) return getResult(i, 1, now);
	// 如果没找到,返回该数据所应该插入的结点及其所在结点的位置
	return getResult(i, 0, parent);
}

/* 获得一个结果类型 */
Result getResult(int i, int tag, BTree data) {
	Result result;
	result.i = i;
	result.tag = tag;
	result.data = data;

	return result;
}

查询实现不难,就是不断找到 key 应该在的地方,对比该地方是否为所需要的值
如果不是,则保留当前值作为双亲存起来(为什么要呢?),再继续向下寻找

注意点2:
这里可以注意到,now = now->child[i - 1] 中的 i-1,为什么是 i-1 呢?
因为 i 值表示的是 key 所该在的位置,但目前的位置不是 key,而是比 key 大的值,所以要找 key 应该找该记录的左孩子(找小的),即找 i-1

这里有一个技巧,如果 i 值是指向某记录,则 child[i-1] 为该记录的左孩子,child[i] 为该记录的右孩子
接着如果正常找到了,则返回 getResult(i, 1, now) 即 key 所在结点 now 和其在结点的位置 i

注意点3:
但如果没找到呢?返回的是 getResult(i, 0, parent),这里就是 parent 和 i 的作用所在了
没找到的话,i 就表示 key 本来应该存放在 parent 中的位置,为插入和删除操作做准备

③ 打印操作

在了解插入和删除操作之前,大家可以先搞一下树形的打印,为过程中验证结果作准备
B 树的打印函数参考了:https://blog.youkuaiyun.com/Baibair/article/details/112055649 这篇博客
本人只是将其修改成 C 语言以及稍微美化一下,感谢这位师兄(应该是师兄吧~)

打印的具体逻辑就不废话了,直接上代码

/* 计算出整棵树上记录条数的总和 */
int CountKeyNum(BTree tree) {
	// 空树则为 0
	if (tree == NULL) return 0;

	// 计算所有子树记录条数总和
	int childTotal = 0;
	for (int i = 0; i <= tree->keyNum; i++) {
		childTotal += CountKeyNum(tree->child[i]);
	}

	// 子树加上自身的记录条数,即为总记录条数
	return tree->keyNum + childTotal;
}

/* 打印 B 树 */
void TraverseBTree(BTree tree) {
	// 动态创建一个数组,用于存放当前结点是否为最后结点的标识
	int nowNum = 0;
	int* isLast = (int*)malloc(sizeof(int) * (CountKeyNum(tree) + 10));
	
	// 打印树形
	printf("\n");
	PrintBTree(tree, isLast, nowNum);
}

/* 递归打印树形 */
void PrintBTree(BTree tree, int isLast[], int nowNum) {
	// 空树直接跳过
	if (tree == NULL) return;

	// 打印新节点先打印一下平台
	printf("┓\n");
	for (int i = 0; i <= tree->keyNum; i++) {
		if (tree->child[i] != NULL) {
			printBranch(isLast, nowNum);
			printf("┣━━━━━━");
			isLast[nowNum++] = (i == tree->keyNum);
			PrintBTree(tree->child[i], isLast, nowNum);
			nowNum--;
		}
		if (i != tree->keyNum) {
			printBranch(isLast, nowNum);
			printf("┣━ %d\n", tree->key[i+1]);
		}
	}
}

/* 打印树枝 */
void printBranch(int isLast[], int nowNum) {
	for (int i = 0; i < nowNum; i++) {
		if (isLast[i]) printf(" ");
		else printf("┃");
		printf("      ");
	}
}

下面是打印完的树形,还是非常清新的
在这里插入图片描述

④ 插入操作

接着是插入操作,该操作比查询要复杂许多
即便在理论层面上完全理解了该操作是什么样子的,但实现起来还是屡屡碰壁,插入思路如下:

  • 先通过查找操作获得结果集 Result,利用找到的结点 data 和 位置 i,直接将记录插入到对应位置中
    由于插入操作必然发生在叶子节点中,故插入后该记录的右孩子为 NULL
  • 然后判断是否超过关键字数量最大值,如果是的话则分裂
    分裂出来的左半部分存放在原结点,右半部分存放在新结点,中间部分单独作插入处理
  • 如果需要分裂的是根节点,则中间部分作为新根,左右孩子分别指向分裂好的左右半部分
    如果非根节点,则将中间部分插入到双亲结点中
  • 最后将结点移动到双亲结点,检查双亲是否也需要分裂,循环分裂

原理之后,实现代码如下:

/* 对 B 树执行插入操作 */
Status InsertBTree(BTree& tree, Record rcd) {
	// 树为空时,新建根节点
	if (tree == NULL) return InitBTree(tree, rcd);
	
	// 树非空时,先进行查找
	Result result = SearchBTree(tree, rcd->key);

	// 若记录存在,则插入失败
	if (result.tag == 1) return ERROR;
	// 记录不存在,则在找到的结点中插入新记录,该新记录的后继孩子为空
	InsertBTNode(result.data, result.i, rcd, NULL);

	// 判断本结点存放的关键字是否越界了,越界则进行分裂操作
	BTree newNode;
	while (result.data->keyNum > MAX_KEY) {
		// 分裂本结点,使其右半部分到 newNode 中
		SplitBTNode(result.data, newNode);

		// 如果结点为根节点,则直接生成新根
		if (result.data->parent == NULL) newRoot(tree, result.data->rcd[MID_KEY], result.data, newNode);
		// 否则将中间记录插入到双亲结点中,然后将 result.data 作为左孩子, newNode 作为右孩子
		else {
			// 找到中间记录需要插入到双亲的哪个位置
			int pi = Search(result.data->parent, result.data->key[MID_KEY]);
			// 将记录插入到双亲的 pi 位置,并将 newNode 作为右孩子
			InsertBTNode(result.data->parent, pi, result.data->rcd[MID_KEY], newNode);
		}
		
		// 将结果结点移动到其双亲,继续检测双亲是否需要分裂
		result.data = result.data->parent;
	}
	return OK;
}
注意点4:
整体逻辑这里需要注意的是分裂后对左右两个结点的处理,因为分裂可能发生在根和非终端结点中
如果是在根结点则需要新建一个初始值为中间值的根(中间值放到双亲位置上了),然后左右孩子分别是分裂出来的两部分
如果是在非终端节点,那首先需要得到中间值应该插入到双亲的位置,这里通过 Search 函数找到该位置(注意这里 i 代表关键字位置),然后分裂出来的右结点作为该位置的右孩子

插入函数实现了整体的逻辑,但重点还是在于各个辅助函数:InsertBTNode SplitBTNode 和 newRoot

/* 将新记录 rcd 插入到结点 node 的第 i 个位置,同时将新子节点 newNode 作为后继孩子 */
void InsertBTNode(BTree& node, int i, Record rcd, BTree newNode) {
	// 从最后面开始,依次将记录往后移动
	for (int j = node->keyNum; j >= i; j--) {
		node->key[j + 1] = node->key[j];
		node->rcd[j + 1] = node->rcd[j];
		node->child[j + 1] = node->child[j];
	}

	// 空出一个位置给新记录
	node->keyNum++;
	node->key[i] = rcd->key;
	node->rcd[i] = rcd;
	node->child[i] = newNode;

	// 如果新结点不为空,则将其双亲置为本结点
	if (newNode != NULL) newNode->parent = node;
}

InsertBTNode 函数实现了将记录和对应孩子插入到指定结点位置的功能,是个基本操作,后边很多地方会用到

/* 将 node 结点从中间分裂成两部分,前半部分留在原位,后半部分进入 newNode 并指向原结点的双亲 */
Status SplitBTNode(BTree& node, BTree& newNode) {
	// 生成新结点,并使其指向原结点 node 的双亲,作为兄弟存在
	newNode = (BTree)malloc(sizeof(BTNode));
	if (newNode == NULL) return OVERFLOW;

	newNode->keyNum = 0;
	newNode->parent = node->parent;

	// 中间值的右孩子应该作为新结点的左孩子
	newNode->child[0] = node->child[MID_KEY];
	// 同理中间值的右孩子双亲应指向新结点(这个非常非常重要,掉坑了。不修改的话,后边孩子的双亲关系会乱套,致命!)
	if (node->child[MID_KEY] != NULL) node->child[MID_KEY]->parent = newNode;
	// 将其余的孩子置空
	for (int i = 1; i <= M; i++) newNode->child[i] = NULL;

	// 中间值后面的数据全部搬到新结点
	for (int i = MID_KEY + 1, j = 1; i <= M; i++, j++) {
		InsertBTNode(newNode, j, node->rcd[i], node->child[i]);
		node->rcd[i] = NULL;
	}
	
	// 更新原结点的 keyNum
	node->keyNum = MID_KEY - 1;

	return OK;
}
注意点5:
分裂函数是插入操作最重要的一步,其中最重要的是对孩子归属的处理
新产生的新结点,双亲应该和受分裂的节点一样,而左孩子应该是中间值的右孩子(需双向设置双亲的孩子指针和孩子的双亲指针),而受分裂节点本身并不需要变化,孩子指针和双亲均没发生变化

可以说,分裂操作完成了新结点的双亲指定以及其与孩子的双向绑定,但双亲并没有将新结点作为孩子(这一点需要明确)
所以接下来的操作 newRoot 和 InsertBTNode 就是完成双亲和新结点的
完整的分裂过程可以看下面一张图帮助理解(4 阶 B 树)

可以看到,生成新节点后, 中间记录 4 的右孩子 ③ 需要作为新结点的左孩子,而记录 3 则不需要改变,依旧拥有左右俩个孩子以及正确指向了双亲。新结点的 5 6 记录及其右孩子,则由函数 InsertBTNode 完成

/* 生成一个新根 */
Status newRoot(BTree& root, Record rcd, BTree child1, BTree child2) {
	// 初始化根
	if (!InitBTree(root, rcd)) return ERROR;

	// 然后将根可能存在的俩个孩子赋值给根
	root->child[0] = child1;
	root->child[1] = child2;

	// 再将两个可能存在的孩子的双亲设置为该根
	if (child1 != NULL) child1->parent = root;
	if (child2 != NULL) child2->parent = root;

	return OK;
}

生成新根就比较好写了,就是将记录初始化为新树,然后将左右两部分分别作为左右孩子
因为有前边的分裂作为铺垫,新结点已经和孩子进行双向绑定,接下来就剩双亲来认领新结点了(图例如下)

至此插入操作已经完成了,集中最需要注意的地方是:每一次分配新结点,生成新根,都需要调整好双亲和孩子的关系,一定要保证双亲和孩子是双向绑定的(不能双亲认了孩子,孩子不认双亲,这种关系缺陷很隐蔽,反过来的话在打印阶段就能发现了)

⑤ 删除操作

最后就是重头戏了,删除操作,最难的地方就在于删除后对树形的调整
在调整的过程中,依旧要牢记一个问题:双亲和孩子的双向绑定(这是在踩了很多坑后总结出来的)

删除操作思路如下:

  • 首先依旧通过查询操作获得结果集 Result,找不到的话(tag=0)直接返回
    通过找到的结点判断其是否为最底层非终端结点,如果不是的话,则通过 Successor 函数找到要删除的 key 的前驱关键字,替换原关键字,转化为删除最底层非终端节点
  • 在删除最底层非终端结点时,我采取的方法是先记下该结点在双亲中的位置,好在后续需要向兄弟或者双亲借记录时,能快速找到它们。这里我的思路是先将要删除的 key 保留下来,然后再删除该 key,最后重新到该结点的双亲寻找该 key,可以预知,该 key 已经不存了不可能找到,所以能找到的只有其本应该在双亲中的位置,通过该位置就能快速定位到左右双亲以及左右兄弟
  • 删除完毕后,再进行树形的调整(重中之重)

首先先上整体逻辑代码

/* 对 B 树执行删除操作 */
Status DeleteBTree(BTree tree, KeyType key) {
	// 先找到需要删除的结点,不存在该节点则返回失败
	Result result = SearchBTree(tree, key);
	if (result.tag == 0) return UNSUCCESS;

	// 找到该结点后,若非最下层非终端结点,则处理成最下层非终端结点
	// 将前驱记录替换要删除的记录,并且 data 和 i 换成前驱结点及其记录的位置,即转化为删除最下层非终端结点
	if (result.data->child[0] != NULL) Successor(result.data, result.i);
	
	// 先将要删除的 key 保留下来
	key = result.data->key[result.i];
	// 再将指定的记录删除
	RemoveRecord(result.data, result.i);
	// 然后就可以通过保留的 key 找到要删除的记录在双亲结点的位置
	// 因为 key 已经被删除,所以可以定位到其本应该再双亲结点的位置,为下面调整树形做准备
	int pi = Search(result.data->parent, key) - 1;

	// 判断删除之后,若关键字数量小于最小数量,需另外处理
	// 如果是根,则不需要调整了,因为既然删除操作发生在了根,说明已经没有左右兄弟和双亲了
	if (result.data->parent != NULL && result.data->keyNum < MIN_KEY) RestoreBTree(result.data, pi);

	return OK;
}
注意点6:
这里的 RestoreBTree 函数,我和其他地方的不一样,参数 pi 并不是关键字在目标结点中的位置,而是整个目标节点在双亲节点中的位置,这样做的好处上边也提到了,可以很方便地找到左右双亲和左右兄弟
也可以注意到 pi = Search -1; 这一句,和上边的 child[i-1] 是一样的道理,都是找到双亲中孩子的位置

然后一步一步来,Successor 函数其实是做了两件事(在我的代码思路中是这样)
第一件是找到前驱关键字后,将其替换调原结点的记录值
第二件是将需要删除的结点及其位置换成前驱节点及其关键字的位置,为接下来继续删除提供条件

/*
* 找到非最底层非终端节点节点的前驱节点
* 并将结点 node 换成前驱结点
* 将位置 i 换成前驱节点需要删除的关键字的位置
*/
void Successor(BTree& node, int& i) {
	// 判非法输入(结点为空或为最底层非终端结g点)
	if (node == NULL) return;
	if (node->child[i - 1] == NULL) return;

	// 找到左子树最右边的值
	BTree search = node->child[i - 1];
	while (search->child[search->keyNum] != NULL) search = search->child[search->keyNum];

	// 替换掉原结点的的记录值
	node->key[i] = search->key[search->keyNum];
	node->rcd[i] = search->rcd[search->keyNum];

	// 最后修改结点和位置信息为前驱结点的信息
	node = search;
	i = search->keyNum;
}

调整完毕之后,就是正式删除最底层非终端结点了
按找思路先保存该结点在双亲中的位置,然后删除记录(代码如下)

/* 将指定结点中第 i 个记录移除 */
Status RemoveRecord(BTree& node, int i) {
	// 判空
	if (node == NULL) return UNSUCCESS;

	// 将所有记录前移
	for (; i < node->keyNum; i++) {
		node->key[i] = node->key[i + 1];
		node->rcd[i] = node->rcd[i + 1];
	}

	node->keyNum--;
	node->rcd[i] = NULL;

	return SUCCESS;
}

/* 将 rcd 插入到指定结点的第 i 个位置 */
Status InsertRecord(BTree& node, int i, Record rcd) {
	// 判空
	if (node == NULL) return UNSUCCESS;

	// 从最后面开始,依次将记录往后移动
	for (int j = node->keyNum; j >= i; j--) {
		node->key[j + 1] = node->key[j];
		node->rcd[j + 1] = node->rcd[j];
	}

	// 空出一个位置给新记录
	node->key[i] = rcd->key;
	node->rcd[i] = rcd;
	node->keyNum++;
}
注意点7:
删除记录这一部分,我省略了删除孩子指针的步骤,为什么呢?
这里是为了方便后续的树形调整函数
如果只删除记录的话,会导致孩子指针比原本多出一个,但好处也很明显,到后边有一个双亲记录替换的需求,我提供了只删除记录和只插入记录的操作,可以在不影响孩子指针的情况下完成记录值的替换

这个操作也不难理解,下面就是全文重点了,调整树形(代码如下)
里边所有的操作我都是基于基本操作和理论思路来的,按照下面思路走一遍应该就很清晰了

删除操作后树形需要调整的情况,无非就是某一结点的关键字数目小于最小的关键字数目了,需要向亲戚借
而借的对象有 4 个,左右兄弟和左右双亲,所以对应的可以分为 4 种情况:

  • ① 左兄弟有富余记录: 这时可以将左兄弟的记录值补到目标节点中
    • 但不能够直接交换值,因为左兄弟最大的值,也比左双亲的值小
      而目标节点作为左双亲的右孩子,值肯定都是需要比左双亲大的
    • 这时候,就只能先将左双亲的记录拿下来,然后将左兄弟的记录补回左双亲,完成一次交换
      且左双亲取下的记录为受调结点记录的最小值,应插入到第 1 个位置,而左兄弟需要拿最大记录补回去
      注意这时候双亲结点的孩子并没发生变化,双亲与孩子的双向绑定没影响到,只是完成了记录值的交换
    • 但对于左兄弟和目标节点来说,与孩子的双向绑定关系是需要改变的
      目标结点由于插入了一条来自双亲的记录,原本的左孩子需成为新记录的右孩子(原左孩子比双亲记录大)
      而新记录的左孩子空出来了,需要由左兄弟最大记录值的右孩子来填充(刚好该记录值补回双亲后,会多出最右边一个孩子,这个孩子比双亲记录值小,所以作为目标节点新记录值的左孩子刚刚好)
    • 以上操作相当于完成了一次右旋
  • ② 右兄弟有富余记录: 该情况和上边一种是对称关系,即需要完成一次左旋,但处理上略有不同
    • 首先依旧是不能直接交换值,而是将右双亲记录拿下来,右兄弟记录补上去
      右双亲的记录应插入到受调结点的最后一个位置,而右兄弟需要取第 1 个记录补回去
    • 然后对于孩子的绑定关系,右双亲记录插入到目标节点的最右边,空出来一个右孩子,由右兄弟第 0 个孩子作为补充
  • ③ 左右兄弟穷,先向左右双亲取(算 2 种情况): 这种情况不管双亲够不够借,都可以直接拿下来(来自爹妈的爱)
    • 这里向双亲取值和上边的过程差不多,依旧是将双亲的记录拿下来,再删除双亲的记录值
    • 但需要注意的是,已经没有来自左右孩子的记录补充了,所以双亲的孩子数量会减少 1
      此时就需要将受调兄弟与左右兄弟合并起来(向左双亲借则和左兄弟合并,向右双亲则和右兄弟)
      然后双亲的孩子指针也需要手动左移(上边也提到了,对记录的操作被设计为不影响孩子指针,此时就需要手动了)
    • 最后借完之后,还需要看看双亲是否也需要调整树形(孩子需要先拿去,自己不够再向亲戚借,类目了)
      但这也需要分情况,双亲已经是根结点以及不是根节点
      • 是根节点的话,说明双亲以及没有兄弟,也没有双亲了,也就意味着她,哪里都借不了
        而由 B 树定义我们知道,根节点最少有两棵子树,也就是最少 1 个关键字,这时候如果关键字数不够且是根节点的话,只有一种情况,那就是双亲已经没有关键字了,空空如也。而孩子合并之后,也只可能是该根节点的唯一孩子。所以这时候,老根就只能在最后的力气给予孩子之后沉眠,永远退出这棵 B 树,而唯一的孩子代替她成为新根(哇,没想到一个数据结构,也能产生这么一段感人的故事,这时我在写代码时没有注意到的,再次类目)
      • 如果双亲不是新根的话,那么好办,继续递归调整双亲的树形
注意点8:
上边提到的向双亲借的情况,需要俩兄弟合并,那这样做会不会使合并后的关键字数量超出范围呢?
答案是不会的,不用担心
因为按照定义,最小关键字数量是 (M-1)/2,而需要借的情况,在向双亲借了一个之后也是该值 (M-1)/2
而即便与之合并的兄弟节点关键字则是刚好达到最小关键字数(否则就去向兄弟借啦),也就是 (M-1)/2
那么两个值加起来,即两个关键字最小数相加,最多也只能是 M-1,刚好等于关键字最大数,并不会超出
/*
* 针对某个结点调整一颗 B 树
* node	:受调整的结点
* pi	:受调整结点在双亲结点的位置
*/
void RestoreBTree(BTree& node, int pi) {
	// 双亲和可能存在的左右兄弟
	BTree parent, brother;
	parent = node->parent;

	// 用于递归调整双亲的记录存储
	KeyType key;

	// 先看看左兄弟有没有可以借的
	if (pi > 0 && (brother = parent->child[pi - 1])->keyNum > MIN_KEY) {
		// 先将左双亲的记录拿下来(从左双亲拿的记录,必为受调整结点记录的最小值,直接插入到受调结点的第 1 位,插入后新记录的右孩子为原先的第 0 位孩子)
		// 这里是将第 0 位孩子插入到第 1 位,相当于第 0 和 1 俩个孩子是一样的,只不过为了将 0 好位置腾出来接纳左兄弟最大的孩子,和向右兄弟借的情况不太一样
		InsertBTNode(node, 1, parent->rcd[pi], node->child[0]);
		// 然后修改新记录的左孩子,应为左兄弟的最大的孩子
		node->child[0] = brother->child[brother->keyNum];
		if (brother->child[brother->keyNum] != NULL) brother->child[brother->keyNum]->parent = node;
		// 然后将左双亲记录删去(这里仅仅只是删除记录,而不动孩子指针,因为删除了该记录,还有来自左兄弟的记录补给呀)
		RemoveRecord(parent, pi);
		// 再将左兄弟的值补回左双亲(从左孩子拿的记录,必为左兄弟最大的一位,且插入到双亲的第 pi 位,做双亲右孩子不改变,插在第 pi 位右孩子位置为 pi)
		InsertRecord(parent, pi, brother->rcd[brother->keyNum]);
		// 最后将左兄弟对应记录删去
		RemoveRecord(brother, brother->keyNum);
	}
	// 再看看右兄弟,其操作思路同理左兄弟,只不过细节略微不同
	else if (pi < parent->keyNum && (brother = parent->child[pi + 1])->keyNum > MIN_KEY) {
		// 首先将右双亲的记录拿下来插入到最右边(比受调结点所有记录都大),且新记录的右孩子为右兄弟最小的孩子(该最小孩子是受调结点的最大孩子)
		// 这里直接将右兄弟最小孩子插入即可,不需要再挪动孩子位置了
		InsertBTNode(node, node->keyNum + 1, parent->rcd[pi + 1], brother->child[0]);
		// 然后继续删除右双亲记录,插入右兄弟记录,再删除右兄弟记录
		RemoveRecord(parent, pi + 1);
		InsertRecord(parent, pi + 1, brother->rcd[1]);
		RemoveRecord(brother, 1);
		// 最后多了一步,将左兄弟最小孩子的指针删去(这里将右兄弟第 1 条记录删除了,最左边的记录已经被拿走了,此时因为是在第一位对后边有影响,故需要删掉,不同于上边)
		for (int i=0; i <= brother->keyNum; i++) brother->child[i] = brother->child[i + 1];
	}
	// 都没有的话直接向双亲借,这里先向左双亲借
	else if (pi > 0) {
		brother = parent->child[pi - 1];
		// 先将左双亲的记录拿下来,同样将受调结点的最小孩子向右挪动一位
		InsertBTNode(node, 1, parent->rcd[pi], node->child[0]);
		// 然后将左双亲记录删去
		RemoveRecord(parent, pi);
		// 再将受调结点合并到左兄弟,并释放掉受调结点(这时候原本受调结点空出来的最小孩子,刚好由左兄弟的最大孩子填补了)
		CombineBTNode(brother, node);
		// 这时记得把 node 补回来,因为函数的设计默认是合并到左兄弟,而右兄弟被释放
		node = brother;
		// 最后将左双亲 pi 处的孩子指针也删去(因为左双亲的记录被拿走了,而且没有新记录补充)
		for (int i = pi; i <= parent->keyNum; i++) parent->child[i] = parent->child[i + 1];

		// 借完之后再看看双亲需不需要调整
		if (parent->keyNum < MIN_KEY) {
			// 如果需要调整的是根结点的话,此时的根节点必然没有关键字了(因为根节点最少的关键字数是 1)
			// 此时根节点必然只有一个孩子,所以直接将该孩子代替根节点,就解决了
			if (parent->parent == NULL) DeleteRoot(parent);
			// 否则的话继续调整双亲结点
			else RestoreBTree(parent, Search(parent->parent, node->key[1]) - 1);
		}
	}
	// 没有左双亲的话(受调结点在最左边了),则向右双亲借,操作思路同理,这里就不写注释了
	else {
		brother = parent->child[pi + 1];
		InsertBTNode(node, node->keyNum + 1, parent->rcd[pi + 1], brother->child[0]);
		RemoveRecord(parent, pi + 1);
		CombineBTNode(node, brother);
		// 最后将左双亲 pi 处的孩子指针也删去(因为左双亲的记录被拿走了,而且没有新记录补充)
		for (int i = pi + 1; i <= parent->keyNum; i++) parent->child[i] = parent->child[i + 1];

		if (parent->keyNum < MIN_KEY) {
			if (parent->parent == NULL) DeleteRoot(parent);
			else RestoreBTree(parent, Search(parent->parent, node->key[node->keyNum + 1]) - 1);
		}
	}
}
注意点9:
我们注意到,上边的左右兄弟使用的是 child[pi-1] 和 child[pi+1]
这代表的是相对于当前结点 child[pi] 来说的左右兄弟,当然就是 pi-1 和 pi+1 了
需要和上边的 child[pi-1] 和 child[pi] 表示左右孩子区分开来,这种情况是相对于记录来说的
(小声)本人其中一个坑就是把这俩搞混了
/* 将右结点合并到左结点,结果放在左结点 left 中,右结点 right 将被释放 */
void CombineBTNode(BTree& left, BTree& right) {
	// 判异常操作
	if (left == NULL) return;

	// 将右节点的记录和孩子依次插入到左节点的最右边
	for (int i = 1; i <= right->keyNum; i++) {
		InsertBTNode(left, left->keyNum + 1, right->rcd[i], right->child[i]);
	}

	// 释放右结点
	free(right);
}

对于合并操作,需要做的就只是将右兄弟合并到左兄弟
为什么是这个顺序呢,因为左兄弟比右兄弟小,在合并时能直接将右兄弟插入到左兄弟后边,很方便
这里就隐含了一个逻辑,左兄弟最大的孩子指针变成了原右兄弟最小的孩子了,填补了右兄弟最小孩子空缺的情况
当然合并起来后就是一个结点了,很融洽的一个结点,其和双亲的双向绑定关系并没有改变

/* 删除一个空根 */
void DeleteRoot(BTree& root) {
	// 空根只有一个孩子
	BTree child = root->child[0];

	// 将该孩子的数据全都搬到新根,再释放孩子就行
	// 这里需要额外将第 0 个孩子指针移动到根结点(注意了,孩子的双亲需要手动改!)
	root->child[0] = child->child[0];
	if (child->child[0] != NULL) child->child[0]->parent = root;
	for (int i = 1; i <= child->keyNum; i++) InsertBTNode(root, i, child->rcd[i], child->child[i]);
}

最后删除空根这里,可以看到我都是直接选择 0 号单元,因为空根就只有一个孩子了(关键字数为 0)
然后和往常一样,在搬动结点时,注意手动将结点最小的孩子和双亲进行双向绑定
因为 InsertBTNode 是从第 1 个孩子开始绑定的


—— 结语 ——

最后,文章到这里就结束啦
能坚持看到这里结束还是很不容易的
希望大家看完能收获满满,这是对我最大的认可啦~(当然如果能给点鼓励的话,会更开心噢 /·v·\)

我自认为本文是很用心在写了
可能文中还有很多细节没有提到,如果大家有哪些地方还有些模糊的话
也非常欢迎大家提出来,我会将解析更新回文章中,让文章变得更有营养!


星之所在,心之所向(IceClean)

B树之C语言实现(包括查找、删除、插入) 我在大二上学期的数据结构实验设计中选择了B树这个题目,该B树的数据结构实现采用了C语言。趁现在寒假整理完写一篇博文记录我的学习。文末提供了项目源代码的地址。 B树的定义 一棵m阶B树(Balanced Tree of order m),或为空树,或为满足下列特性对的m叉树。 树中每个结点最多含有m棵子树。 若根结点不是叶子结点,则至少有2个子树。 除根结点之外的所有非终端结点至... 阅读详情

相关推荐

B树的插入、删除算法思想及代码实现

算法思想看这个就可以添加链接描述 代码实现: 源程序(测试函数): (根据上面的链接的插入、删除步骤的测试) #include<stdio.h> #include<iostream> #include<malloc.h> #include<stdlib.h> using namespace std; #define m 5 // 几阶树 #define datatype int #include"标头.h" int main(

weixin_46080371的博客 682

【C语言】B树查找、插入、删除以及代码实现

B树的查找、插入、删除算法简单介绍,以及使用C语言进行实现。

weixin_44348253的博客 2257

C语言 B+树

【代码】C语言 B+树。

wentzheng的博客 686

B树总结(概念、操作及C语言实现)

一、B树定义 B树,又称多路平衡查找树,B树中所有结点孩子个数的最大值为B树的阶。m阶B树定义如下:空树或者满足如下特征的m叉树:   1)树中每个结点至多有m棵子树,即至多有m-1个关键字;   2)若根结点不是终端结点,则至少有2棵子树,即至少一个关键字;   3)除根节点外所有非叶结点至少有⌈m/2⌉\lceil m/2 \rceil⌈m/2⌉棵子树,即至少有⌈m/2⌉\lceil m/2 \rceil⌈m/2⌉-1个关键字;   4)所有非叶结点结构如下:    其中,Ki(i=0,1,2,…,n

qq_41699954的博客 1241

B_树的插入、删除、查找的算法(C语言描述).doc

c语言比如,一棵3阶B-树,m=3。它满足: (1)每个结点的孩子个数小于等于3。 (2)除根结点外,其他结点至少有=2个孩子。 (3)根结点有两个孩子结点。 (4)除根结点外的所有结点的n大于等于=1,小于等于2。 (5)所有叶结点都在同一层上。

HCIA-Storage 存储工程师学习笔记——1. 存储技术趋势

文章目录1. 存储技术趋势1.1 数据、信息与数据存储1.2 存储技术的发展①存储架构的发展②存储介质的发展③ 接口协议的发展1.3 存储产品的发展趋势 华为HCIA-Storage认证地址:https://e.huawei.com/cn/talent/#/cert/roadmap 1. 存储技术趋势 1.1 数据、信息与数据存储 什么是数据? ​ 数据是对所有事物的数字表示。 数据的种类? 结构化数据 半结构化数据 非结构化数据 数据的处理周期 ​ 数据处理是人或机器对数据进行的重组或重新排序,

超级大洋葱的博客 2846

数据结构——B-树(c++)

简介 在计算机科学中,B树(英语:B-tree)是一种自平衡的树,能够保持数据有序。这种数据结构能够让查找数据、顺序访问、插入数据及删除的动作,都在对数时间内完成。B树,概括来说是一个一般化的二叉查找树(binary search tree),可以拥有多于2个子节点。与自平衡二叉查找树不同,B树为系统大块数据的读写操作做了优化。B树减少定位记录时所经历的中间过程,从而加快存取速度。B树这种数据结构可以用来描述外部存储。这种数据结构常被应用在数据库和文件系统的实现上。 定义 一颗m阶B-T...

weixin_48560325的博客 3516

CLCD土地覆盖数据在ArcGIS中的实战应用:从导入到空间分析的完整指南

本文详细介绍了CLCD土地覆盖数据在ArcGIS中的实战应用,从数据导入到空间分析的完整流程。涵盖坐标系转换、数据质量检查、土地利用变化检测等核心技术,并分享城市扩张模拟和跨数据集融合等高级应用技巧,帮助研究者高效利用这一权威数据集进行环境监测和城市规划。

weixin_29292535的博客 591

C语言实现B-tree算法

C语言实现B-tree算法

m0_52041679的博客 302

《供应链金融风控模型设计:多维度数据整合与动态风险预警机制》

注:方案突出供应链金融「三流合一」(资金流、物流、信息流)的风控逻辑,通过多维度数据整合与动态预警,解决传统风控中中小企业信用评估难、链条风险传导监测滞后的问题。◦ 构建供应链拓扑图,计算「核心企业违约对上下游企业的影响系数」(如某电子厂商违约时,其前五大供应商的风险权重自动提升40%)• 区块链应用:将核心企业应付账款上链存证,防止「一票多融」欺诈(如某供应商用同一应收账款在多家银行融资)◦ 财务数据:核心企业审计报告、上下游企业纳税数据(增值税发票频次/金额)

2501_91651383的博客 1549

华为铁三角(AR、SR、FR)的考核指标设计

以和为核心,通过和实现协同。考核维度分为和,并随市场阶段动态调整权重。

从技术实践立足,从项目管理方法论延伸思考 2769

手搓B-树 全代码实现 算是半自主研发了吧(雾) 以洛谷P1177桶排验证

B-树实现。

qq_16762209的博客 441

解决 TensorFlow 和 Keras 的 Python 版本兼容问题:详细技术指南

本文提供了TensorFlow和Keras与Python版本不兼容问题的完整解决方案。通过版本对照表指出不同TensorFlow版本对应的Python支持范围,强调2.13.x仅支持Python 3.9-3.11,且不兼容Python 3.12+。给出最佳实践建议:使用虚拟环境管理项目依赖,推荐venv或conda创建独立环境;指导正确安装TensorFlow和Keras版本,建议优先使用tf.keras避免冲突;针对常见报错如DLL加载失败、模块导入错误等提供具体解决方法。最后给出一个在兼容环境中运行的M

专注于Python爬虫开发,分享爬虫技巧、项目实战与反爬经验,使用Scrapy、BeautifulSoup等工具,解决数据抓取难题。 1664

B树 详解及C语言简单实现

C语言B树的实现

Serendipity_Shy 1635

在macOS上使用GitHub的超详细指南

GitHub:免费的远程仓库,可用来托管自己的开源项目。它利用了Git强大的克隆和分支功能,让我们参与别人的开源项目和让别人参与自己的开源项目变得简单高效。

Mars的博客 3391

挣脱枷锁:网页禁止复制/F12失效/右键失灵时的破局之道

大家在网上冲浪的否遇到过这种情况?急需网页上的一段关键信息、一段优美的文字、一个重要的代码片段,或者只是想收藏某篇文章,却发现:文本选择被阻止,右键菜单被禁用,开发者工具(F12)被屏蔽,查看源代码(Ctrl+U)失效?本文会有力解决这些封锁。

安德洛夫 2109

B+tree详解及实现(C语言)

1 什么是B+tree 如果你还不了解B-tree,那么请自行了解。可以参考我的这篇帖子---B-tree详解及实现。 B+tree是B-tree的一个变种:在B-tree的基础上,为叶子节点增加链表指针,而且所有的关键字都在叶子节点中出现,且数据只存储在叶子节点中。非叶子节点的关键字仅作为叶子节点的索引。下图是一棵B+tree。 B+tree 的插入和删除基本和B-tree一样,只是多了数据结构多了next字段,所以要注意。

Coding365 2万+

IDEA的使用指南

IDEA基于java的使用手册,可以最快速的上手,在使用中不断的丰富自己的使用技巧.

B-树的插入、查找、删除

<br /><br /> 前面讨论的查找都是内查询算法,被查询的数据都在内存。当查询的数据放在外存,用平衡二叉树作磁盘文件的索引组织时,若以结点为内外存交换的单位,则找到需要的关键字之前,平均要进行lgn次磁盘读操作,而磁盘、光盘的读写时间要比随机存取的内存代价大得多。其二,外存的存取是以“页”为单位的,一页的大小通常是1024字节或2048字节。<br /> 针对上述特点,1972年R.Bayer和E.M.Cright提出了一种B-树的多路平衡查找树,以适合磁盘等直接存取设备上组织动态查找表。B-树上算法

hitheu的专栏 2125

迈瑞BS系列全自动生化分析仪LIS协议接口手册(内含HL7和ASTM)

迈瑞BS系列全自动生化分析仪LIS协议接口手册,内含HL7和ASTM)

LinePhone3.2.7(android端最新版本) sip客户端

SIP客户端For android:LinePhone3.2.7(最新版本),官网地址:http://www.linphone.org/

B-树的性质及插入和删除操作

B-树的性质及插入和删除操作

Jiangshang_jiu 1470

计算机控制技术及应用

《计算机控制技术及应用》通过总结作者的科研成果和吸收国内外的先进理论、方法和技术,以计算机控制系统的网络化、开放化、智能化和集成化发展趋势为主线索,主要介绍计算机控制系统的基本概念和计算机控制系统的典型形式,计算机控制系统的数学描述与基本分析方法,以及数字PID控制器、最少拍控制系统、达林算法、状态变量设计法等设计方法及其各种改进算法。重点突出计算机控制系统输入输出通道接口的设计方法和典型控制系统的设计案例与实现方法等。

B+tree - B+树深度解析+C语言实现+opencv绘图助解

B+tree很重要,它被广泛应用于数据库和文件系统中作为索引结构。全站讲B+tree的,基本上都是主讲b-tree,最后顺带一两句的B+tree。有一些能配图讲解b+tree的插入和删除,但也是部分场景,不全面。本文将深度解析B+tree,系统的讲清楚B+tree的实现细节。并使用opencv来绘制B+tree,协助理解B+tree。包括绘制一些关键的中间临时状态的B+tree,高亮关键节点展示算法过程等,以进一步理解B+tree的实现原理。

Mr.H的专栏 1133

数据结构(C):B-树的插入和删除

1.B-树 B-树定义:一种平衡的多路查找树。用作索引组织文件,可减少访问外存次数,提高访问速度、减少时间。一棵m阶B-树或为空树,或满足下列特性: 1、树中每个结点至多有m个子树;(结点中的关键字个数最多m-1) 2、若根结点不是叶子结点,至少有两棵子树(根至少有一个关键字) 3、除根结点外,其余非叶子结点至少有m/2(下取整)棵子树。(比关键字个数多一个) 4.关键字的个数,范围是: m/2...

i6223671的博客 6810

B-tree - B树深度解析+C语言实现+opencv绘图助解

看过很多讲btree的,大多一知半解。有一些能大概讲清原理,但是实现的代码漏洞百出。最近花了点时间整理了一下这些年的资料,建了个代码仓库,从0开始实现了btree。这里成文记录,争取把btree讲透彻。详细介绍btree的算法实现逻辑,用C语言实现一种Btree的算法,该算法代码:支持构建任意阶的Btree(只要满足m >= 3就行)支持存储任意类型的key代码尽量简洁易懂纯C实现,以便有需要的同学可直接拿去应用。当btree应用于内核这种更底层的场景时,C++实现的版本就不可用了。

Mr.H的专栏 1676

算法-B-树、B树、B+树详解

书接上文——算法-查找算法-树表的查找(二叉排序树、平衡二叉树)(类C语言版)B-tree树即B树,B即Balanced,平衡的意思。因为B树的原英文名称为B-tree,而国内很多人喜欢把B-tree译作B-树,其实,这是个非常不好的直译,很容易让人产生误解。如人们可能会以为B-树是一种树,而B树又是另一种树。而事实上是,B-tree就是指的B树。B-树,磁盘管理系统中的目录管理,以及数据库系统中的索引组织多数都采用B-树这种数据结构。一棵m阶的B-树,或为空树,或为满足下列特性的m叉树: (1)树中每个结

秃了也弱了 7313

B树的查找、插入、删除(附源代码)

B-Tree Index B-Tree搜索 B-Tree插入 分裂节点 插入节点 B-Tree删除 合并节点 删除节点 Basic B-Tree有两个比较重要的性质: 所有的leaf均在同一个level上 除了root之外,其它所有node中所储存的数据至少为Minimum Degree - 1,至多为Minimum Degree * 2 - 1

csao204282的博客 6585

B-树(插入删除)代码+逻辑

如果只有子树是t-1相邻的兄弟至少t个,则将x的一个关键字下移到子树中,将相邻的兄弟中的一个关键字上移到x中,相应的孩子指针也上移,使得x新增加一个关键字(直接返回不需要递归)删除关键字C,前一个孩子为AB,后一个孩子为DE(此处假设F已经被删掉了),均小于t=3,合并关键字C和结点DE到结点AB中,此时C在叶子结点中,直接删除。删除关键字C,其前一个孩子为AB,小于t=3,后一个孩子DEF不小于3,关键字C的直接后继为D,删除D,然后将C替换为D。当数据量很大时,内存不够,找磁盘。

Candy___i的博客 628
上一篇: 【C语言】打印二叉树树形(制表符实现,清晰+高拓展)(2022-10-22 更新—偏移量说明)
下一篇: 【MySQL之轨迹】MySQL 常见报错解决方案(Unit not found,Host ‘xxx‘ is not allowed,Can‘t connect to MySQL...)
评论 14
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

寒冰小澈IceClean

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值