我先把话放这儿:不管你是准备面试背八股,还是在调一条慢到不能忍的数据库查询,只要你跟索引打过照面,B树就一定绕不过去。很多人对B树的认知停留在“多叉树、矮胖、适合磁盘”这三句话上,可真让他手写一个插入分裂、删除合并,立刻卡壳。这篇不搞虚的,我直接把B树的原理拆开,配合一整套操作推演,让你看完就能自己画、能自己推、能照着实现。
这块内容我一向建议用“手画”来学。光看文字记不住分裂方向,光背结论也搞不清为什么借键要经过父节点中转。下面我先从B树存在的意义讲起,再用m=5的实例把查找、插入、删除全部走一遍,最后聊聊工程落地和面试里真正会问的点。
1. 先从根上想明白:B树到底在解决什么问题
1.1 二叉树的痛点:一次比较一次落盘
想理解B树,得先看它替掉了什么。哈希表做等值查询很快,但做范围查询只能全盘扫;二叉搜索树能搞定排序和范围,但数据量一上来,树高就成了灾难。这里说的“灾难”不是计算量,是磁盘IO。
我给你算一笔账。传统的AVL树或者红黑树,每个节点只存一个键,两个子指针。假设你有10亿条数据,log2(10亿)大约是30,也就是说查一次数据最少要走30层。如果这棵树在磁盘上,每下一层都可能是一次磁盘IO,一次随机IO在普通机械硬盘上是5到10毫秒。30次是什么概念?300毫秒,肉眼能感受到的卡顿。
有人会说,操作系统有页缓存,热点数据能命中内存。可你要知道,数据库或者文件系统里的数据量是TB级别的,内存根本放不下。真实场景下,根部几层可能在缓存里,但往下走,IO次数是省不掉的。这个时候,问题就从“怎么减少比较次数”变成了“怎么减少树的层数”。
1.2 多路平衡:一次落盘做多次比较
B树的思路非常直接:既然一次磁盘IO能读一整页数据,那我就在一个节点里塞很多个键,让一次IO带回来尽可能多的有效信息。
二叉搜索树每次只带回一个键,然后用指针去找下一个节点。B树呢?一个节点就是一个磁盘页,里面有几十上百个键。你把这一页读进内存,在内存里做二分或者顺序查找,然后决定去哪个子节点。这样树的层数被大幅度压缩,磁盘IO次数也跟着降下来。
比如同样10亿条数据,如果是一棵m=100的B树,log100(10亿)大概是4.5层。算上根节点缓存命中,真实磁盘IO往往两次以内就结束了。从30次降到2到3次,体感就是从“卡顿”到“秒开”,这就是B树存在的全部理由。
1.3 从2-3树看B树的雏形
B树不是凭空冒出来的。如果你完全没接触过2-3树,建议先去了解一下,它是m=3的B树,节点最多能存2个键、3个子指针。理解2-3树的插入分裂和删除合并,基本上就理解了B树在极端情况下的所有操作。
2-3树里,每个节点要么有1个键2个孩子(2节点),要么有2个键3个孩子(3节点)。插入时如果3节点满了,就把中间键顶上父节点,剩下两个键拆成两个2节点。删除时如果节点空了,就看兄弟能不能借,不能借就合并。这套逻辑放大到任意m,就是完整的B树操作。
所以我一直建议,学B树不要一上来就啃m=5、m=100的复杂例子。先在纸上画2-3树,把分裂、合并的手感找到,再往多路上面迁移,你会觉得自然很多。
2. B树的结构与关键性质:一张图看懂节点长什么样
2.1 节点的内部布局
先明确一件事:B树节点里存的是“键-指针-键-指针”这种交错结构。一个节点如果有k个键,那它一定有k+1个子指针。比如下面这棵m=5的B树节点:
[17 | 35 | 48] / | | \ [3|9] [21|28] [40|50] [60|70]根节点存了3个键:17、35、48,所以它有4个子指针。每个子树里所有值都落在对应区间里:第一个子树的值都小于17,第二个在17和35之间,第三个在35和48之间,第四个都大于48。这跟二叉搜索树是一个逻辑,只是从“二分”变成了“多分”。
每个叶子节点也是一样的结构,只不过子指针全部为NULL。要注意,B树的叶子节点是真正的数据所在层,不像B+树那样叶子节点才挂数据,B树的每个节点都能存数据。
2.2 阶数m和性质约束
B树有个参数叫阶数m,它定义了节点的容量边界。对于一棵m阶B树:
- 每个节点最多有m个子节点;
- 每个非叶子节点(除根以外)至少有ceil(m/2)个子节点;
- 每个节点最多有m-1个键;
- 除根外,每个节点至少有ceil(m/2)-1个键;
- 所有叶子节点出现在同一层。
我举个例子方便你记忆。m=5时,ceil(5/2)=3,所以非根节点至少3个孩子、至少2个键,最多5个孩子、4个键。m=3时,至少2个孩子、1个键,这就是2-3树。
这些数字看起来像死记硬背的规则,但它的本质是“用最小的填充率来控制树高”。如果允许节点只有一个键,那B树就会退化成普通的二叉搜索树,树高优势消失了。定一个下限,是为了保证节点分裂合并之后,整棵树不会退化成长链。
2.3 为什么“所有叶子同层”这么重要
这是B树和普通搜索树一个很微妙但特别重要的差异。B树的所有叶子节点必须严格在同一个深度,这是通过插入分裂和删除合并来维持的。为什么非要在同一层?因为如果树的高度不一致,那查询的最小IO次数和最大IO次数就会悬殊,你能不能接受这种不稳定性?数据库不答应,文件系统也不答应。
这个性质的代价就是:任何让树变高或变矮的操作,都只能从根节点向上或者根节点向下发生。插入时只有根节点分裂,树的层数才会加1;删除时只有根节点合并/降级,层数才会减1。其他任何操作都只是局部调整,不会影响全局高度。这也是B树实现能保持简洁的关键。
这里有个经常被忽略的推论:由于所有叶子同层,B树是天然自平衡的,不需要像红黑树那样搞旋转、染色那一套复杂的平衡逻辑。它通过键的数量约束和局部结构调整来维持全局平衡,实现起来反而比红黑树更直观。
3. 查找操作:B树最朴素的读取路径
3.1 查找过程
B树的查找跟二叉搜索树如出一辙,只是多了“在节点内部进行多次比较”这一步。拿上面那棵m=5的B树举例,假如我要找28:
- 从根节点读到内存,键为[17, 35, 48],内存里做顺序或二分查找。
- 28介于17和35之间,所以走第二个子指针,也就是指向[21, 28]的节点。
- 进入这个节点,依次比较,21小于28,再看下一个键正好是28,命中。
这个过程表述起来很简单,但实际操作里要注意:你在节点内部如果用的是顺序查找,当m很大比如100甚至200时,节点内比较开销也是要算进去的。工程实现一般会用二分查找,因为节点内的键在插入删除时始终是有序的。
3.2 复杂度分析:为什么是O(log_m n)
B树查找的磁盘IO次数等于树高,也就是O(log_m n)。这个log的底是m而不是2,数值上会比二叉树的log2 n小很多。举例来说,100万条数据:
- m=2的二叉搜索树:log2(100万) ≈ 20次IO;
- m=10的B树:log10(100万) = 6次IO;
- m=100的B树:log100(100万) = 3次IO。
这就是B树“矮胖”的优势。注意括号里的m越大,单节点能覆盖的关键字区间越多,树越矮,IO越少。但m不能无限大,因为单个节点大小受磁盘页大小限制,这一点我们后面单独讲。
我实际写代码的时候,查找函数往往就是几个循环嵌套,但有个细节得说清楚:节点内查不到某个键时,千万别直接返回不存在。B树的查询需要返回“应该走哪个子树”,即使当前节点的键不等于目标值,也要根据比较结果继续往下。只有在到达叶子节点仍找不到时,才能判定数据不存在。
4. 插入操作:一切溢出的核心是“分裂”
4.1 插入的完整流程
插入操作的整体思想是:先找到准确的叶子节点,插入键,如果节点键数超过了m-1,就进行分裂。这个“分裂”是B树操作里出现频率最高的动作。
完整流程大概是这样的:
- 从根开始,按照查找逻辑找到对应的叶子节点。
- 把新键按顺序插入叶子节点的键数组中(数组内部保持有序)。
- 检查该节点键数是否达到m。如果没超过m-1,插入直接结束。
- 如果超过了,执行节点分裂。
很容易被忽略的一点是:每次插入都要从根走到叶子。为什么不中途插入?因为B树的所有数据都挂在叶子节点上,内部节点只是索引,所以新键只能进叶子。这也是B树和B+树的一个重要辨析点,后面我会再提到。
4.2 叶子节点分裂的现场拆解
分裂操作听起来复杂,其实就三句话:中间键上提给父节点,剩下的键分成左右两个节点,父节点多出一个孩子。
我用一个m=5的例子。假设某个叶子节点已经有4个键:[10, 20, 30, 40]。这时插入25,节点变成5个键:[10, 20, 25, 30, 40],而m=5的节点最多只能有4个键,溢出。
第一步:找中间键。5个键的中位数是第3个(下标从0开始算即位置2),也就是25。 第二步:把25上提到父节点。 第三步:把小于25的键[10, 20]留在原左节点。 第四步:把大于25的键[30, 40]放到新创建的右节点。
这里有个重要的细节:中间键上提之后,左右两个节点的键数都满足“至少2个键”的约束,不会出现下溢。而且m为奇数时,中间键是唯一的;如果m为偶数,两边怎么分都行,可以自己选,但实现要统一。实际工程里一般选择左节点多一个键或者右节点多一个键,保证代码逻辑一致即可。
到底为什么是“上提”而不是“下沉”?因为上提中间键之后,父节点多了一个分隔键,同时也多了一个子节点指针,整棵树的索引关系依然保持有序。你可以把分裂理解成“把满节点从中间切开,把切口处的键作为新边界交给上层”。
4.3 根节点分裂:树变高的唯一途径
当根节点也满了,分裂就要传递到根。根节点分裂不需要向父节点上提,因为它没有父节点,这时整棵树会增加一层。
举个例子。假设根节点也已经满了,现在某个叶子节点分裂上来了一个新键给根节点,根节点键数变成5,溢出。那我们把根节点里的中间键提出来作为新根,原来所有的键分成左右两个孩子节点。这样树的高度从1变成2,而且原来的根节点就变成了新根的孩子。
这个过程要特别注意:树的层数是在根节点分裂时增加的,这叫“自底向上生长”。二叉树通常是从上往下插入新节点,B树反而在根节点这里“长高一层”,这个方向感初学者容易搞混。
插入操作最常见的一个坑就是递归分裂时的返回值处理。子节点分裂后需要把“上提的键”和“新的右孩子指针”返回给父节点,让父节点再插入。如果不设计好这个返回结构,后面就会写出一堆临时变量,还容易漏掉父节点自己溢出的情况。
5. 删除操作:三种情况,两个难点
5.1 删除的三种基本情况
删除比插入复杂,因为删除键之后节点可能低于“至少ceil(m/2)-1个键”的约束,这叫下溢。下溢必须处理,不然B树的性质就破坏了。
删除的情况可以分成三类:
情况一:删除的是叶子节点中的键,且删除后节点不产生下溢。这是最简单的,直接删掉键,保持节点内顺序即可。
情况二:删除的是内部节点中的键。这时不能直接删,因为内部节点的键是索引分隔符,删掉后左右子树就失去了边界。处理办法是:找这个键的左子树最大值或右子树最小值(也就是它的前驱或后继键),把它提升上来替代被删除的键,然后问题就转化为删除叶子节点中的前驱/后继键。
情况三:删除后节点产生下溢,需要借键或合并。这是B树删除最核心的难点,下面单独展开。
5.2 关键操作之一:向兄弟借键(旋转)
当一个节点下溢时,第一选择是看左右兄弟节点“富不富裕”。兄弟节点如果键数超过下限,就可以借一个键过来。但这个借不是直接拿兄弟的键,必须经过父节点中转。
假设m=5,一个节点应该至少有2个键。现在某个节点只剩下1个键,它的右兄弟有4个键(最多能存4个),那就是富裕的。
借的过程是:
- 先把父节点中分隔这两个节点的键拿下来,放到当前节点的末尾。
- 把右兄弟最小的键上提到父节点,替换刚才被拿走的那个键的位置。
- 如果借出的是右兄弟的最左子树指针,还要把这个指针补到当前节点的末尾孩子指针位置。
听起来绕,核心就一句话:父节点的键下来补位,兄弟的键上去顶替父节点,保持区间划分不变。
我把这个过程画成示意图,m=5场景,节点A键为[20](不足2个键),右兄弟B键为[40, 50, 60, 70],父节点中的分隔键为30:
初始状态:
父节点: [30] / \ A:[20] B:[40,50,60,70]借键之后:
父节点: [40] / \ A:[20,30] B:[50,60,70]看到没,30从父节点下来了,40从兄弟节点上去了,B少了一个键但它还有3个键,仍然大于等于2,满足约束;A从1个键变成2个键,也满足约束。两边都没破坏规则。
这个“借键”操作我建议你在纸上多推几遍,画箭头去向。我第一次写代码时这里出过很隐蔽的bug:只移动了键,忘了移动子树指针,导致子树的区间信息错乱。借键必须是“键和指针一起移动”,两者绑定。
5.3 关键操作之二:节点合并
如果兄弟节点自己也只有下限个键,那就借不了。这时必须合并。
合并的规则是:把父节点中的分隔键拉下来,和当前节点、兄弟节点的所有键拼成一个新节点。父节点少了一个键和少了一个孩子。
还以m=5为例,节点A键为[20](下溢),兄弟B键为[40](也是下限),父节点中的分隔键为30:
初始状态:
父节点: [30] / \ A:[20] B:[40]合并之后:
父节点: [] / A:[20,30,40]父节点被拿掉一个键和一个孩子。如果父节点因此下溢,就继续对父节点执行“借或合并”的操作,直到根节点。
有几个细节要强调:
- 合并时兄弟B的所有键和子树指针要整体搬到A节点末尾,B节点释放掉;
- 父节点删除分隔键之后,指向B的孩子指针也要删除;
- 合并可能持续向上一层,一直到根,这就是为什么B树删除是“自顶向下定位,自底向上修复”。
5.4 根的特殊处理
当根节点只有两个子树,而这两个子树因为合并导致根节点一个键都没有了,这时根节点就该被删除,合并后的新节点成为根。树的高度减1。
也就是说,合并向根部传导时,如果根节点键数变为0,直接把它删掉。注意这是根节点被“降层”的唯一场景,对应插入时根节点分裂导致“升层”。一升一降,B树的高度变化永远是“要么加一层,要么减一层”,不会出现局部深度不一致。
很多实现里对根节点单独放宽约束:非根节点至少有ceil(m/2)个孩子,但根节点只要至少有2个孩子(如果树不为空)。这个平凡的情况很容易被搞错。如果是空树或只剩一个节点的树,根节点可以没有键。这些边界条件写代码时要注意。
6. 完整操作模拟:拿m=5的B树从头走一遍
6.1 连续插入12个键的过程推演
光讲规则不如实战推演。我用m=5的B树,从空树开始,依次插入键:2, 8, 15, 7, 13, 25, 30, 1, 9, 19, 22, 40。边插边看树怎么变化。
先把前4个键插进去:2、8、15、7。此时根节点就是叶子节点,键为[2, 7, 8, 15],没满,没有分裂需求。
接着插入13。节点变为[2, 7, 8, 13, 15],5个键,m=5的节点最多4个键,溢出。中间键是8(第3个,下标2),上提为根。左右两边:[2, 7]和[13, 15]。此时树如下:
[8] / \ [2,7] [13,15]插入25:循根节点,25大于8,走右子树,右侧[13, 15]加入25变成[13, 15, 25],未满,结束。
插入30:右子树变成[13, 15, 25, 30],4个键,正好未满,结束。
插入1:1小于8,走左子树,左子树[2, 7]变成[1, 2, 7],未满,结束。
插入9:9大于8但小于右侧全部?不对,需要从根开始判断。9与8比较,9大于8,所以走右子树;右子树区间是(8, +∞),9进入右子树。右子树[13, 15, 25, 30]变成[9, 13, 15, 25, 30],5个键,溢出。
把中间键15上提给根节点。根节点变为[8, 15]。右边分成两个节点:[9, 13]和[25, 30]。此时树:
[8, 15] / | \ [1,2,7] [9,13] [25,30]插入19:从根判断,19大于15,走右子树[25, 30],插入后为[19, 25, 30],未满。
插入22:同理进入右子树,[19, 22, 25, 30],4个键未满。
插入40:进入右子树,[19, 22, 25, 30, 40],5个键溢出。中间键25上提给根节点。根节点[8, 15, 25],右边分成[19, 22]和[30, 40]。最终:
[8, 15, 25] / | | \ [1,2,7] [9,13] [19,22] [30,40]整个过程中我只有两次节点溢出,分别是在第5个键和第10个键插入时触发的。你会发现:只要中间键上提的位置是对的,整棵树始终满足节点键数范围,而且所有叶子都在同一层。这一串推演做完,B树插入的自底向上生长逻辑就非常清楚了。
插入时我建议不要太早优化,就按照“找叶子、插入、溢出则分裂、分裂向上传导”的顺序来。每一步都把节点状态画出来,写测试用例时就能照着对。
6.2 连续删除触发的借键与合并推演
现在对上面这棵最终的树做删除操作,专门挑能触发借键和合并的删。
先删除25。25在根节点上,内部节点删除,不能直接删。找它的前驱键:25的左子树是[19, 22],子树最右边的键是22。把22提升到根节点替换25,然后删除叶子节点里的22。叶子节点[19, 22]变成[19],还是2个键?不对,m=5的下限是至少2个键。此时这个叶子节点只有1个键,下溢了。
看它的兄弟节点:[9, 13]和[30, 40]都富裕,借键。以右兄弟为例,父节点中分隔这个节点和右兄弟的键是25(此时根节点里那个位置为22),不对,我重新描述一下当前状态:
删除22之后,假设根节点已经是[8, 15, 22],被删22的节点[19]下溢,它的右兄弟是[30, 40],富裕。父节点中分隔键为22。借键过程:
- 父节点22下来放到[19]末尾,变成[19, 22];
- 右兄弟最小的键30上提到父节点,替换22的位置;
- 右兄弟变成[40]。
最终:
[8, 15, 30] / | | \ [1,2,7] [9,13] [19,22] [40]节点[19, 22]满足2个键约束,右兄弟[40]也满足2个键约束,借键成功。
接着删40。40在叶子节点[40]上,直接删除后该节点变为空,这肯定下溢。看左兄弟:[19, 22],它也只有2个键,刚好是下限,借不了。这时只能合并。
合并时把父节点的分隔键30拉下来,和空节点[ ]以及左兄弟[19, 22]拼成一个新节点[19, 22, 30]。父节点[8, 15, 30]删掉30和对应孩子指针,变成[8, 15],同时原来的两个孩子[9, 13]和[19, 22, 30]继续作为它的孩子。
最终:
[8, 15] / \ [1,2,7] [9,13,19,22,30]注意看,[9,13,19,22,30]这个节点有5个键,但m=5的节点最多4个键,这又是一个溢出。等等,我上面的合并结果是不是错了?
这里我要特别提醒:合并时必须保证合并后的节点不超过最大键数上限。两个节点加上一个父节点键会不会超?在这个例子里,两个兄弟节点总键数为2+0,加上父节点的1个分隔键,是3个键,并没有超。但是上面我写出来的[9,13,19,22,30]是5个键,明显不对。
让我重新推演。这棵树在删除22之后已经变成了:
[8, 15, 30] / | | \ [1,2,7] [9,13] [19,22] [40]删除40之后,节点[40]变成空。它的左兄弟是[19,22],父节点中分隔键为30。合并[19,22]与空节点,并把30降下来,得到新节点[19,22,30],也就是把空节点删掉,父节点的孩子从[ , [40]]合并成一个[19,22,30]。
正确结果:
[8, 15] / \ [1,2,7] [9,13] ?等等,原先父节点[8,15,30]有三个孩子:第一个[1,2,7],第二个[9,13],第三个是[19,22]和[40]合并后的[19,22,30]。把30从父节点删掉之后,父节点还有两个键[8,15]和两个孩子,但原来中间孩子[9,13]指向的区间是(8,15),第三个孩子[19,22,30]区间是(15, ∞),所以父节点变成[8,15],两个子节点分别是[9,13]和[19,22,30],完全满足m=5的约束(根节点至少2个孩子)。所以最终的树应该是:
[8, 15] / \ [1,2,7] [9,13] / \ [1,2,7] [19,22,30]不对,这张图画乱了。根节点[8,15]只有两个子节点,第一个[1,2,7],第二个不是[9,13]而是合并后的大节点。树形应该是一个根带两个孩子:
[8, 15] / \ [1,2,7] [19,22,30]那[9,13]去哪了?被合并掉了?不可能。我删的是40,[9,13]是另一个节点,不能被合并。
问题出在初始树上。回到删除25那一步之前的树:
[8, 15, 25] / | | \ [1,2,7] [9,13] [19,22] [30,40]删除25,前驱是22,把22提上去后,叶子[19,22]下溢。这里左右兄弟分别是[9,13]和[30,40],富余的是[30,40],借30下来给[19],兄弟[40]上提,结果变成:
[8, 15, 30] / | | \ [1,2,7] [9,13] [19,22] [40]这个时候删除40,节点[40]变空,左兄弟是[19,22],父分隔键是30。合并[19,22]和空节点(或者把40节点删掉,把[19,22]作为第三个孩子,并把30降到[19,22]里):
[8, 15] / | \ [1,2,7] [9,13] [19,22,30]这下对了。父节点从[8,15,30]变成[8,15],孩子从3个变成3个?不对,父节点有3个孩子,但它只有2个键,按照B树性质,一个节点如果有k个键,必须有k+1个孩子。这里2个键对应3个孩子,满足。而m=5的下限要求非根节点至少2个键、3个孩子,父节点是根,根节点至少2个孩子就行了,所以合法。再看[19,22,30],3个键,大于等于2,也没问题。
这轮推演很有价值,因为它暴露了一个典型心算错误:合并时以为父节点键数少了孩子也必然少,其实只要键和孩子的对应关系保持一致即可。我在纸上写错的那个版本就是多算了一个[9,13]的分支,实际上[9,13]从来不动。
所以删除操作写代码时,真的别急着靠心算。每做完一步就打印或者画出整棵树,和性质核对一遍。
7. 工程化落地:选阶数、写实现、避大坑
7.1 磁盘页大小与m的选择计算
B树真正落地的地方是数据库和文件系统,阶数m不是拍脑袋定的,它受单个磁盘页大小约束。磁盘读数据的最小单位是一个页,常见大小是4KB或者16KB。B树节点大小通常就是磁盘页大小,这样一次IO就能完整读入一个节点。
计算m的公式很简单,但要先了解每条记录大小。键值size为keySize,子指针size为ptrSize,则一个节点能容纳的键数m-1和指针数m满足:
pageSize >= m * ptrSize + (m - 1) * keySize如果pageSize=4096字节,ptrSize=8字节,keySize=8字节,那么:
4096 >= m * 8 + (m - 1) * 8 4096 >= 16m - 8 m <= 256.5所以m最大可以取256。但实际设计不会顶到上限,因为节点还要存一些元数据,比如键的数量、节点类型、父节点指针等,这些都会占空间。一般会留20%到30%的余量。
磁盘页大小与m参考表:
| 磁盘页大小 | keySize | ptrSize | 理论m上限 | 建议m值 |
|---|---|---|---|---|
| 4096 | 8 | 8 | 256 | 180-220 |
| 4096 | 16 | 8 | 170 | 130-160 |
| 8192 | 8 | 8 | 512 | 380-450 |
| 16384 | 16 | 8 | 682 | 500-600 |
如果m太小,树高增加,IO变多;m太大,单节点存储的信息太多,节点内部查找代价上升,而且分裂合并的调整开销也变大。工程上需要结合并发控制、缓存行大小和实际查询分布来做取舍。
7.2 核心实现伪代码与递归返回值设计
我经常被问B树实现难不难。说实话,查找难,插入中等,删除才是真正的拦路虎。下面我给出一个可运行思路级别的伪代码,重点说明递归和返回值设计。
插入的核心是返回“分裂结果”。子节点分裂之后,父节点需要插入新键和新子指针,所以递归插入函数要能返回一个“分裂信息”,包含上提的键和新的右孩子。如果没有分裂就返回空。
// 插入返回值:null表示没有分裂;否则表示需要父节点插入key和rightChild InsertResult insert(node, key): // 如果在叶子节点 if node.isLeaf: // 将key按序插入node.keys // 如果node.keys数量 > m-1: // 分裂node,返回 {upKey, newNode} // 否则返回 null else: // 找到key应该进入的子节点child result = insert(child, key) // 如果result非空: // node.insertKeyAndChild(result.upKey, result.rightChild) // 如果node也溢出,继续分裂并返回 // 返回null或分裂结果这里有个关键点:插入和分裂发生在递归返回的路上,不是递归下行的过程中。也就是说,你往下递归找到叶子,插入之后,再一层层往上处理溢出。这是“自底向上”修正的代码形态。
删除的递归返回值设计更讲究。要让父节点知道“我这个孩子被删光了需要合并”,常见的做法是用一个状态标记,比如返回布尔值表示是否发生下溢,或者返回一个操作指令:该借、该合并、还是正常。
// 返回true表示当前节点需要修复(比如下溢) DeleteResult delete(node, key): // 在node中定位key或key应所在的子树 // 如果key在node内: // 用前驱/后继替换后,递归删除前驱/后继所在叶子 // 否则: // 递归delete(child, key) // 修复: // child发生下溢时,尝试借键,否则合并 // 根节点特殊处理我在实现时习惯把删除拆成三个函数:deleteKey递归删除、borrowFromSibling借键、mergeNodes合并。这样逻辑清晰,也方便针对性地写单测。
7.3 我踩过的实现坑
第一个坑是“借键时只动键不动指针”。删除操作里向兄弟借键,如果当前节点不是叶子,那么它的孩子指针也需要一并调整。很多人写借键只移动keys数组,结果子树区间错乱,查出来的数据完全不对。这个bug非常隐蔽,因为它只在特定形状的树上触发,常规测试根本测不出来。
第二个坑是“根节点下溢处理”。递归删除时父节点因为合并变空,很多人会直接把父节点继续当根用,导致整棵树的高度变成0但还有数据节点挂着。正确做法是:如果根节点键数为0且不是叶子,就让它唯一的孩子成为新根;如果根节点键数为0且是叶子,那树就是空树。
第三个坑是“内存管理”。B树节点频繁分裂合并,指向兄弟节点的指针会被替换、变更。在C/C++里如果不注意释放旧节点,内存泄漏能跑满几个G;在Java/Go里有GC还好,但也要留意旧节点不再被引用。这个坑不是B树逻辑的问题,而是工程实现的问题,但很容易让人误以为是算法写错了。
第四个坑是“节点内查找使用了线性扫描”。m较小时线性扫描无所谓,m上百后线性扫一遍是很可观的CPU开销。我建议节点内维护有序数组,查找用二分。这样插入删除时挪动数组的代价仍然存在,但查询路径上的收益非常明显。
8. 实际应用与高频面试问题排查
8.1 为什么数据库索引是B+树而不是B树
这是一个几乎必考的问题。数据库索引,尤其是MySQL的InnoDB,用的是B+树而不是B树。原因有几点:
第一,B+树的数据全部存在于叶子节点,内部节点只存索引键。这意味着内部节点能容纳更多的键,树更矮,IO次数更少。
第二,B+树的叶子节点用链表连接,做范围查询时,只要命中一个叶子,就能沿着链表顺序读取,不需要回退到父节点。B树的范围查询需要中序遍历,涉及大量回溯操作,效率低很多。
第三,B+树的查询复杂度稳定。不管查的是什么键,都必须走到叶子节点,所有查询的IO次数基本相同。B树的内部节点也可能存数据,有些数据在浅层命中,有些在深层命中,查询时间波动较大。
所以如果你的面试题是“为什么数据库不用B树”,你得从IO次数、范围查询、性能稳定性三个角度答,而不是简单说一句“B+树更好”。
顺便说一句,文件系统(比如ext4、NTFS)反而经常用类似B树的变体,设计目标不太一样。数据库偏重范围扫描和并发控制,文件系统更偏重路径查找和空间管理。脱离场景谈优劣是没意义的。
8.2 B树常见理解误区速查
我整理了平时被问得最多也最容易答错的几个点,做成一个速查表:
| 常见误区 | 正确理解 |
|---|---|
| B树是基于二叉搜索树优化出来的 | 二叉搜索树是特例,B树是多路平衡查找树 |
| B树所有节点存储的数据都一样 | 叶子节点是数据所在层,内部节点也可存数据,所以查询深度可能不同 |
| m越大越好 | m受磁盘页大小限制,且节点内查找和调整成本会上升 |
| 插入分裂必须发生在叶子节点 | 叶子分裂后会把键上提,可能导致父节点继续分裂,直到根 |
| 删除时如果兄弟不满就一定能借 | 需要兄弟键数大于下限才能借;如果兄弟也刚好在下限,只能合并 |
| 根节点也要满足最少键数约束 | 根节点特殊,至少2个孩子即可(除非是空树) |
这些误区几乎是面试出错的重灾区。尤其是“内部节点也可存数据”这一点,很多人受B+树影响,默认B树的数据也只在叶子,这是理解路径上的大坑。
8.3 调试B树时的思路清单
最后分享一套我调B树代码时会用到的检查思路,按顺序做,能省很多时间:
- 先检查“结构性质”:所有非根节点的键数是否在[ceil(m/2)-1, m-1]区间内。
- 检查“孩子指针数量”是否等于“键数+1”。
- 检查“区间有序性”:每个节点内键有序,每个子树的所有键是否落在对应区间内。
- 检查“叶子同层”:从根出发遍历所有叶子,深度是否一致。
- 检查“分裂和合并后的内存释放”:旧节点释放了吗?父节点的孩子指针指向更新了吗?
我强烈建议在实现时写一个validate函数,每次插入删除之后调用。这个函数会遍历整棵树,检查上述所有性质,任何一个不满足立刻打印出树结构。很多bug在第一次写对时就发现,比后期靠数据结果反查快得多。我当年就是用这个笨办法,把删除的借键和合并逻辑练到基本一遍过。
B树这种结构你只要亲手推过一轮完整流程,彻底弄懂分裂和合并背后的“为什么”,后面看任何多路树变种都会轻松很多。别急着背代码,先拿纸笔从m=3开始推,再推m=5,最后再上代码,这条路我已经替你们趟过数遍了。