2. Красно черное дерево
В предыдущей статье 1. AVL дерево мы детально исследовали концепцию строгой математической самобалансировки. AVL-дерево эталонно подходит для поисковых операций: его жесткий инвариант ($|BF| \le 1$) поддерживает суммарную высоту минимально возможной. Однако за эту бескомпромиссную строгость приходится платить высокую вычислительную цену: при интенсивном потоке вставок (Insert) и удалений (Delete) AVL-дерево вынуждено непрерывно выполнять каскадные цепочки поворотов (Tree Rotations), поднимаясь от листьев к самому корню, что сжигает драгоценные такты процессора.
Инженерам системного программирования требовалась структура данных, обеспечивающая гарантированную логарифмическую сложность $O(\log N)$, но минимизирующая количество дорогих структурных перестановок при частых мутациях коллекции. Таким индустриальным триумфом стало Красно-черное дерево (Red-Black Tree, RBT), математически обоснованное Рудольфом Байером и Робертом Седжвиком.
Концепция: Ослабленный баланс
Вместо того чтобы непрерывно измерять и выравнивать разность физических высот поддеревьев каждого узла, Красно-черное дерево вводит остроумную систему двухцветной разметки узлов (Red и Black) и набор строгих структурных ограничений.
Ключевое архитектурное отличие RBT от AVL-дерева:
Оно допускает, чтобы одна ветвь была длиннее другой, но не более чем в 2 раза. Это так называемый «ослабленный (мягкий) баланс». Благодаря этому допущению дерево требует на порядок меньше поворотов при добавлении и удалении элементов.
5 святых инвариантов RBT
Чтобы дерево оставалось валидным Красно-черным деревом и гарантировало строгую оценку поиска $O(\log N)$, в нем обязаны непрерывно соблюдаться 5 базовых правил:
- Каждый узел окрашен строго либо в Красный, либо в Черный цвет.
- Корень дерева всегда является Черным.
- Все фиктивные пустые листья (
NIL/null) считаются Черными. - Красное правило: Если узел Красный, оба его непосредственных ребенка обязаны быть Черными (два красных узла подряд на одном пути категорически запрещены).
- Черная высота: Любой простой путь от заданного узла до любого из его листьев (
NIL) содержит строго одинаковое количество Черных узлов.
Именно синергия правил 4 и 5 создает баланс: самый длинный возможный путь (строгое чередование Черный-Красный-Черный-Красный) ровно в 2 раза длиннее самого короткого пути (состоящего исключительно из Черных узлов).
flowchart TD
N13["13 (Черный корень)"] -->|"L"| N8["8 (Красный)"]
N13 -->|"R"| N17["17 (Красный)"]
N8 -->|"L"| N1["1 (Черный)"]
N8 -->|"R"| N11["11 (Черный)"]
N17 -->|"L"| N15["15 (Черный)"]
N17 -->|"R"| N25["25 (Черный)"]
classDef blackNode fill:#0f172a,stroke:#64748b,stroke-width:2px,color:#f8fafc;
classDef redNode fill:#4c0519,stroke:#f43f5e,stroke-width:2px,color:#f8fafc;
class N13,N1,N11,N15,N25 blackNode;
class N8,N17 redNode;
Mechanical Sympathy: Память и Pointer Tagging
Спроектируем представление узла RBT на языке Go. Нам необходимо хранить полезную нагрузку, ссылки на потомков и родителя, а также флаг цвета:
package rbt
type Color bool
const (
Black Color = true
Red Color = false
)
type Node struct {
Key int // 8 байт
Value string // 16 байт
Left *Node // 8 байт
Right *Node // 8 байт
Parent *Node // 8 байт (необходим для навигации вверх при балансировке)
Color Color // 1 байт (bool)
// 7 байт Padding для выравнивания компилятором
}
Балансировка: Перекраска vs Повороты
Каждый вновь создаваемый узел всегда вставляется Красным (чтобы ни в коем случае не нарушить правило 5 о черной высоте). Если родительский узел оказался Черным — инварианты не нарушены, вставка завершена. Но если родитель тоже Красный — возникает запрещенная коллизия «два красных узла подряд» (нарушение правила 4).
Для устранения коллизии RBT использует два фундаментальных механизма:
- Перекраска (Recoloring): Сверхлегковесная операция $O(1)$. Мы просто инвертируем флаги
Colorу узлов. Процессор выполняет эту операцию со скоростью регистров, так как затрагиваемые узлы уже подтянуты в L1-кэш. - Поворот (Rotation): Локальная перепривязка указателей (аналогично AVL-дереву).
Выбор конкретного действия всецело определяется цветом «Дяди» (родного брата родительского узла):
- Случай 1: Дядя Красный.
Идеальный сценарий: достаточно выполнить Перекраску (Recoloring). Мы перекрашиваем родителя и дядю в Черный цвет, а дедушку — в Красный. Ноль поворотов! Проблема поднимается на два уровня выше (к дедушке) и проверяется рекурсивно. - Случай 2: Дядя Черный (или NIL).
Перекраской обойтись невозможно: выполняется 1 или 2 локальных Поворота (Rotation) вокруг дедушки/родителя, после чего цвета корректируются.
В худшем сценарии при любой операции вставки в RBT потребуется не более двух поворотов. При удалении узла — не более трех поворотов. Это математически доказанная граница, делающая Красно-черное дерево абсолютным чемпионом по скорости модификаций.
Идиоматичная реализация на Go: Каркас
Полная реализация RBT со всеми сценариями удаления занимает более 350 строк кода. Сфокусируемся на архитектурном каркасе и важнейшем паттерне обработки граничных условий.
package rbt
var sentinel = &Node{Color: Black} // Глобальный узел-страж (NIL)
type RedBlackTree struct {
Root *Node
}
func New() *RedBlackTree {
return &RedBlackTree{Root: sentinel}
}
// leftRotate выполняет левый поворот вокруг x
func (tree *RedBlackTree) leftRotate(x *Node) {
y := x.Right
x.Right = y.Left
if y.Left != sentinel {
y.Left.Parent = x
}
y.Parent = x.Parent
if x.Parent == sentinel {
tree.Root = y
} else if x == x.Parent.Left {
x.Parent.Left = y
} else {
x.Parent.Right = y
}
y.Left = x
x.Parent = y
}
// rightRotate выполняет правый поворот вокруг y (зеркально leftRotate)
func (tree *RedBlackTree) rightRotate(y *Node) {
x := y.Left
y.Left = x.Right
if x.Right != sentinel {
x.Right.Parent = y
}
x.Parent = y.Parent
if y.Parent == sentinel {
tree.Root = x
} else if y == y.Parent.Left {
y.Parent.Left = x
} else {
y.Parent.Right = x
}
x.Right = y
y.Parent = x
}
// Вставка и fixup (восстановление свойств RBT)
func (tree *RedBlackTree) Insert(key int, value string) {
z := &Node{
Key: key,
Value: value,
Color: Red,
Left: sentinel,
Right: sentinel,
Parent: sentinel,
}
// Стандартный спуск BST для поиска места вставки
var y *Node = sentinel
x := tree.Root
for x != sentinel {
y = x
if z.Key < x.Key {
x = x.Left
} else {
x = x.Right
}
}
z.Parent = y
if y == sentinel {
tree.Root = z
} else if z.Key < y.Key {
y.Left = z
} else {
y.Right = z
}
tree.insertFixup(z)
}
func (tree *RedBlackTree) insertFixup(z *Node) {
// Пока родитель красный — инвариант нарушен (два красных узла подряд)
for z.Parent.Color == Red {
if z.Parent == z.Parent.Parent.Left {
y := z.Parent.Parent.Right // Дядя
if y.Color == Red {
// Случай 1: Дядя красный. Только перекраска!
z.Parent.Color = Black
y.Color = Black
z.Parent.Parent.Color = Red
z = z.Parent.Parent // Поднимаемся к дедушке
} else {
// Случай 2: Дядя черный. Нужны повороты
if z == z.Parent.Right {
z = z.Parent
tree.leftRotate(z) // LR случай
}
z.Parent.Color = Black
z.Parent.Parent.Color = Red
tree.rightRotate(z.Parent.Parent) // LL случай
}
} else {
// Зеркальный случай: родитель является правым потомком дедушки
y := z.Parent.Parent.Left // Дядя слева
if y.Color == Red {
z.Parent.Color = Black
y.Color = Black
z.Parent.Parent.Color = Red
z = z.Parent.Parent
} else {
if z == z.Parent.Left {
z = z.Parent
tree.rightRotate(z)
}
z.Parent.Color = Black
z.Parent.Parent.Color = Red
tree.leftRotate(z.Parent.Parent)
}
}
}
tree.Root.Color = Black // Корень всегда черный
}
Где применяется Красно-Черное дерево?
Красно-черное дерево — это универсальная «рабочая лошадка» мировой индустрии системного ПО. Если в технической документации упоминается «сбалансированное дерево поиска» или упорядоченный словарь (Ordered Map), в 99% случаев ядро построено на RBT:
- Планировщик ядра Linux (CFS — Completely Fair Scheduler): Планировщик Linux непрерывно регистрирует и снимает с выполнения сотни потоков ОС, выбирая задачу с минимальным виртуальным временем работы (
vruntime). RBT подходит идеально: узел с минимальнымvruntimeвсегда находится в крайней левой позиции дерева ($O(1)$ доступ через кэшированный указатель), а добавление и удаление завершаются практически мгновенно благодаря минимальному числу поворотов. - C++
std::mapиstd::set: Базовые упорядоченные контейнеры стандартной библиотеки C++ реализованы через RBT. - Java
TreeMapи защитаHashMap: Когда в хэш-таблице Java скапливается критическая масса коллизий (более 8 элементов в одном бакете), цепочка связного списка автоматически трансформируется в Красно-черное дерево, надежно защищая сервис от атак Hash DoS.
Переход к базам данных
И деревья AVL, и Красно-черные деревья безупречно справляются со своими задачами... пока их узлы находятся в пределах оперативной памяти (RAM).
Но как только объемы данных вырастают до сотен гигабайт и терабайт, дерево неизбежно приходится размещать на вторичном хранилище — SSD или жестком диске HDD (как это устроено в СУБД PostgreSQL, MySQL, SQLite). И в этой среде бинарные деревья терпят сокрушительный крах.
Один узел RBT хранит всего один ключ и занимает 32–48 байт памяти. Физический диск оперирует данными строго фиксированными блоками — Страницами (Pages) размером 4096 байт (4KB) или 8 КБ. Чтобы найти ключ на глубине 20 в классическом дереве, RBT заставит контроллер диска совершить 20 независимых случайных операций чтения. Это означает чтение $20 \times 4\text{KB} = 80\text{KB}$ физических данных ради одного крошечного 8-байтового числа! Дисковый I/O мгновенно парализует любую базу данных.
Чтобы адаптировать древовидные структуры под физику дисковых накопителей, инженеры пошли на смелый шаг: они сделали узлы «широкими и плоскими», разместив в одном узле сразу сотни и тысячи ключей, тем самым сократив высоту дерева до 3–4 уровней. Так родились структуры данных, на которых держится вся мировая индустрия баз данных. В следующей статье мы разберем их во всех деталях: 3. B дерево и B+ дерево.