Что может заставить обратить внимание на красно-чёрные деревья, и как их реализовать? Статья ответит на оба эти вопроса. Бонусом будет показано, как на основе красно-чёрного сконструировать дерево интервалов.


Важное замечание: статья фокусируется на коде. “О” большое, двоичные деревья, связные списки — всё это останется за кадром. Статьи ценны оригинальностью материала, а перечисленные вещи (включая механику красно-чёрных деревьев) обсуждались уже много раз, в том числе и здесь, на Хабре.

Если жажда готовых решений у вас так же сильна, как была у меня полгода назад, когда я начинал разбираться с темой, то можете сразу переходить к разделу Построение. Или вообще в конец статьи к ссылкам на файлы.

Оглавление

Зачем?

Перед тем, как окунуться в информационную ботанику, отвечу на вопрос, заданный в начале статьи. Возникла задача:

  • нужна упорядоченная (отсортированная по некоему атрибуту) коллекция объектов;

  • порядок не должен нарушаться при добавлении и удалении данных;

  • указанные операции, а также поиск объекта в коллекции следует выполнять максимально быстро.

Как и у прошлых моих статей, у этой ноги растут из работы над библиотекой DryWetMIDI. Недавний релиз позволил вносить изменения в воспроизводимые объекты (MIDI-события, ноты и т.п.) на лету. Если кратко, теперь можно удалять, добавлять и менять данные без необходимости пересоздавать экземпляр класса Playback. Учитывая, что объекты внутри должны идти в хронологическом порядке, приходим к показанной выше задаче.

Бесценный опыт предыдущих поколений подсказывает — нужно самобалансирующееся бинарное дерево поиска (self-balancing binary search tree). Или двоичное, кому как больше нравится. Сложность добавления, удаления и поиска элемента в такой структуре O(logN), как в среднем, так и в худшем случаях. Чего не скажешь про обычное BST (binary search tree), после серии модификаций в котором легко получить, например, такую гирлянду:

Сложность операций будет, очевидно, линейной (т.е. O(N)). Поддержание минимальной высоты дерева (что и даёт заветный логарифм) — задача балансировки, суть которой в переупорядочивании узлов без нарушения свойств BST.

И так, со структурой данных определились. Но самобалансирующиеся бинарные деревья поиска бывают разными. Наиболее известны AVL-дерево и гвоздь программы — красно-чёрное (red-black tree, RBT). На этом месте я должен был сказать о проведённом исследовании плюсов и минусов каждого из вариантов, но что-то пошло не так. Про красно-чёрное дерево и его свойства я уже знал, а потому выбор пал на него. Профессионально, не так ли?

Но писать статьи это не только делиться знаниями. Во время работы над текстом возникает желание глубже разобраться в теме. Было бы совсем глупо даже сейчас не задаться вопросом, какая же структура данных лучше. Краткий пересказ различных материалов таков: в целом, они одинаковы. В конце концов, сложность у обоих O(logN) для всех операций. Нюансы, конечно, присутствуют:

  • AVL чуть быстрее при поиске данных ввиду меньшей высоты дерева;

  • RBT выигрывает в скорости удаления из-за меньшего количества вращений при балансировке (AVL-дереву требуется O(logN) таковых в худшем случае по сравнению с O(1) у красно-чёрного).

Мне повезло, потому что операции изменения дерева в моём случае важнее. Поиск происходит нечасто: при прыжках по таймлайну воспроизводимых данных. В этом случае, конечно, потребуется найти событие, с которого должно начаться проигрывание в новой позиции.

Статья призвана дать исчерпывающие листинги кода с реализацией красно-чёрного дерева, как основных операций, так и полезных вспомогательных. И совсем скоро вы увидите эти поля моноширинного текста.

“А почему бы не взять уже готовый код из Интернета?” — может возникнуть вопрос. Признаюсь, у меня, как типичного представителя профессии, это было первым желанием. Но сказать легче, чем сделать. Результаты поиска по фразе “red black tree c#”:

“Но наверняка же есть библиотеки с готовыми классами?” — снова спросите вы. Отвечаю:

  1. Возможно, мои навыки поиска плохи, но я не обнаружил заслуживающую доверия библиотеку в NuGet, где красно-чёрное дерево удовлетворяло бы моим требованиям (см. следующий раздел).

  2. В своём проекте я намеренно избегаю сторонних зависимостей. Тянуть целую библиотеку ради одного класса или метода не всегда разумно.

  3. Мне требовалось реализовать некоторые дополнительные методы, требующие доступа ко внутренностям дерева.

Построение

Как в моём понимании должен выглядеть класс, представляющий красно-чёрное дерево:

  1. Ключ и значение разные сущности. Если в качестве значения используется что-то сложнее int, то почти наверняка само значение не будет ключом.

  2. Класс должен быть обобщённым (дженериком). Не нужно строить догадок по поводу ключа и значения, или использовать всеядный object (вряд ли вам нужен боксинг).

  3. Ключи могут повторяться. Пример: группа нот в MIDI-файле, которые составляют аккорд. Выходит, что события старта таких нот (= значения) имеют одинаковое время (= ключ).

Последний пункт наиболее интересен, ибо влияет на финальную структуру дерева. Изначально я решил не мудрить: пусть узлы с одинаковыми ключами всегда идут вправо. Противоречие — слово, приходящее мне на ум сейчас. Ведь по такой логике дерево из одинаковых объектов будет содержать только правые узлы, что меняет сложность операций в сторону линейной. Если же провести балансировку, то нарушится принцип построения.

Справедливости ради, этот подход жизнеспособен (я проверял). Но некоторые описываемые далее операции становятся чуть сложнее. Я остановился на том, что ключи в узлах дерева всё-таки должны быть уникальными. При этом хранить данные с повторяющимися идентификаторами нужно уметь. Решение: внутри узла размещать не одно значение, а коллекцию оных. Как именно это будет устроено — решать вам. Я выбрал LinkedList<>.

Чтобы не запутаться в дальнейшем повествовании и избежать многословности, примем такую терминологию:

  • дерево состоит из узлов (tree node), имеющих ключ;

  • узел содержит список элементов (node element), в нашем случае каждый элемент это LinkedListNode<>;

  • в элементе хранится значение (value), т.е. объект данных, которые мы помещаем в дерево.

Перед тем, как начать программировать, дисклеймер: код может быть (и будет) где-то неидеальным, где-то не использующим возможности новых версий C#, где-то не соответствующий вашим профессиональным вкусам. Пример неоднозначного решения — способ представления цвета узла. Правила хорошего тона подсказывают, что нужно определить enum с двумя значениями (Red и Black) и использовать его для свойства Color в узле. В первой итерации я так и сделал. Но потом подумал — цвет ведь не более чем метафора для атрибута, нужного процедуре балансировки дерева. И убрал enum, а свойство Color заменил на булево IsRed. Бритва Оккама в действии.

Что ж, вперёд! Первым делом опишем узел:

public sealed class RedBlackTreeNode<TKey, TValue>
    where TKey : IComparable<TKey>
{
    public static readonly RedBlackTreeNode<TKey, TValue> Void =
        new RedBlackTreeNode<TKey, TValue>(default(TKey), null);

    public RedBlackTreeNode(
        TKey key,
        RedBlackTreeNode<TKey, TValue> parent)
    {
        Key = key;
        Parent = parent;
    }

    public TKey Key { get; set; }

    public LinkedList<TValue> Values { get; } = new LinkedList<TValue>();

    public RedBlackTreeNode<TKey, TValue> Left { get; set; }

    public RedBlackTreeNode<TKey, TValue> Right { get; set; }

    public RedBlackTreeNode<TKey, TValue> Parent { get; set; }

    public bool IsRed { get; set; }
}

Можно заметить странную константу (если вы позволите назвать так static readonly поле) — Void. Что это? В качестве пособия по красно-чёрному дереву я выбрал книгу “Introduction to Algorithms” (для русскоязычного читателя сей кирпич на 1000+ страниц знаком под названием “Алгоритмы. Построение и анализ”). Для удобства обработки граничных случаев листьями там всегда является объект nil. Я же просто назвал его Void. В моём коде будут и другие расхождения с книгой. Например, такие выразительные имена переменных, как w, x и y, я решил не сохранять.

Касательно IComparable<TKey>: ключи не могут быть совсем уж любыми. Мы, очевидно, должны уметь их сравнивать для навигации по дереву. Но на этом всё, больше никаких требований ни к ключу, ни к значению не будет и не должно быть.

И так, дерево состоит из узлов, узлы содержат списки элементов, а каждый элемент хранит значение. Но тогда выходит, что узлом мы не можем указать на конкретное значение. Для решения данной проблемы я ввёл понятие координаты:

public sealed class RedBlackTreeCoordinate<TKey, TValue>
    where TKey : IComparable<TKey>
{
    public RedBlackTreeCoordinate(
        RedBlackTreeNode<TKey, TValue> treeNode,
        LinkedListNode<TValue> nodeElement)
    {
        TreeNode = treeNode;
        NodeElement = nodeElement;
    }

    public RedBlackTreeNode<TKey, TValue> TreeNode { get; }

    public LinkedListNode<TValue> NodeElement { get; }

    public TKey Key => TreeNode.Key;

    public TValue Value => NodeElement.Value;
}

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

public class RedBlackTree<TKey, TValue>
    where TKey : IComparable<TKey>
{
    protected RedBlackTreeNode<TKey, TValue> _root =
        RedBlackTreeNode<TKey, TValue>.Void;

    public int Count { get; private set; }

    protected bool IsVoid(
        RedBlackTreeNode<TKey, TValue> node)
    {
        return node == null || node == RedBlackTreeNode<TKey, TValue>.Void;
    }

    private RedBlackTreeNode<TKey, TValue> NodeOrNull(
        RedBlackTreeNode<TKey, TValue> node)
    {
        return IsVoid(node) ? null : node;
    }
}

Теперь можно реализовать добавление данных:

public RedBlackTreeCoordinate<TKey, TValue> Add(TKey key, TValue value)
{
    var currentNode = _root;
    var lastNode = RedBlackTreeNode<TKey, TValue>.Void;

    while (!IsVoid(currentNode))
    {
        lastNode = currentNode;

        var compareResult = key.CompareTo(currentNode.Key);
        if (compareResult < 0)
            currentNode = currentNode.Left;
        else if (compareResult > 0)
            currentNode = currentNode.Right;
        else
        {
            Count++;
            var coordinateOnExistingNode = new RedBlackTreeCoordinate<TKey, TValue>(
                currentNode, currentNode.Values.AddLast(value));
            return coordinateOnExistingNode;
        }
    }

    var newNode = new RedBlackTreeNode<TKey, TValue>(key, lastNode);
    var result = new RedBlackTreeCoordinate<TKey, TValue>(
        newNode,
        newNode.Values.AddLast(value));

    if (IsVoid(lastNode))
        _root = newNode;
    else if (key.CompareTo(lastNode.Key) < 0)
        lastNode.Left = newNode;
    else
        lastNode.Right = newNode;

    newNode.Left = RedBlackTreeNode<TKey, TValue>.Void;
    newNode.Right = RedBlackTreeNode<TKey, TValue>.Void;
    newNode.IsRed = true;

    InsertFixup(newNode);

    Count++;
    return result;
}

Метод Add возвращает координату, указывающую на добавленное значение. При наличии в дереве узла с ключом key просто вставляем значение в его список элементов. Если же ключ в дереве пока отсутствует, создаём новый узел с переданным значением, и добавляем его в дерево.

Но прямо сейчас код выше по сути ничем не отличается от такового для обыкновенного BST. Да и компилятор не обрадуется, ведь InsertFixup не реализован. Это и есть та самая балансировка:

private void InsertFixup(RedBlackTreeNode<TKey, TValue> node)
{
    RedBlackTreeNode<TKey, TValue> parent;

    while ((parent = node.Parent).IsRed)
    {
        var grandParent = parent.Parent;

        if (parent == grandParent.Left)
        {
            var uncle = grandParent.Right;
            if (uncle.IsRed)
            {
                parent.IsRed = false;
                uncle.IsRed = false;
                grandParent.IsRed = true;
                node = grandParent;
            }
            else
            {
                if (node == parent.Right)
                {
                    node = parent;
                    LeftRotate(node);
                    parent = node.Parent;
                    grandParent = parent.Parent;
                }

                parent.IsRed = false;
                grandParent.IsRed = true;
                RightRotate(grandParent);
            }
        }
        else
        {
            var uncle = grandParent.Left;
            if (uncle.IsRed)
            {
                parent.IsRed = false;
                uncle.IsRed = false;
                grandParent.IsRed = true;
                node = grandParent;
            }
            else
            {
                if (node == parent.Left)
                {
                    node = parent;
                    RightRotate(node);
                    parent = node.Parent;
                    grandParent = parent.Parent;
                }

                parent.IsRed = false;
                grandParent.IsRed = true;
                LeftRotate(grandParent);
            }
        }
    }

    _root.IsRed = false;
}

Здесь возникают операции вращения — сердце балансировки, как после добавления, так и после удаления данных. Вращение влево:

private void LeftRotate(RedBlackTreeNode<TKey, TValue> node)
{
    var rightChild = node.Right;
    var leftGrandchild = rightChild.Left;

    node.Right = leftGrandchild;
    if (!IsVoid(leftGrandchild))
        leftGrandchild.Parent = node;
            
    var parent = node.Parent;
    rightChild.Parent = parent;

    if (IsVoid(parent))
        _root = rightChild;
    else if (node == parent.Left)
        parent.Left = rightChild;
    else
        parent.Right = rightChild;

    rightChild.Left = node;
    node.Parent = rightChild;
}

И вращение вправо (код отличается от предыдущего заменой Left на Right и наоборот):

private void RightRotate(RedBlackTreeNode<TKey, TValue> node)
{
    var leftChild = node.Left;
    var rightGrandChild = leftChild.Right;

    node.Left = rightGrandChild;
    if (!IsVoid(rightGrandChild))
        rightGrandChild.Parent = node;

    var parent = node.Parent;
    leftChild.Parent = parent;

    if (IsVoid(parent))
        _root = leftChild;
    else if (node == parent.Right)
        parent.Right = leftChild;
    else
        parent.Left = leftChild;

    leftChild.Right = node;
    node.Parent = leftChild;
}

Данные добавлены, пора научиться их удалять. Кстати, вы замечали свойство List у LinkedListNode<>? Каждый узел содержит ссылку на связный список, которому принадлежит. Довольно полезная вещь, если вы не хотите случайно удалить объект не из той коллекции. Мы пойдём тем же путём. Добавим свойство в RedBlackTreeNode<>:

public RedBlackTree<TKey, TValue> Tree { get; set; }

Кроме того, нам пригодится несколько вспомогательных методов:

public RedBlackTreeCoordinate<TKey, TValue> GetMinimumCoordinate()
{
    return GetMinimumCoordinate(_root);
}

public RedBlackTreeCoordinate<TKey, TValue> GetMinimumCoordinate(
    RedBlackTreeNode<TKey, TValue> node)
{
    while (!IsVoid(node?.Left))
        node = node.Left;

    return !IsVoid(node)
        ? new RedBlackTreeCoordinate<TKey, TValue>(node, node.Values.First)
        : null;
}

public RedBlackTreeCoordinate<TKey, TValue> GetMaximumCoordinate()
{
    return GetMaximumCoordinate(_root);
}

public RedBlackTreeCoordinate<TKey, TValue> GetMaximumCoordinate(
    RedBlackTreeNode<TKey, TValue> node)
{
    while (!IsVoid(node?.Right))
        node = node.Right;

    return !IsVoid(node)
        ? new RedBlackTreeCoordinate<TKey, TValue>(node, node.Values.Last)
        : null;
}

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

public bool Remove(RedBlackTreeCoordinate<TKey, TValue> coordinate)
{
    if (coordinate == null || coordinate.NodeElement.List == null)
        return false;

    var node = coordinate.TreeNode;
    if (IsVoid(node) || node.Tree != this)
        return false;

    node.Values.Remove(coordinate.NodeElement);
    if (node.Values.Count > 0)
        return true;

    return Remove(node);
}

Первым делом мы изымаем значение из списка элементов узла. Если после этого в списке остались данные, выходим из метода. Пустой же узел подлежит удалению:

public bool Remove(RedBlackTreeNode<TKey, TValue> node)
{
    if (IsVoid(node) || node.Tree != this)
        return false;

    RedBlackTreeNode<TKey, TValue> child = null;
    var nextNode = node;
    var isNextNodeRed = nextNode.IsRed;
            
    if (IsVoid(node.Left))
    {
        child = node.Right;
        Transplant(node, node.Right);
    }
    else if (IsVoid(node.Right))
    {
        child = node.Left;
        Transplant(node, node.Left);
    }
    else
    {
        nextNode = GetMinimumCoordinate(node.Right).TreeNode;
        isNextNodeRed = nextNode.IsRed;
        child = nextNode.Right;
                
        if (nextNode != node.Right)
        {
            Transplant(nextNode, nextNode.Right);
            nextNode.Right = node.Right;
            nextNode.Right.Parent = nextNode;
        }
        else
            child.Parent = nextNode;

        Transplant(node, nextNode);
        nextNode.Left = node.Left;
        nextNode.Left.Parent = nextNode;
        nextNode.IsRed = node.IsRed;
    }

    if (!isNextNodeRed)
        RemoveFixup(child);

    node.Tree = null;
    Count--;

    return true;
}

Дабы не видеть ошибок в логах компиляции, нужно реализовать два метода: Transplant и RemoveFixup. Первый выполняет замену одного поддерева на другое:

private void Transplant(
    RedBlackTreeNode<TKey, TValue> oldRoot,
    RedBlackTreeNode<TKey, TValue> newRoot)
{
    var parent = oldRoot.Parent;

    if (IsVoid(parent))
        _root = newRoot;
    else if (oldRoot == parent.Left)
        parent.Left = newRoot;
    else
        parent.Right = newRoot;

    newRoot.Parent = parent;
}

Второй метод — балансировка:

private void RemoveFixup(RedBlackTreeNode<TKey, TValue> node)
{
    while (node != _root && !node.IsRed)
    {
        var parent = node.Parent;

        if (node == parent.Left)
        {
            var sibling = parent.Right;
            if (IsVoid(sibling))
                break;

            if (sibling.IsRed)
            {
                sibling.IsRed = false;
                parent.IsRed = true;
                LeftRotate(parent);
                sibling = parent.Right;
            }

            if (!sibling.Left.IsRed && !sibling.Right.IsRed)
            {
                sibling.IsRed = true;
                node = parent;
            }
            else
            {
                if (!sibling.Right.IsRed)
                {
                    sibling.Left.IsRed = false;
                    sibling.IsRed = true;
                    RightRotate(sibling);
                    sibling = parent.Right;
                }

                sibling.IsRed = parent.IsRed;
                parent.IsRed = false;
                sibling.Right.IsRed = false;
                LeftRotate(parent);
                node = _root;
            }
        }
        else
        {
            var sibling = parent.Left;
            if (IsVoid(sibling))
                break;

            if (sibling.IsRed)
            {
                sibling.IsRed = false;
                parent.IsRed = true;
                RightRotate(parent);
                sibling = parent.Left;
            }

            if (!sibling.Right.IsRed && !sibling.Left.IsRed)
            {
                sibling.IsRed = true;
                node = parent;
            }
            else
            {
                if (!sibling.Left.IsRed)
                {
                    sibling.Right.IsRed = false;
                    sibling.IsRed = true;
                    LeftRotate(sibling);
                    sibling = parent.Left;
                }

                sibling.IsRed = parent.IsRed;
                parent.IsRed = false;
                sibling.Left.IsRed = false;
                RightRotate(parent);
                node = _root;
            }
        }
    }

    node.IsRed = false;
}

Добавлять и удалять данные научились, осталась одна фундаментальная операция: поиск. Чтобы получить координату по заданным ключу и значению, реализуем несколько небольших методов:

public RedBlackTreeCoordinate<TKey, TValue> GetCoordinate(
    TKey key,
    TValue value)
{
    return GetCoordinatesByKey(key).FirstOrDefault(c => c.Value.Equals(value));
}

public IEnumerable<RedBlackTreeCoordinate<TKey, TValue>> GetCoordinatesByKey(
    TKey key)
{
    var node = GetNodeByKey(key);
    if (IsVoid(node))
        yield break;

    for (var element = node.Values.First; element != null; element = element.Next)
    {
        yield return new RedBlackTreeCoordinate<TKey, TValue>(node, element);
    }
}

public RedBlackTreeNode<TKey, TValue> GetNodeByKey(TKey key)
{
    var node = _root;

    while (!IsVoid(node) && key.CompareTo(node.Key) != 0)
    {
        if (key.CompareTo(node.Key) < 0)
            node = node.Left;
        else
            node = node.Right;
    }

    return NodeOrNull(node);
}

Тут стоит сделать неприятное замечание. Да, в среднем случае сложность поиска логарифмическая, но что будет при наличии большого числа значений с одинаковыми ключами? Коль мы ищем объект в узле последовательно, то увы, сложность окажется линейной. Но это худший случай.

Можно, наверное, прийти как-то к логарифму, но быстро решение не придумалось. Хранить значения в мини-дереве с хеш-кодом в качестве ключа тоже не вариант: объекты ведь могут быть мутабельными (изменяемыми). Да и устройство узла станет значительно сложнее. В общем, я посчитал, что игра не стоит свеч. Возможно, вы сможете подсказать алгоритм.

И так, основные операции реализованы. Но этого мало. Какая коллекция без конструктора, принимающего набор элементов? Добавим такой:

public RedBlackTree()
{
}

public RedBlackTree(IEnumerable<TValue> values, Func<TValue, TKey> keySelector)
{
    foreach (var v in values)
    {
        Add(keySelector(v), v);
    }
}

Пара ремарок:

  1. Если вы заранее знаете, что передаваемая коллекция отсортирована, можно оптимизировать создание исходного дерева: первая половина будет левым поддеревом, вторая — правым, и повторяем процедуру для поддеревьев. Останется только правильно “раскрасить” узлы и не забыть про группировку объектов с одинаковыми ключами.

  2. Я передаю не сами ключи, а функцию-селектор, которая по значению отдаёт его ключ.

Возвращаясь к воспроизведению данных в DryWetMIDI: а как перейти к следующему событию, если пришло время его проигрывать? “Указатель” на текущее событие это как раз координата в дереве. Получить следующую в хронологическом порядке помогает метод GetNextCoordinate:

public RedBlackTreeCoordinate<TKey, TValue> GetNextCoordinate(
    RedBlackTreeCoordinate<TKey, TValue> coordinate)
{
    if (coordinate == null || IsVoid(coordinate.TreeNode))
        return null;

    var nextElement = coordinate.NodeElement.Next;
    if (nextElement != null)
        return new RedBlackTreeCoordinate<TKey, TValue>(
            coordinate.TreeNode,
            nextElement);

    var node = coordinate.TreeNode;

    var right = node.Right;
    if (!IsVoid(right))
        return GetMinimumCoordinate(right);

    var nextNode = node.Parent;

    while (!IsVoid(nextNode))
    {
        if (node == nextNode.Left)
            return new RedBlackTreeCoordinate<TKey, TValue>(
                nextNode,
                nextNode.Values.First);

        node = nextNode;
        nextNode = node.Parent;
    }

    return IsVoid(nextNode)
        ? null
        : new RedBlackTreeCoordinate<TKey, TValue>(nextNode, nextNode.Values.First);
}

И метод-антипод:

public RedBlackTreeCoordinate<TKey, TValue> GetPreviousCoordinate(
    RedBlackTreeCoordinate<TKey, TValue> coordinate)
{
    if (coordinate == null || IsVoid(coordinate.TreeNode))
        return null;

    var previousElement = coordinate.NodeElement.Previous;
    if (previousElement != null)
        return new RedBlackTreeCoordinate<TKey, TValue>(
            coordinate.TreeNode,
            previousElement);

    var node = coordinate.TreeNode;

    var left = node.Left;
    if (!IsVoid(left))
        return GetMaximumCoordinate(left);

    var previousNode = node.Parent;

    while (!IsVoid(previousNode))
    {
        if (node == previousNode.Right)
            return new RedBlackTreeCoordinate<TKey, TValue>(
                previousNode,
                previousNode.Values.Last);

        node = previousNode;
        previousNode = node.Parent;
    }

    return IsVoid(previousNode)
        ? null
        : new RedBlackTreeCoordinate<TKey, TValue>(
            previousNode,
            previousNode.Values.Last);
}

Можем даже получить вообще все координаты (разумеется, через IEnumerable<>, побережём память):

public IEnumerable<RedBlackTreeCoordinate<TKey, TValue>> GetAllCoordinates()
{
    var coordinate = GetMinimumCoordinate();
    if (coordinate == null || IsVoid(coordinate.TreeNode))
        yield break;

    do
    {
        yield return coordinate;
    }
    while ((coordinate = GetNextCoordinate(coordinate)) != null);
}

Этот метод открывает нам двери в общество полноценных коллекций — осталось только реализовать IEnumerable<>:

public class RedBlackTree<TKey, TValue> : IEnumerable<TValue>

...

public IEnumerator<TValue> GetEnumerator()
{
    foreach (var coordinate in GetAllCoordinates())
    {
        yield return coordinate.Value;
    }
}

IEnumerator IEnumerable.GetEnumerator()
{
    return GetEnumerator();
}

Красно-чёрное дерево готово к использованию.

Дерево интервалов

Но на этом моё взаимодействие с флорой алгоритмического мира не закончилось. В DryWetMIDI пользователь может вызвать, например, метод MoveToTime или MoveToNextSnapPoint и переместиться в новую точку воспроизведения. При этом по умолчанию библиотека вычисляет, какие ноты находятся в новой позиции. Так как ноты характеризуются началом и концом, задача сводится к нахождению всех интервалов, содержащих заданную точку.

Здесь моих знаний оказалось недостаточно. Как выполнить эффективный (не перебором) поиск отрезков я не знал. И снова всё сделали до нас — великие умы придумали дерево интервалов. Что самое замечательное, его можно построить на основе красно-чёрного.

Идея следующая:

  • значения являются интервалами;

  • каждый узел помимо значений хранит максимум среди концов интервалов текущих и дочерних значений; назовём этот атрибут границей;

  • ключом является начало интервала.

От слов к делу. Само дерево объявим так:

public sealed class IntervalTree<TKey, TValue> : RedBlackTree<TKey, TValue>
    where TKey : IComparable<TKey>
    where TValue : IInterval<TKey>
{
}

Структура не всеядная, значения должны быть интервалами:

public interface IInterval<TEndpoint>
{
    TEndpoint Start { get; }

    TEndpoint End { get; }
}

Согласно построению нам нужно добавить в узел границу. Я не стал возводить сложные иерархии наследования, определяя отдельный класс для узла интервального дерева, и просто снабдил RedBlackTreeNode<> свойством Data:

public TKey Data { get; set; }

Получилось не совсем красиво: название подразумевает всё что угодно, а мы ограничиваем допустимые значения типом TKey. Но для моих нужд это не было проблемой.

Стоит отметить, что методы добавления и удаления данных передаются дереву интервалов по наследству от красно-чёрного без изменений. Поэтому остаётся лишь реализовать поиск. Зная границы узлов (или, иначе говоря, поддеревьев), сделать это не составит труда:

public IEnumerable<RedBlackTreeCoordinate<TKey, TValue>> Search(TKey point)
{
    var queue = new Queue<RedBlackTreeNode<TKey, TValue>>();
    queue.Enqueue(_root);

    while (queue.Count > 0)
    {
        var node = queue.Dequeue();

        if (IsVoid(node) || node.Tree != this)
            continue;

        if (point.CompareTo(node.Data) >= 0)
            continue;

        queue.Enqueue(node.Left);

        for (var element = node.Values.First; element != null; element = element.Next)
        {
            var interval = element.Value;
            if (interval.Start.CompareTo(point) < 0 &&
                interval.End.CompareTo(point) > 0)
                yield return new RedBlackTreeCoordinate<TKey, TValue>(node, element);
        }

        if (point.CompareTo(node.Key) <= 0)
            continue;

        queue.Enqueue(node.Right);
    }
}

Но как происходит установка этого самого свойства Data? Начнём с простого. Напишем метод, обновляющий границу в заданном узле:

public bool UpdateMax(RedBlackTreeNode<TKey, TValue> node)
{
    if (node.Values.Count == 0)
        return false;

    var result = node.Values.First.Value.End;

    foreach (var value in node.Values)
    {
        if (value.End.CompareTo(result) > 0)
            result = value.End;
    }

    var left = node.Left;
    if (!IsVoid(left))
    {
        var leftMax = left.Data;
        if (leftMax.CompareTo(result) > 0)
            result = leftMax;
    }

    var right = node.Right;
    if (!IsVoid(right))
    {
        var rightMax = right.Data;
        if (rightMax.CompareTo(result) > 0)
            result = rightMax;
    }

    if (node.Data.Equals(result))
        return false;

    node.Data = result;
    return true;
}

Также понадобится метод, который запускает обновление свойства Data вверх по дереву. Ведь если значения в узле поменялись, вероятно, нужно обновить границу в его родителе, и в родителе родителя и т.д.

public void UpdateMaxUp(RedBlackTreeNode<TKey, TValue> node)
{
    while (true)
    {
        var parent = node.Parent;
        if (IsVoid(parent) || !UpdateMax(parent))
            break;

        node = parent;
    }
}

Но в каком месте происходит вызов методов UpdateMax и UpdateMaxUp? Что может привести к необходимости пересчитывать границу?

В дереве есть только две операции, модифицирующие его: вставка и удаление. Начнём с первой. Какие конкретно инструкции во время добавления данных могут привести к изменению границ узлов? Прежде всего — вставка нового значения. Добавим в RedBlackTree<> пустой виртуальный метод OnValueAdded:

protected virtual void OnValueAdded(
    RedBlackTreeCoordinate<TKey, TValue> coordinate,
    TValue value)
{
}

И вызовем его внутри Add. Во-первых, тут:

OnValueAdded(coordinateOnExistingNode, value);
return coordinateOnExistingNode;

Во-вторых, здесь:

OnValueAdded(result, value);
InsertFixup(newNode);

Реализация внутри IntervalTree<>:

protected override void OnValueAdded(
    RedBlackTreeCoordinate<TKey, TValue> coordinate,
    TValue value)
{
    base.OnValueAdded(coordinate, value);

    var end = value.End;
    if (!UpdateMaxByNewValue(coordinate.TreeNode, end))
        return;

    UpdateMaxUp(coordinate.TreeNode);
}

При добавлении легко понять, нужно ли провести каскад обновлений границы. Такую проверку (и обновление границы текущего узла) выполняет метод UpdateMaxByNewValue:

private bool UpdateMaxByNewValue(RedBlackTreeNode<TKey, TValue> node, TKey end)
{
    var data = node.Data;
    if (data == null)
        node.Data = end;
    else if (data.CompareTo(end) < 0)
        node.Data = end;
    else
        return false;

    return true;
}

Но есть куда более интересное место, где происходят изменения узлов: балансировка. Я давно не продумывал алгоритмы на бумажке, но вращения в красно-чёрном дереве заставили это сделать.

Проведя какое-то время за рисованием палочек и кружочков, я пришёл к методу внутри RedBlackTree<>:

protected virtual void OnRotated(
    RedBlackTreeNode<TKey, TValue> bottomNode,
    RedBlackTreeNode<TKey, TValue> topNode)
{
}

Его я вызываю в самом  конце методов LeftRotate и RightRotate:

OnRotated(node, rightChild);

и

OnRotated(node, leftChild);

соответственно.

Реализация OnRotated в дереве интервалов:

protected override void OnRotated(
    RedBlackTreeNode<TKey, TValue> bottomNode,
    RedBlackTreeNode<TKey, TValue> topNode)
{
    base.OnRotated(bottomNode, topNode);

    UpdateMax(bottomNode);

    if (bottomNode.Data.CompareTo(topNode.Data) > 0)
        topNode.Data = bottomNode.Data;
}

С добавлением данных разобрались, осталось удаление. По аналогии с OnValueAdded объявим в красно-чёрном дереве метод OnValueRemoved:

protected virtual void OnValueRemoved(
    RedBlackTreeNode<TKey, TValue> node)
{
}

Вызываем здесь:

node.Values.Remove(coordinate.NodeElement);
if (node.Values.Count > 0)
{
    OnValueRemoved(node);
    return true;
}

Реализация в IntervalTree<>:

protected override void OnValueRemoved(RedBlackTreeNode<TKey, TValue> node)
{
    base.OnValueRemoved(node);

    if (UpdateMax(node))
        UpdateMaxUp(node);
}

Как вы помните, удаление выполняет смену одного поддерева на другое. Потратив ещё одну страницу блокнота на понимание, что при этом происходит с деревом, в RedBlackTree<> появился метод OnTransplanted:

protected virtual void OnTransplanted(RedBlackTreeNode<TKey, TValue> node)
{
}

Вызвать его нужно всего один раз, вот тут:

OnTransplanted(nextNode);

if (!isNextNodeRed)
    RemoveFixup(child);

Интервальное дерево просто запускает каскад обновления границы:

protected override void OnTransplanted(RedBlackTreeNode<TKey, TValue> node)
{
    base.OnTransplanted(node);

    UpdateMax(node);
    UpdateMaxUp(node);
}

Про балансировку после удаления можно не думать: зиждется она, как и при добавлении, на вращениях, а о них мы уже позаботились.

Дерево интервалов готово. На самом деле, строить его можно по-разному. Но расширение красно-чёрного, которое уже реализовано, видится наиболее простым способом.

Заключение

Наверное, вы заметили, что многие методы внутри RedBlackTree<T> не являются спецификой красно-чёрного дерева. Они применимы к любому бинарному дереву поиска. Адепты красивой архитектуры сделают класс BinaryTree<>, от которого будет наследоваться RedBlackTree<>. Я же считаю, что в случае возникновения такой необходимости, сделать это не составит труда. А прямо сейчас вводить лишние классы смысла нет.

Как было сказано ранее, мне не удалось найти достойную библиотеку с реализацией красно-чёрного дерева. Возможно, код, представленный в этой статье зажжёт в вас искру для её создания. Но если таковая всё же существует, буду рад узнать.

Как и обещал, закончу статью ссылками на файлы с готовым кодом:

Комментарии (16)


  1. Dhwtj
    20.06.2025 16:36

    Странно, что нет библиотеки нормальной

    Шарп богат всякими прикладными библиотеками

    А в других языках пробовали искать а потом подключить как-то?


    1. Melanchall Автор
      20.06.2025 16:36

      Тоже был удивлён отсутствием либы. В других языках не искал.

      В конце концов я не жалею о проделанной работе, для реализации дерева интервалов всё равно потребовалось бы вникать в работу красно-чёрного. Ну и сам факт, что запрограммировал что-то интересное (и невероятно полезное для моего проекта), радует.


  1. Jijiki
    20.06.2025 16:36

    спасибо интересно очень


  1. Alexandroppolus
    20.06.2025 16:36

    В стандартной библиотеке есть SortedSet, у него под капотом сабж

    https://github.com/dotnet/runtime/blob/5535e31a712343a63f5d7d796cd874e563e5ac14/src/libraries/System.Collections/src/System/Collections/Generic/SortedSet.cs


    1. Melanchall Автор
      20.06.2025 16:36

      Да, а ещё SortedDictionary. Вопрос только в том, как данные классы удовлетворяют выдвинутым мной в статье требованиям? Могу ли я добавить объекты с повторяющимися ключами? Могу ли взять ссылку на конкретный узел, чтобы реализовать нужные мне вещи? А дерево интервалов как над ними построить?

      Что SortedSet, что SortedDictionary попадали в поле моего зрения, вот только я с ними ничего сделать не смогу в рамках своих хотелок.

      К сожалению, кажется, что статья не была прочитана перед написанием комментария.


      1. black_warlock_iv
        20.06.2025 16:36

        Могу ли я добавить объекты с повторяющимися ключами?

        Конечно, так же, как вы сделали в статье — использовать в качестве значения LinkedList. Кстати, зря вы выбрали LinkedList, просто List на современном железе быстрее практически во всех сценариях, тем более в системе со сборкой мусора.

        А дерево интервалов как над ними построить?

        Так же.


        1. Melanchall Автор
          20.06.2025 16:36

          Всё-таки нельзя говорить "структура A быстрее структуры B", не сказав, о какой операции речь. Взятие элемента по индексу? Да, у List это O(1), у LinkedListO(N). Добавление и удаление? У LinkedList это O(1) всегда, но у List будет O(N) в худшем случае.

          Но если вы читали статью, у меня нет операций взятия по индексу, а вот добавление и удаление как раз активно используются. Связный список в этих сценариях хорош тем, что просто меняет пару указателей, а вот списку (List, который на основе массива) нужно ещё периодически выделять память, двигать и копировать элементы.

          Прелесть нотации "O" большое в том, что мне даже не нужно бенчмарки запускать для понимания скорости. Но я всё же их сделал:

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

          Про построение дерева интервалов не понял. Как так же его построить? Стандартные классы .NET предоставляют какие-то коллбэки на добавление и удаление данных, где я могу запустить обновление границ узлов? А саму груницу узлов где повесить? А доступ к узлам вообще есть?


          1. withkittens
            20.06.2025 16:36

            Спойлер: там всё, как и ожидается. Связный список для добавления и удаления лучше.

            У вас там разница меньше статистической погрешности.

            Кроме того, при увеличении N заметна разница в потреблении памяти обеими структурами (LinkedList делает меньше выделений памяти).

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


            1. Melanchall Автор
              20.06.2025 16:36

              У вас там разница меньше статистической погрешности.

              Пусть так. Можно ли при этом говорить, что List быстрее?

              Так вы создали для листа худший случай

              Верно. Я библиотеку не для себя делаю, а для широкого круга пользователей. А потому всегда должен смотреть на худший случай тоже.

              когда вы в него добавляете новый элемент, он у вас забит под завязку, и листу приходится реаллоцироваться

              Верно, но не совсем. Когда List "забит" под завязку, то в большинстве случаев реаллокаций не будет, потому что List с запасом выделяет внутренний массив (если не ошибаюсь, в случае нехватки он делает массив двойной длины). Но при добавлении в середину он будет всегда проводить сдвиг всех элементов после добавляемого.

              Но связному списку реаллоцироваться не нужно. Память он выделяет только под узлы для элементов. При этом да, не спорю, массив (который внутри List'а) при определённых обстоятельствах займёт меньше памяти, чем LinkedList.

              Если нужные мне операции выполняются по скорости не хуже (в пределах статистической погрешности), чем List, но при этом в худшем случае List проигрывает, зачем использовать List? Я LinkedList не просто так выбрал, а исходя из сложности нужных мне операций, взвесив все за и против касательно List. Но я согласен, что нужно было все эти рассуждения включить в статью, дабы выбор был понятен.

              Возвращаясь к дереву: все мои вопросы про доступ к узлам остаются без ответа. Потому что такого доступа стандартные классы не предоставят.


              1. vvdev
                20.06.2025 16:36

                Важно, что LinkedList выделяет в куче индивидуальные ноды, тогда так List - массивы (с запасом).

                Если списки не настолько большие, чтобы перейти в LOH, то начиная с определённого момента затраты на GC и "утрамбовку" памяти для списка могут стать гораздо дешевле, чем для связного списка.

                Не знаю как в вашем случае, а для какого-нибудь хайлоада/хай срупута я бы предпочел список (если быть до конца точным - структуру, основанную на массивах и не допускающую попадания в ЛОХ)


                1. Melanchall Автор
                  20.06.2025 16:36

                  Любопытное замечание, спасибо.


  1. imlex
    20.06.2025 16:36

    Спасибо за статью. Хотел уточнить про добавление в дерево элементов с одинаковым ключем - почему нельзя было бы использовать сложный ключ - что-то типа время+номер трека?


    1. Melanchall Автор
      20.06.2025 16:36

      Интересное предложение. Теоретически, наверное, так сработает. Только второй компонент не номер трека, а noteNumber + noteChannel. Потому что ноту однозначно идентифицирует её номер и канал.

      Но две совершенно одинаковые ноты могут оказаться в одной и той же точке во времени. Это не запрещено стандартом, и, хотя ситуация странная, может возникнуть. И тогда мы снова получим дублированные ключи. Да и сравнивать такие ключи будет сложнее. Что значит A > B? Что у A время больше ИЛИ номер ноты больше ИЛИ номер канала больше?

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

      Всё же более понятным и однозначным вариантом выглядит простой ключ и набор значений в узле.


  1. Zara6502
    20.06.2025 16:36

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


    1. Melanchall Автор
      20.06.2025 16:36

      Так вы просто забирайте код из статьи ;-)


      1. Zara6502
        20.06.2025 16:36

        посмотрим, но я не хотел делать красно-черное дерево, я в целом не понял смысла в делении на красное и черное, плюс балансировка делается изменением указателя, то есть для чего нужно поле с явным указанием на красное или черное я так и не сообразил. Ваш код мне будет подспорьем наверное в том как оформить сам класс и методы, но содержимое будет другим. Вообще я еще думаю делать или нет доступ по индексу, что чтение было O(1).