数据结构:2-3 树 (2-3 Tree)

目录

为什么需要 2-3 树?

AVL 树的代价

思想的飞跃:另一种解决方案

什么是 2-3 树?

规则一:节点结构规则 (The Node Structure Rule)

规则二:完美平衡规则 (The Perfect Balance Rule)

如何创建一个 2-3 树?

2-3树的形状

2-3 树的创建

情况一:插入到一个“有空间”的叶子节点

情况二:插入到一个“已满”的叶子节点

情况三:分裂的连锁反应 (Cascading Split)

逐步完善代码

步骤 1: 定义节点结构

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

步骤 3: 实现节点内部的插入与分裂逻辑


我们从 AVL 树继续向前探索。AVL 树通过精巧的“旋转”操作来维持平衡,这是一种在节点结构固定的前提下(一个 key,两个子节点)调整树的拓扑结构的方法。

现在,我们要从第一性原理出发,提出一个全新的问题:为了维持树的平衡,我们是否必须依赖“旋转”?有没有其他方法?

为什么需要 2-3 树?

AVL 树的代价

AVL 树的平衡条件非常严格(高度差不超过1),一旦树的“形状”(拓扑结构)因为插入或删除而被破坏,就通过旋转 (Rotation) 操作,强行把“歪脖子”的树给“掰正”。这使得它在查找上效率极高。

但代价是,为了维持这种严格的平衡,插入和删除操作需要进行旋转,特别是删除,逻辑可能变得复杂,甚至可能需要一路旋转到根节点。

让我们回到平衡的本质——让树保持“矮胖”,避免出现细长的“长链”。AVL 树的思路是“如果树长歪了,就把它旋转掰正”。

思想的飞跃:另一种解决方案

现在,让我们挑战一下 AVL 树的核心思想。AVL 树的平衡是建立在“刚性”节点之上的。我们不禁要问:

为了维持平衡,我们一定需要“旋转”吗?有没有可能从节点本身入手?

现在我们换个思路:“允许节点本身变得更‘胖’,从而天生就能容纳更多分支,让树很难长高”。

这就是 2-3 树的出发点。它的革命性思想是:

  • 放弃节点的“刚性”,引入“弹性”节点:我们不再限制一个节点只能存 1 个 数据项(key)。如果我们允许一个节点变得更“胖”,比如可以存放 1 个或 2 个 key,会怎么样?

  • 用节点的“弹性”来吸收变化:当新数据到来时,我们不急于增加树的高度,而是尝试让节点“变胖”来容纳新数据。

  • 如果它存 1 个 key,它可以有 2 个孩子(就像普通 BST 节点)。

  • 如果它存 2 个 key,它可以有 3 个孩子。

这种设计天然地倾向于构建出更矮、更胖的树。树的高度越低,所有操作(查找、插入、删除)的平均路径就越短,效率自然就高。

更重要的是,它用一种全新的、更统一的机制来维持平衡,从而完全避免了复杂的旋转操作。

基于这种“弹性节点”思想的、最简单的一种多路搜索树(Multi-way Search Tree),就是 2-3 树 (2-3 Tree)。

我们需要 2-3 树,因为它代表了一种与 AVL 树完全不同的自平衡哲学。它不是在树“歪”了之后去“修正”,而是通过允许节点自身结构的灵活性,从根本上让树难以“长歪”。这为我们理解更高级的、在数据库和文件系统中广泛应用的 B-Tree (B树) 奠定了基础。


什么是 2-3 树?

理解了它的设计哲学后,我们来精确地定义它。一个 2-3 树是满足以下两个绝对规则的树:

规则一:节点结构规则 (The Node Structure Rule)

树中的每一个内部节点(非叶子节点)只能是以下两种类型之一:

2-节点 (2-node)

  • 包含 1 个 数据项(key)。

  • 有 2 个 孩子(left, right)。

  • 这和普通二叉搜索树的节点完全一样。左子树的所有 key < 节点的 key < 右子树的所有 key

      [ k1 ]
     /      \
(< k1)      (> k1)

3-节点 (3-node)

  • 包含 2 个 数据项(key),我们按大小顺序称之为 smallKeylargeKey

  • 有 3 个 孩子(left, middle, right)。

  • 左子树的所有 key < smallKey

  • 中子树的所有 key > smallKey 且 < largeKey

  • 右子树的所有 key > largeKey

    [ sk | lk ]
   /    |    \
(< sk) (>sk, <lk) (> lk)

规则二:完美平衡规则 (The Perfect Balance Rule)

所有的叶子节点(leaf nodes)必须位于树的同一层。

这个规则非常强大,它比 AVL 树的“高度差不超过1”要严格得多。它保证了 2-3 树永远是完美水平的,从根到任何一个叶子的路径长度都完全相等。这直接保证了树的查找性能始终是 O(log N)。


如何创建一个 2-3 树?

我们关注的不是动态地构建一棵树的过程,而是静态地理解一个有效的 2-3 树应该是什么样子。一个“被正确创建”的 2-3 树,必须严格遵守上面提到的两条规则。

2-3树的形状

让我们来看几个例子:

示例 1:一个最小的 2-3 树

      [ 10 ]

这是一个只包含一个根节点的树。它是一个 2-节点。它的所有叶子节点(这里是 NULL 指针)都在同一层(第1层)。它是一个有效的 2-3 树。

示例 2:一个包含 3-节点的简单树

    [ 10 | 20 ]

这也是一个只包含一个根节点的树。它是一个 3-节点。它的所有叶子节点(NULL 指针)都在同一层。它也是一个有效的 2-3 树。

示例 3:一个高度为 2 的 2-3 树

             [ 15 ]
            /      \
         [ 8 ]    [ 25 | 30 ]

让我们来验证它:

  1. 节点规则:根节点 [15] 是一个 2-节点。它的左孩子 [8] 是一个 2-节点,右孩子 [25 | 30] 是一个 3-节点。所有节点都符合规则。

  2. 平衡规则:叶子节点是 [8] 的左右 NULL 孩子,以及 [25 | 30] 的左中右 NULL 孩子。它们都位于同一层。规则满足。

  3. 搜索树规则[8] 小于 15[25 | 30] 都大于 15。规则满足。

所以,这是一个被“正确创建”的 2-3 树。

示例 4:一个根为 3-节点的树

          [ 10 | 20 ]
         /      |      \
      [ 5 ]    [ 15 ]    [ 25 ]

同样,我们可以验证它:

  1. 节点规则:根是 3-节点,三个孩子都是 2-节点。满足。

  2. 平衡规则:所有叶子节点([5], [15], [25]NULL 孩子)都在同一层。满足。

  3. 搜索树规则5 < 1010 < 15 < 2025 > 20。满足。

这是一个有效的 2-3 树。


2-3 树的创建

2-3 树的优雅之处在于,它通过一个统一的操作——分裂 (Splitting)提升 (Promotion) 来始终维持完美的平衡。

我们来一步步推导插入一个新 key 的过程。

核心原则:插入操作永远从根节点开始查找,最终一定会落在一个叶子节点上。

情况一:插入到一个“有空间”的叶子节点

这是最简单的情况。我们找到了一个目标叶子节点,并且这个节点是一个 2-节点

  • 推导:一个 2-节点只有一个 key,但我们的节点设计允许它容纳两个 key。所以它还有空间。

  • 操作:直接将新的 key 放入这个 2-节点中。这个节点就从一个 2-节点变成了一个 3-节点。

  • 结果:操作完成。树的平衡性没有被破坏,因为所有叶子节点依然在同一层,树的高度没有增加。

示例:向 [ 8 ] 中插入 12

    [ 8 ]   -- 插入 12 -->    [ 8 | 12 ]

情况二:插入到一个“已满”的叶子节点

这是 2-3 tree 的精髓所在。当我们试图向一个已经是 3-节点 的叶子节点插入新 key 时,这个节点就没有空间了。

如果我们把新 key 硬塞进去,这个节点就会临时拥有 3 个 key(比如 smallKey, middleKey, largeKey)。这违反了 2-3 树的定义,我们称之为一个“临时的 4-节点”。必须立即解决这个问题。

操作:分裂 (Split) 与提升 (Promote)

  1. 排序:将临时的 3 个 key 进行排序。

  2. 提升 (Promote):将中间那个 key 向上提升到它的父节点中。

  3. 分裂 (Split):将剩下最小的 key 和最大的 key 分裂成两个独立的 2-节点

  4. 连接:这两个新分裂出的 2-节点,成为刚刚接收了“提升 key”的父节点的孩子。

示例:向 [ 8 | 12 ] 中插入 10

  1. 临时节点:节点暂时变为 [ 8 | 10 | 12 ]

  2. 提升与分裂:

  • 中间值 10 将被提升。

  • 812 将被分裂成两个新节点。

如果 [ 8 | 12 ] 是根节点(即树只有一个节点),那么:

    [ 8 | 12 ] -- 插入 10 --> [ 8 | 10 | 12 ] (临时)

                 [ 10 ]      <-- 10 被提升为新的根
                /      \
             [ 8 ]    [ 12 ]   <-- 8 和 12 分裂成两个子节点

看到了吗?这是 2-3 树增加高度的唯一方式:当根节点分裂时,树的高度才会 +1。并且增加之后,所有叶子节点(现在是 [ 8 ][ 12 ])依然在同一层。完美平衡得以维持!


情况三:分裂的连锁反应 (Cascading Split)

如果我们将一个 key 提升到父节点,而父节点本身也已经是一个 3-节点,该怎么办?

  • 推导:这和情况二的逻辑完全一样!父节点接收了这个从下面提升上来的 key 后,自己也变成了一个临时的“4-节点”。

  • 操作:那就对父节点也进行一次“分裂与提升”操作。把父节点的中间 key 再一次向上提升到它的父节点(也就是祖父节点)。

  • 结果:这个分裂过程可能会像多米诺骨牌一样,一层一层地向上传递,直到:

    • 遇到一个 2-节点的祖先,该祖先“吸收”了提升上来的 key,变成了 3-节点,连锁反应停止。

    • 或者,一直传递到根节点,最终导致根节点分裂,树的高度增加 1。


逐步完善代码

实现 2-3 树比二叉树要复杂,关键在于如何定义节点。

步骤 1: 定义节点结构

一个节点需要能存储最多 2 个 key 和 3 个孩子。为了方便分裂和提升,有一个指向父节点的指针会非常有帮助。

// C/C++ 风格的节点定义
struct Node {
    // 数据
    int numKeys;      // 存储的key的数量 (1 或 2)
    int smallKey;     // 如果是2-node, 这是唯一的key。如果是3-node, 这是较小的key。
    int largeKey;     // 只有3-node才有效

    // 连接
    Node* left;       // 左孩子
    Node* middle;     // 中孩子 (只有3-node才有效)
    Node* right;      // 右孩子

    Node* parent;     // 指向父节点,对分裂提升操作很有用

    // 构造函数,方便创建一个新的2-node
    Node(int key, Node* p) {
        numKeys = 1;
        smallKey = key;
        parent = p;
        left = middle = right = NULL;
    }
};

// 全局的树根
Node* root = NULL;

代码讲解

  • numKeys 是一个状态变量,用来区分这是一个 2-节点还是 3-节点。

  • 我们设计了 smallKeylargeKey。当 numKeys = 1 时,只有 smallKey 有效。

  • middle 指针也只在 numKeys = 2 时(即 3-节点)有效。

  • 我们加入了 parent 指针,虽然会增加一些指针维护的开销,但它能极大简化分裂时找到父节点并连接的过程。

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

插入操作是一个递归的过程。我们需要一个主函数和一个递归的辅助函数。

// (这里先不写出具体实现,只展示框架)

// 主插入函数
void insert(int key) {
    // 1. 如果树为空,创建根节点
    if (root == NULL) {
        root = new Node(key, NULL);
        return;
    }
    
    // 2. 找到合适的叶子节点来插入
    Node* leaf = findLeaf(root, key);

    // 3. 在叶子节点上执行插入和可能的分裂操作
    insertIntoNode(leaf, key);
}

这个框架看起来很简单,但魔鬼都在细节里,特别是 insertIntoNode,它需要处理分裂和连锁反应。findLeaf 只是一个简单的搜索,我们暂时略过。

我们聚焦于 insertIntoNode 如何实现。它需要处理我们前面推导的情况一(2-节点变3-节点)和情况二/三(3-节点分裂)。

步骤 3: 实现节点内部的插入与分裂逻辑

这是一个更完善的插入逻辑,它体现了“分裂-提升”的循环。

// 向节点 p 中插入 key。如果 p 分裂, newChild 会指向分裂出的新右侧节点
// 该函数会返回被提升的 key 的指针,如果没有提升则返回 NULL
int* insertIntoNode(Node* p, int key, Node*& newChild) {
    // 如果是叶子节点
    if (/* p is a leaf */) {
        if (p->numKeys == 1) { // 情况一:叶子是2-node
            // 直接插入,变成3-node
            addKeyTo2Node(p, key);
            newChild = NULL; // 没有分裂
            return NULL;     // 没有提升
        } else { // 情况二:叶子是3-node
            // 需要分裂和提升
            return splitAndPromote(p, key, newChild);
        }
    } else { // 如果是内部节点 (这是递归的下一步,现在先不实现)
        // ...
    }
}

可以看到,逻辑正在变得复杂。为了保持清晰,我们最好将“分裂和提升”这个核心操作封装成一个独立的函数。

这是对 2-3 树是什么、为什么需要它,以及它最核心的创建/插入操作的“第一性原理”推导。我们已经定义了节点结构,并勾勒出了插入函数的框架,揭示了其核心是处理节点的“饱和”与“分裂”。下一步就是用代码完整实现这个分裂和连锁提升的逻辑。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值