目录

我们为什么需要 2-3-4 树?

2-3-4 树的插入操作

从零推导代码

B 树 (B-Tree)

从零推导代码


我们沿着自平衡树的演化路径继续前进。我们已经学习了 2-3 树如何通过“分裂与提升”来替代 AVL 树的“旋转”,但其修复过程是“自下而上”的。现在,我们来探讨一种更主动、更高效的平衡策略,它直接引出了 B 树的核心思想。

我们为什么需要 2-3-4 树?

👉 回顾 2-3 树的插入:我们先向下遍历到叶子节点,执行插入。如果叶子节点(一个 3-节点)溢出,我们就分裂它,然后将一个 key 向上提升。

这个提升过程可能会引发连锁反应,导致一路“分裂-提升”回到根节点。这是一个自下而上 (bottom-up) 的修复过程。

数据结构:2-3树的插入 (Insertion)-CSDN博客

这个过程包含两个阶段:

  • 第一阶段:从根向下遍历,找到插入点。

  • 第二阶段:如果发生分裂,再从叶子一路向上返回,进行修复。 在最坏的情况下,我们等于把从根到叶的路径走了两遍。

❓ 提出新问题:有没有办法只遍历一次就完成插入和平衡?我们能否在向下遍历的过程中,就“预处理”好路径,确保当我们最终到达叶子节点时,它一定有空间?

💡解决方案:“主动分裂”

这个想法非常巧妙:在我们自上而下 (top-down) 的遍历路径上,一旦遇到一个“满”的节点,就立刻主动将它分裂,然后再继续向下走。

在 2-3 树里,“满”的节点是 3-节点。但如果我们分裂一个 3-节点,提升的 key 需要插入到它的父节点中。如果父节点也是一个 3-节点,那不是又回到原点了吗?

✅ 为了让这个策略万无一失,我们需要引入一种新的节点类型,让“满”这个概念更清晰。我们引入 4-节点 (4-node)。

我们用例子:依次插入:10, 20, 25, 30, 40, 50

🌳 对照图:Bottom-up vs Top-down

插入 10
普通:          [10]             
主动:          [10]             插入 20
普通:          [10 | 20]        
主动:          [10 | 20]        插入 25
普通:   叶子溢出再分裂            主动:   根满 -> 先分裂[20]                          [20]/   \                        /     \[10]   [25]                  [10]     [25]插入 30
普通:         [20]                  主动:         [20]/   \                                /   \[10]  [25 | 30]                      [10]  [25 | 30]插入 40
普通:   到达右叶溢出后分裂          主动:   下探前发现右叶满 -> 先分裂[20 | 30]                              [20 | 30]/    |    \                            /    |    \[10]  [25]  [40]                      [10]  [25]  [40]插入 50
普通:         [20 | 30]               主动:         [20 | 30]/    |    \                            /    |    \[10]  [25]  [40 | 50]                 [10]  [25]  [40 | 50]

一眼看出区别

  • 25 插入时:

    • 普通插入:先插到 [10|20],发现满了,溢出后分裂。

    • 主动分裂:走到 [10|20] 时,先分裂再继续走。

  • 40 插入时:

    • 普通插入:一路插到 [25|30],插入变成 [25|30|40] 后,再分裂。

    • 主动分裂:往下走时,看到 [25|30] 已满,提前分裂,因此不需要回溯。

  • 最终树:两者 完全相同,但过程不同。

4-节点 (4-node):包含 3 个 key,有 4 个孩子。

现在,一棵 2-3-4 树就诞生了。它的节点可以是 2-节点、3-节点或 4-节点。它的“完美平衡”规则(所有叶子在同一层)保持不变。


2-3-4 树的插入操作

它的插入算法完全体现了“主动分裂”的思想,从而避免了向上的连锁反应。

算法推导

  1. 定义“满”节点:在 2-3-4 树中,只有 4-节点是“满”的。

  2. 向下遍历:从根节点开始,寻找 key 的插入路径。

  3. 主动分裂:在遍历路径上,只要遇到一个 4-节点,就立即执行分裂。

    • 将 4-节点的中间 key 向上提升到其父节点。

    • 剩下的两个 key 分裂成两个 2-节点,并接收原来 4-节点的 4 个孩子。

🔍分裂的优雅之处

  • 当我们分裂一个 4-节点时,它的父节点是什么状态?因为我们是自上而下分裂的,所以父节点一定不是一个 4-节点(如果是,它早被分裂了)。

  • 这意味着,父节点必然是 2-节点或 3-节点,它保证有空间来接收从下面提升上来的 key

  • 因此,分裂操作永远不会向上传播。它是一个局部的、常数时间的操作。

⚠️ 当我们最终到达叶子节点时,由于我们一路扫清了所有 4-节点,这个叶子节点必然不是 4-节点。它必定有空间。

最终直接将 key 插入这个有空间的叶子节点即可。

2-3-4 树的插入操作通过“自上而下”的主动分裂,将一个可能需要向上回溯的复杂过程,变成了一个单次向下遍历的简单过程,效率更高。


从零推导代码

步骤 1: 节点结构

我们需要一个能容纳 3 个 key 和 4 个 children 的结构。

// 2-3-4 树的节点结构
struct Node {int numKeys;int keys[3];Node* children[4];Node* parent; // 仍然有用,尤其是在分裂时Node(int key, Node* p = nullptr) {numKeys = 1;keys[0] = key;parent = p;for (int i = 0; i < 4; ++i) children[i] = nullptr;}// ... 其他辅助函数 ...bool isLeaf() const { return children[0] == nullptr; }void sort() { std::sort(keys, keys + numKeys); }
};

这个结构和我们之前为 2-3 树设计的“溢出”结构几乎一样,但在 2-3-4 树中,这是它的标准形态。

步骤 2: 插入入口和分裂逻辑

class Tree234 {
private:Node* root;// 分裂函数是核心void splitChild(Node* parent, int childIndex);public:Tree234() : root(nullptr) {}void insert(int key);
};void Tree234::insert(int key) {if (root == nullptr) {root = new Node(key);return;}Node* current = root;while (true) {// ----------------------------------------------------// 第一步:主动分裂满节点// ----------------------------------------------------if (current->numKeys == 3) {// 如果根节点是 4-节点,特殊处理if (current == root) {Node* newRoot = new Node(current->keys[1]);splitChild(newRoot, 0); // 伪代码,实际更复杂root = newRoot;} else {// 分裂内部节点// (这需要找到 current 在其父节点中的索引,然后分裂)}// 分裂后,需要从父节点重新开始判断往哪走,逻辑复杂// 让我们换一种更优雅的实现方式}// 如果是叶子节点,插入并结束if (current->isLeaf()) {current->keys[current->numKeys] = key;current->numKeys++;current->sort();return;}// ----------------------------------------------------// 第二步:继续向下遍历// ----------------------------------------------------// (找到正确的孩子,current = child)}
}

面的实现思路暴露了一个问题:在循环中分裂当前节点 current 后,需要重新调整 current 指针,代码会变得混乱。B 树的实现给出了一个更优雅的解决方案,我们马上会看到。

2-3-4 树的关键思想:它是 B 树的一个具体实例,并且它与另一种著名的自平衡树——红黑树(Red-Black Tree)是等价的,可以相互转换。它最重要的贡献是引入了自上而下的主动修复思想。


B 树 (B-Tree)

我们从 BST 开始,为了平衡引入了 AVL 树(旋转),然后探索了 2-3 树(分裂),再到 2-3-4 树(主动分裂)。这些结构都假设数据存储在内存 (RAM) 中。在内存中,指针跳转和 key 的比较速度都很快。

当数据量巨大,无法全部放入内存,必须存储在磁盘 (Hard Disk Drive, HDD)固态硬盘 (Solid State Drive, SSD) 上时,游戏规则彻底改变了。

  • 根本矛盾:CPU 访问内存的速度,比访问磁盘的速度要快数万倍甚至百万倍

  • 瓶颈分析:在对树进行操作时,从磁盘读取一个节点(Node)到内存中,是一次磁盘 I/O。这个 I/O 操作的时间开销,远远超过节点加载到内存后,我们对它进行的所有 key 的比较操作。

ℹ️ 既然磁盘 I/O 是最昂贵的操作,那么我们的算法设计的核心目标必须是:尽可能地减少磁盘 I/O 的次数。

💡 如何减少 I/O 次数?

  • 在树的查找过程中,I/O 的次数正比于树的高度。

  • 因此,我们必须不惜一切代价降低树的高度。

  • 思想飞跃:如何极限地压缩树的高度?让树变得极度地“矮胖”。怎么实现?让每个节点都变得巨大,可以容纳海量的 key

✅ B 树的诞生 B 树就是这个思想的直接产物。它是一个广义的 2-3-4 树。我们不再局限于 2、3、4 这几个数字,而是定义一个度 (degree) t

  • 每个节点最少有 t-1key,最多有 2t-1key

  • 每个节点最少有 t 个孩子,最多有 2t 个孩子(根节点除外)。

  • 关键设计:t 的选择,通常使得一个大小为 2t-1 的 B 树节点,其总体积正好等于一个磁盘页(Page)的大小(例如 4KB, 8KB, 16KB)。

📌我们每次执行一次磁盘 I/O,就能将成百上千个 key 加载到内存中。这使得树的分支因子(branching factor)极大,树的高度被极度压缩。一个存储了上亿个条目的 B 树,其高度可能只有 3 或 4 层!这意味着最多只需要 3-4 次磁盘读取就能找到任何数据。

B 树是为外部存储而生的终极平衡查找树。它通过让节点大小与磁盘块大小相匹配,将树高压缩到极致,从而最小化昂贵的磁盘 I/O 操作。


从零推导代码

步骤 1: 节点结构

keychildren 的数量不再固定,必须是动态分配的。

// B 树的节点
class BTreeNode {
public:int t;            // 最小度 (Minimum degree)int numKeys;      // 当前 key 的数量int* keys;        // key 数组 (大小为 2t-1)BTreeNode** children; // 孩子指针数组 (大小为 2t)bool isLeaf;      // 是否是叶子节点BTreeNode(int degree, bool leaf) {t = degree;isLeaf = leaf;// 根据度 t 动态分配内存keys = new int[2 * t - 1];children = new BTreeNode*[2 * t];numKeys = 0;}// ... 需要析构函数来释放内存 ...
};

代码解释

  • t (通常称为最小度) 是 B 树最重要的参数。

  • keyschildren 都是动态分配的指针,它们的大小由 t 决定。

  • isLeaf 标志非常重要。B 树明确区分叶子和内部节点,这能简化很多操作。

步骤 2: 插入操作的整体框架

B 树的插入完美地运用了 2-3-4 树的“自上而下主动分裂”思想。

class BTree {
private:BTreeNode* root;int t;// 私有辅助函数void insertNonFull(BTreeNode* node, int key);void splitChild(BTreeNode* parent, int childIndex);public:BTree(int degree) {root = nullptr;t = degree;}void insert(int key);
};void BTree::insert(int key) {// ----------------------------------------------------// 第一步:处理根节点// ----------------------------------------------------if (root == nullptr) {root = new BTreeNode(t, true);root->keys[0] = key;root->numKeys = 1;return;}// ----------------------------------------------------// 第二步:如果根节点满了,必须先分裂根// 这是保证“父节点必有空间”的前提// ----------------------------------------------------if (root->numKeys == 2 * t - 1) {// 创建一个新的根节点BTreeNode* newRoot = new BTreeNode(t, false); // 新根不是叶子newRoot->children[0] = root;// 分裂旧的根splitChild(newRoot, 0);// 更新树的根root = newRoot;// 现在从新根开始,为 key 找到正确的插入路径insertNonFull(root, key);} else {// 如果根没满,直接从根开始寻找插入位置insertNonFull(root, key);}
}

步骤 3: 核心辅助函数 splitChildinsertNonFull

// 分裂父节点的第 i 个孩子(这个孩子必须是满的)
void BTree::splitChild(BTreeNode* parent, int i) {BTreeNode* fullChild = parent->children[i];// 1. 创建新的兄弟节点BTreeNode* newSibling = new BTreeNode(t, fullChild->isLeaf);newSibling->numKeys = t - 1; // B树分裂后,每个新节点有 t-1 个 key// 2. 将满孩子节点的后 t-1 个 key 复制到新兄弟节点for (int j = 0; j < t - 1; j++) {newSibling->keys[j] = fullChild->keys[j + t];}// 3. 如果满孩子不是叶子,将其后 t 个孩子指针复制到新兄弟if (!fullChild->isLeaf) {for (int j = 0; j < t; j++) {newSibling->children[j] = fullChild->children[j + t];}}// 4. 更新满孩子节点的 key 数量fullChild->numKeys = t - 1;// 5. 将父节点中,i 位置之后的孩子指针向后移动一位for (int j = parent->numKeys; j >= i + 1; j--) {parent->children[j + 1] = parent->children[j];}// 6. 将新兄弟节点连接到父节点parent->children[i + 1] = newSibling;// 7. 将父节点中,i 位置之后的 key 向后移动一位for (int j = parent->numKeys - 1; j >= i; j--) {parent->keys[j + 1] = parent->keys[j];}// 8. 将满孩子节点的中间 key 提升到父节点parent->keys[i] = fullChild->keys[t - 1];parent->numKeys++;
}// 在一个“保证不满”的节点中插入 key
void BTree::insertNonFull(BTreeNode* node, int key) {int i = node->numKeys - 1;// ----------------------------------------------------// 情况一:当前节点是叶子节点// ----------------------------------------------------if (node->isLeaf) {// 从后往前找到 key 的插入位置,并移动元素while (i >= 0 && node->keys[i] > key) {node->keys[i + 1] = node->keys[i];i--;}node->keys[i + 1] = key;node->numKeys++;return;}// ----------------------------------------------------// 情况二:当前节点是内部节点// ----------------------------------------------------// 找到 key 应该插入的子树while (i >= 0 && node->keys[i] > key) {i--;}i++; // i 现在是目标子树的索引// 检查目标子树是否已满if (node->children[i]->numKeys == 2 * t - 1) {// 如果满了,就地分裂splitChild(node, i);// 分裂后,父节点增加了一个key,需要重新判断应该去哪个子树if (key > node->keys[i]) {i++;}}// 递归地向“保证不满”的子树插入insertNonFull(node->children[i], key);
}

代码解释

  • insert: 它的唯一职责是确保根节点在调用 insertNonFull 之前不是满的。它通过预先分裂根节点来做到这一点,这是整个算法优雅的关键。

  • insertNonFull: 这是递归的主体。它只处理不满的节点。在向下递归之前,它会“侦察”下一层的节点。如果目标子节点是满的,它会调用 splitChild 来处理,然后再安全地递归下去。

  • splitChild: 这是 B 树的核心操作。它精确地执行“分裂-提升”的逻辑,涉及到大量的数组操作,将一个满的节点分裂成两个半满的节点,并将一个 key 提升给父节点。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/bicheng/94708.shtml
繁体地址,请注明出处:http://hk.pswp.cn/bicheng/94708.shtml
英文地址,请注明出处:http://en.pswp.cn/bicheng/94708.shtml

如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Python爬虫实战:构建港口物流数据采集和分析系统

1. 引言 1.1 研究背景与意义 在全球化背景下,港口作为 “一带一路” 倡议的关键节点,其运营效率直接影响国际贸易流通速度。港口管理部门、物流企业及贸易公司需实时掌握船舶动态、货物吞吐量等信息以优化调度、降低成本。然而,这些信息分散于: 港口官方网站(如上海港、…

新型隐蔽恶意软件利用TP-Link、思科等路由器漏洞获取远程控制权

攻击概况安全研究人员近期发现针对多品牌网络设备的新型恶意软件攻击活动&#xff0c;受影响设备包括DrayTek、TP-Link、Raisecom和思科等厂商的路由器。2025年7月期间&#xff0c;攻击者通过利用嵌入式Web服务中的未授权命令注入漏洞传播隐蔽加载程序。初始入侵通过简单的HTTP…

对线性代数伴随矩阵的深入理解

伴随矩阵的几何直观&#xff1a;缩放倍率为det⁡(A)n−1\det (A)^{n-1}det(A)n−1的逆变换。 A⋅A∗∣A∣EA\cdot A^*|A|EA⋅A∗∣A∣E 最终得到的结果是将原像空间各基向量缩放了det⁡(A)\det (A)det(A)倍&#xff0c;故空间总体上是被放大了∣A∣n|A|^{n}∣A∣n倍。 为什么是…

uni-app 组件之自定义导航栏

前言上一篇简单的介绍了一下什么是组件&#xff0c;即组件是一个单独且可复用的功能模块的封装。所以这篇文章主要在实际开发中自己动手封装一个简单的导航栏组件&#xff0c;当然在插件市场有很多&#xff0c;但是自己动手封装一个才能真正领会其中的意义。一、自定义组件1.创…

android vehicle

Android Vehicle HAL架构-腾讯云开发者社区-腾讯云 Android vehicle车辆属性新增demo-CSDN博客 【IVI】VehicleService启动_android ivi-CSDN博客

【人工智能】神经网络的优化器optimizer(三):RMSProp动态自适应学习率优化器

一、算法核心原理 RMSProp&#xff08;Root Mean Square Propagation&#xff09;是深度学习先驱Geoffrey Hinton在2012年提出的优化算法&#xff0c;它基于AdaGrad算法的改进&#xff0c;创新性地解决了传统梯度下降法中学习率固定不变的局限性。该算法的核心机制在于采用指数…

全面解析了Java微服务架构的设计模式

一、引言&#xff1a;微服务架构的背景与优势随着互联网应用的复杂度不断提升&#xff0c;传统的单体架构&#xff08;Monolithic Architecture&#xff09;在可维护性、可扩展性、部署灵活性等方面逐渐暴露出瓶颈。微服务架构&#xff08;Microservices Architecture&#xff…

本地组策略编辑器图形化工具

本地组策略图形化工具&#xff0c;添加用户权限分配功能。这将包括常见的用户权限策略设置&#xff1a; 目前版本在优化中&#xff0c;后续会添加更多功能。 # GroupPolicyGUI.ps1 # 需要以管理员权限运行Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName …

深度学习卷积神经网络项目实战——超市商品分类

卷积神经网络项目实战 1.项目简介 1.1项目名称 ​ 基于CNN实现超市商品的混合颗粒度分类&#xff08;500分类&#xff09; 1.2 项目简介 ​ 该项目旨在通过卷积神经网络&#xff08;CNN&#xff09;实现超市商品的混合颗粒度分类&#xff0c;主要针对商品的不同种类进行分…

网站如何被搜索引擎收录(Google、Bing、百度等)

1. 【Google 收录】注册 Google Search Console&#xff1a; https://search.google.com/search-console添加网站&#xff08;主域名、子域名都加&#xff09;验证所有权&#xff08;用 DNS、HTML 文件、Meta Tag 都可以&#xff09;提交 Sitemap&#xff08;/sitemap.xml&…

JDK 8 → JDK 17 升级说明书(面向 Spring Boot / Spring Cloud / Spring )

自从 JDK 8 发布以来&#xff0c;Java 语言在持续进化&#xff0c;带来了许多新的功能和性能改进。而 JDK 17 作为一个长期支持版本&#xff08;LTS&#xff09;&#xff0c;在许多方面超越了 JDK 8&#xff0c;不仅提升了语言本身的能力&#xff0c;还进一步提高了性能、可维护…

【ElasticSearch】使用docker compose,通过编写yml安装es8.15和kibana可视化界面操作,go连接es

使用 Docker 安装 Elasticsearch Docker 搭建 Elasticsearch Kibana 环境&#xff0c;并在过程中标注常见问题和解决方案。 1. 准备工作 在开始之前&#xff0c;请确认你本地已经安装了&#xff1a; 工具版本建议检查方式Docker≥ 20.xdocker -vDocker Compose≥ 2.xdocker …

《C 语言文件操作补充:字符串格式化与随机读写全解析》

目录 一. sprintf函数和sscanf函数 1.1 sprintf 函数&#xff1a;将格式化数据写入字符串 1.2 sscanf 函数&#xff1a;从字符串中格式化读取数据 二. 文件的随机读写 2.1 fseek 函数&#xff1a;移动文件读写指针 2.2 ftell 函数&#xff1a;获取当前指针位置 2.3 rew…

SOME/IP-SD报文中 Entry Format(条目格式)-理解笔记4

逐字段解析 AUTOSAR SOME/IP Service Discovery 中的 Entry 格式。我们将它拆解成几个部分&#xff0c;并用清晰的排版和比喻来确保每个字段都得到解释。&#x1f4dc; Entry 的完整结构&#xff1a;三层蛋糕 一条完整的 SD Entry 信息就像一块三层蛋糕&#xff0c;从上到下分别…

在 vue3 和 vue2 中,computed 计算属性和 methods 方法区别是什么

在 Vue 2 和 Vue 3 中&#xff0c;computed&#xff08;计算属性&#xff09;和 methods&#xff08;方法&#xff09;都是处理数据逻辑的方式&#xff0c;但它们在缓存机制、使用场景、执行时机等方面有显著区别&#xff0c;且这些区别在两个版本中保持一致。 1. 缓存机制&…

android 改机系列之-虚拟摄像头-替换相机预览画面

Android Native 层实现跨进程 YUV 视频帧共享&#xff1a;基于抽象 Unix Socket 的高效通信方案。基于AOSP13源码或者lineage20 或相近版本。非hook 或者lsp 等插件方案。 1.引言 在某些定制化 Android 应用场景中&#xff0c;我们可能需要动态替换系统相机的预览画面 —— 例…

SSM从入门到实战:2.5 SQL映射文件与动态SQL

&#x1f44b; 大家好&#xff0c;我是 阿问学长&#xff01;专注于分享优质开源项目解析、毕业设计项目指导支持、幼小初高的教辅资料推荐等&#xff0c;欢迎关注交流&#xff01;&#x1f680; 12-SQL映射文件与动态SQL &#x1f4d6; 本文概述 本文是SSM框架系列MyBatis进…

vue+vite打包后的文件希望放在一个子目录下

比如我们常规操作是打包的项目文件直接放在域名下面。如果我们希望把项目放在子域名下面应该怎么处理呢&#xff1f;需要两个步骤vite.config.js里面指定base的路径假设我们希望放在子目录加做call那么我们可以这样base:/call/,注意不是build目录哈。return的最外层。如果本地和…

Java:Docx4j类库简介及使用

1.简介 Docx4j 是一个功能强大的 Java 类库&#xff0c;专门用于创建和操作 Microsoft Open XML 格式&#xff08;如 Word DOCX、PowerPoint PPTX 和 Excel XLSX&#xff09;的文件。它深受 Java 开发者喜爱&#xff0c;特别是在需要自动化处理 Office 文档的场景下。 下面是一…

【机械故障】旋转机械故障引起的振动信号调制效应概述

系列文章目录 提示&#xff1a;学习笔记 机械故障信号分析 共振峰 旋转机械故障引起的振动信号调制效应概述系列文章目录一、研究背景与意义二、故障引起的调制效应分类三、非平稳信号分析方法3.1 时频分析方法3.2 信号分解方法一、研究背景与意义 在工程实践中&#xff0c;可…