
大家好,很高兴又和大家见面啦!!! 在上一篇内容中,我们深入探讨了B树的 查找操作 与 树高特性,揭示了 B树 如何通过多路平衡结构显著降低树高,从而优化大规模数据存储场景下的查询效率。 我们特别分析了B树的查找思想、具体过程(包括成功与失败的场景),并推导出B树高度与关键字数量及阶数的数学关系:
这一公式确保了B树在动态数据环境下仍能保持高效检索能力。 然而,高效的查找仅是 B树 价值的 冰山一角 。B树 的真正强大之处在于其动态平衡能力——当插入或删除关键字时,B树 能通过分裂、合并等操作自动调整结构,维持稳定的树高与性能。 无论是数据库索引还是文件系统,B树 都需要频繁处理数据的增删操作。那么,B树 如何在不破坏平衡的前提下实现这些操作?其背后的分裂与合并机制又是如何运作的? 今天,让我们直接进入 B树 的核心动态操作环节,探索其插入与删除的完整流程与底层逻辑。
在说明其具体的插入过程之前,我们需要先认识一下 B树 中的一些概念,
目前存在着两种体系,这里我们以一棵 3阶B树 为例,分别说明这两种体系;
flowchart TB
subgraph A[内部结点]
direction TB
a[n, p0, key1, p1, key2, p2]
b[n, p0, key1, p1, key2, p2]
c[n, p0, key1, p1, key2, p2]
d[n, p0, key1, p1, key2, p2]
a--->b
a--->c
a--->d
subgraph C[终端结点]
direction TB
e[n, p0, key1, p1, key2, p2]
f[n, p0, key1, p1, key2, p2]
g[n, p0, key1, p1, key2, p2]
h[n, p0, key1, p1, key2, p2]
end
b--->e
c--->f
c--->g
d--->h
end
subgraph B[外部结点]
direction TB
e1[NULL]
e2[NULL]
e3[NULL]
e4[NULL]
end
e--->e1
f--->e2
g--->e3
h--->e4这里是将 B树 分为了 内部结点 与 外部结点 两大部分,而内部结点又将其细分出了 终端结点:
flowchart TB
subgraph A[内部结点]
direction TB
a[n, p0, key1, p1, key2, p2]
b[n, p0, key1, p1, key2, p2]
c[n, p0, key1, p1, key2, p2]
d[n, p0, key1, p1, key2, p2]
a--->b
a--->c
a--->d
end
subgraph C[终端结点/叶子结点]
direction TB
e[n, p0, key1, p1, key2, p2]
f[n, p0, key1, p1, key2, p2]
g[n, p0, key1, p1, key2, p2]
h[n, p0, key1, p1, key2, p2]
end
b--->e
c--->f
c--->g
d--->h在该体系中,通用将 B树 分为:内部结点 与 叶子结点 两部分,只不过此时的 终端结点 等同于 叶子结点 ,它是存储数据的最后一层结点,也是查找路径的终点,下方连接着查找失败时的外部结点;而 内部结点 是指所有的 非终端结点;
不管是 体系一 还是 体系二 ,均是采用的 内部结点 + 叶子结点 来构建一棵 B树,这二者的区别就是 终端结点 的归属:
体系二 (内部结点/终端结点二分法)在绝大多数现代数据库、文件系统等领域的资料和工程实践中,是绝对的主流和标准; 但是我们目前所使用的是基于 严蔚敏版的《数据结构》教材 所编写的《数据结构》教材,因此这里我们以 体系一 为准。
在 BST 中执行插入操作时,我们是将待插入的关键字作为一个新的结点插入到树中:
而 B树 的插入操作相比与 BST ,其具体过程要复杂的多。其主要原因是因为 m阶B树中的结点最多可以存储 m - 1 个关键字 ,因此 B树 的插入操作不再是将关键字作为新的结点插入到树中,而是直接在树的结点中插入新的关键字; 与 BST 的插入操作相同的是,新结点的插入一定是发送在 终端结点 中;但是在 B树 中查找到插入的位置后,并不能简单地将新关键字添加到 终端结点 中,这是因为当该结点中已经存在 m - 1 个关键字时,此时的插入操作就会导致整棵树不再满足 m阶B树 的要求。 因此 B树 的具体过程如下所示:
可以看到,B树 的插入操作中会涉及一个十分重要的操作——分裂。该操作的具体方法是:
下面我们以一棵 3阶B树 为例,来说明 B树 的具体插入过程。现在我们需要在一棵 空树 中插入 [5,7,1,3,5,6,4],其具体过程如下所示:
5由于该 3阶B树 是一棵空树,此时我们需要创建一个新的结点,并将该结点作为根结点插入到树中:
flowchart TB
a[n=1, p0, 5, p1, key2, p2]此时我们就完成了第一步插入操作;
7通过查找操作,我们确定了此时的失败结点的直接父结点为根结点,因此我们需要将该关键字插入到根结点中:
flowchart TB
a[n=2, p0, 5, p1, 7, p2]由于此时的关键字个数 n = 2 < m = 3
1通过查找操作,我们确定此时的失败结点的直接父结点为根结点,因此我们需要将该关键字插入到根结点中:
flowchart TB
a[n=3, 1, p0, 5, p1, 7, p2]由于此时的关键字个数 n = 3 == m = 3 因此我们需要对该结点进行分裂操作:
flowchart TB
a[n=3, 1, p0, 5, p1, 7, p2]
b[n, p0, key1, p1, key2, p2]由于当前结点为根结点,因此我们需要再为该结点创建一个父结点作为新的根结点:
flowchart TB
a[n, p0, key1, p1, key2, p2]
b[n=3, 1, p0, 5, p1, 7, p2]
c[n, p0, key1, p1, key2, p2]
a--->b
a--->c这里我们需要注意的是 \lceil \frac{3}{2} \rceil = 2 处的关键字是[1, 5, 7] 这三个关键字的 位序 其对应的下标应该是 i = 1,也就是关键字 5,因此通过分裂操作后的新树如下所示:
flowchart TB
a[n=1, p0, 5, p1, key2, p2]
b[n=1, p0, 1, p1, key2, p2]
c[n=1, p0, 7, p1, key2, p2]
a--->b
a--->c此时我们就完成了第一次分裂操作,由于此时是直接对 根节点 执行的分裂操作,因此 B树 的树高需要增加 1;
3通过查找操作,我们确定此时的失败结点的直接父结点为关键字 1 所在的 终端结点,因此我们需要将该关键字插入到该结点中:
flowchart TB
a[n=1, p0, 5, p1, key2, p2]
b[n=2, p0, 1, p1, 3, p2]
c[n=1, p0, 7, p1, key2, p2]
a--->b
a--->c由于此时该结点的关键字个数 n = 2 < m = 3
5通过查找操作,我们发现 5 已经存在于根结点中,因此不执行任何操作;
6通过查找操作,我们确定此时的失败结点的直接父结点为关键字 7 所在的 终端结点,因此我们需要将该关键字插入到该结点中:
flowchart TB
a[n=1, p0, 5, p1, key2, p2]
b[n=2, p0, 1, p1, 3, p2]
c[n=2, p0, 6, p1, 7, p2]
a--->b
a--->c由于此时该结点的关键字个数 n = 2 < m = 3
4通过查找操作,我们确定此时的失败结点的直接父结点为关键字 7 所在的 终端结点,因此我们需要将该关键字插入到该结点中:
flowchart TB
a[n=1, p0, 5, p1, key2, p2]
b[n=3, p0, 1, p1, 3, p2, 4]
c[n=2, p0, 6, p1, 7, p2]
a--->b
a--->c由于此时的关键字个数 n = 3 == m = 3 因此我们需要对该结点进行分裂操作:
flowchart TB
a[n=1, p0, 5, p1, key2, p2]
b[n=3, p0, 1, p1, 3, p2, 4]
c[n=0, p0, key1, p1, key2, p2]
d[n=2, p0, 6, p1, 7, p2]
a--->b
a--->c
a--->dflowchart TB
a[n=2, p0, 3, p1, 5, p2]
b[n=1, p0, 1, p1, key2, p2]
c[n=1, p0, 4, p1, key2, p2]
d[n=2, p0, 6, p1, 7, p2]
a--->b
a--->c
a--->d此时我们完成了第一次分裂操作,接下来我们需要检查该结点的父结点—— 根结点 中的关键字个数是否满足 1 \leq n \leq 2; 可以看到,此时的根节点的关键字个数满足要求,因此完成插入;
B树 的删除操作与插入操作类似,但是会稍微复杂一点:
接下来我们就来一起看一下 B树 中的删除操作是如何实现的;
B树 的删除过程可以总结为以下步骤:
简单的理解就是 B树 中的删除操作与插入操作一样,一定是发生在 终端结点 中; 那我们应该如何在 终端结点 中进行删除操作?对于不在 终端结点 中的关键字,我们又应该如何删除? 接下来我们就围绕这两个问题继续深入 B树 的删除操作;
当我们通过查找发现目标关键字 key 不在 终端结点 中时,我们需要通过其直接前驱(或直接后继)key' 来替代目标关键字 key ,而该直接前驱(或直接后继)key' 一定是位于 终端结点 中;
flowchart TB
a[22]
b[5, 11]
c[36, 45]
a--->b
a--->c
d[1, 3]
e[6, 8, 9]
f[13, 15]
b--->d
b--->e
b--->f
g[30, 35]
h[40, 42]
i[47, 48, 50, 56]
c--->g
c--->h
c--->i在这棵 5阶B树 中,位于 非终端结点 的关键字有 5 个:5, 11, 22, 36, 45。这些关键字的直接前驱与直接后继如下所示:
5 的直接前驱为关键字 3 ,直接后继为关键字 611 的直接前驱为关键字 9 ,直接后继为关键字 1322 的直接前驱为关键字 15 ,直接后继为关键字 3036 的直接前驱为关键字 35 ,直接后继为关键字 4045 的直接前驱为关键字 42 ,直接后继为关键字 47这里我们以关键字 22 为例,其直接前驱与直接后继所在结点我们分别以红色和蓝色进行标注:
flowchart TB
a[22]
b[5, 11]
c[36, 45]
a--->b
a--->c
d[1, 3]
e[6, 8, 9]
f[13, 15]
b--->d
b--->e
b--->f
g[30, 35]
h[40, 42]
i[47, 48, 50, 56]
c--->g
c--->h
c--->i
classDef R color: white, fill: red, stroke: red, stroke-width: 2px
classDef B color: white, fill: blue, stroke: blue, stroke-width: 2px
class f R
class g B可以看到,其直接前驱位于其左侧子树的最右侧的 终端结点 中,其具体位置为该结点中的 最右侧元素; 其直接后继位于其右侧子树的最左侧 终端结点 中,其具体位置为该结点中的 最左侧元素; 明确了关键字 key 的替代关键字 key' 的具体位置后,下面我们就来看一下我们应该如何执行从 非终端结点 到 终端结点 的转换操作,这里我们还是以删除关键字 22 为例:
15 来直接替代关键字 22flowchart TB
subgraph tree[转换前]
direction TB
a[22]
b[5, 11]
c[36, 45]
a--->b
a--->c
d[1, 3]
e[6, 8, 9]
f[13, 15]
b--->d
b--->e
b--->f
g[30, 35]
h[40, 42]
i[47, 48, 50, 56]
c--->g
c--->h
c--->i
end
subgraph tree1[转换后]
direction TB
A[15]
B[5, 11]
C[36, 45]
A--->B
A--->C
D[1, 3]
E[6, 8, 9]
F[13, 15]
B--->D
B--->E
B--->F
G[30, 35]
H[40, 42]
I[47, 48, 50, 56]
C--->G
C--->H
C--->I
end
tree ---> tree1
classDef R color: white, fill: red, stroke: red, stroke-width: 2px
classDef B color: white, fill: blue, stroke: blue, stroke-width: 2px
class a R
class f B
class A R
class F B30 来直接替代关键字 22flowchart TB
subgraph tree[转换前]
direction TB
a[22]
b[5, 11]
c[36, 45]
a--->b
a--->c
d[1, 3]
e[6, 8, 9]
f[13, 15]
b--->d
b--->e
b--->f
g[30, 35]
h[40, 42]
i[47, 48, 50, 56]
c--->g
c--->h
c--->i
end
subgraph tree1[转换后]
direction TB
A[30]
B[5, 11]
C[36, 45]
A--->B
A--->C
D[1, 3]
E[6, 8, 9]
F[13, 15]
B--->D
B--->E
B--->F
G[30, 35]
H[40, 42]
I[47, 48, 50, 56]
C--->G
C--->H
C--->I
end
tree ---> tree1
classDef R color: white, fill: red, stroke: red, stroke-width: 2px
classDef B color: white, fill: blue, stroke: blue, stroke-width: 2px
class a R
class g B
class A R
class G B从上图中我们不难发现,不管是使用直接前驱,还是直接后继,实际上我们只需要将对应的值存储到目标结点中来替代原关键字 key 即可; 若无法理解 替代 这一过程,那我们也可以理解为,先删除原关键字 key 再插入其直接前驱或者直接后继 key' 到该结点中; 由于执行的是一次删除 + 一次插入,因此整个 替代 过程不会破坏 B树 的性质;
当我们对 B树 执行删除操作时,不管目标关键字 key 是否位于 终端结点 中,最终都会在 终端结点 中完成删除操作; 由于删除操作会直接减少对应结点中的一个关键字,这就有可能导致结点中的关键字个数少于 \lceil \frac{m}{2} \rceil - 1 ,这时就破坏了 B树 的性质,因此为了保证删除操作后不破坏 B树 的性质,这时我们就需要通过 借 或 合并 操作来维持 B树 的性质;
在 B树 中,对 终端结点 进行删除操作时,会存在 3 种情况:
那么我们应该如何 借 ?当不够 借 时,我们又应该如何 合并 呢?下面我们继续来深入探讨;
所谓的 借 实际上是将兄弟结点中的关键字 key1 删除后插入到父结点中,并将父结点中的关键字 key2 删除后,插入到当前结点中。下面我们就以一棵 5阶B树 的删除操作来说明整个借的过程:
flowchart TB
a[n = 2, key1, key2]
b[n = 2, key3, key4]
c[n = 3, key5, key6, key7]
a--->b
a--->c在上述这棵 5阶B树 中,根据 B树 的性质,除了根结点外,其余结点中的结点数在 [2, 4] 这个范围中,当我们要删除 key4 时,其具体过程如下所示:
flowchart TB
a[n = 2, key1, key2]
b[n = 1, key3]
c[n = 3, key5, key6, key7]
a--->b
a--->c
classDef R color: white, fill: red, stroke: red, stroke-width: 2px
class b R此时结点中只存在一个关键字,而与其相邻的右兄弟结点中有 3 个关键字,满足 3 > 2key5;
flowchart TB
subgraph root1[第一步: 删除 key5,并将其插入到父结点中]
direction TB
a[n = 3, key1, key2, key5]
b[n = 1, key3]
c[n = 2, key6, key7]
a--->b
a--->c -.->|将关键字key5 插入到父结点|a
end
subgraph root2[第二步: 删除 key1,并将其插入到当前结点中]
direction TB
A[n = 2, key2, key5]
B[n = 2, key3, key1]
C[n = 2, key6, key7]
A--->B
A--->C
A-.->|将关键字key1插入到当前结点中|B
end
root1 ---> root2
classDef R color: white, fill: red, stroke: red, stroke-width: 2px
class b R
class B R也就是说,虽然 借 操作是从相邻的兄弟结点中 借 一个关键字,但是实际上我们是执行了两次 借 操作:
而这个 借 的关键字也是有要求的:
这里就以上图中的关键字为例:
而这些关键字对应的结点为:
当我们向右兄弟 借 一个结点并将其插入到根结点中时,我们只能够找根结点中最大关键字 key2 的直接后继,也就是 key5 ,因此完成第一次 借 后,各关键字的位置做出了如下调整:
$$ \underbrace{key3}{当前结点} < \underbrace{key1 < key2}{根结点} < \underbrace{\textcolor{red}{key5} < key6 < key7}{右兄弟结点} \\ \ \underbrace{key3}{当前结点} < \underbrace{key1 < key2 < \textcolor{red}{key 5}}{根结点} < \underbrace{key6 < key7}{右兄弟结点} $$
同理,当我们要从根结点中 借 一个关键字插入到当前结点中,我们也只能找 key3 的直接后继 key1,因此完成第二次 借 后,各关键字的位置做出了如下调整:
$$ \underbrace{key3}{当前结点} < \underbrace{\textcolor{red}{key1} < key2 < key 5}{根结点} < \underbrace{key6 < key7}{右兄弟结点} \\ \ \underbrace{key3 < \textcolor{red}{key1}}{当前结点} < \underbrace{key2 < key 5}{根结点} < \underbrace{key6 < key7}{右兄弟结点} \ $$
现在大家应该能够理解为什么我们是借的 key5 与 key1 了。若大家在后续的实战中,还是不太明白如何 借 ,那可以像我这样将相关结点中的关键字按照大小顺序进行排序,并对排序后的各关键字进行结点划分,这样就能一清二楚了; 若各位不想这么麻烦,这里我们也给大家做一个 借 的规则总结:
因此我们可以将 借 的总结为六个字:左借大,右借小;
当相邻的左右兄弟均不够借时,我们就需要通过 合并 来保证 B树 的性质。 合并 顾名思义,就是将两个结点 合并 成一个新的结点,但是,该合并过程并不是直接合并两个结点,而是在不改变各关键字的先后顺序的前提下,将当前结点、兄弟结点以及根结点中的关键字进行合并,组成新的结点,之后再对结点进行处理,使其继续保持 B树 的性质; 下面我们就以一棵 5阶B树 为例,说明 合并 的具体过程;
flowchart TB
a[22]
b[5, 11]
c[36, 45]
a--->b
a--->c
d[1, 3]
e[6, 8, 9]
f[13, 15]
b--->d
b--->e
b--->f
g[30, 35]
h[40, 42]
i[47, 48, 50, 56]
c--->g
c--->h
c--->i在这棵 5阶B树 中,除根节点外,其余结点中的关键字数量需要保证在 [2, 4] 这个范围内; 假设我们需要删除关键字 35 。从图中我们不难发现,关键字 35 所在的结点以及与其相邻的两个兄弟结点中关键字的数量均为 2 ; 也就是说,当我们删除 35 这个关键字后,当前结点中的关键字数量就已经不满足 B树 的要求,并且我们也无法从兄弟结点中 借; 在这种情况下,我们就需要通过 合并 操作来保证删除后的 B树 仍满足 B树 的性质。其具体过程如下:
35flowchart TB
a[22]
b[5, 11]
c[36, 45]
a--->b
a--->c
d[1, 3]
e[6, 8, 9]
f[13, 15]
b--->d
b--->e
b--->f
g[30]
h[40, 42]
i[47, 48, 50, 56]
c--->g
c--->h
c--->i
classDef R color: white, fill: red, stroke: red, stroke-width: 2px
class g R此时的树已经不再满足 B树 的性质,因此我们需要选择当前结点与其兄弟结点进行 合并;
合并 操作中的合并对象一定是 相同父结点的兄弟结点 ,因此,对于当前结点而言,与其相邻的且父结点相同的兄弟结点只有一个右兄弟结点,因此我们进行合并的对象一定是 右兄弟; 在合并的过程中,我们需要将当前结点、右兄弟结点,以及这两个结点中间的位于父结点中的关键字 36 进行合并,形成一个新的结点:
flowchart TB
a[22]
b[5, 11]
c[45]
a--->b
a--->c
d[1, 3]
e[6, 8, 9]
f[13, 15]
b--->d
b--->e
b--->f
g[30, 36, 40, 42]
i[47, 48, 50, 56]
c--->g
c--->i
classDef R color: white, fill: red, stroke: red, stroke-width: 2px
class g R可以看到,合并后得到的新结点满足 B树 的性质:n \leq m - 1 ,因此该结点无需做出任何调整,合并完成; 但是我们会发现,此时该结点的父结点中的关键字个数不再满足 B树 的性质,因此我们需要将 合并 操作向上传递,也就是对当前结点的父结点进行合并操作:
flowchart TB
a[22]
b[5, 11]
c[45]
a--->b
a--->c
d[1, 3]
e[6, 8, 9]
f[13, 15]
b--->d
b--->e
b--->f
g[30, 36, 40, 42]
i[47, 48, 50, 56]
c--->g
c--->i
classDef R color: white, fill: red, stroke: red, stroke-width: 2px
class c R从图中我们可以看到,45 所在的结点的具有相同父结点的兄弟结点只有该结点的左兄弟结点,因此我们需要将当前结点、左兄弟结点,以及位于父结点的两个结点中间的关键字进行合并,组成新的结点:
flowchart TB
a[NULL]
b[5, 11, 22, 45]
a--->b
d[1, 3]
e[6, 8, 9]
f[13, 15]
b--->d
b--->e
b--->f
g[30, 36, 40, 42]
i[47, 48, 50, 56]
b--->g
b--->i
classDef R color: white, fill: red, stroke: red, stroke-width: 2px
class b R在此次的 合并 操作完成后,我们发现新结点的父结点中不存在任何关键字,此时我们只需要将其删除,使新结点成为新的根结点即可:
flowchart TB
b[5, 11, 22, 45]
d[1, 3]
e[6, 8, 9]
f[13, 15]
b--->d
b--->e
b--->f
g[30, 36, 40, 42]
i[47, 48, 50, 56]
b--->g
b--->i
classDef R color: white, fill: red, stroke: red, stroke-width: 2px
class b R在完成本次的处理后,此时该棵 5路查找树 再一次恢复了 B树 的性质,并且树的高度由原来的 h = 3 降至 h = 2;
在 m阶B树 中,不管是 插入 还是 删除 均有以下共同点:
不过这两种操作还是会存在不同之处:
在 插入 操作中,分裂 是将一个结点分裂为两个结点,并且可能会产生新的根结点; 在 删除 操作中,借 的规则是 左借大,右借小 ,合并 则是将两个结点以及位于父结点中的两个结点中间的关键字 合并 成一个新的结点,并且可能会直接删除原先的根结点;
通过本文的详细探讨,我们系统性地掌握了B树两大核心动态操作——插入与删除的完整机制。让我们回顾一下其中的关键知识点: 🔄 核心操作流程回顾 B树 的插入与删除操作均遵循严格的平衡维护原则。
⚖️ 平衡机制对比分析 插入与删除操作在维持B树平衡方面展现出不同的特性:
这种差异直接体现在树高变化上:
值得注意的是,所有操作最终都在 终端结点 完成,并通过向上传递的链式反应确保整棵树的平衡性。 💡 实际应用价值 B树通过这些精心设计的操作机制,在外存数据管理(如数据库索引和文件系统)中展现出巨大优势。 其低树高特性显著减少了磁盘I/O次数,而动态平衡能力则确保了数据操作的高效稳定。 理解这些底层机制,对于设计高性能存储系统和进行数据库优化具有重要指导意义。 📚 知识体系整合 本文与前期讨论的B树查找操作和树高特性形成了完整知识体系。 从静态查找到动态维护,我们已全面掌握了B树这一重要数据结构的核心原理,为后续学习B+树等衍生结构奠定了坚实基础。 互动与分享
感谢您的耐心阅读!我们下一篇再见!