Двоичные деревья: бесконечность под рукой

В этой главе мы узнаем

Введение

Информация в связных списках хранится последовательно — в виде цепочки. В этой главе мы познакомимся с новой структурой данных — двоичным деревом. Идея двоичного дерева чрезвычайно важно для программирования и теории алгоритмов. В пятой главе мы изучили рекурсию — с точки зрения Python так называется ситуация, когда функция вызывает сама себя. Теперь же мы рассмотрим двоичное дерево как рекурсивную структуру данных — потому что двоичное дерево состоит из двоичных деревьев! Для начала убедимся в том, что уже известный нам связный список тоже может быть смоделирован как рекурсивная структура.

В самом деле, каждый узел связного списка — это value, полезная нагрузка, и next — ссылка на первый узел хвостовой части списка, которая сама по себе — тоже список. Список, в отличие от статического массива, можно оперативно увеличивать и сокращать, то есть менять N, количество элементов в нём. В примере 6-1 представлена рекурсивная функция sum_list(), которая вычисляет сумму всего его элементов. Сравним её с обычным суммированием в цикле, sum_iterative().

   1 class Node:
   2   def __init__(self, val, rest=None):
   3     self.value = val
   4     self.next = rest
   5 
   6 def sum_iterative(n):
   7   total = 0                             # 1
   8   while n:
   9     total += n.value                    # 2
  10     n = n.next                          # 3
  11   return total
  12 
  13 def sum_list(n):
  14   if n is None:                         # 4
  15     return 0
  16   return n.value + sum_list(n.next)     # 5
  1. Сумма изначально нулевая.

  2. Добавим поле n.value каждого узла списка n к сумме.

  3. Перейдём к следующему узлу связного списка (или к маркеру конца None).

  4. Основание рекурсии: сумма элементов пустого списка нулевая.

  5. Рекурсивный вызов: сумма элементов пустого списка n — это значение n.value его первого узла плюс сумма элементов хвостового списка.

В цикле while мы сначала полагаем сумму total нулевой, а затем прибавляем к ней значения всех элементов списка. В рекурсивной функции наоборот: нулевое значение суммы пустого списка — это основание рекурсии, то есть то, на чём рекурсивный вызов остановится. Если список n состоит хотя бы из одного узла, происходит рекурсивный вызов, который вычисляет сумму элементов хвостового списка (который начинается с n.next), и к результату прибавляется n.value — это и есть общая сумма.

Список из N узлов рекурсивно раскладывается на первый узел и хвостовой список из N - 1 узлов. Это разложение по определению рекурсивно: хвостовой список — это тоже список, причём меньшего размера. Но критерию эффективной рекурсии наша функция не соответствует, потому что сводит обработку объёма данных размера N (сумму списка из N элементов) к обработке данных размера N - 1, ибо в хвостовом списке всего на один элемент меньше. Давайте вообразим рекурсивную структуру данных, в которой разделение на подструктуры более эффективно. Например, возьмём арифметическое выражение, состоящее только из двухместных операций умножения, вычитания, сложения и деления. Атомарное арифметическое выражение — это некоторое число, составное — это операция над двумя арифметическими выражениями. Например (для простоты заключим все операции в скобки):

Таким образом выражения можно сочетать в любом объёме. На иллюстрации 6-1 представлено арифметическое выражение из семи операций над восемью числами. Такую нелинейную структуру уже нельзя представить в виде списка. Кто когда-нибудь разбирался с генеалогическим семейным древом, легко согласится с тем, что нашу структуру можно назвать деревом разбора.

Иллюстрация 6-1. Дерево разбора арифметического выражения
Иллюстрация 6-1. Дерево разбора арифметического выражения

дети

внуки

правнуки

праправнуки

левая часть

правая часть

Узел с умножением имеет два дочерних узла (с делением и разностью), потомками которых в свою очередь являются четыре «внучатых» узла (один из них — число 4), шесть «правнучатых» и два «праправнучатых» узла.

Арифметическое выражение с иллюстрации 6-1 — это произведение двух других арифметических выражений, в чём, собственно, и выражается его рекурсивность. Чтобы его посчитать, надо сначала вычислить — рекурсивно! — левое выражение; получится 1. Затем рекурсивно же вычислить правое; получится 42. Теперь наконец можно и умножить, так что значение всего выражения — 1 · 42 = 42.

На иллюстрации 6-1 исходное выражение представлено в виде рекурсивной структуры. На её вершине — квадрат с операцией умножения, от которого идут стрелки к левому и правому подвыражениям. Если на вершине подвыражения находится квадрат, то это снова некоторая операция, а если круг — это основание рекурсии, числовое значение, на котором рекурсивный разбор останавливается. В примере 6-2 мы смоделировали такую схему разделения на левое и правое подвыражение в классе Expression.

   1 class Value:                            # 1
   2   def __init__(self, e):
   3     self.value = e
   4 
   5   def __str__(self):
   6     return str(self.value)
   7 
   8   def eval(self):
   9     return self.value
  10 
  11 class Expression:                       # 2
  12   def __init__(self, func, left, right):
  13     self.func = func
  14     self.left = left
  15     self.right = right
  16 
  17   def __str__(self):                    # 3
  18     return f"({self.left} {self.func.__doc__} {self.right}")
  19 
  20   def eval(self):                       # 4
  21     return self.func(self.left.eval(), self.right.eval())
  22 
  23 def add(left, right):                   # 5
  24   """+"""
  25   return left + right

Вычисление выражения — рекурсивная операция, которая останавливается, когда дойдёт до объекта типа Value. Разложим рекурсивное вычисление выражения ((1 + 5) * 9) на стадии, как мы это делали в главе 5. Результат разложения приведён на иллюстрации 6-2.

   1 >>> a = Expression(add, Value(1), Value(5))
   2 >>> m = Expression(mult, a, Value(9))
   3 >>> print(m, '=', m.eval())
   4 ((1 + 5) * 9) = 54
   5 

Чтобы вычислить m, необходимо вызвать два метода .eval(), соответственно, для правого и для левого подвыражений. Каждый из этих вызовов может оказаться рекурсивным. Здесь левое подвыражение, a = (1 + 5), приводит к рекурсии, а правое, 9, — нет. Затем вычисляется и возвращается результат — 54. Становится понятно, чем хорошо рекурсивное представление выражение в виде двоичного дерева Expression, да и сами рекурсивные алгоритмы выглядят коротко и ясно.

Очень важно, чтобы данные, которые мы помещаем в рекурсивную структуру, не ломали её. Вот как легко испортить список:

   1 >>> n = Node(3)
   2 >>> n.next = n           # Внимание! Бездонная пропасть!
   3 >>> print(sum_list(n))
   4 RecursionError: maximum recursion depth exceeded
   5 

Вот мы задали связный список из одного узла, n, но вместо добавления элемента положили в поле .next сам этот узел! Функция sum_list() отлично работает на конченых списках, но здесь нарушена сама структура данных — и sum_list(n) никогда не доберётся до основания рекурсии. Та же история и с Expression . Нарушение структуры данных — это ошибка алгоритма, найти которую помогает аккуратное тестирование.

Иллюстрация 6-2. Пошаговый проход рекурсивного вычисления выражения ((1 + 5) ∗ 9)
Иллюстрация 6-2. Пошаговый проход рекурсивного вычисления выражения ((1 + 5) ∗ 9)

↓Время

Двоичные деревья поиска

Двоичное дерево — прародитель всех рекурсивных структур данных. Двоичное дерево поиска хранит произвольные значения (если их можно сравнивать), предоставляя прежде всего эффективную операцию поиска, а также вставки и удаления.

Если хранить данные в упорядоченном массиве, к ним применим двоичный поиск, вычислительная сложность которого O(log N). Есть ещё немало выгод в том, чтобы хранить данные упорядоченно: например, человеку тоже будет проще искать нужное. Технически может возникнуть трудность с созданием очень большого массива, потому что он должен занимать один непрерывный фрагмент памяти, и неизвестно, может ли это обеспечить операционная система. Но главная неприятность — это произвольная вставка и удаление элемента, причём даже для такого динамического массива, как list в Python:

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

N

Вставка в начало

Добавление в конец

Удаление

Дерево

1,024

0.07

0.004

0.01

0.77

2,048

0.11

0.004

0.02

0.85

4,096

0.20

0.004

0.04

0.93

8,192

0.38

0.004

0.09

1.00

16,384

0.72

0.004

0.19

1.08

32,768

1.42

0.004

0.43

1.15

65,536

2.80

0.004

1.06

1.23

131,072

5.55

0.004

2.11

1.30

262,144

11.06

0.004

4.22

1.39

524,288

22.16

0.004

8.40

1.46

1,048,576

45.45

0.004

18.81

1.57

Как и следовало ожидать, время добавления элемента в конец списка — это константа, 0.004; это наилучший случай для вставки в значения структуру типа list. Время добавления тысячи элементов в начало списка практически удваивается с удвоением длины списка N. Стало быть, сложность этого действия линейна — O(N). Возможно, сразу этого видно не было, но теперь становится понятно, что поддерживать порядок элементов в массиве — дело довольно затратное. Столбец «Удаление» таблицы 6-1 показывает, что удалить тысячу элементов из начала списка столь же затратно: вычислительная сложность этого действия тоже линейна. Время добавления удваивается с увеличением размера списка вдвое, в каждой следующей строке этого столбца записано число, примерно вдвое большее числа над ним.

image В второй главе мы уже выяснили: если вставка тысячи элементов имеет сложность O(N), то и вставка одного — тоже O(N). Вставка десяти тысяч элементов — по тем же соображениям — также будет O(N). Отличаются все три числа только мультипликативной постоянной.

Итак, опыт показал, что вставка или удаление произвольного элемента имеет линейную сложность. Это значит, что если в алгоритме имеется массив, то постоянное соблюдение порядка элементов в нём может сильно замедлить программу. А теперь посмотрим на столбец «Дерево» таблицы 6-1, в котором приведены замеры тысячи операций вставки значения в сбалансированное двоичное дерево. Когда количество элементов в дереве увеличивается вдвое, время работы увеличивается — но не в разы, а на фиксированное значение, а это признак логарифмической сложности, O(log N). К тому же помимо эффективной вставки в дерево, мы ожидаем от него не менее эффективных поиска и удаления.

Давайте возьмём упорядоченный массив, который мы использовали для изучения двоичного поиска, и превратим его в двоичное дерево поиска, для чего возьмём центральный элемент массива и поместим его в корень дерева. Два поддерева справа и слева от корня формируются по тому же принципу из отрезков массива справа и слева от центрального элемента. По причинам историческим то, что, например, в стеке или куче называлось «вершиной», в дереве называется «корнем»: да, наши деревья растут сверху вниз! В сумме в нашем дереве получится сем элементов, как и в массиве. Поместим обе структуры данных рядом на иллюстрации 6-3.

Иллюстрация 6-3. Дерево двоичного поиска из семи элементов
Иллюстрация 6-3. Дерево двоичного поиска из семи элементов

корень→

левое поддерево→

←корень правого поддерева

Двоичное дерево поиска

Упорядоченный массив при двоичном поиске

Каждый узел двоичного дерева поиска соответствует примеру 6-3: поле left ссылается на узел, и этот узел — корень собственного поддерева, поле right — аналогично. В дереве поиска вводится два дополнительных общих правила для всех узлов:

Оба свойства соблюдены в дереве на иллюстрации 6-3. Назовём листом узел, в котором оба поддерева пусты — в нашем дереве четыре узла со значениями 3, 15, 26 и 58. Вот тут уже большинство согласится с тем, что деревья в программировании растут сверху вниз: у них не только корень вверху, но и листья внизу!

   1 class BinaryNode:
   2   def __init__(self, val):
   3     self.value = val            # 1
   4     self.left = None            # 2
   5     self.right = None           # 3
  1. Каждый узел хранит значение value

  2. Поле left — это (возможно, пустое) поддерево, все хранимые значения которого ⩽ value.

  3. Поле right — это (возможно, пустое) поддерево, все хранимые значения которого ⩾ value.

Ещё раз посмотрим на иллюстрацию 6-3. Левое поддерево корневого узла — это дерево, корень которого хранит значение 14, левый потомок — лист со значением 3, а правый потомок — лист со значением 15. Это те самые значения, что лежат в массиве в правой части иллюстрации, все они не больше 19, центрального элемента массива. При добавлении элемента по одному двоичные деревья прирастают снизу, как это показано в таблице 6-2.

../images-176-105.png

Вставим 19 — новое поддерево с корнем 19.

../images-176-106.png

Вставим 14. Оно не больше 19, и должно попасть в левое поддерево. Но левое поддерево узла 19 пусто, так что создадим новое поддерево с корнем 14.

../images-176-107.png

Вставим 15. Оно не больше 19, значит, должно попасть в левое поддерево с корнем 14. Оно больше этого 14, и попадает в его правое поддерево, которое пока пусто — так что создаём новое поддерево с корнем 15.

../images-176-108.png

Вставим 53. Оно больше 19, и попадает в правое поддерево — пока пустое, и мы создаём там новое с корнем 53.

../images-176-109.png

Вставим 58. Оно больше 19, попадает в правое поддерево с корнем 53; больше и 53, попадает в его правое поддерево, оно пусто, и мы создаём новое поддерево с корнем 58.

../images-176-110.png

Вставим 3. Оно не больше 19 (попадает в левое поддерево с корнем 14), не больше 14 — должно попасть в левое поддерево узла 14, но там пусто, и мы создаём новое поддерево с корнем 3.

../images-176-111.png

Вставим 26. Оно больше 19 (попадает в правое поддерево с корнем 53), не больше 53 — должно попасть в левое поддерево узла 53, но там пусто, и мы создаём новое поддерево с корнем 26.

Заведём для удобства класс BinaryTree, в котором пока что будет только работа с корнем дерева, но далее в этой главе мы добавим туда другие полезные методы. В примере 6-4 задана также процедура добавления элемента в дерево.

   1 class BinaryTree:
   2   def __init__(self):
   3     self.root = None                                   # 1
   4 
   5   def insert(self, val):
   6     self.root = self._insert(self.root, val)           # 2
   7 
   8   def _insert(self, node, val):
   9     if node is None:
  10       return BinaryNode(val)                           # 3
  11     if val <= node.value:                              # 4
  12       node.left = self._insert(node.left, val)
  13     else:                                              # 5
  14       node.right = self._insert(node.right, val)
  15     return node                                        # 6
  1. Корневой узел древа — self.root (если дерево пусто, равно None).

  2. Вспомогательный метод ._insert() добавляет val в дерево с корнем self.root.

  3. Основание рекурсии: поддерево пусто, возвращаем новый узел BinaryNode.

  4. Если val не больше значения узла, его следует добавить в левое поддерево, node.left.

  5. В противном случае (val больше значения узла) val следует добавить в правое поддерево, node.right.

  6. По договорённости метод ._insert() всегда возвращает — существующий или только что созданный — корень дерева, в которое было добавлено значение.

Метод .insert(значение) рекурсивно вызывает ._insert(узел, значение) с параметром self.root — таким образом либо создаётся прежде пустой корень дерева, либо значение добавляется в одно из поддеревьев self.root1.

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

image В нашем примере все добавляемые значения были разными, но это, конечно, необязательно. Значения, хранящиеся в дереве, могут повторяться — именно поэтому _insert() сравнивает их на меньше либо равно.

Основание рекурсии в ._insert(node, val) — пустой (равный None) параметр node, это бывает, когда val нужно вставить в пустое поддерево. В этом случае просто создаётся новый BinaryNode — это и будет корень возвращаемого поддерева. В рекурсивном вызове выбирается, в какое поддерево нужно добавить val — левое (node.left) или правое (node.right). Во всех случаях должна выполняться договорённость: _insert() добавляет val в дерево с корнем node — и возвращает корень дерева, в которое добавлен val.

Вызов ._insert(node, val) следит за сохранением основного свойства двоичного дерева поиска: все значения в левом поддереве должны не превосходить node.value, а все значения в правом должны быть больше.

image Узел n двоичного дерева может иметь левый и правый дочерний узел. Для n.left и .right узел n называется родительским. Соответственно, потомки n — это все узлы правого и левого поддеревьев. Каждый узел, кроме корневого, имеет по меньшей мере одного или больше предков, чьим наследником он и является.

Попробуем добавить число 29 в двоичное дерево поиска с иллюстрации 6-3. 29 больше корня, 19, значит его нужно добавить в правое поддерево с корнем 53. 29 меньше 53, значит, добавлять нужно в левое поддерево с корнем 26. 29 больше 26, соответствующее правое поддерево пусто, так что число 26 становится корнем нового правого поддерева. Результат приведён на иллюстрации 6-4.

Иллюстрация 6-4. Как добавить 29 в двоичное дерево поиска
Иллюстрация 6-4. Как добавить 29 в двоичное дерево поиска

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

На той же иллюстрации справа — также двоичное дерево поиска, которое получилось после добавления семи последовательно растущих значений. Образовался наихудший случай для двоичного дерева поиска: если повернуть картинку против часовой стрелки на 45°, получится связный список, с одним потомком у каждого узла. Это резко понижает производительность. В этой главе мы попробуем придумать, как добавляя и удаляя элементы не нарушать баланс наших древовидных структур.

Иллюстрация 6-5. Как различаются деревья двоичного поиска
Иллюстрация 6-5. Как различаются деревья двоичного поиска

Поиск значения в двоичном дереве

У нас уже есть метод поиска нужного места в дереве — этим занимается ._insert(), когда рекурсивно проходит до соответствующего пустого поддерева и добавляет туда узел. Можно было бы поступить так же: если нужное значение встретилось, поиск успешен, а если добрались до пустого узла — нет. Однако, как показано в примере 6-5, здесь проще обойтись без рекурсии, обычным циклом while.

   1 class BinaryTree:
   2   def __contains__(self, target):
   3     node = self.root                    # 1
   4     while node:
   5       if target == node.value:          # 2
   6         return True
   7       if target < node.value:           # 3
   8         node = node.left
   9       else:
  10         node = node.right               # 4
  11     return False                        # 5
  1. Начнём поиск с корня.

  2. Если значение текущего узла совпадает с target, поиск успешен; вернём True.

  3. Если target меньше значения текущего узла, продолжим поиск в левом поддереве.

  4. В противном случае (если target был больше) продолжим поиск в правом дереве.

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

Метод .__contains__() мы дописали в класс BinaryTree вдобавок к уже реализованным процедурам2. Работает он подобно процедуре поиска значения в связном списке, но различие в том, что всякий раз выбирается только одно следующее поддерево для поиска — левое или правое.

Удаление значения из двоичного дерева

Удалить значение из связного списка весьма просто, но в случае дерева всё несколько сложнее. Допустим, мы хотим удалить значение, а оно оказалось в корне дерева. Если удалить корень, как потом «срастить» два осиротевших поддерева? А если не в корне — всё равно должен быть какой-то простой и понятный общий способ? Попробуем догадаться что это за способ на примере, в котором удаляется значение именно из корня. Рассмотрим приведённые на иллюстрации 6-6 исходное двоичное дерево поиска и два варианта двоичных деревьев поиска, в которых уже нет удалённого узла.

Иллюстрация 6-6. Два возможных варианта удаления 19 из двоичного дерева поиска
Иллюстрация 6-6. Два возможных варианта удаления 19 из двоичного дерева поиска

Вариант 1

Вариант 2

заменить на наибольшее значение из левого поддерева

заменить на наименьшее значение из правого поддерева

Ещё раз заметим, что в обоих вариантах получаются именно деревья двоичного поиска: значения в левом поддереве не превосходят значения в корне, а значения в правом поддереве не меньше корня. И того, и другого варианта можно достичь сравнительно несложно:

Разницы между вариантами никакой, давайте выберем второй. Убедимся, что полученное дерево — двоичное дерево поиска. Его новый корень, 26, — это наименьшее значение из правого поддерева, и значит, все элементы перестроенного правого поддерева на иллюстрации 6-6 не меньше 26. При этом левое поддерево не изменилось, и все его элементы были не больше 19 — старого корня, — а 26 не меньше этого корня, и стало быть, всех остальных элементов левого поддерева.

Для начала разберёмся с тем, как удалить наименьшее значение в поддереве. Если подумать, становится ясно, что у минимального элемента поддерева нет левого потомка — иначе этот левый потомок сам был бы минимальным элементом3. Например, на иллюстрации 6-7 наименьшее значение правого поддерева с корнем 53 — это 26, и у него нет левого потомка. Если этот узел удалить, останется только «поднять» его правое поддерево (с корнем 29) на место левого поддерева узла 53. Наименьший узел всегда можно при удалении заменять его правым поддеревом, потому что левого поддерева у него нет, и других узлов мы при этом не потеряем.

Иллюстрация 6-7. Удаление наименьшего значения в дереве
Иллюстрация 6-7. Удаление наименьшего значения в дереве

В примере 6-6 мы завели в BinaryTree вспомогательный метод ._remove_min(узел), который удаляет наименьший элемент дерева с корнем узел; метод можно вызывать только на непустых узлах. Если передать этому методу правого потомка дерева с иллюстрации 6-7, произойдёт рекурсивный вызов, а именно — попытка удалить наименьшее значение из левого поддерева с корнем 26. Это уже основание рекурсии, потому что у 26 нет левого потомка, значит, настала пора «поднять» правое поддерево (с корнем 29) на место удалённого узла и вернуть это правое поддерево в качестве нового левого поддерева 53.

   1 def _remove_min(self, node):
   2   if node.left is None:                         # 1
   3     return node.right
   4 
   5   node.left = self._remove_min(node.left)       # 2
   6   return node                                   # 3
  1. Основание рекурсии: у узла нет левого потомка, значит он содержит наименьшее значение в поддереве, «поднимаем» на его место правое поддерево (которое может быть и пустым) и возвращаем его.

  2. Рекурсивный вызов: удаляем наименьшее значение из левого поддерева, а то, что нам возвратил метод ._remove_min(), записываем в левое поддерево узла.

  3. При рекурсивном вызове ._remove_min() сам узел не меняется (возможно, меняется его левое поддерево), возвращаем его.

Снова у нас вышло коротко и ясно! Метод ._remove_min(), как и предыдущие рекурсивные методы, возвращает корень обновлённого поддерева. Теперь можно дополнить класс BinaryTree методом .remove(), которые удаляет значение из двоичного дерева поиска. На примере диаграмм в таблице 6-3 рассмотрим, как именно должно происходить удаление значения 19, которое находится в корне двоичного древа поиска. На диаграммах упоминаются две переменные: original, указывающая на удаляемый узел, и node, в которой окажется узел на замену удаляемому.

../images-183-118.png

Поскольку удаляемый узел находится в корне дерева, original и node изначально указывают на него.

../images-183-119.png

По окончании цикла while переменная node указывает на наименьший узел в правом поддереве original, в данном случае — на узел со значением 26. Этот узел будет новым корнем всего дерева. Заметим, что, во-первых, у него нет левого потомка, во-вторых, это наименьшее из значений поддерева с корнем 53, и в-третьих, 26 не меньше значений дерева с корнем 14.

../images-183-120.png

После того, как наименьший узел удалён из поддерева original.right (с корнем 53), записываем это обновлённое поддерево (с узлами 29, 53 ,и 58) в node.right. Ненадолго original.right и node.right становятся одинаковыми и указывают на поддерево с корнем 53.

../images-183-121.png

Осталось только записать original.left в node.left, и когда _return() вернёт node, он «займёт место» original — и как корень обновлённого дерева поиска, и как потомок какого-нибудь узла (если у original был предок).

На диаграммах перевод не требуется -- FrBrGeorge 2022-11-30 15:53:39

Теперь посмотрим на реализацию .remove() в примере 6-7.

   1 def remove(self, val):
   2   self.root = self._remove(self.root, val)              # 1
   3 
   4 def _remove(self, node, val):
   5   if node is None: return None                          # 2
   6 
   7   if val < node.value:
   8     node.left = self._remove(node.left, val)            # 3
   9   elif val > node.value:
  10     node.right = self._remove(node.right, val)          # 4
  11   else:                                                 # 5
  12     if node.left is None: return node.right
  13     if node.right is None: return node.left             # 6
  14 
  15     original, node = node, node.right                   # 7
  16     while node.left:                                    # 8
  17       node = node.left
  18 
  19     node.right = self._remove_min(original.right)       # 9
  20     node.left = original.left                           # 10
  21   return node
  1. Используем вспомогательный метод ._remove(): ему можно указать корень конкретного поддерева, из котрого надо удалять элемент. В нашем случае это self.root.

  2. Основание рекурсии: если дерево пусто, вернём None.

  3. Первый вариант рекурсивного вызова: удаляемое значение меньше node.value, значит удалять надо из левого поддерева. Запишем в node.left обновлённое поддерево — результат удаления оттуда val.

  4. Второй вариант рекурсивного вызова: удаляемое значение больше node.value, значит удалять надо из правого поддерева, node.right. Обновим node.right, записав туда результат удаления val из него.

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

  6. Сначала немного оптимизации. Если одно из поддеревьев пусто, можно смело удалять именно этот узел, а возвращать оставшееся поддерево. Если и оно было пусто, значит узел — это лист, и вернётся None.

  7. Поищем узел без левого потомка. Запомним исходный узел в original: нам ещё работать с его поддеревьями, .left и .right. В этом месте оба поддерева непусты.

  8. Согласно выбранному правилу, начнём поиск наименьшего элемента в правом поддереве. Теперь node равен node.right, и пока у node есть левое поддерево, мы не можем быть уверены, что его значение наименьшее. Так что мы в цикле будем выбирать самый левый узел без левого потомка — наименьшее значение правого поддерева original будет в нём.

  9. Мы нашли узел node, который должен стать новым корнем с поддеревьями original.left и original.right. Левое его поддерево не изменится, а вот из правого надо удалить наименьшее значение. Воспользуемся для этого уже написанным методом ._remove_min() и положим результат в original.right. Можно заметить, что рекурсивный вызов ._remove_min() выполнит заново всё то, что мы только что уже делали, но так всё-таки понятнее, чем пытаться объединить логику работы обоих методов в одном цикле while.

  10. Теперь оба поддерева original перенесены в node.

Что ещё нам нужно от двоичного дерева поиска? Хотелось бы получить упорядоченную последовательность всех его значений! В программировании это называется обходом дерева.

Обход двоичного дерева

Связный список пройти поэлементно просто: начать с первого узла и в цикле while переходить к элементу, на которое указывает поле next, до тех пор, пока оно не пусто. НО такой линейный подход не годится для деревьев: есть сразу два пути дальнейшего просмотра, left и right. По-видимому, раз уж деревья рекурсивны, то и обход их тоже должен быть рекурсивным. В примере 6-8 мы зададим рекурсивный метод-генератор, и с его помощью аккуратно обойдём все узлы дерева.

   1 class BinaryTree:
   2 
   3   def __iter__(self):
   4     yield from self._inorder(self.root):        # 1
   5 
   6   def _inorder(self, node):
   7     if node is None:                            # 2
   8       return
   9 
  10     for v in self._inorder(node.left):          # 3
  11       yield v
  12 
  13     yield node.value                            # 4
  14 
  15     for v in self._inorder(node.right):         # 5
  16       yield v
  1. Отдадим управление вспомогательному генератор-методу ._inorder(self, node), который обходит в порядке возрастания дерево с корнем node. В качестве параметра передадим ему корень всего дерева, self.root.

  2. Основание рекурсии — пустое поддерево.

  3. Сначала пройдём по левому поддереву, node.left.

  4. Следом вернём значение текущего узла.

  5. Наконец пройдём по правому поддереву, node.right. Таким образом все значения окажутся перечислены в порядке возрастания.

Вспомогательный метод ._inorder(self, node) проходит все узлы дерева с корнем node и возвращает их по одному. Основание рекурсии наступает, когда соответствующее поддерево пусто — ._inorder() ничего не завершает итерацию. Все узлы левого поддерева, node.left, в двоичном дереве поиска по построению не больше корня node.value, а узлы правого дерева, node.right, не меньше него. Значит, если рекурсивно обойти левое поддерево, затем вернуть значение в узле, а затем обойти правое поддерево, значения будут возвращаться в порядке возрастания. Иллюстрация 6-8 представляет этот процесс в виде диаграмм для двоичного дерева T с пятью значениями.

Метод .__iter__() — принятым в Python способом — отдаёт перечисление элементов методу ._inorder() с соответствующим параметром — корнем всего дерева4.

image Обойти двоичное дерево можно ещё двумя способами. Префиксный обход сначала возвращает значение узла, а потом обходит дочерние поддеревья. Это удобно для копирования структуры дерева. Постфиксный обход, наоборот, сначала обходит поддеревья, а затем возвращает значение узла — таки образом можно, например, вычислить выражение на иллюстрации 6-1.

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

Иллюстрация 6-8. Проход по всем узлам двоичного дерева поиска в порядке возрастания их значений
Иллюстрация 6-8. Проход по всем узлам двоичного дерева поиска в порядке возрастания их значений

↓ Время

Исследование быстродействия двоичных деревьев поиска

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

image Высота листового узла равно 0, следовательно, высота пустого узла — узла со значением None — не может быть тоже нулём, она должна быть ещё меньше. В наших вычислениях удобно использовать -1 в качестве высоты пустого узла.

Количество узлов, которые в худшем случае придётся просмотреть при поиске по двоичному дереву, равно высоте этого дерева, то есть высоте корневого узла. Допустим, в дереве N узлов — какова его высота? Мы помним, что высота определяется порядком добавления элементов в дерево. Наилучший случай, то есть максимум узлов при минимуме высоты — это полное двоичное дерево, в котором заполнены все уровни. В дереве при этом 2k-1 узлов, а высота его равна k-1. К примеру, на иллюстрации 6-9 представлено полное дерево высотой 5, в котором 63 узла. Поиск по такому дереву займёт не более шести сравнений: если начать с корневого узла и переходить по ссылкам left и right, мы посетим не более шести узлов. Поскольку 63 = 2⁶-1, время поиска по такому дереву пропорционально log(N + 1). А вот в худшем случае, когда значения добавлялись в возрастающем или убывающем порядке, двоичное дерево поиска превращается в последовательную цепочку, и высота его будет N - 1 (как на иллюстрации 6-5). В общем, если высота двоичного дерева поиска — h, то производительность оценивается на O(h).

Иллюстрация 6-9. Полное дерево содержим маскимум узлов при минимально возможной высоте
Иллюстрация 6-9. Полное дерево содержим маскимум узлов при минимально возможной высоте

Высота = …

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

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

  1. Найти узел с нужным значением

  2. Найти в правом поддеревеве этого узла узел с наименьшим значением

  3. Удалить наименьший узел из правого поддерева (и подменить значение текущего узла)

В наихудщшем случае количество действий в каждой из этих операций пропорционально высоте5, так что в самом худшем варианте врема, потраченное на удаление, будет пропорционально 3·h (где h — высота всего дерева). В главе 2 мы уже выяснили, что мультипликативная постоянная не влияет на общую оценку, так что время удаления элемента также оказывается порядка O(h).

Структура двоичного дерева поиска определяется порядком добавления элементов в него и удаления их оттуда, и нет способа этот порядок заранее определить. Значит, нужна возможность оценить, насколько эффективна сложившаяся структура дерева. В главе 3 мы посмотрели, как масштабируется хеш-таблица при достижении пороговой загруженности: размер увеличивается вдвое, а элементы хешируются заново. Геометрическое масштабирование позволяет сделать эту дорогостоящую (O(N)) операцию настолько редкой, что порядок производительности остаётся неизменным: как мы помним — константным, O(1) для действия get().

О геометрическом масштабировании можно было бы задуматься, если бы мы хранили узлы дерева в массиве (как кучу), но при таком подходе совершенно непонятно, когда именно хранилище нуждается в расширении, и как часто оно будет требоваться. Пороговое значение должно зависеть от N, а, например, если хранить дерево как кучу, поуровнево, порог определяется не количеством элементов, а высотой. Посмотрим на иллюстрацию 6-10: всего пара неудачных вставок — и нам требуется в четыре раза больше места под ещё два уровня! Для наглядности узлы одинаковой высоты обозначены на иллюстрации одинаковым фоном. Заметим, что само дерево при этом получается несбалансированным: кратчайший путь от корня до листового узла оказывается вдвое меньше высоты.

Автор тут натурально сыграл в карго-культ. Вообще-то геометрическое масштабирование — это способ динамического выделения памяти, а не решения всех проблем, и от дисбаланса дерева он помогает не лучше модели грузовоза из мха и веток. Был выбор либо всё это удалить, либо попытаться вдумать туда смысл. Я вдумал. -- FrBrGeorge 2022-12-02 11:24:57

Иллюстрация 6-10. Несбалансированное дерево после добавления двух элементов
Иллюстрация 6-10. Несбалансированное дерево после добавления двух элементов

Добавим 29

Добавим 27

Высота узлов:

[] высота 0

[] высота 1

Полное двоичное дерево на иллюстрации 6-10 целиком сбалансировано: его высота (и высота корня) — 2, высота всех узлов первого уровня — 1, все узлы второго уровня листовые, и их высота — 0. Как только мы добавляем 29 (средняя диаграмма на иллюстрации), в соответствующем месте появляется новый листовой узел. При этом высота всех предков узла 29 увеличивается на единицу (обозначено на диаграмме фоном). После добавления 27 дерево окончательно разбалансируется (правая диаграмма на иллюстрации): левое поддерево, с корнем 14 имеет высоту 1, а правое — с корнем 53 — уже 3. Такая же картина и для других узлов, например, для 26 и 53. В следующем разделе мы посмотрим, как определить, что дерево нуждается в балансировке, и как эту балансировку делать.

Сбалансированные двоичные деревья

Самая ранняя из известных сбалансированных древовидных структур данных, АВЛ-дерево, была изобретена в 1962 году6. Идея была в том, что при всяком добавлении или удалении значения отслеживается эффективность получившегося дерева, и если она недостаточна, дерево реорганизуется. В АВЛ-дереве гарантируется, что в каждой вершине высота правого и левого поддеревьев отличаются не более, чем на 1 (то есть разница между высотой правого поддерева и высотой левого может быть только -1, 0 или 1).

Как показано в примере 6-9, высоту поддерева удобно знать заранее и хранить в BinaryNode в поле height. Всякий раз при добавлении элемента в дерево высоты узлов, изменивших место в иерархии, вычисляются заново, так что несбалансированный узел будет видно сразу.

   1 class BinaryNode:
   2   def __init__(self, val):
   3     self.value = val                                            # 1
   4     self.left = None
   5     self.right = None
   6     self.height = 0                                             # 2
   7 
   8   def height_difference(self):                                  # 3
   9     left_height = self.left.height if self.left else -1         # 4
  10     right_height = self.right.height if self.right else -1
  11     return left_height - right_height                           # 5
  12 
  13   def compute_height(self):                                     # 6
  14     left_height = self.left.height if self.left else -1
  15     right_height = self.right.height if self.right else -1
  16     self.height = 1 + max(left_height, right_height)
  1. BinaryNode в целом такой же, как и в простом двоичном дереве поиска.

  2. Здесь хранится высота узла.

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

  4. Если поддерево пусто, его высота, по договорённости, -1; в противном случае используем настоящую высоту.

  5. Вернём разницу высот. С учётом высоты пустого поддерева это просто разность left_height и right_height.

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

Теперь допишем метод _insert(): он должен вычислять значение узла при добавлении.

   1   def _insert(self, node, val):
   2     if node is None:
   3       return BinaryNode(val)                            # 1
   4 
   5     if val <= node.value:
   6       node.left = self._insert(node.left, val)
   7     else:
   8       node.right = self._insert(node.right, val)
   9 
  10     node.compute_height()                               # 2
  11     return node
  1. Основание рекурсии — создание нового листового узла — не трбует вычисления длины, которая и так по умолчанию 0.

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

Посмотрим на иллюстрацию 6-11. Вот мы вызвали insert(27), и после нескольких рекурсивных вызовов создали новый листовой узел для 27, добавив его в двоичное дерево поиска. Концевой вызов _insert() достиг основания рекурсии и вернул только что созданный листовой узел 27. На иллюстрации видно, что оба узла — новый листовой (27) и старый листовой (29, будущий родитель) — ненадолго оба имеют высоту 0. Осталось только добавить единственный оператор — вычисление новой высоты узла — в конец _insert(), перед возвратом из рекурсивного вызова. Пока рекурсивные вызовы завершаются, высота каждого родителя пересчитывается (отмечено на иллюстрации фоном) — и заметим, что только у этих узлов двоичного дерева могла от добавления элемента измениться высота. Метод compute_height() высчитывает высоту узла из простого логического соображения: высота узла на единицу больше, чем высота самого высокого из поддеревьев этого узла.

Иллюстрация 6-11. Рекурсивные вызовы при добавлении элемента в дерево
Иллюстрация 6-11. Рекурсивные вызовы при добавлении элемента в дерево

Высота узлов

высота 0

высота 1

Итак, с каждым завершением рекурсивного вызова пересчитывается высота соответствующего предка узла 27. Поскольку к этому моменту у всех потомков высота своевременно посчитана, _insert() может определить, что узел стал несбалансированным — это когда высоты правого и левого поддеревьев этого узла отличаются более чем на единицу.

image Разность высот узла определяется как разность между высотой правого поддерева данного узла и высотой его левого поддерева. Высота пустого поддерева равно -1. В АВЛ-деревьях разность высот любого узла должна быть -1, 0 или 1.

Будем говорить, что узел 26 «перевешивает вправо», потому что разница его весов равна -1 - -1 = -2. Соответственно, узел 53 перевешивает влево, потому что разница его весов — 2 - 0 = 2, а корень — вправо с разницей весов 1 - 3 = -2. После того, как мы отметили все потерявшие равновесие узлы, хотелось бы применить какой-нибудь алгоритм для восстановления баланса. Это можно сделать, например, в процессе возврата из рекурсивных вызовов, когда _insert() уже определил, что добавление узла разбалансировало дерево. Возврат из рекурсивных вызовов начинается с конца, с основания рекурсии, то есть первым найденным несбалансированным узлом будет 26.

Тут изобретатели АВЛ-дерева вводят понятие «поворот узла», которое проще всего представить наглядно , как на иллюстрации 6-12. Три узла со значениями 10, 30 и 50 раскрашены в соответствии с их высотами. Корневой узел, 50, имеет вес h. Серые треугольники — это поддеревья, причём они являются двоичными деревьями поиска, и притом сбалансированными. Левое поддерево узла 10 обозначено 10L, в нём содержатся узлы, значение которых не превосходит 10. Нам достаточно знать, что по построению высота такого дерева равна h - 3. Поскольку все поддеревья на диаграмме (10L, 10R, 30R и 50R) раскрашены одинаково, можно сделать вывод о том, что их высоты тоже равны h-3.

Я не стал переводить номенклатуру 10L/10R в 10Л/10П, потому что Л и П визуально иногда не отличаются, зависит от шрифта. -- FrBrGeorge 2022-12-04 11:44:06

Тогда всё дерево перевешивает влево: высота левого поддерева — h - 1, а правого — h - 3, значит, разница весов равна +2. После того, как в АВЛ-дереве определился дисбаланс, происходит «вращение» узлов, и дерево меняет конфигурацию, как это показано на иллюстрации 6-12. После вращения высота всего дерева оказывается h - 1, а узел 30 становится новым корнем. На иллюстрации представлено правое вращение дерева. Если представить себе дерево в виде сцепленных подвесок с одним кольцом сверху и двумя — снизу, то вращение — это когда вы перестаёте держать всю цепочку за старый корень (50), берётесь за новый (30) и встряхиваете всю конструкцию. При этом новый корень поднимается на верхний уровень, а старый корень оказывается справа и опускается ниже. Ненадолго получится, что у 30 три потомка, но зато у 50 теперь точно осталось только правое поддерево (50R), а 30R отлично может стать его левым поддеревом, потому что его узлы не превосходят 50 и не меньше 30.

Иллюстрация 6-12. Как сбалансировать двоичное дерево поиска, вращая его корень вправо
Иллюстрация 6-12. Как сбалансировать двоичное дерево поиска, вращая его корень вправо

Высота узлов

высота h - 3

высота h - 2

Если взять дерево всего из трёх элементов, получится четыре варианта дисбаланса — как на иллюстрации 6-13. В частности «левый-левый» дисбаланс — это упрощённая версия дерева с иллюстрации 6-12. Чтобы восстановить равновесие, такое дерево достаточно повернуть вправо — так же, как и зеркальное к нему дерево с «правым-правым» дисбалансом нужно с той же целью повернуть влево. Варианты дисбаланса поименованы сообразно тому, как расположены в нём узлы, начиная с корневого. Во всех четырёх случаях мы получаем сбалансированное дерево с корнем 30, левым потомком 10 и правым — 50.

Иллюстрация 6-13. Четыре типа вращения узла
Иллюстрация 6-13. Четыре типа вращения узла

Высота узлов:

высота 0

высота 1

высота 2

Лево-правый дисбаланс

Право-левый дисбаланс

левый поворот

правый поворот

правый поворот

левый поворот

Сбалансированное дерево

«Левый-правый» дисбаланс чуть сложнее: такое дерево приводится к норме в два шага. Сначала надо повернуть влево левое поддерево с корнем 10, при этом узел 10 опустится на уровень, а узел 30 — поднимется. Всё дерево при этом приобретает уже знакомый нам «левый-левый» дисбаланс, и теперь его достаточно повернуть вправо — равновесие восстановится. Назовём такую двухступенчатую операцию «лево-правым поворотом». Дерево с «право-левым» дисбалансом зеркально воспроизводит эту ситуацию, и для восстановления равновесия его нужно «повернуть вправо-лево». В репозитории примеров к нашей книге есть оптимизированные версии всех этих операций.

В примере 6-11 приведены функции, которые используют готовые операции поворота и восстанавливают равновесие, когда дерево перевешивает влево или вправо7.

   1   def resolve_left_leaning(node):
   2     if node.height_difference() == 2:           # 1
   3       if node.left.height_difference() >= 0:    # 2
   4         node = rotate_right(node)
   5       else:
   6         node = rotate_left_right(node)          # 3
   7     return node                                 # 7
   8 
   9   def resolve_right_leaning(node):
  10     if node.height_difference() == -2:          # 4
  11       if node.right.height_difference() <= 0:   # 5
  12         node = rotate_left(node)
  13       else:
  14         node = rotate_right_left(node)          # 6
  15     return node                                 # 7
  1. Узел перевешивает влево с разницей +2.

  2. Если левое поддерево не перевешивает вправо, можно просто повернуть узел влево (вызвать rotate_right()).

  3. В противном случае левое поддерево перевешивает вправо, и нужно поворачивать узел «влево-право» (вызывать rotate_left_right()).

  4. Узел перевешивает вправо с разницей –2.

  5. Если правое поддерево не перевешивает влево, можно просто повернуть узел вправо (вызвать rotate_left()).

  6. В противном случае правое поддерево перевешивает влево, и нужно поворачивать узел «вправо-лево» (вызывать rotate_right_left()).

  7. Возвращаем узел (если он был разбалансирован, то мы вернули ему равновесие).

Идея в том, чтобы сразу ликвидировать дисбаланс, как только в процессе работы с деревом он проявился. В последний раз дополним метод ._insert() — как показано в примере 6-12 — в котором для этого сразу вызываются наши вспомогательные функции. Если добавлять значение в левое поддерево, сам узел не может перевесить вправо, так что достаточно исправить перевес влево, если он появился. Аналогично, если добавлять элемент в правое поддерево, сам узел не перевесит влево.

   1   def _insert(self, node, val):
   2     if node is None:
   3       return BinaryNode(val)
   4 
   5       if val <= node.value:
   6         node.left = self._insert(node.left, val)
   7         node = resolve_left_leaning(node)               # 1
   8       else:
   9         node.right = self._insert(node.right, val)
  10         node = resolve_right_leaning(node)              # 2
  11 
  12       node.compute_height()
  13       return node
  1. Левое поддерево может перевешивать — это надо исправить!

  2. Правое поддерево может перевешивать — это тоже надо исправить!

Текст функций поворота можно найти в репозитории к нашей книге. В таблице 6-4 показано, как по мере выполнения функции rotate_left_right() меняется балансируемое дерево. Начинается всё с определения new_root и остальных затронутых дисбалансом узлов, а заканчивается сбалансированным деревом; нелишне заметить, что высоты child и node в нём пересчитаны заново.

Обратим внимание на то, что rotate_left_right() возвращает корень нового сбалансированного дерева: если мы восстанавливаем равновесие некоторого узла в составе большего дерева, нам понадобится старый корень поддерева поменять на новый. При этом пересчитывать высоту new_root не надо — это уже сделано в ._insert() или ._remove(). По последней диаграмме видно, что полученное двоичное дерево — это дерево поиска: например, все значения в 30L не меньше 10 и не больше 30, так что это поддерево вполне может быть правым для узла 10. Точно так же 30R может быть левым поддеревом узла 50.

../images-197-131.png

   1 def rotate_left_right(node):
   2     child = node.left
   3     new_root = child.right
   4     grand1 = new_root.left
   5     grand2 = new_root.right

../images-197-132.png

   1     child.right = grand1
   2     node.left = grand2
   3     new_root.left = child
   4     new_root.right = node
   5     child.compute_height()
   6     node.compute_height()
   7     return new_root

Дополненный метод ._insert() теперь при необходимости восстанавливает баланс двоичного дерева поиска. Доделать ._insert() и ._remove_min() с помощью наших вспомогательных функции тоже довольно просто — это сделано в примере 6-13. В тексте метода отслеживаются все четыре случая, когда меняется структура дерева и требуется вмешательство.

   1   def _remove_min(self, node):
   2     if node.left is None: return node.right
   3 
   4       node.left = self._remove_min(node.left)
   5       node = resolve_right_leaning(node)                   # 1
   6       node.compute_height()
   7       return node
   8 
   9   def _remove(self, node, val):
  10     if node is None: return None
  11 
  12       if val < node.value:
  13         node.left = self._remove(node.left, val)
  14         node = resolve_right_leaning(node)                 # 2
  15       elif val > node.value:
  16         node.right = self._remove(node.right, val)
  17         node = resolve_left_leaning(node)                  # 3
  18       else:
  19         if node.left is None: return node.right
  20         if node.right is None: return node.left
  21 
  22           original = node
  23           node = node.right
  24           while node.left:
  25             node = node.left
  26 
  27           node.right = self._remove_min(original.right)
  28           node.left = original.left
  29           node = resolve_left_leaning(node)                # 4
  30 
  31       node.compute_height()
  32       return node
  1. Если мы удаляем наименьший элемент, это по договорённости происходит в левом поддереве. При этом узел может перевесить вправо — тогда для восстановления баланса понадобится его повернуть.

  2. Если мы удаляем элемент из левого поддерева, узел может перевесить вправо, понадобится восстановление баланса и поворот.

  3. Если мы удаляем элемент из правого поддерева, узел может перевесить влево, понадобится восстановление баланса и поворот.

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

После того, как мы добавили в BinaryTree поддержку свойств АВЛ-дерева, каждое добавление или удаление элемента приводит к балансировке всей структуры. Балансировка выполняет всегда одинаковое количество действий, так что оценка времени её работы должна быть константой — O(1). Методы поиска и обхода менять не нужно, потому что АВЛ-дерево продолжает оставаться двоичным деревом поиска.

Производительность сбалансированных деревьев

И вспомогательный метод .compute_height(), и все процедуры поворота работают константное время — ни в одной из них них нет ни рекурсивного вызова, ни циклов. Все три метода вызываются только когда обнаружился дисбаланс. Если внимательно посмотреть, как элемент добавляется в АВЛ-дерево, обнаружится, что на это потребуется не более одного поворота. Удаление элемента посложнее: теоретически возможен наихудший случай, когда поворотов потребуется несколько (одно из тренировочных заданий посвящено разбору этой ситуации). Но даже в наихудшем случае поворотов будет не больше log₂ N, так что общее быстродействие операций поиска, добавления и удаления элементов можно оценить как O(log N).

Потренировавшись в этой главе на деревьях, мы можем начать исследовать любую рекурсивную структуру данных. А сейчас давайте сравним работу уже известных нам структур — хеш-таблиц и приоритетных очередей — с работой двоичных деревьев: не окажутся ли они эффективнее?

Хранение пар (ключ, значение) в двоичном дереве

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

Иллюстрация 6-14. Двоичное дерево поиска как ассоциативный массив: ключ — атомный вес химического элемента
Иллюстрация 6-14. Двоичное дерево поиска как ассоциативный массив: ключ — атомный вес химического элемента

Ключ

53

Значение

"Йод"

Ключ

20

Ключ

76

Значение

"Кальций"

Значение

"Осмий"

Ключ

5

Ключ

58

Ключ

79

Значение

"Бор"

Значение

"Церий"

Значение

"Золото"

Чтобы реализовать структуру, изображённую на иллюстрации 6-14, необходимо доработать класс BinaryNode — в нём, помимо ключа, должно храниться значение. Пример 6-14 содержит такую доработку.

   1 class BinaryNode:
   2   def __init__(self, k, v):
   3     self.key = k                # 1
   4     self.value = v              # 2
   5     self.left = None
   6     self.right = None
   7     self.height = 0
  1. Ключ используется для поиска по дереву.

  2. Значение может содержать любые данные: они никак не используются при работе с двоичным деревом поиска.

Теперь надо методы .insert(ключ) и __contains__(ключ) заменить .put(ключ, значение) и .get(ключ) — тогда BinaryTree станет совместимо с нашей реализацией хеш-таблиц. Изменения минимальны и сводятся только к заполнению и возврату поля .value, так что мы их здесь не приводим. Полный текст всех примеров можно посмотреть в репозитории к данной книге (https://oreil.ly/fUosk). Обход дерева, то есть выбор между левым и правым поддеревьями, как и прежде, происходит на основании значения node.key.

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

Вспомним, что в главе 3 мы рассматривали хеш-таблицы с открытой адресацией и с раздельным хранением цепочек. Имеет смысл сравнить эффективность двоичного дерева поиска, хранящего значения, и с той, и с другой реализацией хеш-таблиц, которые приведены в таблице 3-4. Для эксперимента мы взяли 321129 слов английского словаря и добавили каждое во все три структуры данных. Какова минимальная высота дерева, в котором помещается 321129 слов? Мы помним, что она равна log(N + 1) - 1, что даёт 17.293. И действительно, если все эти слова добавить в АВЛ-дерево в порядке возрастания, его высота окажется равной 18. В очередной раз убеждаемся, что хранить информацию в АВЛ-дереве весьма удобно.

Оба варианта хеш-таблиц из главы 3 оказываются гораздо производительнее двоичного дерева поиска8, как это видно из таблицы 6-5. Даже если понадобится отсортированный список ключей, выгоднее получить его как есть из хеш-таблицы, а затем отсортировать.

Тип

Открытая адресация

Раздельные цепочки

АВЛ-дерево

Время добавления

0.54

0.38

5.00

Время поиска

0.13

0.13

0.58

Двоичное дерево как приоритетная очередь

Поскольку двоичная куча, описанная в четвёртой глава, очевидно основывается на двоичном дереве, было бы совершенно естественным сравнить производительность приоритетной очереди и аналогичной структуры на базе АВЛ-дерева, в которой ключом для обхода дерева является приоритет — как это изображено на иллюстрации 6-15.

Иллюстрация 6-15. Двоичное дерево поиска как приоритетная очередь: приоритет — это атомный вес химического элемента
Иллюстрация 6-15. Двоичное дерево поиска как приоритетная очередь: приоритет — это атомный вес химического элемента

Приоритет

Значение

Двоичное дерево поиска в качестве приоритетной очереди имеет два достоинства:

Для начала, как это сделано в примере 6-15, добавим в BinaryNode поля .value и .priority: первое поле, значение, как обычно ни на что не влияет, а второе, приоритет, — это ключ для обхода дерева и операций добавления и удаления элемента.

   1 class BinaryNode:
   2   def __init__(self, v, p):
   3     self.value = v              # 1
   4     self.priority = p           # 2
   5     self.left = None
   6     self.right = None
   7     self.height = 0
  1. Значение может содержать любые данные: они никак не используются при работе с двоичным деревом поиска.

  2. Приоритет используется для поиска по дереву.

Мы помним, что в двоичной куче с порядком убывания элемент с наивысшим приоритетом всегда находится в storage[1], и поэтому доступен за время O(1). Если использовать двоичное дерево поиска, будет по-другому: узел с наивысшим приоритетом — самый правый в дереве. Если дерево сбалансировано (например, как сделано в этой главе), на поиск такого узла потребуется O(log N) времени.

Однако главное отличие приоритетной очереди от дерева — в том, что произвольный элемент очереди удалять не приходится никогда: удаляется только элемент с наивысшим приоритетом. Стало быть, и общий метод .remove() не нужен, а вместо него, как это сделано в примере 6-16, добавим в класс PQ вспомогательный метод ._remove_max(). Остальные вспомогательный методы — те же, что у стандартного класса, реализующего приоритетную очередь. Заметим, что класс PQ хранит и отслеживает количество пар в куче, N.

   1 class PQ:
   2   def __init__(self):
   3     self.tree = BinaryTree()                            # 1
   4     self.N = 0
   5 
   6   def __len__(self):
   7     return self.N
   8 
   9   def is_empty(self):
  10     return self.N == 0
  11 
  12   def is_full(self):
  13     return False
  14 
  15   def enqueue(self, v, p):
  16     self.tree.insert(v, p)                              # 2
  17     self.N += 1
  18 
  19   def _remove_max(self, node):                          # 3
  20     if node.right is None:
  21       return (node.value, node.left)                    # 4
  22 
  23     (value, node.right) = self._remove_max(node.right)  # 5
  24     node = resolve_left_leaning(node)                   # 6
  25     node.compute_height()                               # 7
  26     return (value, node)
  27 
  28   def dequeue(self):                                    # 8
  29     (value, self.tree.root) = self._remove_max(self.tree.root)
  30     self.N -= 1
  31     return value                                        # 9
  1. В качестве хранилища используем сбалансированное двоичное дерево поиска.

  2. Поставим в очередь пару (ключ, значение) — добавим её в дерево и увеличим счётчик N.

  3. Вспомогательный метод ._remove_max() во-первых, удаляет узел с наивысшим приоритетом из дерева с корнем node, а во-вторых — возвращает кортеж вида (значение, дерево). Значение берётся из удалённого узла, а дерево — это поддерево, получившееся после удаления.

  4. Основание рекурсии: правое поддерево пусто, значит, мы нашли узел с наивысшим приоритетом. Возвращаем его значение и его левое поддерево (оно займёт место найденного узла).

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

  6. Если узел потерял баланс, восстановим его с помощью поворотов.

  7. Вычислим новую высоту узла и возвратим её вместо с удалённым значением.

  8. Метод .dequeue() удаляет узел с наивысшим приоритетом из двоичного дерева поиска и возвращает значение этого узла.

  9. Уменьшим счётчик, N, и возвратим значение, соответствующее наивысшему приоритету.

У нас получилась приоритетная очередь, быстродействие которой в два раза ниже, чем у аналогичной структуры поверх двоичной кучи (из главы 4) — но порядок сложности остался O(log N), то есть отличие только в мультипликативной постоянной. Операции с АВЛ-деревом требуют больше действий, чем операции с кучей, но если, например, требуется регулярный обход пар (значение, приоритет) в порядке удаления, удобно использовать этот новый вариант.

Заключение

Двоичное дерево — это динамическая рекурсивная структура данных, в которой набор из N значений делится на левое и правое поддерево, в каждом из которых содержится примерно половина всех элементов. Великое множество более сложных и эффективных структур данных основано на двоичных деревьях, например:

По итогам этой главы:

Тренировочные задания

  1. Напишите рекурсивный метод BinaryNode.count(n), который подсчитывает количество элементов двоичного дерева с вершиной в данном узле, кратных n.

  2. Нарисуйте двоичное дерево поиска, в котором для нахождения двух наибольших значений среди N элементов понадобится O(N) действий. А теперь нарисуйте двоичное дерево поиска, в котором для нахождения двух наибольших значений среди N элементов понадобится O(1) действий.

  3. Когда возникает необходимость найти k-й по величине элемент в дереве, можно просто обойти k узлов, но такой способ неэффективен. Добавьте в класс BinaryTree метод .select(k), который будет возвращать k-е наименьшее значение в дереве, где k выбирается из диапазона от 0 до N-1. Для этого добавьте в класс BinaryNode дополнительное поле N, в котором будет хранится общее количество значений в поддереве с корнем в данном узле (включая значение самого узла). Например, в каждом листовом узле N будет равно 1.

    Добавьте ещё один метод, .rank(key), который будет возвращать число от 0 до N-1, — позицию ключа в упорядоченном списке элементов. Иными словами, требуется подсчитать количество ключей в дереве, строго меньших заданного.

  4. Значения из списка [3,14,15,19,26,53,58] можно организовать в виде двоичного дерева поиска 7! = 5040 различными способами. Подсчитайте, сколько из таких деревьев будут строго сбалансированными с высотой 2, как на иллюстрации 6-3. Можно ли обобщить ответ и вывести рекуррентное соотношение для c(k), которое для произвольного k давало бы количество различных строго сбалансированных деревьев из 2k-1 значений?

  5. Добавьте в BinaryTree метод .__contains__(), который вызывал бы метод .__contains__() из BinaryNode.

  6. АВЛ-деревья, как мы это узнали в этой главе, сбалансированы, но не обязательно столь же компактны, как полное двоичное дерево. Какова наибольшая возможная высота АВЛ-дерева из N элементов? Сконструируйте 10000 случайных АВЛ-деревьев из N элементов и зафиксируйте наибольшую высоту, которой удалось достичь при каждом N. Составьте таблицу, по которой можно будет отследить, когда наибольшая высота увеличивается. Попробуйте предсказать такие N, для которых наибольшая высота АВЛ дерева из N элементов на единицу больше высоты АВЛ-дерева из N-1 элемента.

  7. Допишите класс SpeakingBinaryTree из примера 6-17, который отчитывается о производимых с ним действиях при выполнении метода .insert(значение) примерно так, как это сделано в таблице 6-2. Данная рекурсивная процедура отличается от других, изучаемых нами в этой главе: отчёт формируется «от начала», в то время как большинство рекурсивных процедур работают «от конца», то есть от основания рекурсии.

       1       class BinaryNode:
       2         def __init__(self, val):
       3           self.value = val
       4           self.left = None
       5           self.right = None
       6 
       7       class SpeakingBinaryTree:
       8         def __init__(self):
       9           self.root = None
      10 
      11           def insert(self, val):
      12             (self.root,explanation) = self._insert(self.root, val,
      13                  'To insert `{}`, '.format(val))
      14             return explanation
      15 
      16           def _insert(self, node, val, sofar):
      17             """
      18             Return (node, explanation) resulting from inserting val into subtree
      19             rooted at node.
      20             """
    
    • Пример 6-17. Допишите метод _insert() так, чтобы он возвращал описание того, что делает

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

  8. Напишите функцию check_avl_property(n), которая проверяет, что в дереве с корнем в n, во-первых, все поддеревья имеют правильно подсчитанную высоту, и во-вторых, все узлы соответствуют требованию для разности высот АВЛ-дерева.

  9. Напишите функцию tree_structure(n), которая конструирует строку, отражающую прямой обход дерева с корнем в n. Каждое поддерево должно быть заключено в скобки. Прямой обход (NLR, node-left-right) предусматривает, что сначала выводится значение самого узла, затем — левое поддерево, а затем — правое. Например, полное двоичное дерево с иллюстрации 6-3 должно выглядеть так: '(19,(14,(3,,),(15,,)),(53,(26,,),(58,,)))', а дерево из левой части иллюстрации 6-5 — так: '(5,(4,(2,(1,,),(3,,)),),(6,,(7,,)))'. Напишите парную функцию recreate_tree(выражение), которой на вход передаётся выражение, представляющее структуру в формате tree_structure(n). Возвращать эта функция должна корень реконструированного двоичного дерева.

  10. Если считать лево-правый и право-левый поворот АВЛ-дерева такими же атомарными операциями, как левый и правый поворот, добавление элемента в дерево требует не более одной такой операции. Однако удаление элемента из АВЛ-дерева может потребовать более одной операции поворота.

  11. Каков наименьший размер АВЛ-дерева, при удалении элемента из которого может потребоваться более одного поворота? В таком дереве, очевидно, должно быть не менее четырёх узлов. Чтобы ответить на этот вопрос экспериментально, модифицируйте методы поворота так, чтобы для каждого удаления вычислялось количество проделанных поворотов. Кроме того, потребуется tree_structure() из предыдущего упражнения — чтобы можно было восстановить дерево после того, как оказалось, что удаление потребовало более одного поворота. Напишите функцию, которая конструирует 10000 случайных АВЛ-деревьев, содержащих от 4 до 40 узлов, и в каждом таком дереве удаляет случайный элемент. Должны обнаружить АВЛ-деревья, для удаления элемента из которых требуется минимум три поворота. В частности, один поворот может потребоваться для дерева уже из 4 элементов, а два — для дерева из 12 элементов. Каков наименьший размер АВЛ-дерева, при удалении элемента из которого может потребоваться три поворота?

  12. Наиболее компактное двоичное дерево, хранящее N = 2k-1 узел — полное, его высота равна k. А каково «наименее компактное» возможное АВЛ-дерево? Так называемые Фибоначчиевы деревья — это такие АВЛ-деревья, у которых в каждом узле высота левого поддерева больше высоты правого (всего на единицу, ибо больше нельзя). Заметим, что после добавления любого элемента в такое дерево потребуется балансировка. Напишите рекурсивную функцию, fibonacci_avl(N), которая для любого N > 0 возвращала бы объект типа BinaryNode — корень Фибоначчиева дерева из N первых натуральных чисел. Вероятно, проще будет не использовать BinaryTree. Корень такого дерева будет содержать N-е число Фибоначчи! Например, fibonacci_avl(6) вернёт корневой узел дерева, показанного на иллюстрации 6-16. /* 11. A complete binary tree with N = 2k – 1 nodes is the most compact representation for storing N nodes. This question asks what is the “least compact” AVL tree you can construct. A Fibonacci tree is an AVL tree such that in every node, the height of its left subtree is bigger (by just 1) than the height of its right subtree. Think of this as an AVL tree that is one insert away from rebalancing. Write a recursive function, fibonacci_avl(N), for N > 0 that returns a BinaryNode representing the root of a Fibonacci tree. It is simpler to do this without involving any Binary Tree objects. The root node returned contains the value FN. For example, fibonacci_avl(6) would return the root node for the binary tree depicted in Figure 6-16.

    Иллюстрация 6-16. Фибоначчивео дерево из двенадцати узлов
    Иллюстрация 6-16. Фибоначчивео дерево из двенадцати узлов
  1. В Python действует соглашение об именовании атрибутов класса: если имя метода начинается на знак подчёркивания «_"», желательно пользоваться им только в других методах того же класса (прим. автора). (1)

  2. Имя выбрано не случайно. Если в классе реализовать метод __contains__(self, target), для всех экземпляров этого класса сама собой начнёт работать операция in. Например value in tree, где tree — экземпляр класса BinaryTree, будет приводить к вызову этого метода и возвращать True или False (прим. автора). (2)

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

  4. На всякий случай напомним, что имя __iter__() позволяет использовать сам объект типа BinaryTree как итератор — например, проходить его циклом for. (4)

  5. В тренировочных заданиях будет на этот счёт упражнение (прим. автора). (5)

  6. АВЛ-дерево названо по фамилиям его изобретателей: Георгия Максимовича Адельсон-Вельского и Евгения Михайловича Ландиса (прим. автора). (6)

  7. Обе функции можно было бы объединить, в которой явно рассмотрены все четыре варианта, однако получившийся текст хорошо выглядел бы на широком экране, но не в книге. Приводим несколько избыточное авторское решение (7)

  8. Этого стоило ожидать, если вспомнить, что сложность операций над хеш-таблицей константная, а над деревом — всё-так логаифмическая (8)

  9. Откровенно говоря, это фиктивное преимущество. В случае кучи мы можем сами масштабировать массив-хранилище; это относительно несложно сделать, как мы уже видели в одном из тренировочных заданий. Созданием и удалением экземпляра BinaryNode — иными словами, выделением и освобождением оперативной памяти под каждый узел дерева — занимается для нас Python, что, в конечном итоге, приводит к похожей стратегии — только не для одного массива, а для всего набора Python-объектов. (9)

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

FrBrGeorge/Books/LearningAlgorythms/06_Binary_Trees (последним исправлял пользователь FrBrGeorge 2023-01-13 16:56:04)