Красно-чёрное дерево: полная реализация на C#

от автора

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


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

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

Оглавление

Зачем?

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

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

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

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

Как и у прошлых моих статей, у этой ноги растут из работы над библиотекой 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<>. Я же считаю, что в случае возникновения такой необходимости, сделать это не составит труда. А прямо сейчас вводить лишние классы смысла нет.

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/920390/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *