B* дерево (B* tree)
Предпосылки: оценка сложности в O(…); B-дерево — узел как страница фиксированного размера; параметр t и инвариант заполнения от t−1 до 2t−1 ключей; split: переполненный узел расщепляется на два, медиана поднимается в родителя как разделитель; merge и заимствование у соседа через родителя при удалении.
← B-дерево
После split в B-дереве узел делится надвое, и каждая половина получается заполненной примерно наполовину. Это и есть нижняя граница: узел остаётся законным, пока в нём не меньше t−1 ключей — почти половина вместимости 2t−1. Половина страницы под узлом может стоять пустой.
Цена этой пустоты видна на масштабе индекса. Узел занимает целую дисковую страницу; если узлы держатся у нижней границы, индекс на 10 миллионов записей занимает почти вдвое больше страниц, чем при плотной упаковке, — лишние сотни мегабайт на диске. И это не редкий случай: при чередующихся вставках и удалениях узлы подолгу остаются у минимума, и заметная часть страниц индекса стоит наполовину пустой.
Плотнее половины
B дерево* поднимает нижнюю границу заполнения узла с 1/2 до 2/3: вместо «не меньше половины» — «не меньше двух третей». Звёздочка (*) обозначает вариант B-дерева с более плотной упаковкой узлов.
Поднять границу нельзя простым декретом: обычный split B-дерева делит один переполненный узел на два по половине — это даёт ровно 1/2, ниже новой цели. Чтобы узлы держались на 2/3, меняются обе операции, которые управляют заполнением: вставка (что делать с переполнением) и удаление (что делать с недозаполнением). Дальше — как именно.
Вставка: сначала поделиться, потом расщеплять
В B-дереве переполненный узел сразу расщепляется на два половинных. B дерево* добавляет шаг раньше split: прежде чем плодить новый полупустой узел, попробовать пристроить лишний ключ к соседу — другому ребёнку того же родителя, стоящему рядом по порядку. Если у соседа есть место, расщепление вообще не нужно.
Переложить ключ напрямую из переполненного узла в соседний нельзя: между ними в родителе стоит ключ-разделитель, и порядок ключей в дереве должен сохраниться. Поэтому ключи перетекают через родителя — тем же круговым сдвигом, что и при заимствовании в B-дереве: крайний ключ переполненного узла поднимается в родителя на место разделителя, а старый разделитель спускается в соседа. На схеме левый узел переполнен (5 ключей при вместимости 4), у правого есть место:
До: [...| 30 |...]
/ \
[10|20|25|28|29] [35|40] <- левый переполнен: 5 ключей при вместимости 4, у правого место есть
После: [...| 29 |...]
/ \
[10|20|25|28] [30|35|40] <- 29 поднялся в родителя, 30 спустился к соседуРазделителем стал 29 — крайний правый ключ переполненного узла; он поднялся в родителя. Старый разделитель 30 спустился в правый узел и встал перед 35. Порядок сохранён: слева от 29 всё меньше 29, справа — больше, как и требует дерево. Переполнение рассосалось без нового узла.
Если у соседа тоже нет места — он тоже полон, — делиться некому, и тогда выполняется split, но не один-в-два, а два узла в три. В дело идут переполненный узел (в нём на один ключ больше предельного), полный сосед и разделитель между ними — всего 2·(2t−1) + 2 ключа — и раскладываются поровну по трём новым узлам. Каждый получает около 2/3 вместимости — ровно новая нижняя граница. Два ключа-разделителя поднимаются в родителя:
До: [...| 30 |...]
/ \
[10|20|25|28|29] [35|38|40|45] <- левый переполнен, сосед полон — делиться некому
После: [...| 28 | 38 |...]
/ | \
[10|20|25] [29|30|35] [40|45] <- три узла вместо двухВсе ключи обоих узлов плюс разделитель 30 — это 10 20 25 28 29 30 35 38 40 45, десять штук в порядке возрастания. Их режут на три узла двумя ключами-разделителями 28 и 38, и эти двое уходят в родителя. В каждом из трёх узлов оказывается по 2–3 ключа — заметно плотнее, чем оставил бы обычный split B-дерева, который раскидал бы те же ключи по двум полупустым узлам. В этом и смысл правила B*: заполнение держат ближе к 2/3, а не к 1/2.
Удаление: зеркало вставки
Удаление обедняет узел так же, как вставка его переполняет, и чинится теми же двумя ходами в обратную сторону. Если после удаления в узле стало меньше 2/3 — он опустился ниже новой нижней границы. Сначала смотрят на соседа: если у того ключей с запасом, недостающий ключ перетекает через родителя — то же заимствование, что в B-дереве, только порог теперь 2/3, а не 1/2.
Если же и соседи у минимума, заимствовать не у кого, и узлы сливаются. Симметрично тому, как переполнение разводит два узла в три, недозаполнение собирает три узла в два: содержимое трёх соседних детей и два разделителя между ними в родителе складывают вместе и перекладывают в два узла на 2/3, а оба разделителя из родителя при этом исчезают.
До: [...| 20 | 40 |...]
/ | \
[10] [25|30] [45] <- три соседа, все у минимума
После: [...| 30 |...]
/ \
[10|20|25] [40|45] <- два узла на 2/3, один разделительВсе ключи трёх узлов плюс оба разделителя 20 и 40 — это 10 20 25 30 40 45, шесть штук. Их раскладывают на два узла одним новым разделителем 30, который поднимается в родителя; второй из старых разделителей лишний и пропадает вместе с третьим узлом. Получаются два узла на 2/3 — узлов стало меньше, плотность держится.
B-дерево, B+ дерево и B* дерево рядом
Таблица сводит три варианта по тем же признакам. B+ дерево — родственный вариант, который хранит данные только в листьях и связывает листья в связный список для диапазонных обходов; здесь оно даёт точку отсчёта по плотности и устройству узлов.
| Характеристика | B-дерево | B+ дерево | B* дерево |
|---|---|---|---|
| Минимальное заполнение | 1/2 | 1/2 | 2/3 |
| Где лежат данные | Во всех узлах | Только в листьях | Во всех узлах |
| Связь листьев | Нет | Связный список | Нет |
| Split при переполнении | 1 узел → 2 | 1 узел → 2 | 2 узла → 3 |
| Merge при недозаполнении | 2 узла → 1 | 2 узла → 1 | 3 узла → 2 |
| Заимствование у соседа до split | Нет | Нет | Да |
| Сложность реализации | базовая | средняя | высокая |
Звёздочка ставит акцент на плотности упаковки, а не на том, где лежат данные. Поэтому 2/3-заполнение можно совместить и с раскладкой B+ дерева — получится «B*+ дерево» с данными в листьях и плотными внутренними узлами. В таблице B* показано в простом варианте — с данными во всех узлах, как у B-дерева, — но сам приём плотной упаковки от этого выбора не зависит.
Строка «сложность реализации» суммирует разницу в коде: B* добавляет к обычному split случай заимствования у соседа и расщепление два-в-три (и зеркальное слияние три-в-два) — больше веток и больше операций с родителем на каждую перестройку, чем у B-дерева.
Что это даёт и чем платится
B дерево* поднимает нижнюю границу заполнения с 1/2 до 2/3. Для индекса на 10 миллионов записей это меняет худший случай впустую занятого места: в наполовину пустом узле зря лежит до 1/2 страницы, в узле на 2/3 — до 1/3. Пустой запас на узел падает с 50% до 33% — те самые лишние сотни мегабайт сжимаются до десятков.
Плотнее упакованные узлы выгодны и поиску. Чем больше ключей на странице, тем больше детей у каждого узла, тем меньше узлов нужно на те же 10 миллионов записей — и тем ниже дерево. Каждый уровень дерева — это одно чтение страницы с диска при спуске от корня к листу; дерево на уровень ниже экономит одно дисковое чтение на каждый поиск.
Цена — более дорогие обновления. Там, где B-дерево при переполнении трогает один узел и его родителя, B дерево* сперва читает соседа, а в худшем случае перестраивает три узла вместо двух. Каждая такая перестройка — лишние чтения и записи страниц на операцию вставки или удаления. B* выгодно там, где индекс читают намного чаще, чем меняют: экономия места и чтений окупает дорогую запись.
Sources
- Knuth, D. The Art of Computer Programming, Vol. 3: Sorting and Searching. https://www-cs-faculty.stanford.edu/~knuth/taocp.html
- Comer, D. (1979). The Ubiquitous B-Tree. https://doi.org/10.1145/356770.356776
← B-дерево