B+-дерево
TL;DR
B⁺-дерево (B plus tree) — сбалансированное многопутевое дерево поиска, оптимизированное под внешнюю память. Его отличие от обычного B-дерева: все ключи и записи хранятся только в листьях, внутренние узлы содержат лишь разделители для навигации, а все листья связаны в список. Из-за высокого ветвления (сотни ключей на страницу) высота дерева крошечная: для [math]\displaystyle{ 10^9 }[/math] ключей при ветвлении около 100 это примерно 5 уровней, то есть около пяти чтений страниц на поиск. Диапазонные запросы выполняются почти линейно по объёму результата — спуск к началу и последовательный проход по связям листьев. За это B⁺-деревья стали стандартом индексов СУБД (InnoDB, Oracle, PostgreSQL) и файловых систем (NTFS, XFS, ext4, Btrfs).
| Свойство | Значение |
|---|---|
| Класс | Сбалансированное многопутевое дерево поиска (семейство B-деревьев) |
| Изобретение | Развитие B-дерева (Байер и Маккрейт, 1972 [4]); вариант B⁺ сформировался в 1970-е, популяризован обзором Комера, 1979 [5] |
| Данные | Только в листьях; внутренние узлы — разделители |
| Листья | Все на одной глубине, связаны в список |
| Ветвление | [math]\displaystyle{ B \approx }[/math] (размер страницы) / (размер ключа + указатель), обычно десятки–сотни |
| Высота | [math]\displaystyle{ h \le \log_B N + 1 }[/math] (уровней) |
| Поиск | [math]\displaystyle{ O(\log_B N) }[/math] чтений страниц |
| Вставка / удаление | [math]\displaystyle{ O(\log_B N) }[/math] |
| Диапазонный запрос | [math]\displaystyle{ O(\log_B N + k/B) }[/math] — почти линейно по результату |
| Память | [math]\displaystyle{ O(N) }[/math]; узлы занимают страницы фиксированного размера |
| Альтернативы | B-дерево, LSM-дерево, хеш-индекс, КЧ-дерево (в памяти) |
| Где встречается | индексы СУБД (InnoDB, Oracle, PostgreSQL, SQLite), файловые системы (NTFS, XFS, ext4, Btrfs), key-value хранилища |
Определения
B⁺-дерево порядка [math]\displaystyle{ m }[/math] — сбалансированное дерево, в котором:
- Внутренние узлы содержат до [math]\displaystyle{ m-1 }[/math] ключей-разделителей и до [math]\displaystyle{ m }[/math] детей; ключей на один меньше, чем детей.
- Листья содержат до [math]\displaystyle{ m-1 }[/math] ключей (и связанные с ними записи/указатели на данные) и все лежат на одной глубине.
- Листья связаны в список (обычно двунаправленный), что даёт последовательный доступ.
- Для внутреннего узла с ключами [math]\displaystyle{ k_1 \lt k_2 \lt \dots }[/math] и детьми [math]\displaystyle{ c_0, c_1, \dots }[/math]: все ключи в поддереве [math]\displaystyle{ c_i }[/math] меньше [math]\displaystyle{ k_{i+1} }[/math], а в поддереве [math]\displaystyle{ c_{i+1} }[/math] — не меньше [math]\displaystyle{ k_{i+1} }[/math].
Узел содержит массив ключей и массив ссылок; лист дополнительно хранит ссылку next (и, при желании, prev) на соседний лист. Разделитель — ключ внутреннего узла; это копия первого ключа правого поддерева, а не отдельная запись.
Занятость (occupancy). Кроме корня, у внутреннего узла от [math]\displaystyle{ \lceil m/2 \rceil }[/math] до [math]\displaystyle{ m }[/math] детей, а у листа от [math]\displaystyle{ \lceil m/2 \rceil - 1 }[/math] до [math]\displaystyle{ m-1 }[/math] ключей. Нижняя граница учитывается при удалении.
Ветвление (fanout) [math]\displaystyle{ B }[/math] — число детей внутреннего узла; на диске [math]\displaystyle{ B \approx \text{page}/\,(\text{key}+\text{ptr}) }[/math]. Высота [math]\displaystyle{ h }[/math] — число уровней (корень — уровень 1); [math]\displaystyle{ h \le \log_B N + 1 }[/math], где [math]\displaystyle{ N }[/math] — число ключей.
Обозначения. [math]\displaystyle{ N }[/math] — число ключей; [math]\displaystyle{ h }[/math] — высота; [math]\displaystyle{ m }[/math] — порядок (максимум детей); [math]\displaystyle{ B }[/math] — фактическое ветвление; страница — блок диска, вмещающий целый узел.
Мотивация и интуиция
Главное узкое место при работе с диском — не скорость вычислений, а число обращений к страницам: одно чтение с диска на много порядков дороже сравнения в памяти. Поэтому структура для внешней памяти должна минимизировать число страниц на операцию, а не число сравнений.
Отсюда идея B-дерева: держать в одном узле столько ключей, чтобы узел занимал целую страницу. Тогда ветвление [math]\displaystyle{ B }[/math] велико, а высота мала: [math]\displaystyle{ \log_B N }[/math] вместо [math]\displaystyle{ \log_2 N }[/math]. B⁺-дерево доводит эту идею до предела:
- Все данные на одном уровне. Поиск всегда завершается в листе, число обращений одинаково для любого ключа — предсказуемая стоимость.
- Внутренние узлы без данных → выше ветвление. Раз в узле только ключи и указатели, он вмещает больше разделителей, чем если бы хранил ещё и записи; дерево получается ниже.
- Связанные листья. Диапазонные запросы (а их в СУБД большинство) обрабатываются последовательным проходом по листьям, без повторных спусков от корня.
Интуитивно B⁺-дерево — это «оглавление к отсортированному файлу»: внутренние узлы ускоряют поиск нужного места, а сам файл хранится листьями, идя по возрастанию ключей.
Устройство
- Внутренний узел: ключи
[k1, k2, …]и дети[c0, c1, …], гдеciотвечает за ключи, попадающие между разделителями. Данных нет. - Лист: ключи и записи; ссылка
nextна следующий лист (при необходимостиprev). - Корень: либо лист (маленькое дерево), либо внутренний узел; корень может иметь меньше половины детей.
Инварианты, которые поддерживают все операции:
- все листья на одной глубине;
- ключи в каждом узле отсортированы;
- разделители согласованы с поддеревьями (см. определение);
- занятость узлов в допустимых пределах (кроме корня).
На диске дерево хранится страницами; корень и верхние уровни обычно кэшируются в памяти, поэтому реально читаются только нижние уровни и листья. Сравнение с B-деревом показано на рис. 2.
Операции и алгоритмы
Операция: поиск (Search)
Идея. Спуск от корня к листу: на каждом внутреннем узле выбрать ребёнка по разделителям; в листе выполнить поиск ключа.
Пошаговое описание.
- Пока узел внутренний — сравнить ключ с разделителями и выбрать нужного ребёнка.
- Дойдя до листа — найти ключ (бинарным или линейным поиском).
- Если ключ есть — вернуть запись; иначе — «не найдено».
Инвариант. Искомый ключ, если он есть, лежит в поддереве текущего узла.
Псевдокод.
Алгоритм Search(T, k)
1 cur ← root[T]
2 while not leaf[cur]
3 i ← 0
4 while i < numKeys[cur] and k ≥ key[cur][i]
5 i ← i + 1
6 cur ← child[cur][i] # спуск в подходящего ребёнка
7 return Leaf-Search(cur, k) # поиск внутри листа
Сложность: O(log_B N) чтений страниц — ровно высота дерева.
Корректность: разделители направляют k в единственный лист, где он может лежать.
Крайние случаи. Ключ отсутствует — спуск всё равно приводит к «соседнему» листу; сравнение в листе даёт «нет». Малое дерево из одного листа — поиск сразу в корне.
Разбор на примере. Поиск 25: 25 ≥ 20 — в правое поддерево; 25 < 40 — в средний лист [20, 25, 30], где ключ найден (рис. 1).
Операция: вставка (Insert)
Идея. Вставить ключ в лист; при переполнении расщепить лист и поднять разделитель в родителя (рекурсивно вверх).
Пошаговое описание.
- Спуск до листа.
- Вставка ключа в лист (с сохранением порядка); при дубликате — обновление записи.
- Если ключей стало больше [math]\displaystyle{ m-1 }[/math] — расщепить лист на два: половина ключей уходит в новый лист, первый ключ правого листа поднимается разделителем, листья перевязываются.
- Если переполнился внутренний узел — расщепить и его: средний (по разделителям) ключ уходит вверх, дети делятся.
- Если расщепился корень — создать новый корень; высота +1.
Инвариант. Листья остаются на одной глубине; разделители согласованы; нарушением может быть лишь временное переполнение одного узла.
Псевдокод (рекурсивный).
Алгоритм Insert(T, k)
1 (split, sep, right) ← Insert-Rec(root[T], k)
2 if split # расщепился корень
3 newRoot ← внутренний узел с ключом sep и детьми (root[T], right)
4 root[T] ← newRoot # высота +1
Алгоритм Insert-Rec(node, k) # возвращает (split, sep, right)
5 if leaf[node]
6 вставить k в key[node] (если дубликат — обновить запись и вернуть (false, NIL, NIL))
7 if numKeys[node] ≤ m − 1
8 return (false, NIL, NIL)
9 right ← новый лист с правой половиной ключей
10 next[right] ← next[node]; next[node] ← right
11 return (true, key[right][0], right) # разделитель — первый ключ справа
12 i ← выбрать ребёнка по k
13 (split, sep, right) ← Insert-Rec(child[node][i], k)
14 if not split
15 return (false, NIL, NIL)
16 вставить sep и right в node (позиция i)
17 if numKeys[node] ≤ m − 1
18 return (false, NIL, NIL)
19 mid ← средний разделитель
20 right ← новый внутренний узел с разделителями после mid и их детьми
21 обрезать node до mid (ключи и дети)
22 return (true, mid, right)
Сложность: O(log_B N) чтений страниц; расщеплений не более высоты.
Корректность: расщепление листа сохраняет порядок и одну глубину; поднятый разделитель
корректен, так как это минимальный ключ правого листа.
Разбор на примере. Вставка 28 в полный лист [25 | 30 | 35] (максимум 3) даёт четыре ключа; лист расщепляется на [25 | 28] и [30 | 35], разделитель 30 поднимается в родителя (рис. 3). При переполнении родителя расщепление продолжается вверх; расщепление корня повышает высоту.
Крайние случаи. Пустое дерево — единственный лист. Дубликат — обновление записи без роста числа ключей (в «множестве» — игнорирование).
Операция: удаление (Delete)
Идея. Удалить ключ из листа; при недостаче (меньше половины) перераспределить ключи с соседним листом или слить листья, убрав разделитель из родителя.
Пошаговое описание.
- Найти лист и удалить ключ.
- Если занятость в норме — обновить разделитель (если удалён первый ключ) и закончить.
- Если лист «недогружен»:
- у соседа есть запас — перераспределить ключи и обновить разделитель;
- иначе слить лист с соседом и убрать разделитель из родителя.
- Если родитель потерял ребёнка и стал недогружен — повторить пункт 3 выше.
- Если корень остался без детей — сделать его единственного ребёнка новым корнем (высота −1).
Инвариант. Порядок, одна глубина листьев и занятость восстанавливаются на каждом уровне.
Псевдокод (схема).
Алгоритм Delete(T, k)
1 найти лист L с ключом k; если ключа нет — выйти; удалить k из L
2 while L недогружен и L ≠ root[T] # корень с одним ребёнком свернуть до слияния
3 p ← parent[L]; sib ← сосед L справа или слева
4 if sib имеет запас
5 перераспределить ключи между L и sib; обновить разделитель в p
6 break
7 else
8 слить L и sib; убрать разделитель из p # лист: удалить; внутренний: sep в merged.keys
9 L ← p # недостача поднялась выше
10 if root[T] лист и пуст
11 root[T] ← NIL
12 else if корень внутренний с одним ребёнком
13 root[T] ← единственный ребёнок # высота −1
Сложность: O(log_B N) чтений страниц; перераспределение/слияние — O(1).
Корректность: слияние уменьшает число детей родителя на один, сохраняя порядок; пустой
корень заменяется ребёнком, что сохраняет одну глубину листьев.
Для листа слияние объединяет ключи L и соседа, а разделитель p удаляется: он дублирует первый ключ правого листа. Здесь sep — разделитель p между L и sib; для левого соседа направления зеркальны. Для внутреннего узла merged.keys = L.keys + [sep] + sib.keys, merged.children = L.children + sib.children; разделитель sep входит в merged.keys, иначе получится children = keys + 2. При перераспределении внутренних узлов sep переносится вниз, граничный ключ соседа поднимается в p, а соответствующий ребёнок переносится. При удалении первого ключа листа разделитель обновляется в ближайшем предке, где это поддерево является правым. Если у корня остался один ребёнок, корень сворачивается до попытки слияния.
Крайние случаи. Удаление первого ключа листа требует обновить разделитель в ближайшем предке, где это поддерево является правым. Удаление последнего ключа дерева — дерево пустеет.
Разбор на примере. При порядке 4 удаление 15 из листа [15] оставляет его пустым. Если у соседа [30, 45] есть запас, ключ 30 переходит влево, а разделитель становится 45. Если сосед [30] без запаса, листья сливаются в [30], разделитель 30 убирается, и корень становится листом [30] — высота уменьшается на 1 (рис. 4).
Операция: диапазонный запрос (Range Scan)
Идея. Спуститься к листу с началом диапазона, затем последовательно пройти по связям листьев, собирая ключи. Find-Leaf(T, a) — спуск по разделителям до листа, в который попадает [math]\displaystyle{ a }[/math], без поиска внутри листа.
Пошаговое описание.
Find-Leaf(T, a)нижней границы → лист, где начинается диапазон.- Идти по
next, собирая ключи в [math]\displaystyle{ [a, b] }[/math], пока не выйдем за верхнюю границу.
Инвариант. Листья содержат ключи в возрастающем порядке, поэтому обход по связям даёт именно диапазон.
Псевдокод.
Алгоритм Range-Scan(T, a, b)
1 leaf ← Find-Leaf(T, a)
2 result ← пустой список
3 while leaf ≠ NIL
4 for each key k в leaf по возрастанию
5 if k < a continue
6 if k > b return result
7 result.добавить(k)
8 leaf ← next[leaf]
9 return result
Сложность: O(log_B N + k/B) чтений страниц, где k — число ключей в ответе (почти линейно).
Корректность: ключи листьев возрастают, и связи охватывают их все, поэтому результат —
ровно множество ключей в [a, b].
Крайние случаи. Пустой диапазон — результат пуст. Границы вне множества — просто нет совпадающих ключей.
Разбор на примере. Для диапазона [12, 45] спуск приводит в лист [8, 11, 15]; после пропуска 8 и 11 собираются 15, 20, 25, 30, 40, 45, а 50 уже выходит за границу (рис. 5).
Операции: минимум, максимум и обход
Min/Max — спуск к левому/правому листу, [math]\displaystyle{ O(\log_B N) }[/math]. Полный обход по возрастанию — найти левый лист и пройти по связям, [math]\displaystyle{ O(N/B) }[/math] чтений страниц.
Реализация на C++
Учебная реализация (ключи-целые, множество без связанных данных): поиск, вставка с расщеплением листьев и внутренних узлов, диапазонный запрос по связям листьев и проверка инвариантов validate. Удаление оставлено как упражнение — оно объёмнее и по структуре повторяет вставку.
#include <iostream>
#include <vector>
#include <algorithm>
#include <cassert>
class BPlusTree {
static constexpr int MAX_KEYS = 3; // порядок 4: не более 3 ключей в узле
struct Node {
bool leaf = true;
std::vector<int> keys; // листья: ключи; внутренние: разделители
std::vector<Node*> children; // только для внутренних
Node* next = nullptr; // только для листьев
explicit Node(bool l) : leaf(l) {}
};
Node* root_ = nullptr;
struct SplitResult { bool split; int sep; Node* right; };
SplitResult insertRec(Node* node, int k) {
if (node->leaf) {
auto it = std::lower_bound(node->keys.begin(), node->keys.end(), k);
if (it != node->keys.end() && *it == k) return {false, 0, nullptr}; // дубликат
node->keys.insert(it, k);
if (static_cast<int>(node->keys.size()) <= MAX_KEYS)
return {false, 0, nullptr};
// расщепление листа
Node* right = new Node(true);
int mid = static_cast<int>(node->keys.size()) / 2;
right->keys.assign(node->keys.begin() + mid, node->keys.end());
node->keys.resize(mid);
right->next = node->next;
node->next = right;
return {true, right->keys.front(), right}; // разделитель — 1-й ключ справа
}
int i = 0;
while (i < static_cast<int>(node->keys.size()) && k >= node->keys[i]) ++i;
SplitResult s = insertRec(node->children[i], k);
if (!s.split) return {false, 0, nullptr};
node->keys.insert(node->keys.begin() + i, s.sep);
node->children.insert(node->children.begin() + i + 1, s.right);
if (static_cast<int>(node->keys.size()) <= MAX_KEYS)
return {false, 0, nullptr};
// расщепление внутреннего узла
Node* right = new Node(false);
int mid = static_cast<int>(node->keys.size()) / 2;
right->keys.assign(node->keys.begin() + mid + 1, node->keys.end());
right->children.assign(node->children.begin() + mid + 1, node->children.end());
int sep = node->keys[mid];
node->keys.resize(mid);
node->children.resize(mid + 1);
return {true, sep, right};
}
static void destroy(Node* n) {
if (!n) return;
if (!n->leaf) for (Node* c : n->children) destroy(c);
delete n;
}
static int minKey(Node* n) { while (!n->leaf) n = n->children.front(); return n->keys.front(); }
static int maxKey(Node* n) { while (!n->leaf) n = n->children.back(); return n->keys.back(); }
bool check(Node* n, int depth, int& leafDepth) const {
if (n->leaf) {
if (static_cast<int>(n->keys.size()) > MAX_KEYS) return false;
for (std::size_t i = 1; i < n->keys.size(); ++i)
if (!(n->keys[i - 1] < n->keys[i])) return false;
if (leafDepth == -1) leafDepth = depth;
return depth == leafDepth;
}
if (static_cast<int>(n->keys.size()) > MAX_KEYS) return false;
if (n->children.size() != n->keys.size() + 1) return false;
for (std::size_t i = 0; i < n->keys.size(); ++i) {
if (!(maxKey(n->children[i]) < n->keys[i])) return false;
if (!(n->keys[i] <= minKey(n->children[i + 1]))) return false;
}
for (Node* c : n->children)
if (!check(c, depth + 1, leafDepth)) return false;
return true;
}
Node* findLeaf(int k) const {
Node* cur = root_;
while (cur && !cur->leaf) {
int i = 0;
while (i < static_cast<int>(cur->keys.size()) && k >= cur->keys[i]) ++i;
cur = cur->children[i];
}
return cur;
}
public:
BPlusTree() = default;
~BPlusTree() { destroy(root_); }
BPlusTree(const BPlusTree&) = delete;
BPlusTree& operator=(const BPlusTree&) = delete;
void insert(int k) {
if (!root_) {
root_ = new Node(true);
root_->keys.push_back(k);
return;
}
SplitResult s = insertRec(root_, k);
if (s.split) {
Node* nr = new Node(false);
nr->keys.push_back(s.sep);
nr->children.push_back(root_);
nr->children.push_back(s.right);
root_ = nr;
}
}
bool contains(int k) const {
Node* leaf = findLeaf(k);
return leaf && std::binary_search(leaf->keys.begin(), leaf->keys.end(), k);
}
std::vector<int> range(int a, int b) const {
std::vector<int> out;
Node* leaf = findLeaf(a);
while (leaf) {
for (int k : leaf->keys) {
if (k < a) continue;
if (k > b) return out;
out.push_back(k);
}
leaf = leaf->next;
}
return out;
}
bool validate() const {
if (!root_) return true;
int leafDepth = -1;
return check(root_, 0, leafDepth);
}
};
int main() {
BPlusTree t;
for (int i = 0; i < 50; ++i) t.insert((i * 37) % 50); // ключи 0..49 вперемешку
assert(t.validate());
for (int x = 0; x < 50; ++x) assert(t.contains(x));
assert(!t.contains(1000));
std::vector<int> r = t.range(10, 30);
std::vector<int> expect;
for (int x = 10; x <= 30; ++x) expect.push_back(x);
assert(r == expect);
std::cout << "ok\n";
return 0;
}
Замечания по реализации.
MAX_KEYS = 3— учебный порядок; на практике узел заполняют под размер страницы (сотни ключей), тогда высота становится единицами.- Разделитель — копия первого ключа правого узла; ключ остаётся и в листе как данные.
- Верхние уровни обычно держат в памяти; «число операций» здесь считайте чтениями страниц.
- Удаление реализуется тем же рекурсивным приёмом с перераспределением/слиянием.
Сводная таблица сложности
| Операция | Число чтений страниц | Замечание |
|---|---|---|
| Search | [math]\displaystyle{ O(\log_B N) }[/math] | ровно высота; одинаково для всех ключей |
| Insert | [math]\displaystyle{ O(\log_B N) }[/math] | плюс [math]\displaystyle{ O(1) }[/math] записей при расщеплении |
| Delete | [math]\displaystyle{ O(\log_B N) }[/math] | перераспределение/слияние за [math]\displaystyle{ O(1) }[/math] |
| Min / Max | [math]\displaystyle{ O(\log_B N) }[/math] | спуск к крайнему листу |
| Range-Scan [math]\displaystyle{ (k) }[/math] | [math]\displaystyle{ O(\log_B N + k/B) }[/math] | почти линейно по результату |
| Full scan | [math]\displaystyle{ O(N/B) }[/math] | последовательный проход по листьям |
| Память | [math]\displaystyle{ O(N) }[/math] | узлы = страницы фиксированного размера |
Примечание. Оценки даны в обращениях к страницам, а не в сравнениях: именно ввод-вывод определяет стоимость на диске. Из-за большого [math]\displaystyle{ B }[/math] логарифм мал: [math]\displaystyle{ \log_{100} 10^9 \approx 4{,}5 }[/math], поэтому [math]\displaystyle{ h \le 5{,}5 }[/math], то есть не более 5 уровней при фактическом B; при минимальной занятости около 50 худшая оценка даёт до 6 уровней, тогда как [math]\displaystyle{ \log_2 10^9 \approx 30 }[/math]. Поэтому в индексах СУБД B⁺-дерево на порядки выгоднее двоичного дерева поиска.
Зависимость высоты от ветвления показана на рис. 6.
Трассировка примера
Дерево порядка 4 (не более 3 ключей на узел). Вставим [math]\displaystyle{ 10, 20, 30, 40, 50, 60 }[/math] и выполним диапазонный запрос [math]\displaystyle{ [25, 55] }[/math].
| Шаг | Операция | Ключи в листьях (слева направо) | Что произошло |
|---|---|---|---|
| 1 | insert 10 | [10] | первый лист |
| 2 | insert 20 | [10, 20] | — |
| 3 | insert 30 | [10, 20, 30] | узел заполнен |
| 4 | insert 40 | [10, 20], [30, 40] | расщепление листа; разделитель 30 в корень |
| 5 | insert 50 | [10, 20], [30, 40, 50] | во второй лист |
| 6 | insert 60 | [10, 20], [30, 40], [50, 60] | расщепление; разделитель 50 в корень |
| 7 | range 25..55 | 30, 40, 50 | спуск к [10,20], проход по связям: 30, 40, 50 |
Диапазонный запрос нашёл начало в первом «подходящем» листе и далее просто шёл по списку листьев, собирая 30, 40, 50 (ключи 25 и 55 отсутствуют). validate() подтверждает порядок ключей, согласованность разделителей и одну глубину листьев.
Корректность и оценка сложности
Лемма 1 (граница высоты). B⁺-дерево с [math]\displaystyle{ N }[/math] ключами, высотой [math]\displaystyle{ h }[/math] (в уровнях) и ветвлением не менее [math]\displaystyle{ B_0 }[/math] удовлетворяет [math]\displaystyle{ h \le \log_{B_0} N + 1 }[/math].
Доказательство. Все листья находятся на уровне [math]\displaystyle{ h }[/math]; каждый внутренний узел, кроме корня, имеет не менее [math]\displaystyle{ B_0 }[/math] детей, а корень — не менее двух. Поэтому листьев не менее [math]\displaystyle{ 2B_0^{\,h-2} }[/math]. Каждый лист хранит хотя бы один ключ, значит [math]\displaystyle{ N \ge 2B_0^{\,h-2} }[/math], откуда [math]\displaystyle{ h \le \log_{B_0}(N/2) + 2 = \log_{B_0} N + 1 }[/math]. [math]\displaystyle{ \blacksquare }[/math]
Теорема 2 (корректность вставки). После Insert дерево остаётся корректным B⁺-деревом (порядок, одна глубина листьев, согласованные разделители). Если ключа не было, он добавлен; если ключ уже был, обновлена запись, а набор ключей не изменился.
Доказательство. Расщепление листа делит его на два на той же глубине и поднимает минимальный ключ правого листа как разделитель — это сохраняет порядок и условие разделителей. Расщепление внутреннего узла выносит средний разделитель наверх, а детей делит между двумя узлами на одном уровне; при расщеплении корня новый корень добавляет уровень сразу всем листьям. При дубликате Insert-Rec обновляет запись и возвращает split = false, поэтому структура не меняется. Индукция по высоте доказывает корректность. [math]\displaystyle{ \blacksquare }[/math]
Теорема 3 (корректность диапазонного запроса). Range-Scan(a, b) возвращает в точности ключи множества, лежащие в [math]\displaystyle{ [a, b] }[/math], в возрастающем порядке.
Доказательство. Find-Leaf(a) возвращает лист, в который попадает [math]\displaystyle{ a }[/math]; в нём могут быть ключи меньше [math]\displaystyle{ a }[/math], они пропускаются условием [math]\displaystyle{ k \ge a }[/math]. Ключи листьев возрастают и связаны в порядке, поэтому проход по next перебирает все ключи подряд, а условие [math]\displaystyle{ k \le b }[/math] останавливает на верхней границе. Вместе условия отбирают ровно диапазон. [math]\displaystyle{ \blacksquare }[/math]
Оценка сложности. Поиск и изменение идут по одному пути длиной [math]\displaystyle{ h = O(\log_B N) }[/math], делая [math]\displaystyle{ O(1) }[/math] работы на уровне; расщепления/слияния локальны. Диапазонный запрос добавляет [math]\displaystyle{ O(k/B) }[/math] чтений на выдачу результата. Память — [math]\displaystyle{ O(N) }[/math] страниц.
Сравнение с альтернативами
| Структура | Данные | Диапазон | Поиск (страниц) | Обновление | Когда выбирать |
|---|---|---|---|---|---|
| B⁺-дерево | только в листьях, листья связаны | [math]\displaystyle{ O(\log_B N + k/B) }[/math] | [math]\displaystyle{ O(\log_B N) }[/math] | [math]\displaystyle{ O(\log_B N) }[/math] | индексы БД и файловые системы |
| B-дерево | во всех узлах | дороже (нет связей) | [math]\displaystyle{ O(\log_B N) }[/math] | [math]\displaystyle{ O(\log_B N) }[/math] | наборы без диапазонов; меньше дублирование |
| LSM-дерево | несколько уровней, слияние | быстро на запись | дороже на чтение | запись [math]\displaystyle{ O(1) }[/math] аморт. | write-heavy нагрузки (например, RocksDB, Cassandra) |
| Хеш-индекс | — | нет | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(1) }[/math] | точечные запросы равенства |
| КЧ-дерево | в узлах (в памяти) | [math]\displaystyle{ O(\log n + k) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | данные в оперативной памяти |
Вердикт. Для диска B⁺-дерево почти всегда выигрывает: высокое ветвление, предсказуемая стоимость, дешёвые диапазоны. Хеш-индекс быстрее для точечных запросов, но не умеет диапазоны; LSM-дерево лучше там, где запись преобладает; деревья «в памяти» (КЧ, АВЛ) хороши, пока данные целиком помещаются в RAM.
Где применяется
- Индексы реляционных СУБД. InnoDB (MySQL/MariaDB) хранит таблицу как кластерный B⁺-индекс по первичному ключу; вторичные индексы ссылаются на первичный ключ. Oracle, PostgreSQL и SQLite также используют B-деревья/B⁺-деревья для индексов [2], [6].
- Файловые системы. NTFS, XFS, ext4 и Btrfs применяют B-деревья/B⁺-деревья для метаданных и каталогов, извлекая выгоду из высокого ветвления и диапазонных обходов [2], [6].
- Ключ-значение хранилища и встраиваемые БД. Berkeley DB, LMDB и подобные используют B⁺-деревья для упорядоченных наборов на диске [6].
- Пространственные и многомерные индексы. R-деревья и их варианты развивают идеи B⁺-деревьев для диапазонных запросов по нескольким измерениям [6].
- Упорядоченные in-memory индексы. При больших объёмах и страничном подкачивании модель B⁺-дерева применяют и к памяти (например, для батч-запросов и SSDs с блочным доступом) [2].
Историческая справка
- 1972. Р. Байер и Э. Маккрейт ввели B-деревья — основу семейства [4].
- 1970-е. В практике индексов сформировался вариант B⁺-дерева: все данные в листьях, внутренние узлы — разделители, листья связаны. В обзоре Комера (1979) он уже описан как самостоятельный вариант [5].
- 1979. Д. Комер в обзоре «The Ubiquitous B-Tree» популяризовал терминологию и сравнение вариантов B-деревьев [5].
- 1980–2000-е. B⁺-деревья стали стандартом индексов СУБД и файловых систем; появились конкурирующие LSM-деревья для записи-ориентированных нагрузок [2], [5], [6].
Типичные ошибки и подводные камни
- Путаница B-дерева и B⁺-дерева. В B-дереве данные и ключи во всех узлах; в B⁺ — только в листьях, а внутренние узлы лишь дублируют разделители.
- Забыть связать листья. Без ссылок
nextдиапазонный запрос вырождается в повторные спуски и теряет главное преимущество. - Неверный разделитель. Разделитель — минимальный ключ правого поддерева; произвольный ключ ломает поиск.
- Не обновить разделитель при удалении первого ключа листа. Нарушится инвариант «разделитель — копия первого ключа правого поддерева»; поиск может остаться корректным, но каноническая модель будет нарушена.
- Off-by-one в порядке. Ключей на один меньше, чем детей; при расщеплении средний ключ поднимается, а не остаётся в обоих узлах (для внутреннего).
- Слишком маленький порядок. Если узел не заполняет страницу, дерево высокое и теряет смысл; порядок подбирают под размер блока.
- Занятость при удалении. Забытая нижняя граница приводит к «тощим» узлам и росту высоты.
- Дубликаты. Точечное множество хранит ключ один раз; для мультимножества нужны счётчики или разные записи с одинаковым ключом.
- Сравнение в листе линейно. При больших узлах используйте бинарный поиск.
- Кэширование корня. Верхние уровни держат в памяти; не стоит считать каждое сравнение обращением к диску — считайте страницы.
Задачи для практики
- Поиск. Реализуйте спуск и поиск в листе; проверьте на больших наборах. Подсказка: бинарный поиск среди разделителей.
- Вставка с расщеплением. Реализуйте расщепление листа и внутреннего узла; проверьте инварианты. Подсказка:
validateс проверкой одной глубины листьев. - Диапазон. Реализуйте
range(a, b)через спуск и связи листьев; проверьте на случайных данных. Подсказка: не спускайтесь от корня для каждого ключа. - Удаление. Реализуйте перераспределение и слияние; проверьте уменьшение высоты. Подсказка: обновляйте разделитель при удалении первого ключа.
- Двунаправленные листья. Добавьте
prevи реализуйте обход диапазона в убывающем порядке. Подсказка: симметрично обычному. - Оценка ветвления. Для размера страницы 4 КБ и ключа 8 байт посчитайте ветвление и высоту для [math]\displaystyle{ 10^9 }[/math] ключей. Подсказка: [math]\displaystyle{ B \approx 4096/(8+8) }[/math].
- Кластерный индекс. Смоделируйте таблицу как B⁺-индекс по ключу и вторичный индекс; сравните число чтений страниц для точечного и диапазонного запроса.
- B⁺ против B-дерева. На одном множестве ключей сравните число страниц при поиске и диапазоне. Подсказка: диапазон — сильная сторона B⁺.
Ответы (кратко). 1. Спуск и поиск в листе: [math]\displaystyle{ O(\log_B N) }[/math] страниц, сравнений [math]\displaystyle{ O(\log m\cdot\log_B N) }[/math] при бинарном поиске. 2. Расщепление листа и внутреннего узла: [math]\displaystyle{ O(\log_B N) }[/math] страниц. 3. Диапазон: [math]\displaystyle{ O(\log_B N + k/B) }[/math], без повторных спусков. 4. Удаление: перераспределение/слияние; высота уменьшается при пустом корне или корне с одним ребёнком. 5. Обратный обход симметричен прямому. 6. [math]\displaystyle{ B \approx 256 }[/math], [math]\displaystyle{ h \le \log_{256}10^9+1 \approx 5 }[/math]. 7. Кластерный индекс хранит строки, вторичный — ключ первичного индекса. 8. Поиск у B и B⁺ сопоставим, диапазон у B⁺ дешевле.
Связанные темы
- B-дерево — базовое многопутевое дерево, из которого выросло B⁺.
- 2-3 дерево — минимальный пример многоключевых узлов.
- Асимптотическая сложность — нотация [math]\displaystyle{ O }[/math], [math]\displaystyle{ \Omega }[/math], [math]\displaystyle{ \Theta }[/math].
- Красно-чёрное дерево — упорядоченная структура для оперативной памяти.
Источники
- Cormen T. H., Leiserson C. E., Rivest R. L., Stein C. Introduction to Algorithms (CLRS). 3rd ed. — MIT Press, 2009. — Гл. 18 «B-Trees» (многоключевые узлы, расщепление и слияние).
- Silberschatz A., Korth H. F., Sudarshan S. Database System Concepts. — McGraw-Hill, 6th ed., 2011. — Гл. 11 «Indexing and Hashing» (B⁺-деревья, кластерные и вторичные индексы).
- Knuth D. E. The Art of Computer Programming. Vol. 3: Sorting and Searching. — Addison-Wesley, 2nd ed., 1998. — §6.2.4 «Multiway Trees».
- Bayer R., McCreight E. Organization and Maintenance of Large Ordered Indexes // Acta Informatica. — 1972. — Vol. 1, No. 3. — P. 173–189.
- Comer D. The Ubiquitous B-Tree // ACM Computing Surveys. — 1979. — Vol. 11, No. 2. — P. 121–137.
- Wikipedia: B+ tree. URL: https://en.wikipedia.org/wiki/B%2B_tree.