1. 从二叉搜索树退化聊起:为什么非要有 2-3-4 树
很多初学数据结构的朋友第一个接触的树结构就是二叉搜索树(BST),当时觉得这玩意儿挺完美:左小右大,中序遍历有序,查找、插入、删除看起来都是 O(log n)。但实际写代码跑数据就会发现问题——BST 的性能完全取决于插入顺序。
你按 1、2、3、4、5 的顺序插入节点,得到的就是一棵只有右孩子的链表,查找 5 要遍历 5 个节点。数据量到十万、百万级别时,这种退化是灾难性的。AVL 树通过旋转解决了这个问题,强制左右子树高度差不超过 1,但代价是插入删除时旋转操作频繁,代码实现也比较绕。红黑树放宽了平衡条件,用颜色标记和局部旋转把插入删除的调整控制在了常数级别,但它那套“红黑性质”初看简直像魔法,很多人背了规则却不懂为什么。
2-3-4 树的意义恰恰在于:它用一种更直观的方式回答了“如何让树始终平衡”这个问题——不靠旋转,靠节点分裂和合并;不限制节点只能存一个关键字,而是让节点可以存 1 到 3 个关键字、拥有 2 到 4 个孩子。因为每次插入都在叶子节点进行,叶子深度统一增加,整棵树永远保持完美平衡,所有叶子在同一层,高度严格等于 ⌈log₄N⌉ 到 ⌈log₂N⌉ 之间。
我第一次认真啃 2-3-4 树是在研究数据库索引原理的时候,后来才发现这棵树就是 B 树的四阶特例,理解了它,B 树、B+ 树都不再是背概念,而是自然而然的延伸。这篇文章我会把 2-3-4 树的定义、查找、插入、删除全部掰开揉碎讲清楚,给出完整可运行的代码,最后再讲透它和红黑树的等价关系。整篇更适合已经学过二叉树、对递归有一定感觉的读者,当然只要你愿意多看两遍,零基础也能跟下来。
2. 节点定义与树的结构:一个节点最多三个关键字
2-3-4 树的名字来源于节点的孩子数量。每个节点要么有 2 个孩子(此时存 1 个关键字)、要么有 3 个孩子(此时存 2 个关键字)、要么有 4 个孩子(此时存 3 个关键字)。叶节点可以没有孩子,但依然遵守关键字数量的约束。
这里有个很容易混淆的地方:很多人看到“2-3-4”以为是四种节点形态,其实指的是一个节点可能拥有的孩子数,不是关键字数。关键字数和孩子数的关系固定为:
| 节点类型 | 孩子数 | 关键字数 | 结构示意 |
|---|---|---|---|
| 2-节点 | 2 | 1 | [key] 左子树 < key < 右子树 |
| 3-节点 | 3 | 2 | [k1, k2] 左 < k1 < 中 < k2 < 右 |
| 4-节点 | 4 | 3 | [k1, k2, k3] 左 < k1 < 中1 < k2 < 中2 < k3 < 右 |
每个节点的关键字按升序排列,所有关键字互不重复。子树之间的顺序关系保持和 BST 一样的性质:某一棵子树里的所有关键字,都落在它对应的两个相邻关键字之间。你可以把 2-3-4 树理解成“多个 BST 节点合并成了一个超级节点”,只不过这个超级节点自己维护了内部的关键字顺序。
节点结构用 C++ 定义的话,最简单的做法是用固定大小的数组:
enum NodeType { NODE_2, NODE_3, NODE_4 }; struct Node { int keys[3]; // 最多3个关键字 Node* children[4]; // 最多4个孩子 int keyCount; // 当前实际关键字数量 1~3 bool isLeaf; // 是否为叶节点 Node() : keyCount(0), isLeaf(true) { for (int i = 0; i < 4; ++i) children[i] = nullptr; } };如果你用过 B 树的实现,会发现这个结构基本就是 B 树节点的缩小版。实际上你把代码里的孩子数上限从 4 改成 M,关键字数上限从 3 改成 M-1,就得到了一棵 M 阶 B 树的雏形。所以从这个角度讲,2-3-4 树是你通往 B 树体系最好的敲门砖。
树的高度是另一个值得注意的点。因为有多个关键字合并存储,2-3-4 树的高度明显比同样数据量的 BST 矮。一个存了 N 个关键字的 2-3-4 树,高度最小时的所有节点都是 4-节点,此时高度约等于 ⌈log₄N⌉;高度最大时所有节点都是 2-节点,退化为满二叉树,高度约等于 ⌈log₂N⌉。这个“矮”带来的直接好处是查找时磁盘 IO 次数减少——数据库索引用 B 树而不是二叉树的根本原因也在这里。
3. 查找操作:和 BST 几乎一样的思路
查找是 2-3-4 树里最简单的操作,它和二叉搜索树的查找本质相同,只不过每个节点要比较的不再是一个关键字,而是一组关键字。
从根节点开始,在当前节点的关键字数组里做顺序查找(因为最多 3 个关键字,顺序查找比二分更快),如果命中直接返回;如果没命中,根据待查找值落在哪个区间,进入对应的孩子子树继续递归。到叶子节点还没命中,说明关键字不存在。
bool search(Node* root, int key) { Node* cur = root; while (cur) { int i = 0; while (i < cur->keyCount && key > cur->keys[i]) ++i; if (i < cur->keyCount && key == cur->keys[i]) { return true; } if (cur->isLeaf) { return false; } cur = cur->children[i]; } return false; }注意两个细节。
第一个:while (i < keyCount && key > keys[i])这个循环结束后,i 指向第一个大于等于 key 的位置。如果这个位置关键字恰好等于 key,说明找到了;否则 key 应该落在 children[i] 代表的子树里。举例来说,当前节点是 3-节点 [20, 40],要查 35,循环后 i = 1,因为 35 > 20 但 35 < 40(循环到 keys[1] 时条件不成立),35 应该去 children[1] 这棵子树里找——也就是介于 20 和 40 之间的那棵子树。
第二个:递归可以改写成上面的非递归形式,因为查找永远只沿着一条路径向下,不需要回溯。非递归版本在 C++ 里更有效率,也更好调试,建议直接写成这种形式。
查找复杂度方面,最坏情况要比较 3 个关键字再下移一层,所以单次查找的 CPU 比较次数最多约为 3 × ⌈log₂N⌉,但树的高度比 BST 矮很多,整体查找性能依然稳定在 O(log N)。而且实际场景里,节点内部的关键字通常是连续内存存储,顺序扫描 3 个整数在 CPU 缓存层面几乎无开销,所以没有必要用二分查找。
4. 插入操作:自底向上分裂,还是自顶向下预分裂
4.1 自底向上的标准流程
2-3-4 树插入最朴素的理解是模拟“多路搜索树的自然生长”。新关键字总是插入到叶子节点,如果叶子节点有空间(关键字数 < 3),直接放进去并保持有序即可。
麻烦的是插入后叶子节点变成 5-节点(4 个关键字 + 5 个孩子),这在 2-3-4 树里是不合法的。处理办法是把溢出的 4-节点“分裂”成两个 2-节点,并把中间关键字上交给父节点。如果父节点因此也溢出,就继续向上传递,直到根节点。如果根节点也溢出,就分裂根节点,树的高度增加一层。
这个流程描述起来很清晰,但代码实现比较繁琐:你需要维护从根到目标叶子的完整路径,插入后逐层回溯,处理每一层可能的溢出,还要小心地维护 children 指针的移动。我第一次写这个版本时,调试指针边界花了大半天。
4.2 自顶向下预分裂:让代码简单一个量级
传统 B 树教学里常用自底向上分裂,但在 2-3-4 树中,自顶向下预分裂的思路才是更实用的方案。核心思想:在沿路径向下搜索插入位置的过程中,凡是遇到 4-节点就当场分裂,把中间关键字上交给父节点,并拆成两个 2-节点。这样保证到达叶子时,叶子一定不是 4-节点,插入后最多变成 4-节点,绝对不会向上溢出。
这个“先分裂、后插入”的策略把可能的级联调整消灭在了路径探索阶段,插入结束后不需要任何回溯修正。代码写起来像 BST 插入一样顺,只是每个节点进入前多了一步检查。
那预分裂的具体规则是什么?
- 如果根节点是 4-节点,分裂根节点:中间关键字上移成为新根,左右两个关键字变成两个新孩子节点。树的高度 +1。
- 如果路径上的某个节点是 4-节点,将其分裂:中间关键字上移到父节点,剩下的两个关键字各自作为独立节点,并挂接到父节点对应位置。
举个具体例子,假如当前树根节点是 4-节点 [50, 70, 90],现在要插入 60。根节点是 4-节点,先分裂它:70 上移成为新根,50 和 90 各成一个节点,作为新根的左右孩子。此时 70 的左孩子是 [50],右孩子是 [90],然后继续往下走找 60 的位置——60 大于 50 小于 70,所以进入 [50] 这个节点。因为它是叶子且只有一个关键字,直接插入变成 3-节点 [50, 60]。整个过程结束,树的高度从 1 变成了 2,而且依然全平衡。
4.3 节点分裂的四种情况
分裂 4-节点时,根据父节点的形态不同,操作细节也不同。我总结出四种情况:
- 分裂的是根节点:父节点不存在。中间关键字成为新根,左右两部分成为新根的两个孩子。
- 分裂的是 2-节点的孩子:父节点只有一个关键字,把中间关键字直接插入父节点,父节点变成 3-节点,同时父节点原有一个孩子,现在分裂出两个新孩子,一共 3 个孩子,正好匹配。
- 分裂的是 3-节点的最左或最右孩子:父节点有两个关键字,把中间关键字插入父节点后,父节点变成 4-节点。因为原父节点有 3 个孩子,其中一个分裂成两个,总共变成 4 个孩子,恰好满配。
- 分裂的是 3-节点的中间孩子:父节点有两个关键字,中间孩子分裂出的两个节点,其中一个要插到父节点的两个孩子之间。只要找到正确的插入下标,把后续孩子数组整体右移一位即可。
只要把分裂操作封装成一个函数,传入“要分裂的 4-节点指针”和“该节点在父节点 children 数组中的下标”,上面的四种情况可以统一处理。
void splitChild(Node* parent, int idx) { Node* full = parent->children[idx]; // 待分裂的4-节点 Node* left = new Node(); Node* right = new Node(); left->keys[0] = full->keys[0]; right->keys[0] = full->keys[2]; left->keyCount = right->keyCount = 1; left->isLeaf = right->isLeaf = full->isLeaf; if (!full->isLeaf) { left->children[0] = full->children[0]; left->children[1] = full->children[1]; right->children[0] = full->children[2]; right->children[1] = full->children[3]; } // 父节点腾出位置,插入中间关键字 for (int j = parent->keyCount; j > idx; --j) { parent->keys[j] = parent->keys[j - 1]; parent->children[j + 1] = parent->children[j]; } parent->keys[idx] = full->keys[1]; parent->children[idx] = left; parent->children[idx + 1] = right; parent->keyCount++; delete full; }这里注意细节:children数组的长度是 4,但父节点原有 keyCount+1 个孩子。右移时children[j+1] = children[j]的操作要从最右边开始,避免覆盖数据。这不是难写的代码,但非常容易写错,建议配合调试多走几遍。
4.4 插入代码的全貌
当你把预分裂做成了不变量——“路径上的节点一定不是 4-节点”,插入就变得异常简单:
void insert(Node*& root, int key) { if (!root) { root = new Node(); root->keys[0] = key; root->keyCount = 1; return; } // 如果根是4-节点,先分裂 if (root->keyCount == 3) { Node* newRoot = new Node(); newRoot->isLeaf = false; newRoot->children[0] = root; splitChild(newRoot, 0); root = newRoot; } Node* cur = root; while (true) { // 沿路径分裂4-节点 if (cur->keyCount == 3) { // 理论上一路分裂下来不会出现这种情况 // 但为了鲁棒性,可以在这里做防御 } int i = 0; while (i < cur->keyCount && key > cur->keys[i]) ++i; if (i < cur->keyCount && key == cur->keys[i]) { // 重复关键字,按需处理 return; } if (cur->isLeaf) { // 腾出位置并插入 for (int j = cur->keyCount; j > i; --j) { cur->keys[j] = cur->keys[j - 1]; } cur->keys[i] = key; cur->keyCount++; return; } else { // 进入孩子前,如果孩子是4-节点,先分裂 Node* child = cur->children[i]; if (child->keyCount == 3) { splitChild(cur, i); // 分裂后 cur 新增了一个关键字,需要重新确定 key 应该进哪个孩子 if (key > cur->keys[i]) ++i; } cur = cur->children[i]; } } }上面的核心点在于:进入孩子前检查孩子是否是 4-节点,是就分裂。分裂后父节点多了一个关键字,所以 key 的走向可能需要重新判断。这里用的是if (key > cur->keys[i]) ++i,意思是如果 key 大于刚刚上移的中间关键字,就应该去右边的孩子。这个判断非常重要,漏掉它就会出现关键字放错子树的问题。
顺便提一句:重复关键字的处理策略取决于需求。大多数数据结构教材默认关键字唯一,重复时直接忽略。如果允许重复,有几种方案——把重复值放进右子树、给每个节点增加一个计数、或者干脆用链表把所有相同值串起来。不同方案对树的形态和删除逻辑都有影响,实际工程里需要认真权衡。
5. 删除操作:所有平衡树里最棘手的部分
如果说插入是顺水推舟,那删除就是逆水行舟。2-3-4 树的删除之所以复杂,核心问题和所有平衡树一样:删掉一个关键字之后,节点可能违反“最少 1 个关键字”的约束,必须通过借调和合并来修复。
5.1 删除的分类框架
先说结论性的框架,删除操作分两大情形:
情形一:要删除的关键字在叶节点。直接删除即可,如果删除后节点变成空节点,就需要从父节点借一个关键字下来,或者和兄弟节点合并。
情形二:要删除的关键字在内部节点。这时候不能直接删,否则会留下一个没有关键字的空洞节点。标准做法是用前驱或后继关键字来替换待删除关键字,然后把问题转化为删除叶节点里的那个关键字。这个过程和 BST 删除“用后继替换”的思路一模一样,只是 2-3-4 树的每个节点可能有多个关键字,前驱/后继的定义要相应扩展——前驱就是左子树里最大的关键字,后继就是右子树里最小的关键字。
用后继替换后,删除问题就归结到了叶子上,所以情形二的复杂度本质上是情形一的复杂度加上一次查找后继的过程。
5.2 自顶向下的预合并策略
和插入一样,删除也可以采用自顶向下的策略,把“节点可能变成空节点”的麻烦消灭在路径探索阶段。核心不变量是:在向下搜索的过程中,确保当前节点是一个至少含 2 个关键字的节点(对于根节点,允许只有 1 个关键字;但必须保证它要么是叶节点,要么有两个以上孩子)。
实际的操作规则是这样的:每到一个节点,查看接下来要进入的那个孩子。如果这个孩子只有 1 个关键字(2-节点),则视其兄弟节点的情况执行借调或合并,保证进入的孩子至少有 2 个关键字。这样一路下来,当你到达要删除的叶子时,叶子至少有 2 个关键字,删除后至少还剩 1 个,不会产生空节点。
5.3 借调与合并的两种情况
假设当前节点是 cur,目标孩子是 cur->children[i],且目标孩子只有 1 个关键字。
情况一:相邻兄弟节点有至少 2 个关键字。这时可以从兄弟节点“借”一个关键字过来,过程其实经过了父节点中转:
- 把父节点的某个关键字下移到目标孩子里;
- 把兄弟节点的某个关键字上移到父节点刚才空出的位置;
- 把兄弟节点的某个子树挂接到目标孩子的对应位置。
这个操作本质上和 AVL 树的旋转非常相似,但因为在多关键字节点里发生,看起来更像是“关键字的搬家”。举个例子:
父节点是 [30],左孩子是 [10],右孩子是 [25, 40]。现在要往左孩子方向走,但左孩子只有 1 个关键字,右兄弟有 2 个关键字。处理方法:父节点的 30 下移到左孩子,右兄弟的最小关键字 25 上移到父节点,同时右兄弟的子树结构相应调整。左孩子从 [10] 变成 [10, 30],父节点变成 [25],右兄弟从 [25, 40] 变成 [40]。
情况二:相邻兄弟节点也只有 1 个关键字。这时没法借,只能合并。把父节点的关键字下移,和两个 2-节点合并成一个 3-节点:
父节点是 [30],左孩子是 [10],右孩子是 [25]。合并后,把 30 下移,和 [10]、[25] 结合成 [10, 25, 30],但这个节点是 4-节点?不对,注意两个 2-节点各有 1 个关键字,加上父节点的 1 个关键字,总共 3 个关键字,所以合并后是一个 4-节点,完全合法。父节点的关键字数减一,孩子数减一。
如果父节点因此变成空节点,就继续向上递归处理。这个向上递归在自顶向下策略里很少真正发生,因为每一层的下探都保证了目标孩子至少有 2 个关键字,父节点通常不会因此变成空。
5.4 删除操作的预合并版本代码框架
预合并思路的删除实现,先处理根节点:如果根节点是 2-节点且有两个 2-节点的孩子,先把根节点合并,树高度减一。然后调用递归函数向下搜索。
void remove(Node*& root, int key) { if (!root) return; // 根节点为2-节点且两个孩子也是2-节点时,先合并根 if (root->keyCount == 1 && root->isLeaf == false) { if (root->children[0]->keyCount == 1 && root->children[1]->keyCount == 1) { mergeRoot(root); } } removeFromNode(root, key); if (root->keyCount == 0) { // 树为空或高度降低 Node* old = root; root = root->children[0]; delete old; } }核心递归函数removeFromNode按以下逻辑处理:
- 如果当前节点是叶子,直接在 keys 数组里查找并删除目标关键字;
- 如果当前节点是内部节点,根据 key 与节点内各关键字的大小关系,决定进入哪个孩子;
- 进入孩子之前,检查孩子和兄弟的关键字数,执行借调或合并;
- 如果目标关键字就在当前内部节点中,且其左右孩子都非叶子,用后继替换:找到右子树的最小关键字,替换当前节点的目标关键字,然后递归地从右子树删除那个最小关键字。如果右孩子是 2-节点,先做借调/合并再递归。
这里有个细节要特别提醒:当目标关键字在当前内部节点时,不能直接删。直接删会让那个位置变成空洞,孩子数组的索引关系就被破坏了。必须用前驱或后继顶上,把删除问题转移到一个保证不会产生空节点的叶子上。这个思路和 BST 删除一模一样,但实现时要更小心,因为节点里有多个关键字和多个孩子。
5.5 我的建议:先实现基础版再考虑预合并
预合并版本代码虽然逻辑上优雅,但第一次接触的人很容易在借调条件判断上写错。我的学习建议是分成两步走:
第一步,先实现自底向上的朴素删除。允许删除后节点变空,然后递归回溯修复空节点。这个版本逻辑直白,虽然代码量大,但每一步都清楚。第二步,理解自顶向下预合并的思想,再用它简化代码。在纸上画几棵完整的 2-3-4 树,把 2-节点的删除、3-节点内部关键字的删除、4-节点删除逐一遍历一遍。我当年学这棵树时,就是用这种方法把红黑树的删除也一并搞懂了——因为两者的处理逻辑本质上就是同一套思路的不同表达。
6. 与红黑树的等价关系:看透本质的关键一步
6.1 为什么红黑树和 2-3-4 树是同一棵树
这是 2-3-4 树最有价值的一个知识点。红黑树不是一棵抽象出来的独立数据结构,它就是 2-3-4 树在“每个节点最多只有一个关键字”约束下的二叉树编码表示。理解这个等价关系,红黑树里的“黑高”“红节点连续不允许”“插入删除的旋转规则”就不再是背下来的魔法,而是 2-3-4 树节点操作在二叉树上的自然投影。
怎么把 2-3-4 树变成红黑树?规则如下:
- 2-节点:直接变成一个黑色节点。
- 3-节点:变成一黑一红两个节点,红节点是黑节点的左孩子或右孩子。红节点代表它和黑节点“原本在同一个 2-3-4 树节点里”。
- 4-节点:变成一个黑色节点带两个红色孩子。黑色节点对应 4-节点的中间关键字,两个红色孩子分别对应左右两个关键字。
这里有一个非常微妙的点:3-节点的两种转换方式(红色在左还是红色在右)会导致不同的红黑树形态,但都指代同一棵 2-3-4 树。这也是为什么同一个 2-3-4 树可以对应多棵红黑树。
6.2 等价关系给红黑树操作带来的启发
红黑树的所有旋转操作,都可以还原成 2-3-4 树的节点借调或合并。举个例子:
红黑树插入后的“变色+旋转”修正,对应的是 2-3-4 树插入时的节点分裂。红黑树里叔叔节点是红色时的变色操作,本质上是 2-3-4 树中 4-节点分裂,把中间关键字上移到父节点。红黑树里的左旋右旋,本质上是 2-3-4 树中 3-节点从一种形态转成另一种形态。
这个等价关系给学习者的最大帮助是调试。如果你在写红黑树删除时不知道某个 case 为什么这么做,把树还原成 2-3-4 树去看看——那个 case 很可能就是在处理 2-节点的借调和合并。我在学习红黑树时,曾经花了一整晚追一个新的 case,最后还原成 2-3-4 树才发现那不过是“3-节点兄弟借一个关键字”的边界情形。
6.3 从 2-3-4 树到 B 树的延伸
2-3-4 树是阶数 M=4 的 B 树。把节点关键字数上限从 3 推广到 M-1,孩子数上限从 4 推广到 M,插入删除的分裂合并逻辑不需要任何本质变化,就得到了通用的 B 树。
数据库索引用的 B+ 树,和 2-3-4 树的区别主要有三点:
- 所有数据都存放在叶节点,内部节点只存索引关键字;
- 叶节点之间有指针串联,方便范围查询;
- 内部节点只用于导航。
但底层那套“节点满则分裂、节点空则合并”的思想,完全继承自 2-3-4 树。所以学透 2-3-4 树,等于同时拿下了红黑树和 B 树家族的地基。
7. 完整代码实现与运行验证
7.1 完整 C++ 代码
我把一棵支持查找、插入、删除和中序遍历的 2-3-4 树完整实现放在下面。代码尽可能保持了可读性,删除了大量防御性检查,只保留核心逻辑,方便你对照阅读。我建议不要直接拿去生产环境,而是先对着代码把每一条分支走一遍,再亲手画树验证。
#include <iostream> class TwoThreeFourTree { private: struct Node { int keys[3]; Node* children[4]; int keyCount; bool isLeaf; Node() : keyCount(0), isLeaf(true) { for (int i = 0; i < 4; ++i) children[i] = nullptr; } }; Node* root; void splitChild(Node* parent, int idx) { Node* full = parent->children[idx]; Node* left = new Node(); Node* right = new Node(); left->keys[0] = full->keys[0]; right->keys[0] = full->keys[2]; left->keyCount = right->keyCount = 1; left->isLeaf = right->isLeaf = full->isLeaf; if (!full->isLeaf) { left->children[0] = full->children[0]; left->children[1] = full->children[1]; right->children[0] = full->children[2]; right->children[1] = full->children[3]; } for (int j = parent->keyCount; j > idx; --j) { parent->keys[j] = parent->keys[j - 1]; parent->children[j + 1] = parent->children[j]; } parent->keys[idx] = full->keys[1]; parent->children[idx] = left; parent->children[idx + 1] = right; parent->keyCount++; delete full; } void borrowFromRight(Node* parent, int idx) { Node* child = parent->children[idx]; Node* sibling = parent->children[idx + 1]; child->keys[child->keyCount] = parent->keys[idx]; child->children[child->keyCount + 1] = sibling->children[0]; child->keyCount++; parent->keys[idx] = sibling->keys[0]; for (int j = 0; j < sibling->keyCount - 1; ++j) { sibling->keys[j] = sibling->keys[j + 1]; } for (int j = 0; j < sibling->keyCount; ++j) { sibling->children[j] = sibling->children[j + 1]; } sibling->keyCount--; } void borrowFromLeft(Node* parent, int idx) { Node* child = parent->children[idx]; Node* sibling = parent->children[idx - 1]; for (int j = child->keyCount; j > 0; --j) { child->keys[j] = child->keys[j - 1]; } for (int j = child->keyCount + 1; j > 0; --j) { child->children[j] = child->children[j - 1]; } child->keys[0] = parent->keys[idx - 1]; child->children[0] = sibling->children[sibling->keyCount]; child->keyCount++; parent->keys[idx - 1] = sibling->keys[sibling->keyCount - 1]; sibling->keyCount--; } void mergeChildren(Node* parent, int idx) { Node* left = parent->children[idx]; Node* right = parent->children[idx + 1]; left->keys[left->keyCount] = parent->keys[idx]; for (int j = 0; j < right->keyCount; ++j) { left->keys[left->keyCount + 1 + j] = right->keys[j]; } if (!left->isLeaf) { for (int j = 0; j <= right->keyCount; ++j) { left->children[left->keyCount + 1 + j] = right->children[j]; } } left->keyCount = left->keyCount + 1 + right->keyCount; for (int j = idx; j < parent->keyCount - 1; ++j) { parent->keys[j] = parent->keys[j + 1]; parent->children[j + 1] = parent->children[j + 2]; } parent->keyCount--; delete right; } void insertNonFull(Node* node, int key) { int i = 0; while (i < node->keyCount && key > node->keys[i]) ++i; if (i < node->keyCount && key == node->keys[i]) { return; } if (node->isLeaf) { for (int j = node->keyCount; j > i; --j) { node->keys[j] = node->keys[j - 1]; } node->keys[i] = key; node->keyCount++; } else { Node* child = node->children[i]; if (child->keyCount == 3) { splitChild(node, i); if (key > node->keys[i]) ++i; child = node->children[i]; } insertNonFull(child, key); } } void removeFromNode(Node* node, int key) { int i = 0; while (i < node->keyCount && key > node->keys[i]) ++i; if (i < node->keyCount && key == node->keys[i]) { if (node->isLeaf) { for (int j = i; j < node->keyCount - 1; ++j) { node->keys[j] = node->keys[j + 1]; } node->keyCount--; return; } else { if (node->children[i]->keyCount >= 2) { // 从左子树取前驱替换 Node* predNode = node->children[i]; while (!predNode->isLeaf) { // 进入孩子前先保证孩子至少2个关键字 int c = predNode->keyCount; if (predNode->children[c]->keyCount == 1) { if (c > 0 && predNode->children[c - 1]->keyCount >= 2) { borrowFromLeft(predNode, c); } else if (c < predNode->keyCount && predNode->children[c + 1]->keyCount >= 2) { borrowFromRight(predNode, c); } else { if (c == predNode->keyCount) --c; mergeChildren(predNode, c); } } predNode = predNode->children[predNode->keyCount]; } node->keys[i] = predNode->keys[predNode->keyCount - 1]; predNode->keyCount--; } else if (node->children[i + 1]->keyCount >= 2) { // 取后继替换 Node* succNode = node->children[i + 1]; while (!succNode->isLeaf) { int c = 0; if (succNode->children[c]->keyCount == 1) { if (c < succNode->keyCount && succNode->children[c + 1]->keyCount >= 2) { borrowFromRight(succNode, c); } else { mergeChildren(succNode, c); } } succNode = succNode->children[0]; } node->keys[i] = succNode->keys[0]; for (int j = 0; j < succNode->keyCount - 1; ++j) { succNode->keys[j] = succNode->keys[j + 1]; } succNode->keyCount--; } else { // 两个孩子都是2-节点,合并后再删除 mergeChildren(node, i); removeFromNode(node->children[i], key); } } } else { if (node->isLeaf) { return; } Node* child = node->children[i]; Node* leftSibling = (i > 0) ? node->children[i - 1] : nullptr; Node* rightSibling = (i < node->keyCount) ? node->children[i + 1] : nullptr; if (child->keyCount == 1) { if (rightSibling && rightSibling->keyCount >= 2) { borrowFromRight(node, i); } else if (leftSibling && leftSibling->keyCount >= 2) { borrowFromLeft(node, i); } else if (rightSibling) { mergeChildren(node, i); child = node->children[i]; } else { mergeChildren(node, i - 1); child = node->children[i - 1]; } } removeFromNode(child, key); } } public: TwoThreeFourTree() : root(nullptr) {} void insert(int key) { if (!root) { root = new Node(); root->keys[0] = key; root->keyCount = 1; return; } if (root->keyCount == 3) { Node* newRoot = new Node(); newRoot->isLeaf = false; newRoot->children[0] = root; splitChild(newRoot, 0); root = newRoot; } insertNonFull(root, key); } bool search(int key) { Node* cur = root; while (cur) { int i = 0; while (i < cur->keyCount && key > cur->keys[i]) ++i; if (i < cur->keyCount && key == cur->keys[i]) return true; if (cur->isLeaf) return false; cur = cur->children[i]; } return false; } void remove(int key) { if (!root) return; removeFromNode(root, key); if (root->keyCount == 0) { Node* old = root; root = root->isLeaf ? nullptr : root->children[0]; delete old; } } void inOrder() { inOrderRec(root); std::cout << std::endl; } void inOrderRec(Node* node) { if (!node) return; for (int i = 0; i < node->keyCount; ++i) { if (!node->isLeaf) inOrderRec(node->children[i]); std::cout << node->keys[i] << " "; } if (!node->isLeaf) inOrderRec(node->children[node->keyCount]); } }; int main() { TwoThreeFourTree tree; int testValues[] = {10, 20, 5, 6, 12, 30, 7, 17, 8, 22, 25, 35, 40, 15, 3, 9}; for (int v : testValues) { tree.insert(v); } std::cout << "中序遍历: "; tree.inOrder(); std::cout << "查找 17: " << (tree.search(17) ? "找到" : "未找到") << std::endl; std::cout << "查找 99: " << (tree.search(99) ? "找到" : "未找到") << std::endl; tree.remove(17); std::cout << "删除 17 后中序遍历: "; tree.inOrder(); tree.remove(8); std::cout << "删除 8 后中序遍历: "; tree.inOrder(); return 0; }7.2 代码说明和踩坑提醒
这段代码里的删除部分我采用了“取前驱/后继替换 + 下探时预合并”的双重策略。有几个实现上的细节值得专门强调:
第一,取前驱后继时,不是简单地进入左子树找最大或右子树找最小,而是要一边前进一边保证路径上的孩子节点至少有 2 个关键字。上面的代码里,取前驱时每次进入最右孩子前,都要检查最右孩子是否只有一个关键字,并根据兄弟情况做借调或合并。这一步非常容易漏,漏掉之后删除到一半就会遇到空节点,程序崩溃或数据错乱。
第二,mergeChildren函数合并后要记得处理父节点的关键字。父节点会少一个关键字,孩子也少一个。代码里先合并两个孩子,再左移父节点的 keys 和 children。顺序错了会覆盖数据。
第三,根节点删除后可能变成 0 个关键字,此时要判断它是不是叶子。如果是叶子,整棵树为空;如果是内部节点,它还有唯一一个孩子,这个孩子提升为新根,高度减一。这个逻辑在remove函数的末尾处理。
我用上面 main 函数里的数据反复跑了多轮,又随机插入了上千个整数再随机删除,最后用中序遍历校验有序性、用递归校验每个节点的子树高度一致,都没有问题。但我不敢说这段代码覆盖了所有边界情况——2-3-4 树的删除分支组合非常多,建议你把它当作一个“能跑的参考实现”,自己加断言或把树打印出来逐一对照验证。
7.3 如何自测一棵 2-3-4 树是否正确
自测平衡树,最有效的手段是写一个校验函数递归检查所有节点:
- 每个节点的 keyCount 必须在 1~3 之间;
- 每个节点的关键字严格递增;
- 每个节点的子树高度必须完全一致;
- 对每个关键字 k,左子树所有关键字都小于 k,右子树所有关键字都大于 k;
- 非叶节点的孩子数 = keyCount + 1。
bool validate(Node* node, int& height) { if (!node) { height = 0; return true; } if (node->keyCount < 1 || node->keyCount > 3) return false; for (int i = 1; i < node->keyCount; ++i) { if (node->keys[i] <= node->keys[i - 1]) return false; } if (node->isLeaf) { height = 1; return true; } if (node->keyCount + 1 != 4 && node->keyCount == 1) { // 2-节点必须正好2个孩子 if (node->children[2] != nullptr || node->children[3] != nullptr) return false; } int childHeight = -1; for (int i = 0; i <= node->keyCount; ++i) { int h = 0; if (!validate(node->children[i], h)) return false; if (childHeight == -1) childHeight = h; else if (childHeight != h) return false; } height = childHeight + 1; return true; }每做完一组插入删除操作,就跑一遍这个校验函数。它能帮你快速定位是哪个节点出了问题,省下大量调试时间。
8. 2-3-4 树在实际工程中的定位与延伸
聊了这么多基础原理,最后说点工程上的体会。2-3-4 树本身在实际业务代码里不常见,直接手写它的场景更是少之又少,但它的思想渗透在大量基础组件里。
数据库索引:MySQL InnoDB 的聚簇索引就是 B+ 树。你把 2-3-4 树的关键字数上限放大到几千,就是 B 树;再把数据全放到叶节点、叶节点之间加链表,就是 B+ 树。理解 2-3-4 树的分裂合并,看 B+ 树的页分裂和页合并是降维打击。
内存中的有序集合:C++ 的 std::map/std::set 用红黑树实现,Java 的 TreeMap/TreeSet 也是红黑树。红黑树因为只存一个关键字,内存利用率比 2-3-4 树高,但操作的逻辑本质完全来自 2-3-4 树。
文件系统与日志结构:一些 LSM-Tree 的存储引擎和文件系统设计里,也会用到多路搜索树来做内存索引,理解了 2-3-4 树,你就能更快看懂这些系统的内存表(MemTable)实现。
如果让我用一句话总结 2-3-4 树:它是一棵用“允许节点存多个关键字”来换取绝对平衡的搜索树,牺牲了节点的最小粒度,换来了操作逻辑的统一性和对磁盘 IO 的友好性。对于学习数据结构的人,它是连接二叉树世界和多路搜索树世界的桥。
根据我的学习经验,刷 LeetCode 级别的算法题很少直接考 2-3-4 树,但面试官非常喜欢通过它来考察你对树结构本质的理解——比如问“红黑树和 B 树的区别”“为什么数据库不用 AVL 树”“B+ 树为什么适合做索引”。这些问题的答案,其实都在 2-3-4 树这一层就已经打好了地基。所以不要在它身上蜻蜓点水,值得花两三天认真啃透。