#CS225

6.5。沿着树向下走的每一步 (例如,根节点 8 -> 5 -> 6 -> 7) 都需要从磁盘读取一个节点。100ms (对于某些场景是现实的延迟),并且这条路径的树高为 4,那么这次搜索需要 4 * 100ms = 400ms。这还是对于一个只有 12 个节点的微型树!500m) 条。5MB * 500m = 2500m MB = 2.5k TB = 2.5 PB (Petabytes,拍字节)。这显然无法放入内存。h 大约是 $h \approx \log_2 n$。对于 $n = 500 \times 10^6$,$h \approx \log_2(5 \times 10^8) \approx 29$。所以,假设高度约为 30。100ms,一次搜索可能需要高达 $h \times 100ms \approx 30 \times 100ms = 3000ms = 3$ 秒!对于许多应用来说,这太慢了。m 的 B 树 (B-Tree) 正是为了这个目的而设计的。[-3, 8, 23, 25, 31, 42, 43, 55]。这看起来像是节点内部的一个排序数组 (Sorted array)。这里提到了 m=9,这似乎与这个节点如果是内部节点时 可能 拥有的指针数量有关 (我们稍后会澄清属性)。节点本身通常被称为 BTreeNode。1500B。m 的 B 树是一个 m 路树 (m-way tree),意味着节点最多可以有 m 个子节点。
m-1 个键。 (这意味着最多有 m 个子节点指针)。m 个键 (即节点溢出),我们必须分裂 (split) 该节点。m=5): 一个节点初始有键 [3, 9, 17, 21]。我们插入 16。节点变为 [3, 9, 16, 17, 21]。它现在有 m=5 个键,太多了。
[3, 9, 16, 17, 21],中间键是 16。16) “扔到 (Throw up)” 父节点 (parent node)。[3, 9]) 组成一个新的左节点。大于中间键的键 ([17, 21]) 组成一个新的右节点。16) 连接。16 插入 [3, 9, 17, 21] (m=5) 开始。节点变为 [3, 9, 16, 17, 21]。中间键 16 被提升。左子节点 [3, 9],右子节点 [17, 21]。35, 80, 225 被插入到右子节点 [17, 21] 中。它变成 [17, 21, 35, 80, 225]。中间键 35 被提升到根节点。左子节点 [17, 21],右子节点 [80, 225]。根节点现在包含 [16, 35]。80 从 [21, 35, 80, 225] 中被提升,导致根节点为 [17, 80],子节点为 [3, 9], [21, 35], [225, 216??]。我们还是遵循前面和幻灯片 30-32 展示的标准中值提升方法。)可视化Btree Operation B-Tree Visualization
让我们总结一下阶为 m 的 B 树的正式属性:
K,或者确定 K 应该在哪个区间 $(key_i, key_{i+1})$。(这可以通过在节点内部的有序键上进行线性扫描或二分查找来完成)。K,返回成功/关联的值。K 并且该节点是叶子节点,则 K 不在树中。返回失败。K 并且该节点是内部节点,则沿着对应于步骤 2 中确定的区间的子节点指针向下查找 (如果是 $K < key_i$,则为指针 i;如果是 $key_i < K < key_{i+1}$,则为指针 i+1,依此类推)。node._fetchChild(i) 操作,并且是缓慢的步骤 (例如,100ms)。m=3 的树中查找 43。
[23]。43 > 23。跟随右子指针。获取节点 [42, 55] (成本:1 次磁盘读取,例如 100ms)。[42, 55]。43 在 42 和 55 之间。跟随中间子指针。获取节点 [43] (成本:1 次磁盘读取,例如 100ms)。[43]。找到 43。总成本:2 次磁盘读取。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
#include <vector>
#include <iostream>
#include <algorithm>
#include <memory> // For std::unique_ptr, std::make_unique
// 假设 Key 类型是 int
using Key = int;
// 前向声明 BTree 类,以便 BTreeNode 可以引用它
template <int M> // M 是 B 树的阶 (Order)
class BTree;
// B 树节点结构体
template <int M> // M is the order of the B-Tree
struct BTreeNode {
bool isLeaf = true;
std::vector<Key> keys; // 节点中的键
std::vector<std::unique_ptr<BTreeNode<M>>> children; // 指向子节点的指针向量
// 对于内部节点,数量 = keys.size() + 1
// 对于叶节点,此向量通常为空或未使用
// (为了简化,省略构造函数、析构函数等)
// --- 搜索辅助函数 ---
// 在节点内查找键或应该插入的位置 (返回索引 i)
//使得 keys[i] >= key 或者 i == keys.size()
int find_location(const Key& key) const {
// 简单的线性扫描 (对于大的 M,二分查找更优)
int i = 0;
while (i < keys.size() && key > keys[i]) {
i++;
}
return i;
}
// --- 插入辅助函数 ---
// 在叶节点中插入键 (不处理溢出)
void insert_key_leaf(const Key& key) {
int i = find_location(key);
keys.insert(keys.begin() + i, key);
}
// 在内部节点中插入键和子节点指针 (不处理溢出)
void insert_key_internal(const Key& key, std::unique_ptr<BTreeNode<M>> left_child, std::unique_ptr<BTreeNode<M>> right_child) {
int i = find_location(key);
keys.insert(keys.begin() + i, key);
// 在 i+1 处插入新的右子节点,原来的 children[i] 成为新左子节点
children.erase(children.begin() + i); // 移除原来的指针
children.insert(children.begin() + i, std::move(left_child)); // 插入左子节点
children.insert(children.begin() + i + 1, std::move(right_child)); // 插入右子节点
}
};
// --- 分裂结果结构体 ---
// 用于在递归插入中向上传递分裂信息
template<int M>
struct SplitResult {
bool split_occurred = false;
Key promoted_key; // 提升到父节点的键
std::unique_ptr<BTreeNode<M>> new_sibling; // 分裂产生的新右兄弟节点
};
// B 树类
template <int M> // M 是 B 树的阶 (Order)
class BTree {
static_assert(M >= 3, "B-Tree order M must be at least 3");
private:
std::unique_ptr<BTreeNode<M>> root_ = nullptr;
const int minKeys_ = (M + 1) / 2 - 1; // 最小键数 (ceil(M/2) - 1)
const int maxKeys_ = M - 1; // 最大键数
// --- 搜索的私有递归辅助函数 ---
bool _search(const BTreeNode<M>* node, const Key& key) const {
if (!node) {
return false; // 空树或未找到
}
int i = node->find_location(key);
// 检查是否在当前节点找到
if (i < node->keys.size() && node->keys[i] == key) {
return true;
}
// 如果是叶节点且未找到,则键不存在
if (node->isLeaf) {
return false;
}
// --- 模拟磁盘读取 ---
// 在实际应用中,这里需要从磁盘加载 node->children[i]
// std::unique_ptr<BTreeNode<M>> child_node = fetchChild(node->children_raw_pointers[i]);
// 向下递归到正确的子节点
return _search(node->children[i].get(), key);
}
// --- 插入的私有递归辅助函数 ---
SplitResult<M> _insert(BTreeNode<M>* node, const Key& key) {
SplitResult<M> result; // 默认 split_occurred = false
if (node->isLeaf) {
// 1. 如果是叶节点,直接插入
node->insert_key_leaf(key);
// 2. 检查是否溢出
if (node->keys.size() > maxKeys_) {
// 需要分裂
result = split_node(node);
}
// 返回结果 (可能分裂,也可能没分裂)
return result;
} else {
// 3. 如果是内部节点,找到应该插入的子树
int i = node->find_location(key);
// --- 模拟磁盘读取子节点 ---
// BTreeNode<M>* child_node = fetchChild(node->children_raw_pointers[i]);
// 4. 递归插入到子节点
SplitResult<M> child_split_result = _insert(node->children[i].get(), key);
// 5. 检查子节点是否发生了分裂
if (child_split_result.split_occurred) {
// 子节点分裂了,需要将提升的键和新兄弟节点插入当前节点
node->insert_key_internal(child_split_result.promoted_key,
std::move(node->children[i]), // 旧的子节点成为新左子节点
std::move(child_split_result.new_sibling));
// 6. 检查当前节点是否因为插入提升的键而溢出
if (node->keys.size() > maxKeys_) {
// 当前节点也需要分裂
result = split_node(node);
}
}
// 返回结果 (可能因为子节点分裂导致当前节点分裂,也可能没有)
return result;
}
}
// --- 分裂节点的辅助函数 ---
SplitResult<M> split_node(BTreeNode<M>* node) {
SplitResult<M> result;
result.split_occurred = true;
// 找到中间位置
int median_idx = node->keys.size() / 2;
result.promoted_key = node->keys[median_idx]; // 提升中间键
// 创建新的右兄弟节点
result.new_sibling = std::make_unique<BTreeNode<M>>();
result.new_sibling->isLeaf = node->isLeaf;
// 将中间键之后的键移动到新兄弟节点
result.new_sibling->keys.assign(node->keys.begin() + median_idx + 1, node->keys.end());
// 如果不是叶节点,还需要移动相应的子节点
if (!node->isLeaf) {
int children_median_idx = (node->children.size() + 1) / 2; // 子节点分裂点
result.new_sibling->children.assign(
std::make_move_iterator(node->children.begin() + children_median_idx),
std::make_move_iterator(node->children.end())
);
node->children.resize(children_median_idx); // 从原节点移除移动的子节点
}
// 从原节点移除中间键及移动的键
node->keys.resize(median_idx);
return result;
}
public:
// --- 公共搜索接口 ---
bool search(const Key& key) const {
return _search(root_.get(), key);
}
// --- 公共插入接口 ---
void insert(const Key& key) {
// 1. 如果树为空,创建根节点
if (!root_) {
root_ = std::make_unique<BTreeNode<M>>();
root_->isLeaf = true;
root_->keys.push_back(key);
return;
}
// 2. 从根节点开始递归插入
SplitResult<M> root_split_result = _insert(root_.get(), key);
// 3. 处理根节点分裂的特殊情况
if (root_split_result.split_occurred) {
// 创建新的根节点
auto new_root = std::make_unique<BTreeNode<M>>();
new_root->isLeaf = false;
new_root->keys.push_back(root_split_result.promoted_key);
new_root->children.push_back(std::move(root_)); // 旧根成为左子节点
new_root->children.push_back(std::move(root_split_result.new_sibling)); // 新兄弟成为右子节点
root_ = std::move(new_root); // 更新树的根
}
}
// (可以添加打印树结构、删除等其他方法)
};
代码说明:
BTreeNode 结构体:
isLeaf: 标记是否为叶节点。keys: 存储节点中的键,使用 std::vector。children: 存储指向子节点的 std::unique_ptr。使用智能指针可以简化内存管理。对于内部节点,其大小比 keys 多一个。find_location: 辅助函数,用于在节点内查找键或确定其应插入的位置。insert_key_leaf / insert_key_internal: 简化版的插入,仅将键(和子节点)放入向量,不处理溢出。SplitResult 结构体:
BTree 类:
M: 模板参数,表示 B 树的阶。root_: 指向根节点的智能指针。minKeys_, maxKeys_: 根据阶 M 计算的节点最小和最大键数。_search (私有): 递归搜索辅助函数。
node->children[i].get())。注释中标明了实际应用中需要模拟磁盘读取的位置。
_insert (私有): 递归插入辅助函数。
split_node,并返回分裂结果。split_node 并返回分裂结果。split_node (私有): 执行节点分裂的逻辑。
SplitResult。search (公共): 公开的搜索接口,调用私有辅助函数。insert (公共): 公开的插入接口。
_insert。_insert 返回表明根节点发生了分裂,需要创建一个新的根节点,将旧根和新兄弟作为其子节点。关键点回顾:
fetchChild)或写入(节点更新/分裂)的位置。这是 B 树性能的关键所在。M 的选择影响节点的容量和树的高度,进而影响性能。m 个子节点。m-1 个键。k 个键的内部节点恰好有 k+1 个子节点。m 个子节点。m 个子节点。这部分是 B 树理论的核心,解释了为什么 B 树如此高效。
n (number of keys) 和树高 h (height) 之间的关系。h 决定了在搜索、插入或删除操作中,最多需要进行的磁盘读取/寻道次数 (fetches / reads / seeks) (幻灯片 15)。因为磁盘访问非常耗时,我们希望 h 尽可能小。h 的 B 树最少能包含多少节点/键,来反向推导出给定键数 n 时树的最大可能高度 h。即找到关于 n 和 h 的不等式。为了找到高度 h 的 B 树包含的最小键数 n_min,我们首先计算最稀疏 (节点数最少) 的 B 树结构。
t 个子节点。所以 Level 2 至少 $2 \times t$ 个节点 (幻灯片 29)。t 个子节点。所以 Level 3 至少 $2t \times t = 2t^2$ 个节点 (幻灯片 31)。最小总节点数 (推导过程,幻灯片 33-38): 将各层最小节点数相加得到总的最小节点数 $N_{min}$。 $$ N_{min} \ge 1 (\text{root}) + 2 (\text{lvl 1}) + 2t (\text{lvl 2}) + … + 2t^{h-1} (\text{lvl h}) $$ 这是一个几何级数求和 (除了第一项)。 $$ N_{min} \ge 1 + 2 (1 + t + t^2 + … + t^{h-1}) = 1 + 2 \frac{t^h - 1}{t-1} $$
n_min:
n。h (h>=1) 的 B 树,总键数 n 满足:
$$
n \ge 2t^h - 1 \quad (\text{其中 } t = \lceil m/2 \rceil)
$$
这个公式 $n_{min} = 2t^h - 1$ 给出了高度为 h 的 B 树能够容纳的最少键数。我们从上面得到的关于最小键数的不等式出发,来求解 h 的上界:
t 为底的对数 ($\log_t$) : $\log_t \left( \frac{n+1}{2} \right) \ge \log_t(t^h)$即高度 h 的上界为:
$$
h \le \log_t \left( \frac{n+1}{2} \right) = \log_{\lceil m/2 \rceil} \left( \frac{n+1}{2} \right)
$$
h 是以 $\lceil m/2 \rceil$ (约等于 $m/2$) 为底,关于键数 n 的对数关系。因此,记作 $h = O(\log_m n)$。由于 m 通常远大于 2 (例如 100, 1000),这个对数的底数很大,使得 h 的增长非常缓慢,即使 n 非常大,h 也通常很小。