免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

B树原理与操作全解:从插入分裂到删除合并,一篇讲透

B树原理与操作全解:从插入分裂到删除合并,一篇讲透 我先把话放这儿不管你是准备面试背八股还是在调一条慢到不能忍的数据库查询只要你跟索引打过照面B树就一定绕不过去。很多人对B树的认知停留在“多叉树、矮胖、适合磁盘”这三句话上可真让他手写一个插入分裂、删除合并立刻卡壳。这篇不搞虚的我直接把B树的原理拆开配合一整套操作推演让你看完就能自己画、能自己推、能照着实现。这块内容我一向建议用“手画”来学。光看文字记不住分裂方向光背结论也搞不清为什么借键要经过父节点中转。下面我先从B树存在的意义讲起再用m5的实例把查找、插入、删除全部走一遍最后聊聊工程落地和面试里真正会问的点。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亿条数据如果是一棵m100的B树log100(10亿)大概是4.5层。算上根节点缓存命中真实磁盘IO往往两次以内就结束了。从30次降到2到3次体感就是从“卡顿”到“秒开”这就是B树存在的全部理由。1.3 从2-3树看B树的雏形B树不是凭空冒出来的。如果你完全没接触过2-3树建议先去了解一下它是m3的B树节点最多能存2个键、3个子指针。理解2-3树的插入分裂和删除合并基本上就理解了B树在极端情况下的所有操作。2-3树里每个节点要么有1个键2个孩子2节点要么有2个键3个孩子3节点。插入时如果3节点满了就把中间键顶上父节点剩下两个键拆成两个2节点。删除时如果节点空了就看兄弟能不能借不能借就合并。这套逻辑放大到任意m就是完整的B树操作。所以我一直建议学B树不要一上来就啃m5、m100的复杂例子。先在纸上画2-3树把分裂、合并的手感找到再往多路上面迁移你会觉得自然很多。2. B树的结构与关键性质一张图看懂节点长什么样2.1 节点的内部布局先明确一件事B树节点里存的是“键-指针-键-指针”这种交错结构。一个节点如果有k个键那它一定有k1个子指针。比如下面这棵m5的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个键所有叶子节点出现在同一层。我举个例子方便你记忆。m5时ceil(5/2)3所以非根节点至少3个孩子、至少2个键最多5个孩子、4个键。m3时至少2个孩子、1个键这就是2-3树。这些数字看起来像死记硬背的规则但它的本质是“用最小的填充率来控制树高”。如果允许节点只有一个键那B树就会退化成普通的二叉搜索树树高优势消失了。定一个下限是为了保证节点分裂合并之后整棵树不会退化成长链。2.3 为什么“所有叶子同层”这么重要这是B树和普通搜索树一个很微妙但特别重要的差异。B树的所有叶子节点必须严格在同一个深度这是通过插入分裂和删除合并来维持的。为什么非要在同一层因为如果树的高度不一致那查询的最小IO次数和最大IO次数就会悬殊你能不能接受这种不稳定性数据库不答应文件系统也不答应。这个性质的代价就是任何让树变高或变矮的操作都只能从根节点向上或者根节点向下发生。插入时只有根节点分裂树的层数才会加1删除时只有根节点合并/降级层数才会减1。其他任何操作都只是局部调整不会影响全局高度。这也是B树实现能保持简洁的关键。这里有个经常被忽略的推论由于所有叶子同层B树是天然自平衡的不需要像红黑树那样搞旋转、染色那一套复杂的平衡逻辑。它通过键的数量约束和局部结构调整来维持全局平衡实现起来反而比红黑树更直观。3. 查找操作B树最朴素的读取路径3.1 查找过程B树的查找跟二叉搜索树如出一辙只是多了“在节点内部进行多次比较”这一步。拿上面那棵m5的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万条数据m2的二叉搜索树log2(100万) ≈ 20次IOm10的B树log10(100万) 6次IOm100的B树log100(100万) 3次IO。这就是B树“矮胖”的优势。注意括号里的m越大单节点能覆盖的关键字区间越多树越矮IO越少。但m不能无限大因为单个节点大小受磁盘页大小限制这一点我们后面单独讲。我实际写代码的时候查找函数往往就是几个循环嵌套但有个细节得说清楚节点内查不到某个键时千万别直接返回不存在。B树的查询需要返回“应该走哪个子树”即使当前节点的键不等于目标值也要根据比较结果继续往下。只有在到达叶子节点仍找不到时才能判定数据不存在。4. 插入操作一切溢出的核心是“分裂”4.1 插入的完整流程插入操作的整体思想是先找到准确的叶子节点插入键如果节点键数超过了m-1就进行分裂。这个“分裂”是B树操作里出现频率最高的动作。完整流程大概是这样的从根开始按照查找逻辑找到对应的叶子节点。把新键按顺序插入叶子节点的键数组中数组内部保持有序。检查该节点键数是否达到m。如果没超过m-1插入直接结束。如果超过了执行节点分裂。很容易被忽略的一点是每次插入都要从根走到叶子。为什么不中途插入因为B树的所有数据都挂在叶子节点上内部节点只是索引所以新键只能进叶子。这也是B树和B树的一个重要辨析点后面我会再提到。4.2 叶子节点分裂的现场拆解分裂操作听起来复杂其实就三句话中间键上提给父节点剩下的键分成左右两个节点父节点多出一个孩子。我用一个m5的例子。假设某个叶子节点已经有4个键[10, 20, 30, 40]。这时插入25节点变成5个键[10, 20, 25, 30, 40]而m5的节点最多只能有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 关键操作之一向兄弟借键旋转当一个节点下溢时第一选择是看左右兄弟节点“富不富裕”。兄弟节点如果键数超过下限就可以借一个键过来。但这个借不是直接拿兄弟的键必须经过父节点中转。假设m5一个节点应该至少有2个键。现在某个节点只剩下1个键它的右兄弟有4个键最多能存4个那就是富裕的。借的过程是先把父节点中分隔这两个节点的键拿下来放到当前节点的末尾。把右兄弟最小的键上提到父节点替换刚才被拿走的那个键的位置。如果借出的是右兄弟的最左子树指针还要把这个指针补到当前节点的末尾孩子指针位置。听起来绕核心就一句话父节点的键下来补位兄弟的键上去顶替父节点保持区间划分不变。我把这个过程画成示意图m5场景节点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 关键操作之二节点合并如果兄弟节点自己也只有下限个键那就借不了。这时必须合并。合并的规则是把父节点中的分隔键拉下来和当前节点、兄弟节点的所有键拼成一个新节点。父节点少了一个键和少了一个孩子。还以m5为例节点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. 完整操作模拟拿m5的B树从头走一遍6.1 连续插入12个键的过程推演光讲规则不如实战推演。我用m5的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个键m5的节点最多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个键正好未满结束。插入11小于8走左子树左子树[2, 7]变成[1, 2, 7]未满结束。插入99大于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个键不对m5的下限是至少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个键但m5的节点最多4个键这又是一个溢出。等等我上面的合并结果是不是错了这里我要特别提醒合并时必须保证合并后的节点不超过最大键数上限。两个节点加上一个父节点键会不会超在这个例子里两个兄弟节点总键数为20加上父节点的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]完全满足m5的约束根节点至少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个键必须有k1个孩子。这里2个键对应3个孩子满足。而m5的下限要求非根节点至少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如果pageSize4096字节ptrSize8字节keySize8字节那么4096 m * 8 (m - 1) * 8 4096 16m - 8 m 256.5所以m最大可以取256。但实际设计不会顶到上限因为节点还要存一些元数据比如键的数量、节点类型、父节点指针等这些都会占空间。一般会留20%到30%的余量。磁盘页大小与m参考表磁盘页大小keySizeptrSize理论m上限建议m值409688256180-2204096168170130-160819288512380-45016384168682500-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树这种结构你只要亲手推过一轮完整流程彻底弄懂分裂和合并背后的“为什么”后面看任何多路树变种都会轻松很多。别急着背代码先拿纸笔从m3开始推再推m5最后再上代码这条路我已经替你们趟过数遍了。
返回列表