← назад к разделу

Красно-чёрное дерево держит баланс за счёт цветов и поворотов. Дерево 2-3-4 добивается того же другим способом — разрешая узлу хранить несколько ключей и иметь несколько потомков. Оно всегда идеально сбалансировано, а его идея напрямую ведёт к B-деревьям, на которых стоят индексы почти всех баз данных.

Узел с несколькими ключами

В двоичном дереве у узла один ключ и максимум два потомка. В дереве 2-3-4 узел может содержать до трёх ключей и, соответственно, до четырёх потомков — отсюда и название. Узел бывает трёх видов: с одним ключом (2 потомка), с двумя (3 потомка), с тремя (4 потомка).

Ключи внутри узла упорядочены, а потомки «вставлены» между ними: левый потомок — для ключей меньше первого, следующий — между первым и вторым, и так далее. Поиск идёт так же, как в двоичном дереве, только на каждом узле выбор не из двух направлений, а из нескольких: сравниваем искомый ключ с ключами узла и спускаемся в нужного потомка.

Всегда сбалансировано благодаря разбиению

Главное свойство дерева 2-3-4: оно никогда не выходит из равновесия, потому что растёт не вниз отдельными ветками, а «вширь» — все листья всегда на одном уровне. Обеспечивает это операция разбиения узла (split).

При вставке мы спускаемся к листу и по пути разбиваем каждый полный узел (тот, где уже три ключа): средний ключ поднимается в родителя, а оставшиеся два ключа расходятся в два отдельных узла. Разбивая полные узлы заранее, на спуске, мы гарантируем, что всегда есть место для нового ключа, и что дерево при необходимости подрастает от корня вверх — а значит, все ветви остаются одной длины. Никаких поворотов не нужно.

40 10 30 20

Полный 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 ключами в узле — три. Это не разница в порядке роста: логарифм остаётся логарифмом. Различается его основание, а на диске каждый уровень оплачивается отдельным чтением блока.

двоичное дерево 1 ключ 2 ветви 20 уровней дерево 2-3-4 3 ключа 4 ветви 10 уровней B-дерево 255 ключей 256 ветвей 3 уровня

Слева направо: сколько ключей помещается в узел, сколько от него ветвей и сколько уровней выходит на миллионе записей; смотреть на последний столбец.

Именно поэтому индексы в базах данных и файловых системах — это 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+ держит записи только в листьях и связывает листья в цепочку; составной индекс работает от левого префикса ключа.

Что почитать дальше