Красно-чёрное дерево держит баланс за счёт цветов и поворотов. Дерево 2-3-4 добивается того же другим способом — разрешая узлу хранить несколько ключей и иметь несколько потомков. Оно всегда идеально сбалансировано, а его идея напрямую ведёт к B-деревьям, на которых стоят индексы почти всех баз данных.
Узел с несколькими ключами
В двоичном дереве у узла один ключ и максимум два потомка. В дереве 2-3-4 узел может содержать до трёх ключей и, соответственно, до четырёх потомков — отсюда и название. Узел бывает трёх видов: с одним ключом (2 потомка), с двумя (3 потомка), с тремя (4 потомка).
Ключи внутри узла упорядочены, а потомки «вставлены» между ними: левый потомок — для ключей меньше первого, следующий — между первым и вторым, и так далее. Поиск идёт так же, как в двоичном дереве, только на каждом узле выбор не из двух направлений, а из нескольких: сравниваем искомый ключ с ключами узла и спускаемся в нужного потомка.
Всегда сбалансировано благодаря разбиению
Главное свойство дерева 2-3-4: оно никогда не выходит из равновесия, потому что растёт не вниз отдельными ветками, а «вширь» — все листья всегда на одном уровне. Обеспечивает это операция разбиения узла (split).
При вставке мы спускаемся к листу и по пути разбиваем каждый полный узел (тот, где уже три ключа): средний ключ поднимается в родителя, а оставшиеся два ключа расходятся в два отдельных узла. Разбивая полные узлы заранее, на спуске, мы гарантируем, что всегда есть место для нового ключа, и что дерево при необходимости подрастает от корня вверх — а значит, все ветви остаются одной длины. Никаких поворотов не нужно.
Полный 4-узел [10|20|30] разбивается: средний ключ 20 всплывает в родителя, крайние расходятся в два листа. Полные узлы разбиваются заранее на спуске — все листья остаются на одном уровне, поиск и вставка стоят O(log N).
Поскольку дерево всегда сбалансировано, поиск, вставка и удаление стоят O(log N) при любом порядке данных.
Удаление зеркально вставке. Ключ из внутреннего узла сначала подменяют преемником из листа, так что удаляют всегда из листа; а чтобы лист не опустел, на спуске делают обратное разбиению: узел с одним ключом либо занимает ключ у соседа через родителя (поворот ключей), либо, когда у соседа тоже один ключ, сливается с ним и ключом родителя в один узел. Слияние может дойти до корня, и тогда дерево становится на уровень ниже, всё так же от корня, а не от листа. Без слияния структура неполна: разбиение только растит дерево, и только слияние даёт ему сжиматься.
Родство с красно-чёрными деревьями
Дерево 2-3-4 и красно-чёрное дерево — это, по сути, две записи одной идеи. Любое дерево 2-3-4 механически превращается в красно-чёрное и обратно. Узел с двумя ключами это чёрный узел с одним красным потомком, узел с тремя ключами чёрный с двумя красными, а разбиение переполненного узла это перекраска: оба красных потомка становятся чёрными, а их родитель красным, и, если красных подряд оказалось два, поворот. По эффективности они эквивалентны, а выбор между ними — вопрос удобства реализации.
B-деревья: 2-3-4 для диска
Если узлу разрешить не три ключа, а сотни или тысячи, получится B-дерево. Зачем так много? Из-за того, как устроено внешнее хранение — работа с диском.
Чтение с диска несравнимо медленнее чтения из памяти: у быстрого SSD разрыв примерно в тысячу раз, у вращающегося диска — в сотню тысяч. И данные читаются блоками — крупными кусками разом. Значит, важно не число сравнений, а число обращений к диску. B-дерево подгоняет размер узла под размер блока (в PostgreSQL это страница на 8 КБ): один прочитанный блок — сразу сотни ключей и один шаг вниз по дереву.
Дерево при этом получается очень низким, и видно это из арифметики: узел на k ключей даёт k + 1 ветвей, и высота дерева считается как логарифм числа записей по основанию k + 1.
живой пример
public class TreeHeightDemo {
static int levels(long keys, int keysPerNode) {
long capacity = 1;
int levels = 0;
while (capacity < keys) {
capacity *= keysPerNode + 1;
levels++;
}
return levels;
}
public static void main(String[] args) {
long keys = 1_000_000;
System.out.println("двоичное дерево, 1 ключ в узле: " + levels(keys, 1));
System.out.println("дерево 2-3-4, 3 ключа в узле: " + levels(keys, 3));
System.out.println("B-дерево, 255 ключей в узле: " + levels(keys, 255));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import "fmt"
func levels(keys, keysPerNode int64) int {
capacity, count := int64(1), 0
for capacity < keys {
capacity *= keysPerNode + 1
count++
}
return count
}
func main() {
keys := int64(1_000_000)
fmt.Println("двоичное дерево, 1 ключ в узле: ", levels(keys, 1))
fmt.Println("дерево 2-3-4, 3 ключа в узле: ", levels(keys, 3))
fmt.Println("B-дерево, 255 ключей в узле: ", levels(keys, 255))
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
function levels(keys, keysPerNode) {
let capacity = 1;
let count = 0;
while (capacity < keys) {
capacity *= keysPerNode + 1;
count++;
}
return count;
}
const keys = 1_000_000;
console.log("двоичное дерево, 1 ключ в узле: ", levels(keys, 1));
console.log("дерево 2-3-4, 3 ключа в узле: ", levels(keys, 3));
console.log("B-дерево, 255 ключей в узле: ", levels(keys, 255));
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
def levels(keys: int, keys_per_node: int) -> int:
capacity, count = 1, 0
while capacity < keys:
capacity *= keys_per_node + 1
count += 1
return count
keys = 1_000_000
print("двоичное дерево, 1 ключ в узле: ", levels(keys, 1))
print("дерево 2-3-4, 3 ключа в узле: ", levels(keys, 3))
print("B-дерево, 255 ключей в узле: ", levels(keys, 255))
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Три строки — три основания логарифма. Двоичному дереву на миллион ключей нужно двадцать уровней, дереву 2-3-4 — десять, B-дереву с 255 ключами в узле — три. Это не разница в порядке роста: логарифм остаётся логарифмом. Различается его основание, а на диске каждый уровень оплачивается отдельным чтением блока.
Слева направо: сколько ключей помещается в узел, сколько от него ветвей и сколько уровней выходит на миллионе записей; смотреть на последний столбец.
Именно поэтому индексы в базах данных и файловых системах — это B-деревья (точнее, их вариант B+). Когда вы создаёте индекс в PostgreSQL, под капотом строится именно оно, и быстрый поиск по индексу — спуск за считанные обращения к диску.
Три свойства B-дерева, ради которых бекендер это читает, лежат чуть глубже картинки с тремя уровнями.
Узлы заполнены хотя бы наполовину. Правило разбиения делит полный узел пополам, поэтому в любом узле, кроме корня, не меньше половины ключей от максимума. Отсюда высота: миллион ключей при 255 в узле это три уровня даже при худшей заполненности. И отсюда же цена удалений: удалённые ключи оставляют в страницах дыры, страница остаётся наполовину пустой, пока не сольётся с соседом, и индекс после массовых удалений занимает вдвое больше места, чем нужно данным; в PostgreSQL это лечит REINDEX.
Случайные ключи дробят страницы, последовательные нет. Когда ключ растёт монотонно (bigserial, время), новые записи всегда попадают в последний лист, он заполняется до конца и разбивается ровно один раз на страницу: страницы полные, дерево компактное, а горячая страница одна и она в кеше. Когда ключ случайный (UUID версии 4), каждая вставка попадает в случайный лист, полные листья разбиваются пополам, и индекс живёт с заполненностью около половины, вдвое больше страниц, вдвое больше чтений с диска и вдвое больше записи в журнал. Это главный практический довод против случайного UUID в первичном ключе большой таблицы и за монотонные варианты вроде UUID версии 7.
Данные только в листьях. В варианте B+ внутренние узлы держат не записи, а только ключи-разделители, «куда спускаться», и потому вмещают больше ключей, а дерево получается ниже; все записи лежат в листьях, и листья связаны в цепочку. Ровно это и отличает B+ от B: поиск одного ключа в обоих спуск, а диапазон в B+ это спуск к первому листу и чтение цепочки подряд.
Из этого же устройства следует правило левого префикса для составного индекса по нескольким колонкам: ключи в дереве упорядочены сначала по первой колонке, внутри равных по второй, и спуск возможен только от первой колонки; индекс по (город, дата) ускоряет запрос по городу и по паре, но не запрос только по дате. Подробно, вместе с порядком колонок, разбирает статья про составные индексы.
Как это сделано в стандартной библиотеке
Берут TreeMap (и TreeSet поверх него). Внутри у него красно-чёрное дерево, а оно, как мы только что видели, — то же 2-3-4 в двоичной записи: 3-узел разворачивается в чёрный узел с одним красным потомком, 4-узел — в чёрный с двумя красными, а разбиение превращается в перекраску. Обещанная сложность та же: get, put, remove — гарантированные O(log n) при любом порядке данных.
Самого дерева 2-3-4 в java.util нет, как и B-дерева, и это не упущение. Весь выигрыш B-дерева — в экономии обращений к внешнему хранилищу: широкий узел окупается тогда, когда за одно чтение вы поднимаете с диска целый блок. java.util работает с тем, что уже лежит в памяти, и никакого диска под ним нет. Поэтому B-деревья в Java-мире живут не в коллекциях, а внутри баз данных, встраиваемых хранилищ и файловых систем — там, где чтение стоит дорого.
Вторая разница — в диапазонных запросах.
живой пример
import java.time.LocalDate;
import java.util.TreeMap;
public class RangeDemo {
public static void main(String[] args) {
TreeMap<LocalDate, String> orders = new TreeMap<>();
orders.put(LocalDate.parse("2026-03-01"), "A-1");
orders.put(LocalDate.parse("2026-03-07"), "A-2");
orders.put(LocalDate.parse("2026-03-19"), "A-3");
orders.put(LocalDate.parse("2026-04-02"), "A-4");
System.out.println(orders.subMap(
LocalDate.parse("2026-03-05"), true,
LocalDate.parse("2026-03-25"), true));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Такой диапазон TreeMap собирает обходом дерева — прыжками вверх-вниз по ссылкам. В B+-дереве листья дополнительно связаны в цепочку, поэтому диапазон — это один спуск и линейное чтение подряд идущих блоков. Ровно поэтому выборка «с даты по дату» по индексированной колонке в базе данных стоит так дёшево.
Грабля. Соблазн «закешировать таблицу в TreeMap, чтобы не ходить в базу» работает ровно до тех пор, пока данные помещаются в память с запасом. Каждая запись — отдельный объект-узел: на миллионах записей это миллионы объектов, давление на сборщик мусора и долгое восстановление после перезапуска, потому что дерево строится заново. Порог, за которым пора возвращаться к индексу в базе, задаётся не O-нотацией — она у обеих структур логарифмическая, — а объёмом данных и тем, сколько у вас памяти.
Ни дерева 2-3-4, ни B-дерева, ни красно-чёрного в стандартной библиотеке Go нет: упорядоченную структуру в памяти собирают из отсортированного среза или берут пакетом. И это не упущение. Весь выигрыш B-дерева — в экономии обращений к внешнему хранилищу: широкий узел окупается тогда, когда за одно чтение вы поднимаете с диска целый блок. Стандартная библиотека работает с тем, что уже лежит в памяти, и никакого диска под ней нет.
Зато в мире Go B-дерево живёт на удивление близко к прикладному коду. Пакет github.com/google/btree — это B-дерево в памяти: узел по умолчанию держит до 63 ключей, потому что широкий узел окупается не только на диске, но и в кеше процессора, и именно на нём etcd и Kubernetes держат свои индексы. На диске то же дерево, уже B+, лежит в bbolt, встраиваемом хранилище, на котором etcd хранит данные: файл, отображённый в память, страницы по 4 КБ, ключи в листьях подряд. Обещанная сложность у обоих та же: поиск, вставка, удаление — гарантированные O(log n) при любом порядке данных.
Вторая разница — в диапазонных запросах. У отсортированного среза диапазон это два двоичных поиска и срез между ними:
живой пример
package main
import (
"fmt"
"slices"
"strings"
)
func main() {
days := []string{"2026-03-01", "2026-03-07", "2026-03-19", "2026-04-02"}
codes := map[string]string{"2026-03-01": "A-1", "2026-03-07": "A-2", "2026-03-19": "A-3", "2026-04-02": "A-4"}
lo, _ := slices.BinarySearchFunc(days, "2026-03-05", strings.Compare)
hi, _ := slices.BinarySearchFunc(days, "2026-03-26", strings.Compare)
for _, day := range days[lo:hi] {
fmt.Println(day, codes[day])
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Даты в формате ISO сравниваются как строки правильно, поэтому strings.Compare здесь достаточно. Дерево в памяти тот же диапазон собирает обходом — прыжками вверх-вниз по указателям, у google/btree для этого есть AscendRange. В B+-дереве листья дополнительно связаны в цепочку, поэтому диапазон — это один спуск и линейное чтение подряд идущих блоков. Ровно поэтому выборка «с даты по дату» по индексированной колонке в базе данных стоит так дёшево.
Грабля. Соблазн «закешировать таблицу в map или срез, чтобы не ходить в базу» работает ровно до тех пор, пока данные помещаются в память с запасом. Миллионы записей в map со строковыми ключами это миллионы объектов и заметная пауза сборщика мусора на каждом цикле, а после перезапуска кеш строится заново. Порог, за которым пора возвращаться к индексу в базе, задаётся не O-нотацией — она у обеих структур логарифмическая, — а объёмом данных и тем, сколько у вас памяти.
Ни дерева 2-3-4, ни B-дерева, ни красно-чёрного в стандартной библиотеке JavaScript нет: упорядоченную структуру в памяти собирают из отсортированного массива или берут пакетом вроде sorted-btree. И это не упущение. Весь выигрыш B-дерева — в экономии обращений к внешнему хранилищу: широкий узел окупается тогда, когда за одно чтение вы поднимаете с диска целый блок. Стандартная библиотека работает с тем, что уже лежит в памяти, и никакого диска под ней нет.
Зато B-деревья окружают код на Node со всех сторон. Встраиваемая SQLite, которую приложения на Node подключают через better-sqlite3, а с Node 22 и через встроенный модуль node:sqlite, хранит таблицы и индексы B-деревьями со страницами по 4 КБ; IndexedDB в браузере лежит поверх LevelDB, у которой на диске уже не B-дерево, а LSM-дерево, тот же ответ на ту же задачу с другим компромиссом между чтением и записью. Обещанная сложность везде та же: поиск, вставка, удаление — гарантированные O(log n) при любом порядке данных.
Вторая разница — в диапазонных запросах. У отсортированного массива диапазон это два двоичных поиска и slice между ними:
живой пример
function lowerBound(a, key) {
let lo = 0;
let hi = a.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (a[mid] < key) lo = mid + 1;
else hi = mid;
}
return lo;
}
const orders = [["2026-03-01", "A-1"], ["2026-03-07", "A-2"], ["2026-03-19", "A-3"], ["2026-04-02", "A-4"]];
const days = orders.map(([day]) => day);
const from = lowerBound(days, "2026-03-05");
const to = lowerBound(days, "2026-03-26");
console.log(orders.slice(from, to));
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Даты в формате ISO сравниваются как строки правильно, поэтому обычного < здесь достаточно. Дерево в памяти тот же диапазон собирает обходом — прыжками вверх-вниз по ссылкам. В B+-дереве листья дополнительно связаны в цепочку, поэтому диапазон — это один спуск и линейное чтение подряд идущих блоков. Ровно поэтому выборка «с даты по дату» по индексированной колонке в базе данных стоит так дёшево.
Грабля. Соблазн «закешировать таблицу в Map, чтобы не ходить в базу» работает ровно до тех пор, пока данные помещаются в память с запасом. У процесса Node куча по умолчанию ограничена парой гигабайт, миллионы записей в Map это миллионы объектов и долгие паузы сборщика мусора, а после перезапуска кеш строится заново. Порог, за которым пора возвращаться к индексу в базе, задаётся не O-нотацией — она у обеих структур логарифмическая, — а объёмом данных и тем, сколько у вас памяти.
Ни дерева 2-3-4, ни красно-чёрного в стандартной библиотеке Python нет: упорядоченную структуру в памяти собирают из отсортированного списка с bisect или берут sortedcontainers. И это не упущение. Весь выигрыш B-дерева — в экономии обращений к внешнему хранилищу: широкий узел окупается тогда, когда за одно чтение вы поднимаете с диска целый блок, а bisect работает с тем, что уже лежит в памяти.
А вот само B-дерево в стандартной библиотеке Python есть, только не в виде коллекции: модуль sqlite3 возит с собой встраиваемую базу, и каждая её таблица и каждый индекс — это B-дерево на страницах по 4 КБ. То есть индекс с диапазонным запросом доступен без единой зависимости:
живой пример
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("create table orders(day text primary key, code text)")
db.executemany("insert into orders values (?, ?)", [
("2026-03-01", "A-1"), ("2026-03-07", "A-2"), ("2026-03-19", "A-3"), ("2026-04-02", "A-4"),
])
print(db.execute("select day, code from orders where day between '2026-03-05' and '2026-03-25'").fetchall())
for row in db.execute("explain query plan select * from orders where day between '2026-03-05' and '2026-03-25'"):
print(row[-1])
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первичный ключ создал индекс, и план запроса показывает SEARCH ... USING INDEX: база спустилась по B-дереву к первой дате и прочитала листья подряд до последней. Обещанная сложность та же, что у дерева в памяти: поиск, вставка, удаление — гарантированные O(log n) при любом порядке данных.
Вторая разница — в диапазонных запросах в памяти. У отсортированного списка диапазон это два bisect и срез между ними, days[bisect_left(days, "2026-03-05"):bisect_left(days, "2026-03-26")], даты в формате ISO сравниваются как строки правильно. Дерево в памяти тот же диапазон собирает обходом — прыжками вверх-вниз по ссылкам. В B+-дереве листья дополнительно связаны в цепочку, поэтому диапазон — это один спуск и линейное чтение подряд идущих блоков. Ровно поэтому выборка «с даты по дату» по индексированной колонке в базе данных стоит так дёшево.
Грабля. Соблазн «закешировать таблицу в dict, чтобы не ходить в базу» работает ровно до тех пор, пока данные помещаются в память с запасом. Каждая запись в Python — несколько объектов: ключ, значение, их поля; на миллионах записей это гигабайты, паузы сборщика мусора и долгое восстановление после перезапуска, потому что кеш строится заново. Порог, за которым пора возвращаться к индексу в базе, задаётся не O-нотацией — она у обеих структур логарифмическая, — а объёмом данных и тем, сколько у вас памяти.
Коротко
- В дереве 2-3-4 узел хранит до 3 ключей и до 4 потомков; поиск — многопутевой спуск.
- Дерево всегда идеально сбалансировано благодаря разбиению полных узлов на спуске: оно растёт от корня вверх, все листья на одном уровне. Отсюда гарантированный
O(log N). - Дерево 2-3-4 эквивалентно красно-чёрному: это две формы одной идеи, и упорядоченные коллекции в библиотеках, например
TreeMapв Java, — как раз двоичная форма. - Увеличив число ключей в узле до размера дискового блока, получаем B-дерево — основу индексов баз данных и файловых систем, где цель — минимум обращений к диску.
- Миллион записей — это двадцать уровней в двоичном дереве и три в B-дереве с узлом на 255 ключей: порядок роста один, цена одного шага разная.
- В B+-дереве листья связаны в цепочку, поэтому выборка по диапазону — один спуск и чтение подряд; дерево в памяти тот же диапазон собирает обходом, а отсортированный массив — двумя двоичными поисками.
- Удаление в 2-3-4 зеркально вставке: занять ключ у соседа или слить узлы через родителя; только слияние даёт дереву сжиматься.
- Узлы B-дерева заполнены хотя бы наполовину; монотонный ключ даёт полные страницы и одно разбиение на страницу, случайный
UUIDдробит листья и удваивает индекс. - B+ держит записи только в листьях и связывает листья в цепочку; составной индекс работает от левого префикса ключа.
Что почитать дальше
- Красно-чёрные деревья — та же идея в двоичной записи: повороты и перекраска вместо разбиения.
- Хеш-таблицы — поиск за
O(1), но без порядка и без диапазонов. - Двоичные деревья — с чего начинается дерево поиска и почему оно вырождается.
- Как выбрать структуру данных — когда дерево, когда хеш-таблица, а когда массив.