B-дерево (B-tree)

Предпосылки: оценка сложности в O(…), двоичное дерево поиска (BST) — инвариант порядка, поиск за O(h); понятие страницы дисковой памяти (блок фиксированного размера, обычно 4–16 KB, который диск читает за одну операцию).

BST хранит один ключ в узле. В оперативной памяти это работает хорошо, но на диске каждый узел — потенциальная отдельная операция чтения. Представим базу данных с 10 миллионами записей на HDD с временем поиска (seek time) ~5 ms. BST имеет высоту ~23 (log₂ 10⁷ ≈ 23), значит один поиск может потребовать порядка 23 случайных чтений × 5 ms = 115 ms — больше десятой доли секунды на один запрос.

При этом одна страница диска/буфера (обычно несколько KB) вмещает сотни ключей, а один узел BST занимает порядок десятков байт (ключ + указатели). Чтение всё равно происходит страницами — значит, выгодно хранить в одном узле столько ключей, сколько помещается на страницу. B-дерево делает именно это.

Название и происхождение

B-дерево предложили Rudolf Bayer и Edward McCreight в 1972 году в Boeing Labs. Буква «B» не имеет официального объяснения от авторов. Популярные интерпретации — Balanced, Bayer, Boeing, Broad — все работают как мнемоника. Главное: B — это не «binary». B-дерево — противоположность бинарному дереву: вместо двух детей у узла могут быть сотни.

Структура узла

В отличие от BST, узел B-дерева содержит много ключей и много указателей на детей:

┌─────────────────────────────────────────────────────┐
│  P₀ │ K₁ │ P₁ │ K₂ │ P₂ │ K₃ │ P₃ │ ... │ Kₙ │ Pₙ  │
└─────────────────────────────────────────────────────┘

K₁, K₂, … Kₙ — ключи, отсортированные по возрастанию. P₀, P₁, … Pₙ — указатели на детей. Ключей n, указателей n+1. Ключи работают как разделители диапазонов: P₀ ведёт к значениям меньше K₁, P₁ — к значениям от K₁ (включительно) до K₂, P₂ — от K₂ до K₃, и так далее. Последний указатель Pₙ ведёт к значениям не меньше Kₙ.

Параметр t

B-дерево определяется параметром t — минимальной степенью (minimum degree). В теории графов степень вершины — количество рёбер, выходящих из неё; для дерева это количество детей. Минимальная степень t означает: у внутреннего узла минимум t детей.

ХарактеристикаЗначение
Минимум ключей в узлеt − 1
Максимум ключей в узле2t − 1
Минимум детей у внутреннего узлаt
Максимум детей у внутреннего узла2t

Корень — исключение: он может содержать от 1 до 2t−1 ключей (для только что созданного или сжавшегося дерева).

В разных источниках используют разные параметры. CLRS (Cormen и др.) задаёт минимальную степень t — минимум t детей. Knuth задаёт порядок m — максимум m детей, тогда m = 2t. Это одно и то же дерево с разным именованием параметра.

На практике t подгоняют под размер страницы диска. При странице 4 KB, ключе 8 байт и указателе 8 байт один узел с n ключами занимает n×8 + (n+1)×8 = 16n + 8 байт. Чтобы уместить узел в 4096 байт: n ≤ 255, то есть 2t − 1 ≈ 255, откуда t ≈ 128. Это грубая оценка: реальные узлы имеют заголовки, метаданные и выравнивание. Типичные значения t — от нескольких десятков до нескольких сотен.

Инварианты

B-дерево поддерживает четыре инварианта. Каждый узел (кроме корня) содержит от t−1 до 2t−1 ключей — это ограничение заполнения. Узел с n ключами имеет ровно n+1 детей, если он не лист. Все листья находятся на одной глубине — дерево сбалансировано по определению. Ключи внутри каждого узла отсортированы по возрастанию.

Поиск

Поиск начинается с корня. В текущем узле бинарным поиском определяется, есть ли искомый ключ среди ключей узла. Если найден — поиск завершён. Иначе определяется указатель на поддерево, в котором может быть искомый ключ, и поиск продолжается в ребёнке. Процесс повторяется до нахождения ключа или достижения листа.

МетрикаЗначениеПояснение
Посещённых узловO(log n)Высота дерева с основанием ~t
Сравнений в узлеO(log t)Бинарный поиск внутри узла
Итого сравненийO(log n)
Чтений с дискаO(log n)Главная метрика для дисковых систем

При t = 128 и 10 миллионах записей поиск требует ~3 чтения с диска (log₁₂₈ 10⁷ ≈ 3.3). На том же HDD с 5 ms seek time это 3 × 5 ms = 15 ms вместо 115 ms в BST — разница почти на порядок.

Вставка

Вставка находит листовой узел так же, как при поиске, и помещает ключ в нужную позицию, сохраняя сортировку. Если после вставки узел содержит 2t ключей (переполнен), выполняется split — расщепление.

Split находит медианный ключ (позиция t) и разделяет узел на два: левый получает t−1 ключей до медианы, правый — t−1 ключей после. Медианный ключ поднимается в родительский узел:

До:    [... переполненный узел с 2t ключами ...]
                  ↓ split
После: [левая половина]  [медиана ↑ в родителя]  [правая половина]

Если родитель тоже переполнился — split повторяется каскадно вверх. Если split дошёл до корня и корень переполнен, он расщепляется на два узла, и создаётся новый корень с одним ключом (медианой). Два узла становятся его детьми — дерево вырастает на один уровень.

B-дерево растёт вверх, а не вниз. В BST новые узлы добавляются как листья и глубина растёт неравномерно. В B-дереве новый уровень появляется только при split корня, все листья всегда на одной глубине.

МетрикаЗначение
Спуск до листаO(log n) узлов
Каскадный split (худший случай)O(log n) узлов
Работа на каждом уровнеO(1) — размер узла фиксирован
ИтогоO(log n)
Записей на дискO(log n), на практике обычно 3–4

Удаление

Удаление ключа зависит от того, где он находится. Если ключ в листе — просто удаляем. Если во внутреннем узле — заменяем на predecessor (максимальный ключ в поддереве слева от него) или successor (минимальный ключ в поддереве справа), оба всегда в листе. Задача сводится к удалению из листа.

После удаления узел может стать «бедным» — содержать меньше t−1 ключей. Восстановить инвариант можно двумя способами.

Заимствование (borrowing): если у соседнего узла (с тем же родителем) больше t−1 ключей, разделитель из родителя спускается в бедный узел, а ближайший ключ соседа поднимается в родитель как новый разделитель.

До:     [...| P |...]
           /     \
      [бедный]  [богатый сосед]
 
После:  [...| Q |...]
           /     \
    [пополнен]  [отдал ключ]

Слияние (merge): если у соседа тоже ровно t−1 ключей, заимствовать не у кого. Бедный узел, разделитель из родителя и сосед объединяются в один узел с (t−2) + 1 + (t−1) = 2t−2 ключами — это допустимо по инварианту.

До:     [...| P |...]
           /     \
      [t−2]     [t−1]
 
После:  [...]
           |
    [объединённый: 2t−2 ключей]

При merge внутренних узлов объединяются не только ключи, но и дети обоих узлов — инвариант «n ключей → n+1 детей» сохраняется.

Каскадный merge: родитель отдал ключ-разделитель и мог сам стать бедным — процесс повторяется выше. Если merge дошёл до корня и корень стал пустым, единственный ребёнок становится новым корнем — высота дерева уменьшается на единицу.

МетрикаЗначение
Поиск ключаO(log n)
Поиск predecessor/successorO(log n)
Каскадный merge (худший случай)O(log n)
ИтогоO(log n)
Записей на дискO(log n), на практике обычно 3–4

Симметрия вставки и удаления

ВставкаУдаление
Переполнение (> 2t−1)Недозаполнение (< t−1)
Split (расщепление)Merge (слияние)
Ключ поднимается в родителяКлюч спускается из родителя
Дерево растёт вверх при split корняДерево сжимается при пустом корне

Все три операции — поиск, вставка, удаление — выполняются за O(log n) с основанием ~t. При t = 128 это означает 3–4 обращения к диску на 10 миллионов записей вместо 23 у BST.

B-дерево хранит данные (или указатели на данные) во всех узлах — и внутренних, и листовых. Это означает, что внутренние узлы тратят часть страницы на данные вместо ключей-разделителей. Если убрать данные из внутренних узлов и хранить их только в листьях, на страницу поместится больше ключей, дерево станет шире и ниже. Эту идею реализует B+ дерево.

Sources

Sources