1. AVL дерево

Если мы возьмем обычное Двоичное дерево поиска (BST) и начнем последовательно вставлять в него отсортированные данные (например, монотонные ключи 1, 2, 3, 4, 5), оно неминуемо выродится в обыкновенный односвязный список. Вся алгоритмическая магия логарифмической сложности испарится, а производительность поиска деградирует до линейного $O(N)$.

Чтобы предотвратить подобное искривление, структуры данных обязаны уметь самобалансироваться непосредственно во время выполнения мутирующих операций. Первым в истории компьютерных наук алгоритмом, успешно решившим эту фундаментальную задачу еще в 1962 году, стало AVL-дерево (названное в честь советских математиков Георгия Максимовича Адельсона-Вельского и Евгения Михайловича Ландиса).


Концепция: Идеальный баланс

В отличие от хеш-таблиц, которые хаотично распределяют ключи по бакетам, деревья сохраняют естественный порядок (Order). Это открывает возможность эффективного выполнения интервальных запросов (Range Queries, например SELECT * WHERE id BETWEEN 10 AND 50). Однако за поддержание строгого порядка приходится платить постоянной топологической дисциплиной.

Главный инвариант (правило) AVL-дерева:

Для любого узла дерева высота его левого поддерева и высота его правого поддерева могут отличаться не более чем на 1.

Эта разность высот формально называется Фактором баланса (Balance Factor, BF):
$$BF = Height(Left) - Height(Right)$$

В корректном AVL-дереве BF для каждого узла может принимать исключительно три допустимых значения: -1, 0 или +1. Если после очередной операции вставки или удаления фактор баланса становится равным +2 или -2, узел теряет равновесие, и алгоритм обязан немедленно восстановить инвариант с помощью локальных топологических перестроений.


Структура узла в Go: Mechanical Sympathy

Спроектируем структуру узла для AVL-дерева на Go. В академических учебниках в узле часто хранят непосредственно поле BalanceFactor или высоту как тип int. Но мы инженеры высоконагруженных систем, и обязаны оценивать структуру сквозь призму архитектуры процессора и выравнивания памяти:

Go
package avl

// Node представляет узел AVL-дерева
type Node struct {
    Key    int    // 8 байт (на 64-битной архитектуре)
    Value  string // 16 байт (указатель на данные + длина)
    Left   *Node  // 8 байт
    Right  *Node  // 8 байт
    Height int8   // 1 байт
    // padding: 7 байт (аппаратное выравнивание компилятора до границы 8 байт)
}

Магия восстановления баланса: Повороты (Rotations)

Когда алгоритм обнаруживает нарушение баланса узла ($BF = +2$ или $BF = -2$), он инициирует процедуру Поворота (Rotation). Это строго локальная операция перепривязки указателей с константной сложностью $O(1)$, которая восстанавливает допустимую разность высот, абсолютно не нарушая порядок бинарного поиска (все ключи слева остаются меньше текущего, справа — больше).

В зависимости от направления искривления выделяют 4 типа дисбаланса и 4 соответствующих поворота:

1. Правый поворот (LL Case - Левое-Левое)

Дерево критически перевешивает влево (новый ключ был вставлен в левое поддерево левого потомка). Мы «тянем» структуру вправо: узел X поднимается на место родителя, а узел Y смещается вправо, становясь его правым ребенком.

flowchart TD
    subgraph Before["До правого поворота (LL Case: BF = 2)"]
        Y1["Узел Y (BF = +2)"] -->|"Left"| X1["Узел X (BF = +1)"]
        Y1 -->|"Right"| T3_1["Поддерево T3"]
        X1 -->|"Left"| T1_1["Поддерево T1 (Вставка)"]
        X1 -->|"Right"| T2_1["Поддерево T2"]
    end

    subgraph After["После правого поворота (Баланс: BF = 0)"]
        X2["Узел X (Новый корень: BF = 0)"] -->|"Left"| T1_2["Поддерево T1"]
        X2 -->|"Right"| Y2["Узел Y (BF = 0)"]
        Y2 -->|"Left"| T2_2["Поддерево T2"]
        Y2 -->|"Right"| T3_2["Поддерево T3"]
    end

    classDef badNode fill:#4c0519,stroke:#f43f5e,stroke-width:1.5px,color:#f8fafc;
    classDef goodNode fill:#14532d,stroke:#10b981,stroke-width:1.5px,color:#f8fafc;
    classDef subTree fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc;

    class Y1 badNode;
    class X1,X2,Y2 goodNode;
    class T1_1,T2_1,T3_1,T1_2,T2_2,T3_2 subTree;
  

2. Левый поворот (RR Case - Правое-Правое)

Зеркальная ситуация. Дерево перевешивает вправо ($BF = -2$) из-за вставки в правое поддерево правого потомка. Мы «тянем» структуру влево: правый потомок становится новым корнем поддерева.

3. Лево-Правый поворот (LR Case - Левое-Правое)

Дерево перекошено влево ($BF = +2$), однако дисбаланс спровоцирован вставкой в правое поддерево левого потомка (форма «зигзага»).
Одиночный правый поворот здесь бессилен: он лишь перекинет зигзаг на противоположную сторону.
Алгоритмическое решение: Проблема решается в два этапа:

  1. Выполняем малый Левый поворот вокруг левого ребенка X, выпрямляя зигзаг в прямую линию (сводя к классическому случаю LL).
  2. Выполняем большой Правый поворот вокруг разбалансированного узла Y.

4. Право-Левый поворот (RL Case)

Зеркальный аналог случая LR. Ветка перевешивает вправо ($BF = -2$), но новый элемент попал в левое поддерево правого потомка. Сначала выполняется малый Правый поворот на ребенке, затем большой Левый поворот на корневом узле.


Идиоматичная реализация на Go

Реализуем алгоритмическое ядро AVL-дерева на Go. Для надежности создадим безопасную функцию-хелпер height(), которая защищает от паники при обращении к nil-указателям:

Go
package avl

// height безопасно возвращает высоту узла
func height(n *Node) int8 {
    if n == nil {
        return 0
    }
    return n.Height
}

// max возвращает большее из двух int8
func max(a, b int8) int8 {
    if a > b {
        return a
    }
    return b
}

// getBalance вычисляет фактор баланса
func getBalance(n *Node) int8 {
    if n == nil {
        return 0
    }
    return height(n.Left) - height(n.Right)
}

// rightRotate выполняет правый поворот вокруг узла y
func rightRotate(y *Node) *Node {
    x := y.Left
    T2 := x.Right

    // Выполняем поворот связей: меняем указатели
    x.Right = y
    y.Left = T2

    // Обновляем высоты (сначала y, так как он опустился ниже, затем x)
    y.Height = max(height(y.Left), height(y.Right)) + 1
    x.Height = max(height(x.Left), height(x.Right)) + 1

    // Возвращаем новый корень поддерева
    return x
}

// leftRotate выполняет левый поворот вокруг узла x
func leftRotate(x *Node) *Node {
    y := x.Right
    T2 := y.Left

    y.Left = x
    x.Right = T2

    x.Height = max(height(x.Left), height(x.Right)) + 1
    y.Height = max(height(y.Left), height(y.Right)) + 1

    return y
}

Вставка с балансировкой

Посмотрим на целостную реализацию рекурсивной вставки, использующей наши повороты:

Go
// Insert вставляет новый ключ и возвращает обновленный корень
func Insert(node *Node, key int, value string) *Node {
    // 1. Обычная вставка для BST
    if node == nil {
        return &Node{Key: key, Value: value, Height: 1}
    }

    if key < node.Key {
        node.Left = Insert(node.Left, key, value)
    } else if key > node.Key {
        node.Right = Insert(node.Right, key, value)
    } else {
        // Ключ уже существует, обновляем значение
        node.Value = value
        return node
    }

    // 2. Обновляем высоту текущего узла
    node.Height = max(height(node.Left), height(node.Right)) + 1

    // 3. Получаем фактор баланса
    balance := getBalance(node)

    // 4. Если узел потерял баланс, есть 4 случая:

    // LL Case
    if balance > 1 && key < node.Left.Key {
        return rightRotate(node)
    }

    // RR Case
    if balance < -1 && key > node.Right.Key {
        return leftRotate(node)
    }

    // LR Case
    if balance > 1 && key > node.Left.Key {
        node.Left = leftRotate(node.Left)
        return rightRotate(node)
    }

    // RL Case
    if balance < -1 && key < node.Right.Key {
        node.Right = rightRotate(node.Right)
        return leftRotate(node)
    }

    // Возвращаем неизмененный узел, если баланс в норме
    return node
}

AVL-дерево на собеседованиях: Битва деревьев

На техническом интервью уровня Senior/Lead вас редко попросят написать код всех четырех поворотов наизусть. Интервьюера интересует архитектурная глубина вашего понимания trade-off-ов.

Почему деревья не используются в БД? (Проблема Cache Locality)

Мы детально изучили работу AVL-дерева в оперативной памяти. Но если вы попытаетесь построить дисковый индекс базы данных на основе классического AVL или RBT, система окажется катастрофически неэффективной.

Каждый узел в Go создается через аллокатор рантайма (malloc) и оказывается произвольно размещен в куче (Heap). Навигация по ссылкам — это постоянный Pointer Chasing (погоня за указателями), генерирующий непрерывные промахи процессорного кэша (Cache Miss). Но самое главное: чтение одного легковесного узла размером 32–48 байт с дискового накопителя чудовищно расточительно, так как контроллеры SSD и операционные системы оперируют данными страницами по 4–16 КБ.

Для преодоления этого разрыва концепция деревьев была фундаментально модернизирована: узлы сделали «широкими», чтобы один узел целиком заполнял дисковую страницу или кэш-линию CPU.

Однако прежде чем перейти к архитектуре дисковых хранилищ, мы обязаны досконально изучить классику оперативной памяти. В следующей статье мы разберем главного индустриального соперника AVL: 2. Красно черное дерево.