Бинарное дерево поиска.

Немного о бинарном дереве поиска

Бинарное дерево — это иерархическая структура данных, в которой каждый узел имеет значение (оно же является в данном случае и ключом) и ссылки на левого и правого потомка. Узел, находящийся на самом верхнем уровне (не являющийся чьим либо потомком) называется корнем. Узлы, не имеющие потомков (оба потомка которых равны NULL) называются листьями










История

>

Полная теория по бинарному дереву

Бинарное дерево поиска похоже на дерево из примера выше, но строится по определенным правилам:

  • У каждого узла не более двух детей.
  • Любое значение меньше значения узла становится левым ребенком или ребенком левого ребенка.
  • Любое значение больше или равное значению узла становится правым ребенком или ребенком правого ребенка.

Давайте посмотрим на дерево, построенное по этим правилам:

png
Рис. 1 Бинарное дерево

Обратите внимание, как указанные ограничения влияют на структуру дерева. Каждое значение слева от корня (8) меньше восьми, каждое значение справа — больше либо равно корню. Это правило применимо к любому узлу дерева. Учитывая это, давайте представим, как можно построить такое дерево. Поскольку вначале дерево было пустым, первое добавленное значение — восьмерка — стало его корнем. Мы не знаем точно, в каком порядке добавлялись остальные значения, но можем представить один из возможных путей. Узлы добавляются методом Add, который принимает добавляемое значение.

  1. BinaryTree tree = new BinaryTree();
  2. tree.Add(8);
  3. tree.Add(4);
  4. tree.Add(2);
  5. tree.Add(3);
  6. tree.Add(10);
  7. tree.Add(6);
  8. tree.Add(7);

Рассмотрим подробнее первые шаги.

В первую очередь добавляется 8. Это значение становится корнем дерева. Затем мы добавляем 4. Поскольку 4 меньше 8, мы кладем ее в левого ребенка, согласно правилу 2. Поскольку у узла с восьмеркой нет детей слева, 4 становится единственным левым ребенком.

После этого мы добавляем 2. 2 меньше 8, поэтому идем налево. Так как слева уже есть значение, сравниваем его со вставляемым. 2 меньше 4, а у четверки нет детей слева, поэтому 2 становится левым ребенком 4.

Затем мы добавляем тройку. Она идет левее 8 и 4. Но так как 3 больше, чем 2, она становится правым ребенком 2, согласно третьему правилу.

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

Класс BinaryTreeNode

Класс BinaryTreeNode представляет один узел двоичного дерева. Он содержит ссылки на левое и правое поддеревья (если поддерева нет, ссылка имеет значение null), данные узла и метод IComparable.CompareTo для сравнения узлов. Он пригодится для определения, в какое поддерево должен идти данный узел. Как видите, класс BinaryTreeNode очень простой:

  1. class BinaryTreeNode : IComparable
  2. where TNode : IComparable
  3. {
  4. public BinaryTreeNode(TNode value)
  5. {
  6. Value = value;
  7. }
  8.  
  9. public BinaryTreeNode Left { get; set; }
  10. public BinaryTreeNode Right { get; set; }
  11. public TNode Value { get; private set; }
  12.  
  13. ///
  14. /// Сравнивает текущий узел с данным.
  15. ///
  16. /// Сравнение производится по полю Value.
  17. /// Метод возвращает 1, если значение текущего узла больше,
  18. /// чем переданного методу, -1, если меньше и 0, если они равны
  19. public int CompareTo(TNode other)
  20. {
  21. return Value.CompareTo(other);
  22. }
  23. }

Класс BinaryTree

Класс BinaryTree предоставляет основные методы для манипуляций с данными: вставка элемента (Add), удаление (Remove), метод Contains для проверки, есть ли такое значение в дереве, несколько методов для обхода дерева различными способами, метод Count и Clear.

Кроме того, в классе есть ссылка на корневой узел дерева и поле с общим количеством узлов.

  1. public class BinaryTree : IEnumerable
  2. where T : IComparable
  3. {
  4. private BinaryTreeNode _head;
  5. private int _count;
  6.  
  7. public void Add(T value)
  8. {
  9. throw new NotImplementedException();
  10. }
  11.  
  12. public bool Contains(T value)
  13. {
  14. throw new NotImplementedException();
  15. }
  16.  
  17. public bool Remove(T value)
  18. {
  19. throw new NotImplementedException();
  20. }
  21.  
  22. public void PreOrderTraversal(Action action)
  23. {
  24. throw new NotImplementedException();
  25. }
  26.  
  27. public void PostOrderTraversal(Action action)
  28. {
  29. throw new NotImplementedException();
  30. }
  31.  
  32. public void InOrderTraversal(Action action)
  33. {
  34. throw new NotImplementedException();
  35. }
  36.  
  37. public IEnumerator GetEnumerator()
  38. {
  39. throw new NotImplementedException();
  40. }
  41.  
  42. System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
  43. {
  44. throw new NotImplementedException();
  45. }
  46.  
  47. public void Clear()
  48. {
  49. throw new NotImplementedException();
  50. }
  51.  
  52. public int Count
  53. {
  54. get;
  55. }
  56. }

Метод Add

  • Поведение: Добавляет элемент в дерево на корректную позицию.
  • Сложность: O(log n) в среднем; O(n) в худшем случае.

Добавление узла не представляет особой сложности. Оно становится еще проще, если решать эту задачу рекурсивно. Есть всего два случая, которые надо учесть:

  • Дерево пустое.
  • Дерево не пустое.

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

  1. public void Add(T value)
  2. {
  3. //Случай 1: Если дерево пустое, просто создаем корневой узел.
  4. if (_head == null)
  5. {
  6. _head = new BinaryTreeNode(value);
  7. }
  8. //Случай 2: Дерево не пустое =>
  9. //ищем правильное место для вставки.
  10. else
  11. {
  12. AddTo(_head, value);
  13. }
  14.  
  15. _count++;
  16. }
  17.  
  18. //Рекурсивная ставка.
  19. private void AddTo(BinaryTreeNode node, T value)
  20. {
  21. //Случай 1: Вставляемое значение меньше значения узла
  22. if (value.CompareTo(node.Value) < 0)
  23. {
  24. //Если нет левого поддерева, добавляем значение в левого ребенка,
  25. if (node.Left == null)
  26. {
  27. node.Left = new BinaryTreeNode(value);
  28. }
  29. else
  30. {
  31. //в противном случае повторяем для левого поддерева.
  32. AddTo(node.Left, value);
  33. }
  34. }
  35. //Случай 2: Вставляемое значение больше или равно значению узла.
  36. else
  37. {
  38. //Если нет правого поддерева, добавляем значение в правого ребенка,
  39. if (node.Right == null)
  40. {
  41. node.Right = new BinaryTreeNode(value);
  42. }
  43. else
  44. {
  45. //в противном случае повторяем для правого поддерева.
  46. AddTo(node.Right, value);
  47. }
  48. }
  49. }

Метод Remove

  • Поведение: Удаляет первый узел с заданным значением.
  • Сложность: O(log n) в среднем; O(n) в худшем случае.

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

В целом, алгоритм удаления элемента выглядит так:

  • Найти узел, который надо удалить.
  • Удалить его.

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

Случай 1: У удаляемого узла нет правого ребенка.

png
Рис. 2 Удаление элемена

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

Случай 1: У удаляемого узла нет правого ребенка.

png
Рис. 3 Удаление элемена

Случай 2: У удаляемого узла есть только правый ребенок, у которого, в свою очередь нет левого ребенка.

png
Рис. 4 Удаление элемена

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

png
Рис. 5 Удаление элемена

Случай 3: У удаляемого узла есть первый ребенок, у которого есть левый ребенок.

png
Рис. 6 Удаление элемена

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

Давайте посмотрим, почему это так. Мы знаем о поддереве, начинающемся с удаляемого узла следующее:

  • Все значения справа от него больше или равны значению самого узла.
  • Наименьшее значение правого поддерева — крайнее левое.

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

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

png
Рис. 7 Удаление элемена

Теперь, когда мы знаем, как удалять узлы, посмотрим на код, который реализует этот алгоритм.

Отметим, что метод FindWithParent (см. метод Contains) возвращает найденный узел и его родителя, поскольку мы должны заменить левого или правого ребенка родителя удаляемого узла.

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

  1. public bool Remove(T value)
  2. {
  3. BinaryTreeNode current, parent;
  4.  
  5. // Находим удаляемый узел.
  6. current = FindWithParent(value, out parent);
  7.  
  8. if (current == null)
  9. {
  10. return false;
  11. }
  12.  
  13. _count--;
  14.  
  15. //Случай 1: Если нет детей справа,
  16. //левый ребенок встает на место удаляемого.
  17. if (current.Right == null)
  18. {
  19. if (parent == null)
  20. {
  21. _head = current.Left;
  22. }
  23. else
  24. {
  25. int result = parent.CompareTo(current.Value);
  26. if (result > 0)
  27. {
  28. //Если значение родителя больше текущего,
  29. //левый ребенок текущего узла становится левым ребенком родителя.
  30. parent.Left = current.Left;
  31. }
  32. else if (result < 0) {
  33. //Если значение родителя меньше текущего,
  34. // левый ребенок текущего узла становится правым ребенком родителя.
  35. parent.Right = current.Left;
  36. }
  37. }
  38. }
  39. //Случай 2: Если у правого ребенка нет детей слева
  40. //то он занимает место удаляемого узла.
  41. else if (current.Right.Left == null) {
  42. current.Right.Left = current.Left;
  43. if (parent == null) {
  44. _head = current.Right;
  45. } else {
  46. int result = parent.CompareTo(current.Value);
  47. if (result > 0)
  48. {
  49. //Если значение родителя больше текущего,
  50. //правый ребенок текущего узла становится левым ребенком родителя.
  51. parent.Left = current.Right;
  52. }
  53. else if (result < 0) {
  54. //Если значение родителя меньше текущего,
  55. // правый ребенок текущего узла становится правым ребенком родителя.
  56. parent.Right = current.Right;
  57. }
  58. }
  59. }
  60. //Случай 3: Если у правого ребенка есть дети слева,
  61. //крайний левый ребенок
  62. //из правого поддерева заменяет удаляемый узел.
  63. else {
  64. //Найдем крайний левый узел.
  65. BinaryTreeNode leftmost = current.Right.Left;
  66. BinaryTreeNode leftmostParent = current.Right;
  67. while (leftmost.Left != null) {
  68. leftmostParent = leftmost; leftmost = leftmost.Left;
  69. }
  70. //Левое поддерево родителя становится правым поддеревом
  71. //крайнего левого узла.
  72. leftmostParent.Left = leftmost.Right;
  73. //Левый и правый ребенок текущего узла становится левым
  74. //и правым ребенком крайнего левого. leftmost.
  75. Left = current.Left;
  76. leftmost.Right = current.Right;
  77. if (parent == null) {
  78. _head = leftmost;
  79. } else {
  80. int result = parent.CompareTo(current.Value);
  81. if (result > 0)
  82. {
  83. //Если значение родителя больше текущего,
  84. //крайний левый узел становится левым ребенком родителя.
  85. parent.Left = leftmost;
  86. }
  87. else if (result < 0)
  88. {
  89. //Если значение родителя меньше текущего,
  90. //крайний левый узел становится правым ребенком родителя.
  91. parent.Right = leftmost;
  92. }
  93. }
  94. }
  95.  
  96. return true;
  97. }

Метод Contains

  • Поведение: Возвращает true если значение содержится в дереве. В противном случает возвращает false.
  • Сложность: O(log n) в среднем; O(n) в худшем случае.

Метод Contains выполняется с помощью метода FindWithParent, который проходит по дереву, выполняя в каждом узле следующие шаги:

  • Если текущий узел null, вернуть null.
  • Если значение текущего узла равно искомому, вернуть текущий узел.
  • Если искомое значение меньше значения текущего узла, установить левого ребенка текущим узлом и перейти к шагу 1.
  • В противном случае, установить правого ребенка текущим узлом и перейти к шагу 1.

Поскольку Contains возвращает булево значение, оно определяется сравнением результата выполнения FindWithParent с null. Если FindWithParent вернул непустой узел, Contains возвращает true.

Метод FindWithParent также используется в методе Remove.

  1. public bool Contains(T value)
  2. {
  3. //Поиск узла осуществляется другим методом.
  4. BinaryTreeNode parent;
  5. return FindWithParent(value, out parent) != null;
  6. }
  7.  
  8. //Находит и возвращает первый узел с заданным значением.
  9. //Если значение не найдено, возвращает null.
  10. //Также возвращает родителя найденного узла (или null)
  11. //для использования в методе Remove.
  12. private BinaryTreeNode FindWithParent(T value,
  13. out BinaryTreeNode parent)
  14. {
  15. //Попробуем найти значение в дереве.
  16. BinaryTreeNode current = _head;
  17. parent = null;
  18.  
  19. //До тех пор, пока не нашли...
  20. while (current != null)
  21. {
  22. int result = current.CompareTo(value);
  23.  
  24. if (result > 0)
  25. {
  26. //Если искомое значение меньше, идем налево.
  27. parent = current;
  28. current = current.Left;
  29. }
  30. else if (result < 0)
  31. {
  32. //Если искомое значение больше, идем направо.
  33. parent = current;
  34. current = current.Right;
  35. }
  36. else
  37. {
  38. //Если равны, то останавливаемся
  39. break;
  40. }
  41. }
  42.  
  43. return current;
  44. }