B 树及其基本操作
教材原话:「考研大纲对 B 树和 B+树的要求各不相同,重点在于考查 B 树,不仅要求理解 B 树的基本特点,还要求掌握 B 树的建立、插入和删除操作,而对 B+树则只考查基本概念。」
这句话直接决定了 7.4 两节的写法:B 树要练手,B+树只要背清楚差异。
B 树是 二叉排序树 的多路化版本。动机不是「更快」,而是减少磁盘存取次数——B 树常存储在磁盘上,在磁盘上进行查找的次数即目标结点在 B 树上的层次数,决定了 B 树的查找效率。把二路变成
口径差异:
教材在 7.4.1 的脚注里留了一句必须知道的话: 「多数教材将 B 树的叶结点定义为失败结点,本书也采用这种定义, 但 408 真题将 B 树的叶结点定义为最底层的终端结点。」 同一个词,教材和真题指的不是同一层。 做真题时看到「叶结点」要按真题口径(最底层有关键字的那层), 读教材时按教材口径(不带信息的外部结点)。这是本章最容易被无声扣分的一处。 另外,B 树也可写成「B-树」,这里的「-」是连接词,不能读作「减」。
机制
定义
所谓
一棵
- 树中每个结点至多有
棵子树,即至多有 个关键字。 - 若根结点不是叶结点,则至少有 2 棵子树,即至少有 1 个关键字。
- 除根结点外的所有非叶结点至少有
棵子树,即至少有 个关键字。 - 所有非叶结点的结构为
。其中 为关键字且 ; 为指向子树根结点的指针,且 所指子树中所有结点的关键字均小于 , 所指子树中所有结点的关键字均大于 ; 为结点中关键字的个数, 。 - 所有的叶结点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的失败结点,实际上这些结点并不存在,指向这些结点的指针为空)。
边界辨析:
「
阶」的 是子树数,不是关键字数。 关键字数的上限是 。 这一条错了,后面每一个 都会跟着错。 记忆抓手:结点的孩子个数 该结点中关键字个数 ——关键字是「隔板」,子树是「格子」,格子总比隔板多一个。
以一棵 5 阶 B 树为例(教材图 7.28):
flowchart TD R["22"] --- A["5 11"] R --- B["36 45"] A --- A1["1 3"] A --- A2["6 8 9"] A --- A3["13 15"] B --- B1["30 35"] B --- B2["40 42"] B --- B3["47 48 50 56"] A1 --- L1["□ 外部结点(第 4 层)"] A2 --- L2["□"] A3 --- L3["□"] B1 --- L4["□"] B2 --- L5["□"] B3 --- L6["□"] classDef inner fill:#e3f2fd,stroke:#1565c0,stroke-width:2px classDef leaf fill:#ffe0b2,stroke:#e65100,stroke-dasharray:4 3 class R,A,B,A1,A2,A3,B1,B2,B3 inner class L1,L2,L3,L4,L5,L6 leaf
对照定义验算:
- 结点的孩子个数等于该结点中关键字个数加 1:根结点 1 个关键字 → 2 个孩子;
5 11有 2 个关键字 → 3 个孩子。 - 除根外的非叶结点至少有
棵子树(至少 个关键字),至多 5 棵子树(至多 4 个关键字)。 - 结点中的关键字从左到右递增有序,关键字两侧的指针所指子树的关键字落在它划分出的区间内。第二层最左结点的 2 个关键字把值域划分成 3 个区间
、 、 。 - 所有叶结点均在第 4 层,代表查找失败的位置。
查找
在 B 树上进行查找与二叉排序树很相似,只是每个结点都是多个关键字的有序表,在每个结点上所做的不是两路分支决定,而是根据该结点的子树所做的多路分支决定。
B 树的查找包含两个基本操作:
| 操作 | 在哪里做 | 用什么方法 |
|---|---|---|
| ① 在 B 树中找结点 | 磁盘上 | 沿指针下降,次数 = 结点的层次数 |
| ② 在结点内找关键字 | 内存中 | 顺序查找法或折半查找法 |
这一分工是 B 树全部设计意图的所在:磁盘操作慢,所以要少;内存操作快,所以每个结点可以塞很宽。在磁盘上进行查找的次数即目标结点在 B 树上的层次数,决定了 B 树的查找效率。
查找到叶结点时(对应指针为空),则说明树中没有对应的关键字,查找失败。
高度:两个方向的界
首先要明确:B 树的高度不包括最后的不带任何信息的叶结点所处的那一层(有些书对 B 树的高度的定义中包含最后那一层)。
对任意一棵包含
最小高度(每个结点塞满):因为每个结点最多
最大高度(每个结点最少):第一层至少 1 个结点;第二层至少 2 个结点;除根外每个非叶结点至少
例:一棵 3 阶 B 树共有 8 个关键字,则
关联对照:
插入:定位 + 分裂
与二叉排序树的插入操作相比,B 树的插入操作要复杂得多。在 B 树中查找到插入的位置后,并不能简单地将其添加到终端结点(最底层的非叶结点)中,因为此时可能会导致整棵树不再满足 B 树定义中的要求。
- 定位。 利用 B 树查找算法,找出插入该关键字的终端结点。(在 B 树中查找
key时,会找到表示查找失败的叶结点,因此插入位置一定是最底层的非叶结点。) - 插入。 每个非根结点的关键字个数都在
。若结点插入后的关键字个数小于 ,可以直接插入;若插入后的关键字个数大于 ,必须对结点进行分裂。
分裂的方法:取一个新结点,在插入 key 后的原结点,从中间位置
flowchart LR A["(a) 插入前<br/><b>30</b><br/>20 | 50 52"] -->|"插入 60 后,溢出"| B["(b) 结点溢出<br/><b>30</b><br/>20 | <b>50 52 60</b>"] B -->|"结点分裂"| C["(c) 分裂后<br/><b>30 52</b><br/>20 | 50 | 60"] classDef ok fill:#c8e6c9,stroke:#1b5e20 classDef bad fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px class A,C ok class B bad
上图是 50 52 60 有 3 个关键字,超限。中间位置 50 留在原结点,右边 60 进新结点。
分裂是唯一能让 B 树长高的操作,而且它只从根部长高——所以 B 树永远是所有叶结点在同一层的。
删除:三种情况
B 树的删除操作与插入操作类似,但要稍微复杂一些,即要使得删除后的结点中的关键字个数
第 0 步:把非终端结点的删除转化为终端结点的删除。 当被删关键字
然后只需讨论终端结点的三种情况:
| 情况 | 判据 | 动作 |
|---|---|---|
| ① 直接删除 | 所在结点删除前的关键字个数 | 直接删去该关键字 |
| ② 兄弟够借 | 所在结点删除前关键字个数 | 调整该结点、右(或左)兄弟结点及其双亲结点(父子换位法) |
| ③ 兄弟不够借 | 所在结点删除前关键字个数 | 将关键字删除后与左(或右)兄弟结点及双亲结点中的关键字进行合并 |
情况 ③ 的连锁反应:在合并过程中,双亲结点中的关键字个数会减 1。
- 若双亲结点是根结点且关键字个数减少至 0(根结点关键字数为 1 时,有 2 棵子树),则直接将根结点删除,合并后的新结点成为根——这是 B 树唯一变矮的方式。
- 若双亲结点不是根结点,且关键字个数减少到
,则又要与它自己的兄弟结点进行调整或合并操作,并重复上述步骤,直至符合 B 树的要求为止。
flowchart TD S["删除关键字 k"] --> Q0{"k 在终端结点?"} Q0 -->|"否"| P["用前驱/后继 k′ 替代 k<br/>转为删除 k′"] P --> Q1 Q0 -->|"是"| Q1{"该结点关键字数<br/>≥ ⌈m/2⌉ ?"} Q1 -->|"是"| C1["① 直接删除<br/>结束"] Q1 -->|"否"| Q2{"兄弟关键字数<br/>≥ ⌈m/2⌉ ?"} Q2 -->|"是"| C2["② 兄弟够借<br/>父子换位法<br/>结束"] Q2 -->|"否"| C3["③ 兄弟不够借<br/>与兄弟 + 双亲关键字合并"] C3 --> Q3{"双亲关键字数<br/>减到 ⌈m/2⌉−2 ?"} Q3 -->|"根且减到 0"| C4["删根,新结点成为根<br/>树高 −1,结束"] Q3 -->|"非根且不足"| Q2 Q3 -->|"仍合法"| C5["结束"] classDef done fill:#c8e6c9,stroke:#1b5e20 classDef loop fill:#ffcdd2,stroke:#b71c1c,stroke-width:3px classDef norm fill:#e3f2fd,stroke:#1565c0 class C1,C2,C4,C5 done class C3,Q3 loop class S,Q0,P,Q1,Q2 norm
层次辨析:
插入只会让树长高,删除只会让树变矮,而且都只发生在根。 这是 B 树「所有叶结点在同一层」的维持机制: 中间层永远不会单独增减一层。与 AVL 树 的旋转对比着看—— AVL 是局部换形状,B 树是整层地长和缩。
手算模板
判定合法性(
| 位置 | 子树数 | 关键字数 |
|---|---|---|
| 根(非叶) | ||
| 其他非叶结点 |
插入 key:
- 从根查到最底层的非叶结点(终端结点)。
- 按序插进去。
- 数关键字:
→ 完; → 分裂。 - 分裂:取第
个关键字上升给父亲,左边留原结点,右边进新结点。 - 父亲若也满了,回第 3 步。传到根就长高一层。
删除 key:
- 不在终端结点 → 用前驱(左子树最右下)或后继(右子树最左下)替代,转为删那个。
- 删除前数一下本结点的关键字个数:
→ 直接删。 - 否则看相邻兄弟:
→ 借(父子换位:兄弟的关键字上去顶父亲,父亲的下来补自己)。 - 兄弟也只有
→ 合并:自己 + 双亲的分隔关键字 + 兄弟,并成一个结点。 - 双亲少了一个关键字,回第 2 步检查双亲。根空了就删根,树矮一层。
记忆抓手:够就直接删,不够先借,借不到再并;并完往上再查一遍。
边界
| 说法 | 判断 | 说明 |
|---|---|---|
| 「 | ❌ | 至多 |
| 「B 树的叶结点是最底层的终端结点」 | ⚠️ | 教材:叶结点 = 失败结点(不带信息);408 真题:叶结点 = 最底层终端结点。 看语境切换 |
| 「B 树的高度包含叶结点那一层」 | ❌ | 教材定义不包含;有些书包含,做题按教材 |
| 「根结点至少有 | ❌ | 根至少 2 棵; |
| 「B 树中所有结点的关键字数下限都是 | ❌ | 根结点是 1 |
| 「插入时新关键字可能插到非终端结点」 | ❌ | 一定插在最底层的非叶结点,因为查找失败必然停在叶结点上方 |
| 「分裂时中间关键字留在原结点」 | ❌ | 中间关键字上升到父结点 |
| 「B 树可以从中间层长高」 | ❌ | 只能从根长高,所以叶结点永远同层 |
| 「删除非终端结点的关键字要重建子树」 | ❌ | 用前驱或后继替代,转为删终端结点 |
| 「兄弟够借时只调整两个结点」 | ❌ | 要调三个:本结点、兄弟、双亲(父子换位法) |
| 「合并后一定结束」 | ❌ | 双亲可能因此不足,需逐层上溯 |
| 「B 树查找失败时比较次数等于树高」 | ⚠️ | 磁盘存取次数等于层次数;结点内还要再做一次顺序/折半查找 |
口径差异:
算法竞赛几乎不写 B 树——它的收益在磁盘 I/O,而竞赛全程在内存里跑,
路分支反而不如二叉快。 所以「B 树是为了减少磁盘存取次数」这个动机在算竞视角里是看不见的, 容易把 B 树当成「另一种平衡树」来记,从而记不住为什么结点要那么宽。 这一节的所有数量约束( 、分裂点、高度公式)都是从「一个结点 = 一个磁盘块」推出来的, 先接受这个前提,后面的式子才不是死记。
对照速查
| 量 | 式子 |
|---|---|
| 关键字数范围(非根非叶) | |
| 子树数范围(非根非叶) | |
| 根结点(非叶) | 子树 |
| 最小高度 | |
| 最大高度 | |
| 失败结点数 | |
| 孩子数与关键字数 | 孩子数 |
| 关键字下限 | 关键字上限 | ||
|---|---|---|---|
| 3 | 2 | 1 | 2 |
| 4 | 2 | 1 | 3 |
| 5 | 3 | 2 | 4 |
| 6 | 3 | 2 | 5 |
考点
是子树数,关键字数 子树数 。 - 教材与 408 真题对「叶结点」的定义不同(脚注原文)。
- 高度的上下界公式及「高度不含叶结点层」。
- 插入的分裂点是
,中间关键字上升(2020 命题追踪)。 - 删除的三种情况与判据(2012、2022 命题追踪),尤其「删除前的关键字个数」这个时点。
- 父子换位法要动三个结点。
- 合并会向上传播,根空则树矮一层(2023 命题追踪)。
- 孩子数 = 关键字数 + 1(2013、2014、2018、2021 反复考)。
链接
- 🏠 返回总览:数据结构第 7 章:查找总览
- ⬅️ 上一节:7.3.3 红黑树
- ➡️ 下一节:7.4.2 B+树的基本概念
- 🔗 二路的原型:7.3.1 二叉排序树
- 🔗 同样虚构
个失败结点:7.2.2 折半查找 - 🔗 磁盘块与 I/O 代价:OS 5.3.1 磁盘
- 📖 名词库:第 7 章名词库