目录
一、B + 树的定义
B + 树是一种基于 B 树的改进数据结构,主要用于数据库索引等场景,以优化数据的查询和检索效率。其具有以下关键特性:
- 非叶节点仅存索引:非叶节点只包含键值,这些键值用于确定数据记录在树中的存储位置范围,起到索引的作用。例如,在一个存储学生成绩信息的 B + 树索引中,非叶节点的键值可能是成绩区间的划分值,如 [60, 80, 90],用于引导搜索到对应的叶节点子树。
- 数据集中于叶节点:所有的数据记录都存储在叶节点中,并且叶节点之间通过指针连接形成有序链表。这使得在进行范围查询时,可以方便地沿着链表顺序访问数据,无需在树的不同分支间频繁回溯。比如,要查询成绩在 70 到 85 之间的学生记录,找到起始叶节点后,可顺着链表依次获取符合条件的记录。
- 叶节点键值冗余且有序:叶节点中的键值不仅用于数据存储,还在链表中起到连接作用,并且与非叶节点的键值有对应关系,保证了数据的有序性。例如,叶节点存储学生成绩记录时,键值为成绩,同时这些键值在链表中有序排列,方便快速定位和遍历。
二、B + 树的节点结构
- 非叶节点:
- 键值数组(keys):存储用于索引的键值,数量有限且有序。例如,
int keys[MAX_KEYS];
,其中 MAX_KEYS 为设定的最大键值数量。 - 子节点指针数组(children):指针数量比键值数量多 1,用于指向子节点,确定数据所在的子树范围。如
BPlusNonLeafNode* children[MAX_KEYS + 1];
。 - 键值数量计数器(keyCount):记录当前节点中键值的实际数量。例如,
int keyCount;
。
- 键值数组(keys):存储用于索引的键值,数量有限且有序。例如,
- 叶节点:
- 键值数组(keys):存储数据记录对应的键值,且有序排列,如
int keys[MAX_KEYS];
。 - 数据记录数组(data):存放实际的数据内容,与键值一一对应。例如,
DataRecord data[MAX_KEYS];
,其中 DataRecord 是自定义的数据结构,包含具体的数据字段。 - 下一叶节点指针(next):用于连接叶节点形成有序链表,方便范围查询和顺序访问。如
BPlusLeafNode* next;
。 - 键值数量计数器(keyCount):记录叶节点中键值的数量,即数据记录的数量。例如,
int keyCount;
。
- 键值数组(keys):存储数据记录对应的键值,且有序排列,如
以下是简化的 C++ 结构体定义示例:
const int MAX_KEYS = 100; // 假设最大键值数量
// 非叶节点结构体
struct BPlusNonLeafNode {
int keys[MAX_KEYS];
BPlusNonLeafNode* children[MAX_KEYS + 1];
int keyCount;
BPlusNonLeafNode() {
keyCount = 0;
for (int i = 0; i < MAX_KEYS + 1; i++) {
children[i] = NULL;
}
}
};
// 叶节点结构体
struct BPlusLeafNode {
int keys[MAX_KEYS];
DataRecord data[MAX_KEYS];
BPlusLeafNode* next;
int keyCount;
BPlusLeafNode() {
keyCount = 0;
next = NULL;
}
};
三、B + 树的基本操作
(一)搜索操作
- 操作原理:从根节点开始,将目标键值与当前非叶节点的键值进行比较。根据比较结果确定目标键值可能存在的子树范围,然后递归地在对应的子树中继续搜索,直到到达叶节点。在叶节点中,进一步检查是否存在目标键值。例如,在搜索成绩为 85 的学生记录时,从根节点开始,若根节点键值为 [60, 90],因为 85 大于 60 且小于 90,所以进入对应的子树继续搜索,直到叶节点,再在叶节点中查找是否有键值为 85 的记录。
- C++ 代码实现:
// 搜索函数,返回叶节点指针
BPlusLeafNode* search(BPlusNonLeafNode* root, int target) {
BPlusNonLeafNode* current = root;
while (current!= NULL && current->children[0]!= NULL) {
int i = 0;
while (i < current->keyCount && target > current->keys[i]) {
i++;
}
current = current->children[i];
}
// 此时 current 为叶节点,在叶节点中查找目标键值
BPlusLeafNode* leaf = (BPlusLeafNode*)current;
for (int j = 0; j < leaf->keyCount; j++) {
if (leaf->keys[j] == target) {
return leaf;
}
}
return NULL; // 未找到目标键值
}
- 时间复杂度分析:搜索操作的时间复杂度与树的高度 h 相关,通常为 O (log_m n),其中 m 是节点的分支因子(子节点数量),n 是树中的总键值数量。由于 B + 树的高度相对较低,相比于二叉树的 O (log n),在大规模数据下具有更好的搜索性能。
- 空间复杂度分析:搜索操作主要依赖于递归调用栈的空间,最坏情况下递归深度为树的高度 h,所以空间复杂度为 O (h),由于 h 相对较小(与数据量 n 相比),空间消耗在可接受范围内。
(二)插入操作
- 操作原理:首先按照搜索操作找到目标键值应该插入的叶节点位置。如果叶节点未满,则直接将键值插入到合适位置并保持节点内键值有序,同时更新数据记录数组。若叶节点已满,需要进行节点分裂操作。将节点分裂为两个节点,中间键值提升到父节点(如果有),左右两侧的键值分别分配到新分裂的两个叶节点中,然后根据情况可能需要调整父节点以及更上层节点,以保持 B + 树的平衡和性质。插入操作可能会向上传播,直到根节点,若根节点也需要分裂,则创建新的根节点。例如,一个叶节点原本有 5 个键值 [70, 75, 80, 85, 90],当插入键值 82 时,节点已满,会将其分裂为两个节点,如 [70, 75, 80] 和 [82, 85, 90],中间键值 80 提升到父节点(如果存在)。
- C++ 代码实现(简化示意节点分裂部分):
// 向非满叶节点插入键值和数据
void insertNonFull(BPlusLeafNode* node, int key, DataRecord record) {
int i = node->keyCount - 1;
// 找到插入位置并移动键值腾出空间
while (i >= 0 && key < node->keys[i]) {
node->keys[i + 1] = node->keys[i];
node->data[i + 1] = node->data[i];
i--;
}
node->keys[i + 1] = key;
node->data[i + 1] = record;
node->keyCount++;
}
// 分裂叶节点
void splitLeaf(BPlusLeafNode* leaf, BPlusNonLeafNode* parent, int index) {
BPlusLeafNode* newLeaf = new BPlusLeafNode();
// 分配键值和数据到新叶节点
for (int j = MAX_KEYS / 2; j < MAX_KEYS; j++) {
newLeaf->keys[j - MAX_KEYS / 2] = leaf->keys[j];
newLeaf->data[j - MAX_KEYS / 2] = leaf->data[j];
}
newLeaf->keyCount = MAX_KEYS - MAX_KEYS / 2;
leaf->keyCount = MAX_KEYS / 2;
// 将新叶节点插入到父节点的子节点列表中
if (parent!= NULL) {
for (int j = parent->keyCount; j >= index + 1; j--) {
parent->children[j + 1] = parent->children[j];
}
parent->children[index + 1] = newLeaf;
// 调整父节点的键值
for (int j = parent->keyCount - 1; j >= index; j--) {
parent->keys[j + 1] = parent->keys[j];
}
parent->keys[index] = newLeaf->keys[0];
parent->keyCount++;
} else { // 如果分裂的是根节点的叶节点,创建新的根节点
BPlusNonLeafNode* newRoot = new BPlusNonLeafNode();
newRoot->keys[0] = newLeaf->keys[0];
newRoot->children[0] = leaf;
newRoot->children[1] = newLeaf;
newRoot->keyCount = 1;
// 更新根节点指针
root = newRoot;
}
// 连接叶节点链表
newLeaf->next = leaf->next;
leaf->next = newLeaf;
}
// 插入操作入口
void insert(BPlusNonLeafNode* root, int key, DataRecord record) {
BPlusLeafNode* leaf = search(root, key);
if (leaf!= NULL) { // 键值已存在,根据具体需求处理(如更新数据等)
return;
}
BPlusNonLeafNode* current = root;
while (current->children[0]!= NULL) {
int i = 0;
while (i < current->keyCount && key > current->keys[i]) {
i++;
}
current = current->children[i];
}
if (current->keyCount < MAX_KEYS) {
insertNonFull(current, key, record);
} else {
splitLeaf(current, NULL, 0);
insert(root, key, record); // 重新插入到分裂后的树中
}
}
- 时间复杂度分析:插入操作的时间复杂度主要由搜索插入位置和可能的节点分裂操作决定。搜索插入位置的时间复杂度为 O (log_m n),而节点分裂操作在最坏情况下可能需要从叶节点一直向上调整到根节点,每次分裂操作的时间复杂度为常数级,但由于树的高度 h 相对较小,总体时间复杂度仍为 O (log_m n)。
- 空间复杂度分析:插入操作除了可能创建新的节点(在节点分裂时)导致空间增加外,递归调用栈的空间复杂度为 O (h)。在最坏情况下,新创建的节点数量与树的高度 h 和节点分支因子 m 相关,但总体空间复杂度仍主要取决于节点存储,可近似认为是 O (n),其中 n 是树中的总键值数量,因为节点存储是主要的空间消耗部分。
(三)删除操作
- 操作原理:首先按照搜索操作找到要删除的键值所在的叶节点。如果叶节点的键值数量足够,直接删除该键值并调整节点内键值顺序,同时更新数据记录数组。若叶节点键值数量不足,可能需要从兄弟节点借键值或者与兄弟节点合并。如果是非叶节点,需要用其前驱或后继键值(通常是左子树的最大键值或右子树的最小键值)来替换要删除的键值,然后在对应的子树中删除该前驱或后继键值,这个过程可能会引发一系列的节点调整操作,如借键值、合并节点等,以保持 B + 树的平衡和性质。例如,要删除叶节点中的一个键值,若该叶节点的兄弟节点有多余键值,可以从兄弟节点借一个键值过来填补空缺;若兄弟节点键值数量也不足,则需要将该叶节点与兄弟节点合并,并调整父节点的键值和指针。
- C++ 代码实现(简化示意部分关键操作):
// 从叶节点删除键值和数据
void deleteFromLeaf(BPlusLeafNode* leaf, int key) {
int i = 0;
while (i < leaf->keyCount && leaf->keys[i]!= key) {
i++;
}
if (i < leaf->keyCount) {
// 移动键值和数据覆盖要删除的位置
for (int j = i; j < leaf->keyCount - 1; j++) {
leaf->keys[j] = leaf->keys[j + 1];
leaf->data[j] = leaf->data[j + 1];
}
leaf->keyCount--;
}
}
// 从非叶节点删除键值
void deleteFromNonLeaf(BPlusNonLeafNode* node, int key) {
int i = 0;
while (i < node->keyCount && node->keys[i]!= key) {
i++;
}
BPlusLeafNode* predecessor = getPredecessor(node->children[i]); // 获取前驱叶节点
node->keys[i] = predecessor->keys[predecessor->keyCount - 1]; // 用前驱键值替换要删除的键值
deleteFromLeaf(predecessor, predecessor->keys[predecessor->keyCount - 1]); // 在叶节点中删除前驱键值
}
// 合并叶节点
void mergeLeaves(BPlusLeafNode* leaf1, BPlusLeafNode* leaf2, BPlusNonLeafNode* parent, int index) {
// 将 leaf2 的键值和数据合并到 leaf1
for (int i = 0; i < leaf2->keyCount; i++) {
leaf1->keys[leaf1->keyCount + i] = leaf2->keys[i];
leaf1->data[leaf1->keyCount + i] = leaf2->data[i];
}
leaf1->keyCount += leaf2->keyCount;
// 更新叶节点链表
leaf1->next = leaf2->next;
// 从父节点删除指向 leaf2 的指针和键值
for (int i = index; i < parent->keyCount - 1; i++) {
parent->keys[i] = parent->keys[i + 1];
parent->children[i + 1] = parent->children[i + 2];
}
parent->keyCount--;
// 释放 leaf2 节点内存
delete leaf2;
}
// 删除操作入口
void deleteKey(BPlusNonLeafNode* root, int key) {
BPlusLeafNode* leaf = search(root, key);
if (leaf == NULL) return; // 键值不存在,直接返回
BPlusNonLeafNode* parent = getParent(root, leaf); // 获取叶节点的父节点
if (leaf->keyCount >= MIN_KEYS) { // 叶节点键值数量足够,直接删除
deleteFromLeaf(leaf, key);
} else { // 叶节点键值数量不足
int index = getIndex(parent, leaf); // 获取叶节点在父节点中的索引
BPlusLeafNode* leftSibling = (index > 0)? (BPlusLeafNode*)parent->children[index - 1] : NULL;
BPlusLeafNode* rightSibling = (index < parent->keyCount)? (BPlusLeafNode*)parent->children[index + 1] : NULL;
if (leftSibling!= NULL && leftSibling->keyCount > MIN_KEYS) { // 从左兄弟节点借键值
// 从左兄弟借键值并调整父节点键值
for (int i = leaf->keyCount; i > 0; i--) {
leaf->keys[i] = leaf->keys[i - 1];
leaf->data[i] = leaf->data[i - 1];
}
leaf->keys[0] = parent->keys[index - 1];
leaf->data[0] = getRecordFromLeaf(leftSibling, parent->keys[index - 1]);
parent->keys[index - 1] = leftSibling->keys[leftSibling->keyCount - 1];
leftSibling->keyCount--;
leaf->keyCount++;
} else if (rightSibling!= NULL && rightSibling->keyCount > MIN_KEYS) { // 从右兄弟节点借键值
// 从右兄弟借键值并调整父节点键值
leaf->keys[leaf->keyCount] = parent->keys[index];
leaf->data[leaf->keyCount] = getRecordFromLeaf(rightSibling, parent->keys[index]);
parent->keys[index] = rightSibling->keys[0];
for (int i = 0; i < rightSibling->keyCount - 1; i++) {
rightSibling->keys[i] = rightSibling->keys[i + 1];
rightSibling->data[i] = rightSibling->data[i + 1];
}
rightSibling->keyCount--;
leaf->keyCount++;
} else { // 与兄弟节点合并
if (leftSibling!= NULL) {
mergeLeaves(leftSibling, leaf, parent, index - 1);
} else {
mergeLeaves(leaf, rightSibling, parent, index);
}
// 递归删除父节点中可能多余的键值
if (parent->keyCount == 0 && parent!= root) {
BPlusNonLeafNode* grandparent = getParent(root, parent);
int pIndex = getIndex(grandparent, parent);
deleteKey(root, parent->keys[0]);
}
}
}
}
- 时间复杂度分析:删除操作类似于插入操作,时间复杂度主要由搜索删除位置和可能的节点调整操作决定。搜索删除位置的时间复杂度为 O (log_m n),节点调整操作在最坏情况下可能需要从叶节点向上调整到根节点,但由于树的高度 h 相对较小,总体时间复杂度仍为 O (log_m n)。
- 空间复杂度分析:删除操作可能会导致节点的合并或调整,从而影响空间使用。在最坏情况下,可能会减少节点数量,但总体空间复杂度仍主要由节点存储决定,可近似认为是 O (n),其中 n 是树中的总键值数量。递归调用栈的空间复杂度为 O (h),相对节点存储空间可忽略不计。
(四)修改操作
- 操作原理:先通过搜索操作找到要修改的键值所在的叶节点及对应的记录。然后根据具体的修改需求对数据记录进行更新。例如,如果是修改学生成绩信息,在找到对应的学生成绩记录后,直接修改成绩字段的值。由于 B + 树的数据都存储在叶节点,且叶节点之间有链表连接,这种结构方便定位和修改数据,同时在修改过程中,若涉及到键值的变化(如成绩修改后导致排序位置变化),可能需要进行类似于插入和删除操作的节点调整,以维持 B + 树的性质。
- C++ 代码实现(简化示意):
// 修改操作入口
void update(BPlusNonLeafNode* root, int key, DataRecord newRecord) {
BPlusLeafNode* leaf = search(root, key);
if (leaf == NULL) return; // 键值不存在,无法修改
// 找到键值对应的记录并更新
for (int i = 0; i < leaf->keyCount; i++) {
if (leaf->keys[i] == key) {
leaf->data[i] = newRecord;
// 若键值更新后需要调整树结构,此处可添加相应逻辑(类似插入或删除操作中的调整部分)
break;
}
}
}
- 时间复杂度分析:修改操作首先需要进行搜索操作来定位数据,时间复杂度为 O (log_m n)。如果键值更新后不需要调整树结构,那么修改操作的时间复杂度主要取决于搜索操作。若需要调整树结构,如因键值变化导致节点分裂或合并等情况,时间复杂度会增加到与插入或删除操作类似的 O (log_m n),但总体来说,在大多数情况下,修改操作的时间复杂度接近 O (log_m n)。
- 空间复杂度分析:与搜索操作类似,修改操作主要依赖于递归调用栈的空间,最坏情况下递归深度为树的高度 h,所以空间复杂度为 O (h)。如果在修改过程中需要进行节点调整,可能会增加额外的空间消耗,如创建新节点(在节点分裂时),但总体空间复杂度仍主要由节点存储决定,可近似认为是 O (n),其中 n 是树中的总键值数量,因为节点存储是主要的空间消耗部分,且在实际应用中,修改操作通常不会大幅改变树的节点数量和结构,对空间复杂度的影响相对较小。
B + 树因其独特的结构特点,在数据库索引等领域表现卓越。它有效地提高了数据的查询效率,特别是在范围查询和顺序访问方面。在大型数据库系统中,如企业级的客户关系管理系统(CRM)或电子商务平台的数据库,B + 树索引能够快速响应用户的各种查询请求,如查询某个时间段内的订单信息、获取特定价格范围内的商品列表等。同时,其相对稳定的时间复杂度和合理的空间利用,使得数据库系统在面对大规模数据和高并发查询时仍能保持良好的性能,为数据的高效存储和检索提供了强有力的支持。