mirror of
https://github.com/krahets/hello-algo.git
synced 2026-05-05 06:30:36 +08:00
1080 lines
76 KiB
Markdown
1080 lines
76 KiB
Markdown
# Деревья
|
||
|
||
{width="3.71875in" height="4.8125in"}
|
||
|
||
#### двоичные деревья
|
||
|
||
> *Двоичное (бинарное) дерево* -- это нелинейная структура данных, представля- ющая отношения между предками и потомками и отражающая логику «раз- деляй и властвуй». Подобно спискам, основным элементом двоичного дерева является узел, который содержит значение, ссылку на левый дочерний узел и ссылку на правый дочерний узел.
|
||
>
|
||
> class TreeNode:
|
||
>
|
||
> \"\"\" Класс узла двоичного дерева.\"\"\" def init (self, val: int):
|
||
>
|
||
> self.val: int = val \# Значение узла.
|
||
>
|
||
> self.left: TreeNode \| None = None \# Ссылка на левый дочерний узел. self.right: TreeNode \| None = None \# Ссылка на правый дочерний узел.
|
||
>
|
||
> Каждый узел имеет две ссылки (указателя), указывающие на левый и пра- вый дочерние узлы. Текущий узел называется родительским для этих двух до- черних узлов. Для заданного узла дерево, образованное его левым дочерним узлом и всеми его подузлами, называется левым поддеревом. Аналогично определяется правое поддерево.
|
||
>
|
||
> **Узлы, не имеющие дочерних узлов**, **называются листьями**, **все осталь- ные узлы содержат дочерние узлы и непустые поддеревья**. Если рассма- тривать узел 2 на рис. 7.1 как родительский, то его левым и правым дочерними узлами будут узел 4 и узел 5 соответственно. Левое поддерево -- это узел 4 и все узлы ниже него, а правое поддерево -- узел 5 и все узлы ниже него.
|
||
|
||

|
||
|
||
> **Рис. 7.1.** Родительский узел, дочерние узлы, поддеревья
|
||
|
||
1. **Основные понятия двоичного дерева**
|
||
|
||
> Основные понятия двоичного дерева изображены на рис. 7.2.
|
||
|
||
- **Корневой узел**: узел, находящийся на верхнем уровне дерева и не име- ющий родительского узла.
|
||
|
||
- **Листовой узел**: узел, не имеющий дочерних узлов, оба его указателя указывают на None.
|
||
|
||
- **Ребро**: отрезок, соединяющий два узла, т. е. ссылка (указатель) узла.
|
||
|
||
- **Уровень узла**: увеличивается сверху вниз, уровень корневого узла равен 1.
|
||
|
||
- **Степень узла**: количество дочерних узлов узла. В двоичном дереве сте- пень может быть 0, 1 или 2.
|
||
|
||
- **Высота двоичного дерева**: количество ребер от корневого узла до са- мого удаленного листового узла.
|
||
|
||
- **Глубина узла**: количество ребер от корневого узла до данного узла.
|
||
|
||
- **Высота узла**: количество ребер от самого удаленного листового узла до данного узла.
|
||
|
||

|
||
|
||
> **Рис. 7.2.** Основные понятия двоичного дерева
|
||
|
||
### Основные операции с двоичными деревьями
|
||
|
||
##### Инициализация двоичного дерева
|
||
|
||
> Подобно спискам, сначала инициализируются узлы, затем строятся ссылки (указатели).
|
||
>
|
||
> \# === File: binary_tree.py ===
|
||
>
|
||
> \# Инициализация двоичного дерева. \# Инициализация узлов.
|
||
>
|
||
> n1 = TreeNode(val=1) n2 = TreeNode(val=2) n3 = TreeNode(val=3) n4 = TreeNode(val=4) n5 = TreeNode(val=5)
|
||
>
|
||
> \# Построение ссылок (указателей) между узлами. n1.left = n2
|
||
>
|
||
> n1.right = n3 n2.left = n4 n2.right = n5
|
||
|
||
##### Вставка и удаление узлов
|
||
|
||
> Подобно спискам, в двоичном дереве вставку и удаление узлов можно выпол- нять путем изменения указателей. На рис. 7.3 приведен пример.
|
||
|
||

|
||
|
||
> **Рис. 7.3.** Вставка и удаление узлов в двоичном дереве
|
||
>
|
||
> \# === File: binary_tree.py === \# Вставка и удаление узлов.
|
||
>
|
||
> p = TreeNode(0)
|
||
>
|
||
> \# Вставка узла P между n1 и n2.
|
||
>
|
||
> n1.left = p p.left = n2
|
||
>
|
||
> \# Удаление узла P. n1.left = n2
|
||
|
||
### Основные типы двоичных деревьев
|
||
|
||
##### Идеальное двоичное дерево
|
||
|
||
> В идеальном двоичном дереве все уровни узлов полностью заполнены, см. рис. 7.4. В таком дереве степень листовых узлов равна 0, а степень всех осталь- ных узлов равна 2. Если высота дерева равна *h*, то общее количество узлов равно 2*h*+1 − 1, что представляет собой стандартное экспоненциальное соотношение, отражающее явление деления клеток, которое часто встречается в природе.
|
||
|
||

|
||
|
||
> **Рис. 7.4.** Идеальное двоичное дерево
|
||
|
||
##### Совершенное двоичное дерево
|
||
|
||
> В совершенном двоичном дереве (complete binary tree) заполнены не полно- стью только узлы на самом нижнем уровне, и они заполняются слева направо, см. рис. 7.5. Обратите внимание, что идеальное двоичное дерево также явля- ется полным.
|
||
|
||

|
||
|
||
> **Рис. 7.5.** Совершенное двоичное дерево
|
||
|
||
##### Полное двоичное дерево
|
||
|
||
> В полном двоичном дереве (full binary tree) все узлы, кроме листовых, имеют два дочерних узла, см. рис. 7.6.
|
||
|
||

|
||
|
||
> **Рис. 7.6.** Полное двоичное дерево
|
||
|
||
##### Сбалансированное двоичное дерево
|
||
|
||
> В сбалансированном двоичном дереве абсолютное значение разности высот левого и правого поддеревьев любого узла не превышает 1, см. рис. 7.7.
|
||
|
||

|
||
|
||
> **Рис. 7.7.** Сбалансированное двоичное дерево
|
||
|
||
### Вырождение двоичного дерева
|
||
|
||
> На рис. 7.8 изображена идеальная и вырожденная структура двоичного дерева. Когда каждый уровень двоичного дерева полностью заполнен узлами, оно ста- новится идеальным. Если все узлы смещены в одну сторону, двоичное дерево вырождается в связный список.
|
||
|
||
- Идеальное двоичное дерево является оптимальным случаем, позволя- ющим в полной мере использовать преимущество подхода «разделяй и властвуй».
|
||
|
||
- Связный список представляет собой другой крайний случай, когда все опе- рации становятся линейными, а временная сложность деградирует до *O*(*n*).
|
||
|
||

|
||
|
||
> **Рис. 7.8.** Идеальная и вырожденная структуры двоичного дерева
|
||
>
|
||
> Как показано в табл. 7.1, в идеальной и вырожденной структурах двоичного дерева количество листьев, общее количество узлов и высота достигают мак- симальных или минимальных значений.
|
||
>
|
||
> **Таблица 7.1.** Идеальная и вырожденная структуры двоичного дерева
|
||
>
|
||
> **Идеальное двоичное дерево Связный список**
|
||
>
|
||
> Количество узлов на уровне *i* 2*i*−1 1
|
||
>
|
||
> Количество листьев в дереве высоты *h* 2*h* 1
|
||
>
|
||
> Общее количество узлов в дереве высоты *h*
|
||
>
|
||
> 2*h*+1 − 1 *h* + 1
|
||
>
|
||
> Высота дерева с *n* узлами log2(*n* + 1) − 1 *n* − 1
|
||
|
||
#### Обход двоичного дерева
|
||
|
||
> С физической точки зрения дерево является структурой данных, основанной на связном списке, поэтому его обход осуществляется последовательным до- ступом к узлам через указатели. Однако, будучи нелинейной структурой дан- ных, обход дерева сложнее, чем обход связного списка, и требует использова- ния алгоритмов поиска.
|
||
>
|
||
> Наиболее распространенные методы обхода двоичного дерева включают обход по уровням, прямой, симметричный и обратный обходы.
|
||
|
||
### Обход по уровням
|
||
|
||
> Обход по уровням осуществляется сверху вниз, выполняется последователь- ный обход двоичного дерева с посещением узлов на каждом уровне слева на- право, как показано на рис. 7.9.
|
||
>
|
||
> **Обход в ширину**
|
||
>
|
||
> **Обход по уровням**
|
||
>
|
||
> {width="8.0582895888014e-2in" height="8.0582895888014e-2in"}(узлы посещаются по точкам )
|
||
>
|
||
> **Рис. 7.9.** Обход двоичного дерева по уровням
|
||
>
|
||
> Обход по уровням по своей сути является обходом в ширину, также назы- ваемым поиском в ширину, который характеризуется постепенно расширяю- щимся кольцом от центра к периферии.
|
||
|
||
##### Код реализации
|
||
|
||
> Обход в ширину обычно реализуется с использованием очереди. Очередь сле- дует принципу «первый вошел -- первый вышел», а обход в ширину -- принципу
|
||
>
|
||
> «поэтапное продвижение», что делает их концептуально схожими. Ниже при- веден код реализации.
|
||
>
|
||
> \# === File: binary_tree_bfs.py ===
|
||
>
|
||
> def level_order(root: TreeNode \| None) -\> list\[int\]: \"\"\" Обход по уровням.\"\"\"
|
||
>
|
||
> \# Инициализация очереди, добавление корневого узла. queue: deque\[TreeNode\] = deque()
|
||
>
|
||
> queue.append(root)
|
||
>
|
||
> \# Инициализация списка для сохранения последовательности обхода. res = \[\]
|
||
>
|
||
> while queue:
|
||
>
|
||
> node: TreeNode = queue.popleft() \# Извлечение из очереди. res.append(node.val) \# Сохранение значения узла.
|
||
>
|
||
> if node.left is not None:
|
||
>
|
||
> queue.append(node.left) \# Добавление левого дочернего узла в оче-
|
||
>
|
||
> редь.
|
||
>
|
||
> if node.right is not None:
|
||
>
|
||
> queue.append(node.right) \# Добавление правого дочернего узла в очередь.
|
||
>
|
||
> return res
|
||
|
||
##### Анализ сложности
|
||
|
||
> **Временная сложность** *O*(*n*): каждый узел посещается один раз, что занимает
|
||
>
|
||
> *O*(*n*) времени выполнения, где *n* -- количество узлов.
|
||
>
|
||
> **Пространственная сложность** *O*(*n*): в худшем случае, т. е. в полном двоич- ном дереве, до достижения самого нижнего уровня в очереди может находить- ся одновременно (*n* + 1)/2 узлов, что занимает *O*(*n*) пространства.
|
||
|
||
### Прямой, симметричный и обратный обходы
|
||
|
||
> Прямой, симметричный и обратный обходы относятся к обходам в глубину, также называемым поиск в глубину, который характеризуется подходом «сна- чала до конца, затем возврат и продолжение».
|
||
>
|
||
> На рис. 7.10 демонстрируется принцип работы обхода в глубину для дво- ичного дерева. **Обход в глубину можно представить как обход двоичного дерева по периметру**, при этом на каждом узле встречаются три позиции, соответствующие прямому, симметричному и обратному обходам.
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 7.10.** Прямой, симметричный и обратный обходы двоичного дерева
|
||
|
||
##### Код реализации
|
||
|
||
> Поиск в глубину обычно реализуется на основе рекурсии.
|
||
>
|
||
> \# === File: binary_tree_dfs.py === def pre_order(root: TreeNode \| None):
|
||
>
|
||
> \"\"\" Прямой обход.\"\"\"
|
||
>
|
||
> if root is None: return
|
||
>
|
||
> \# Приоритет посещения: корневой узел -\> левое поддерево -\> правое поддерево. res.append(root.val)
|
||
>
|
||
> pre_order(root=root.left) pre_order(root=root.right)
|
||
>
|
||
> def in_order(root: TreeNode \| None): \"\"\" Симметричный обход.\"\"\"
|
||
>
|
||
> if root is None: return
|
||
>
|
||
> \# Приоритет посещения: левое поддерево -\> корневой узел -\> правое поддерево. in_order(root=root.left)
|
||
>
|
||
> res.append(root.val) in_order(root=root.right)
|
||
>
|
||
> def post_order(root: TreeNode \| None): \"\"\" Обратный обход.\"\"\"
|
||
>
|
||
> if root is None: return
|
||
>
|
||
> \# Приоритет посещения: левое поддерево -\> правое поддерево -\> корневой узел. post_order(root=root.left)
|
||
>
|
||
> post_order(root=root.right) res.append(root.val)
|
||
>
|
||
> На рис. 7.11 демонстрируется рекурсивный процесс прямого обхода двоич- ного дерева, который можно разделить на два противоположных этапа: рекур- сия и возврат.
|
||
|
||
1. Рекурсия означает начало нового метода, в процессе которого програм- ма посещает следующий узел.
|
||
|
||
2. Возврат означает возвращение функции, что указывает на завершение посещения текущего узла.
|
||
|
||

|
||
|
||
> **Рис. 7.11.** Рекурсивный процесс прямого обхода. Шаги 1--2
|
||
>
|
||
> 
|
||
|
||

|
||
|
||
> **Рис. 7.11.** *Продолжение*. Шаги 3--5
|
||
>
|
||
> 
|
||
|
||

|
||
|
||
> **Рис. 7.11.** *Продолжение*. Шаги 6--8
|
||
>
|
||
> 
|
||
|
||

|
||
|
||
> **Рис. 7.11.** *Окончание*. Шаги 9--11
|
||
|
||
##### 2. Анализ сложности
|
||
|
||
> **Временная сложность** *O*(*n*): все узлы посещаются один раз, что занимает *O*(*n*) времени.
|
||
>
|
||
> **Пространственная сложность** *O*(*n*): в худшем случае, когда дерево вырож- дается в список, глубина рекурсии достигает *n*, система занимает *O*(*n*) про- странства стека.
|
||
|
||
#### представление двоичного дерева с помощью массива
|
||
|
||
> При представлении в виде списка единицей хранения двоичного дерева яв- ляется узел TreeNode, а узлы соединяются между собой указателями. В преды- дущем разделе были рассмотрены основные операции с двоичным деревом, представленным в виде списка.
|
||
>
|
||
> Можно ли представить двоичное дерево с помощью массива? Ответ поло- жительный.
|
||
|
||
### Представление идеального двоичного дерева
|
||
|
||
> Сначала рассмотрим простой пример. Если дано идеальное двоичное дерево и все его узлы хранятся в массиве в порядке обхода по уровням, то каждому узлу соответствует уникальный индекс массива.
|
||
>
|
||
> На основе свойств обхода по уровням можно вывести формулу соответ- ствия между индексами родительского и дочерних узлов: **если индекс узла равен** *i*, **то индекс его левого дочернего узла равен** 2*i* + 1, **а правого** -- 2*i* + 2. На рис. 7.12 показаны отношения соответствия между индексами узлов.
|
||
|
||

|
||
|
||
> **Рис. 7.12.** Представление идеального двоичного дерева с помощью массива
|
||
>
|
||
> **Формула соответствия играет роль**, **аналогичную ссылкам (указате- лям) в списке**. Имея любой узел в массиве, можно с помощью формулы полу- чить доступ к его левому и правому дочерним узлам.
|
||
|
||
### Представление произвольного двоичного дерева
|
||
|
||
> Идеальное двоичное дерево является частным случаем. Обычно на средних уровнях двоичного дерева присутствует много пустых значений None. Но по- следовательность обхода по уровням не содержит этих None, поэтому невоз- можно по этой последовательности определить количество и расположение пустых значений. **Это означает**, **что существует множество структур дво- ичных деревьев**, **соответствующих данной последовательности обхода по уровням**.
|
||
>
|
||
> Для такого неидеального двоичного дерева вышеописанный метод пред- ставления с помощью массива уже не работает, см. рис. 7.13.
|
||
|
||

|
||
|
||
> **Рис. 7.13.** Для одной последовательности обхода по уровням существует несколько возможных вариантов двоичного дерева
|
||
>
|
||
> Для решения этой проблемы **можно явно записать все значения** None **в последовательности обхода по уровням**. После такой обработки последо- вательность обхода по уровням уже может однозначно представлять двоич- ное дерево, как показано на рис. 7.14. Ниже приведен пример кода.
|
||
>
|
||
> \# Представление двоичного дерева с помощью массива. \# Использование None для обозначения пустых мест.
|
||
>
|
||
> tree = \[1, 2, 3, 4, None, 6, 7, 8, 9, None, None, 12, None, None, 15\]
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 7.14.** Представление произвольного двоичного дерева с помощью массива
|
||
>
|
||
> Стоит отметить, что **совершенное двоичное дерево очень удобно пред- ставлять с помощью массива**. Вспоминая определение совершенного дво- ичного дерева, None появляются только на самом нижнем уровне и в правой части, поэтому **все значения** None **обязательно находятся в конце последо- вательности обхода по уровням**.
|
||
>
|
||
> Это означает, что при использовании массива для представления совершен- ного двоичного дерева можно опустить хранение всех None, что очень удобно. На рис. 7.15 приведен пример такого представления.
|
||
|
||

|
||
|
||
> **Рис. 7.15.** Представление совершенного двоичного дерева с помощью массива
|
||
>
|
||
> В коде ниже реализуется двоичное дерево, основанное на представлении с помощью массива, включая следующие операции.
|
||
|
||
- Для заданного узла получение его значения, левого и правого дочернего узла, родительского узла.
|
||
|
||
- Получение последовательностей обхода в прямом, симметричном, об- ратном порядке и в порядке обхода по уровням.
|
||
|
||
> \# === File: array_binary_tree.py === class ArrayBinaryTree:
|
||
>
|
||
> \"\"\" Класс двоичного дерева, представленного с помощью массива.\"\"\"
|
||
>
|
||
> def init (self, arr: list\[int \| None\]): \"\"\" Конструктор.\"\"\"
|
||
>
|
||
> self.\_tree = list(arr)
|
||
>
|
||
> def size(self):
|
||
>
|
||
> \"\"\" Вместимость списка.\"\"\" return len(self.\_tree)
|
||
>
|
||
> def val(self, i: int) -\> int \| None:
|
||
>
|
||
> \"\"\" Получение значения узла с индексом i.\"\"\"
|
||
>
|
||
> \# Если индекс выходит за границы, возвращается None, \# обозначающее пустое место.
|
||
>
|
||
> if i \< 0 or i \>= self.size(): return None
|
||
>
|
||
> return self.\_tree\[i\]
|
||
>
|
||
> def left(self, i: int) -\> int \| None:
|
||
>
|
||
> \"\"\" Получение индекса левого дочернего узла для узла с индексом i.\"\"\" return 2 \* i + 1
|
||
>
|
||
> def right(self, i: int) -\> int \| None:
|
||
>
|
||
> \"\"\" Получение индекса правого дочернего узла для узла с индексом i.\"\"\" return 2 \* i + 2
|
||
>
|
||
> def parent(self, i: int) -\> int \| None:
|
||
>
|
||
> \"\"\" Получение индекса родительского узла для узла с индексом i.\"\"\" return (i - 1) // 2
|
||
>
|
||
> def level_order(self) -\> list\[int\]: \"\"\" Обход по уровням.\"\"\" self.res = \[\]
|
||
>
|
||
> \# Прямой обход массива.
|
||
>
|
||
> for i in range(self.size()):
|
||
>
|
||
> if self.val(i) is not None: self.res.append(self.val(i))
|
||
>
|
||
> return self.res
|
||
>
|
||
> def dfs(self, i: int, order: str): \"\"\" Обход в глубину.\"\"\"
|
||
>
|
||
> if self.val(i) is None: return
|
||
>
|
||
> \# Прямой обход.
|
||
>
|
||
> if order == \"pre\": self.res.append(self.val(i)) self.dfs(self.left(i), order)
|
||
>
|
||
> \# Симметричный обход. if order == \"in\":
|
||
>
|
||
> self.res.append(self.val(i)) self.dfs(self.right(i), order)
|
||
>
|
||
> \# Обратный обход. if order == \"post\":
|
||
>
|
||
> self.res.append(self.val(i))
|
||
>
|
||
> def pre_order(self) -\> list\[int\]: \"\"\" Прямой обход.\"\"\"
|
||
>
|
||
> self.res = \[\] self.dfs(0, order=\"pre\") return self.res
|
||
>
|
||
> def in_order(self) -\> list\[int\]: \"\"\" Симметричный обход.\"\"\" self.res = \[\]
|
||
>
|
||
> self.dfs(0, order=\"in\") return self.res
|
||
>
|
||
> def post_order(self) -\> list\[int\]: \"\"\" Обратный обход.\"\"\" self.res = \[\]
|
||
>
|
||
> self.dfs(0, order=\"post\") return self.res
|
||
|
||
### Преимущества и ограничения
|
||
|
||
> Представление двоичного дерева с помощью массива имеет следующие пре- имущества:
|
||
|
||
- массив хранится в непрерывной области памяти, что хорошо для кеши- рования. Скорость доступа и обхода достаточно высока;
|
||
|
||
- не требуется хранение указателей, что экономит пространство;
|
||
|
||
- позволяет выполнять произвольный доступ к узлам.
|
||
|
||
> Однако представление с помощью массива имеет и некоторые ограничения.
|
||
|
||
- Хранение в массиве требует непрерывной области памяти, поэтому не подходит для хранения деревьев с очень большим объемом данных.
|
||
|
||
- Добавление и удаление узлов требует выполнения операций вставки и удаления в массиве, которые менее эффективны.
|
||
|
||
- Когда в двоичном дереве содержится много значений None, доля данных узлов в массиве низка, что приводит к низкой эффективности использо- вания пространства.
|
||
|
||
#### двоичное дерево поиска
|
||
|
||
> *Двоичное дерево поиска* удовлетворяет следующим условиям, см. рис. 7.16:
|
||
|
||
1) для корневого узла все значения узлов в левом поддереве \< значение корневого узла \< все значения узлов в правом поддереве;
|
||
|
||
2) левое и правое поддеревья любого узла также являются двоичными де- ревьями поиска, т. е. удовлетворяют условию 1.
|
||
|
||

|
||
|
||
> **Рис. 7.16.** Двоичное дерево поиска
|
||
|
||
### Операции с двоичным деревом поиска
|
||
|
||
> Мы инкапсулируем двоичное дерево поиска в класс BinarySearchTree и объявля- ем переменную-член root, указывающую на корневой узел дерева.
|
||
|
||
##### Поиск узла
|
||
|
||
> Для заданного значения целевого узла num можно выполнить поиск, основы- ваясь на свойствах двоичного дерева поиска. Мы объявляем текущий узел cur, начиная с корневого узла дерева root, и в цикле сравниваем значения узлов cur.val и num, как показано на рис. 7.17.
|
||
|
||
- Если cur.val \< num, значит целевой узел находится в правом поддереве
|
||
|
||
> cur, поэтому выполняется переход cur = cur.right.
|
||
|
||
- Если cur.val \> num, значит целевой узел находится в левом поддереве cur, поэтому выполняется переход cur = cur.left.
|
||
|
||
- Если cur.val = num, значит целевой узел найден, выполняется выход из цикла и возврат этого узла.
|
||
|
||
> 
|
||
|
||

|
||
|
||
> **Рис. 7.17.** Пример поиска узла в двоичном дереве поиска. Шаги 1--3
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 7.17.** *Окончание*. Шаг 4
|
||
>
|
||
> Операция поиска в двоичном дереве поиска аналогична принципу работы алго- ритма двоичного поиска, который исключает половину случаев на каждой итера- ции. Максимальное количество циклов равно высоте двоичного дерева, и при сба- лансированном дереве требуется *O*(log *n*) времени. Ниже приведен пример кода.
|
||
>
|
||
> \# === File: binary_search_tree.py ===
|
||
>
|
||
> def search(self, num: int) -\> TreeNode \| None: \"\"\" Поиск узла.\"\"\"
|
||
>
|
||
> cur = self.\_root
|
||
>
|
||
> \# Циклический поиск, выход после прохождения листового узла. while cur is not None:
|
||
>
|
||
> \# Целевой узел в правом поддереве cur. if cur.val \< num:
|
||
>
|
||
> cur = cur.right
|
||
>
|
||
> \# Целевой узел в левом поддереве cur. elif cur.val \> num:
|
||
>
|
||
> cur = cur.left
|
||
>
|
||
> \# Найден целевой узел, выход из цикла.
|
||
>
|
||
> else:
|
||
>
|
||
> break return cur
|
||
|
||
##### Вставка узла
|
||
|
||
> Нужно вставить новый элемент num и при этом сохранить свойство двоичного дерева поиска: левое поддерево \< корневой узел \< правое поддерево. Процесс вставки показан на рис. 7.18.
|
||
|
||
1. **Поиск позиции для вставки**: аналогично операции поиска, начиная с корневого узла, в цикле выполняется поиск вниз по дереву в зависимо- сти от соотношения значений текущего узла и num, пока не будет пройден листовой узел (достигнуто None), после чего цикл завершается.
|
||
|
||
2. **Вставка узла в найденную позицию**: инициализация узла num и раз- мещение его в позиции None.
|
||
|
||

|
||
|
||
> **Рис. 7.18.** Вставка узла в двоичное дерево поиска
|
||
>
|
||
> В коде реализации следует обратить внимание на следующие моменты.
|
||
|
||
- В двоичном дереве поиска не допускается наличие дублирующихся уз- лов, иначе будет нарушено его определение. Поэтому, если узел, который нужно вставить, уже существует в дереве, вставка не выполняется, и про- исходит возврат.
|
||
|
||
- Для выполнения вставки узла необходимо использовать узел pre, чтобы сохранить узел предыдущей итерации цикла. Таким образом, при дости- жении None можно получить родительский узел и завершить операцию вставки узла.
|
||
|
||
> \# === File: binary_search_tree.py === def insert(self, num: int):
|
||
>
|
||
> \"\"\" Вставка узла.\"\"\"
|
||
>
|
||
> \# Если дерево пусто, инициализация корневого узла. if self.\_root is None:
|
||
>
|
||
> self.\_root = TreeNode(num) return
|
||
>
|
||
> \# Циклический поиск, выход после прохождения листового узла. cur, pre = self.\_root, None
|
||
>
|
||
> while cur is not None:
|
||
>
|
||
> \# Найден дублирующий узел, возврат. if cur.val == num:
|
||
>
|
||
> return pre = cur
|
||
>
|
||
> \# Позиция для вставки в правом поддереве cur. if cur.val \< num:
|
||
>
|
||
> cur = cur.right
|
||
>
|
||
> \# Позиция для вставки в левом поддереве cur.
|
||
>
|
||
> else:
|
||
>
|
||
> cur = cur.left \# Вставка узла.
|
||
>
|
||
> node = TreeNode(num) if pre.val \< num:
|
||
>
|
||
> pre.right = node
|
||
>
|
||
> else:
|
||
>
|
||
> pre.left = node
|
||
>
|
||
> Как и в случае поиска узла, вставка узла выполняется за время *O*(log *n*).
|
||
|
||
##### Удаление узла
|
||
|
||
> Сначала в двоичном дереве выполняется поиск целевого узла, после чего он удаляется. Как и при вставке узла, необходимо гарантировать, что после за- вершения операции удаления сохраняется свойство двоичного дерева поиска: левое поддерево \< корневой узел \< правое поддерево. Поэтому, в зависимости от количества дочерних узлов целевого узла (0, 1 или 2), выполняются соответ- ствующие операции его удаления.
|
||
>
|
||
> Если степень удаляемого узла равна 0, значит он является листовым, и его можно удалить напрямую, см. рис. 7.19.
|
||
|
||

|
||
|
||
> **Рис. 7.19.** Удаление узла в двоичном дереве поиска (степень 0)
|
||
>
|
||
> Если степень удаляемого узла равна 1, его можно заменить дочерним узлом, см. рис. 7.20.
|
||
|
||
 **Удаление узла из двоичного дерева поиска**
|
||
|
||
1. *Найти узел* **cur**,
|
||
|
||
> *подлежащий удалению*
|
||
>
|
||
> *У узла* **cur** *количество дочерних узлов = 1*
|
||
|
||
2. *Заменить узел* **cur** *его дочерним узлом*
|
||
|
||
> *Выполнить* **pre**.left = **cur**.right
|
||
>
|
||
> **Рис. 7.20.** Удаление узла в двоичном дереве поиска (степень 1)
|
||
>
|
||
> Если степень удаляемого узла равна 2, его нельзя удалить напрямую, и не- обходимо заменить его другим узлом. Согласно свойству двоичного дерева по- иска левое поддерево \< корневой узел \< правое поддерево, **этот узел может быть минимальным узлом правого поддерева или максимальным узлом левого поддерева**.
|
||
>
|
||
> Предположим, что мы выбираем минимальный узел правого поддерева (следующий узел при симметричном обходе), тогда процесс удаления будет следующим (см. рис. 7.21):
|
||
|
||
1) найти следующий узел в последовательности симметричного обхода для узла, который необходимо удалить, и обозначить его как tmp;
|
||
|
||
2) заменить значение удаляемого узла значением tmp и рекурсивно уда- лить узел tmp из дерева.
|
||
|
||

|
||
|
||
> **Рис. 7.21.** Удаление узла в двоичном дереве поиска (степень 2). Шаг 1
|
||
>
|
||
> 
|
||
|
||

|
||
|
||
> **Рис. 7.21.** *Продолжение*.. Шаг 2--3
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 7.21.** *Окончание*. Шаг 4
|
||
>
|
||
> Операция удаления узла также выполняется за время *O*(log *n*). При этом по- иск удаляемого узла требует времени *O*(log *n*) и получение следующего узла при симметричном обходе также требует времени *O*(log *n*). Ниже приведен пример кода.
|
||
>
|
||
> \# === File: binary_search_tree.py === def remove(self, num: int):
|
||
>
|
||
> \"\"\" Удаление узла.\"\"\"
|
||
>
|
||
> \# Если дерево пусто, немедленный возврат. if self.\_root is None:
|
||
>
|
||
> return
|
||
>
|
||
> \# Циклический поиск, выход после прохождения листового узла. cur, pre = self.\_root, None
|
||
>
|
||
> while cur is not None:
|
||
>
|
||
> \# Найден узел для удаления, выход из цикла. if cur.val == num:
|
||
>
|
||
> break pre = cur
|
||
>
|
||
> \# Узел для удаления находится в правом поддереве cur. if cur.val \< num:
|
||
>
|
||
> cur = cur.right
|
||
>
|
||
> \# Узел для удаления находится в левом поддереве cur.
|
||
>
|
||
> else:
|
||
>
|
||
> cur = cur.left
|
||
>
|
||
> \# Если узел для удаления не найден, возврат. if cur is None:
|
||
>
|
||
> return
|
||
>
|
||
> \# Количество дочерних узлов = 0 или 1.
|
||
>
|
||
> if cur.left is None or cur.right is None:
|
||
>
|
||
> \# Если количество дочерних узлов = 0 / 1, child = null / этот дочерний узел. child = cur.left or cur.right
|
||
>
|
||
> \# Удаление узла cur. if cur != self.\_root:
|
||
>
|
||
> if pre.left == cur: pre.left = child
|
||
>
|
||
> else:
|
||
>
|
||
> pre.right = child
|
||
>
|
||
> else:
|
||
>
|
||
> \# Если удаляемый узел - корень, переназначаем корень. self.\_root = child
|
||
>
|
||
> \# Количество дочерних узлов = 2.
|
||
>
|
||
> else:
|
||
>
|
||
> \# Получение следующего узла при симметричном обходе для cur. tmp: TreeNode = cur.right
|
||
>
|
||
> while tmp.left is not None: tmp = tmp.left
|
||
>
|
||
> \# Рекурсивное удаление узла tmp. self.remove(tmp.val)
|
||
>
|
||
> \# Замена cur на tmp cur.val = tmp.val
|
||
|
||
##### 4. Упорядоченность симметричного обхода
|
||
|
||
> Симметричный обход двоичного дерева следует порядку лево → корень → пра- во, а двоичное дерево поиска удовлетворяет соотношению левый узел \< корне- вой узел \< правый узел, см. рис. 7.22.
|
||
>
|
||
> Это означает, что при симметричном обходе двоичного дерева поиска всег- да сначала будет посещаться следующий минимальный узел, что приводит к важному свойству: **последовательность симметричного обхода двоично- го дерева поиска является возрастающей**.
|
||
>
|
||
> Используя свойство возрастающей последовательности симметричного обхода, можно получить упорядоченные данные в двоичном дереве поиска за время *O*(*n*) без необходимости в дополнительных операциях сортировки, что очень эффективно.
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 7.22.** Симметричный обход двоичного дерева поиска
|
||
|
||
### Эффективность двоичного дерева поиска
|
||
|
||
> Для заданного набора данных можно использовать для его хранения массив или двоичное дерево поиска. Как показано в табл. 7.2, временная сложность операций в двоичном дереве поиска имеет логарифмический порядок, что обеспечивает стабильную и высокую производительность. Только в случае ча- стого добавления и редкого поиска и удаления данных массив будет более эф- фективен, чем двоичное дерево поиска.
|
||
>
|
||
> **Таблица 7.2.** Сравнение эффективности массива и дерева поиска
|
||
|
||
+------------------------+------------------------------+------------------------------+
|
||
| > **Операция** | > **Неупорядоченный массив** | > **Двоичное дерево поиска** |
|
||
+========================+==============================+==============================+
|
||
| > Поиск элемента | > *O*(*n*) | > *O*(log *n*) |
|
||
+------------------------+------------------------------+------------------------------+
|
||
| > Вставка элемента | > *O*(1) | > *O*(log *n*) |
|
||
+------------------------+------------------------------+------------------------------+
|
||
| > Удаление элемента | > *O*(*n*) | > *O*(log *n*) |
|
||
+------------------------+------------------------------+------------------------------+
|
||
| > В идеальных условиях | > двоичное дерево поиска | > является сбалансирован- |
|
||
+------------------------+------------------------------+------------------------------+
|
||
|
||
> ным, что позволяет находить любой узел за log *n* итераций.
|
||
>
|
||
> Однако, если в двоичном дереве поиска постоянно добавлять и удалять узлы, это может привести к его вырождению в список, как показано на рис. 7.23. Тог- да временная сложность различных операций также деградирует до *O*(*n*).
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 7.23.** Вырождение двоичного дерева поиска
|
||
|
||
### Типичные сценарии применения двоичного дерева поиска
|
||
|
||
- Используется в качестве многоуровневого индекса в системах для эф- фективного поиска, вставки и удаления.
|
||
|
||
- Служит базовой структурой данных для некоторых алгоритмов поиска.
|
||
|
||
- Применяется для хранения потока данных для поддержания его упоря- доченного состояния.
|
||
|
||
1. **АВЛ-дерево\***
|
||
|
||
> В разделе «Двоичное дерево поиска» упоминалось, что после многократ- ных операций вставки и удаления двоичное дерево поиска может выродит- ся в список. В таких случаях временная сложность всех операций ухудшается с *O*(log *n*) до *O*(*n*).
|
||
>
|
||
> На рис. 7.24 приведен пример, когда после двух операций удаления узлов двоичное дерево поиска вырождается в список.
|
||
>
|
||
> В другом примере после вставки двух узлов в идеальное двоичное дерево, показанное на рис. 7.25, дерево сильно наклоняется влево, и временная слож- ность операций поиска также ухудшается.
|
||
>
|
||
> В 1962 году советские математики Г. М. Адельсон-Вельский и Е. М. Ландис в статье «Один алгоритм организации информации» предложили структуру АВЛ- дерева. В статье подробно описывается серия операций, которые гарантируют, что после постоянного добавления и удаления узлов АВЛ-дерево не деградиру- ет, что позволяет поддерживать временную сложность различных операций на уровне *O*(log *n*). Иными словами, в сценариях, требующих частых операций до- бавления, удаления, поиска и изменения, АВЛ-дерево обеспечивает высокую эф- фективность операций с данными и имеет значительную прикладную ценность.
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 7.24.** Вырождение АВЛ-дерева после удаления узлов
|
||
|
||

|
||
|
||
> **Рис. 7.25.** Вырождение АВЛ-дерева после вставки узлов
|
||
|
||
### Основные понятия АВЛ-дерева
|
||
|
||
> *АВЛ-дерево* является одновременно и двоичным деревом поиска, и сбалан- сированным двоичным деревом, удовлетворяя всем свойствам этих двух типов деревьев. Таким образом, оно представляет собой сбалансированное двоичное дерево поиска.
|
||
|
||
##### Высота узла
|
||
|
||
> Поскольку операции с АВЛ-деревом требуют получения высоты узла, необхо- димо добавить в класс узла переменную height.
|
||
>
|
||
> class TreeNode:
|
||
>
|
||
> \"\"\"Класс узла AVL-дерева.\"\"\"
|
||
>
|
||
> def init (self, val: int):
|
||
>
|
||
> self.val: int = val \# Значение узла. self.height: int = 0 \# Высота узла.
|
||
>
|
||
> self.left: TreeNode \| None = None \# Ссылка на левый дочерний узел. self.right: TreeNode \| None = None \# Ссылка на правый дочерний узел.
|
||
>
|
||
> Высота узла определяется как расстояние от данного узла до самого удаленно- го листа, т. е. количество ребер, через которые проходит этот путь. Следует особо отметить, что высота листа равна 0, а высота пустого узла равна --1. Нам понадо- бятся две вспомогательные функции для получения и обновления высоты узла.
|
||
>
|
||
> \# === File: avl_tree.py ===
|
||
>
|
||
> def height(self, node: TreeNode \| None) -\> int: \"\"\"Получение высоты узла.\"\"\"
|
||
>
|
||
> \# Высота пустого узла равна -1, высота листа равна 0.
|
||
>
|
||
> if node is not None:
|
||
>
|
||
> return node.height return -1
|
||
>
|
||
> def update_height(self, node: TreeNode \| None): \"\"\"Обновление высоты узла.\"\"\"
|
||
>
|
||
> \# Высота узла равна высоте самого высокого поддерева + 1.
|
||
>
|
||
> node.height = max(\[self.height(node.left), self.height(node.right)\]) + 1
|
||
|
||
##### Фактор баланса узла
|
||
|
||
> Фактор баланса узла определяется как высота левого поддерева узла минус высота правого поддерева, при этом фактор баланса пустого узла равен 0. Мы обернем функцию получения фактора баланса узла в отдельную функцию для удобства дальнейшего использования.
|
||
>
|
||
> \# === File: avl_tree.py ===
|
||
>
|
||
> def balance_factor(self, node: TreeNode \| None) -\> int: \"\"\"Получение балансировочного фактора.\"\"\"
|
||
>
|
||
> \# Фактор баланса пустого узла равен 0.
|
||
>
|
||
> if node is None:
|
||
>
|
||
> return 0
|
||
>
|
||
> \# Фактор баланса узла = высота левого поддерева - высота правого поддерева. return self.height(node.left) - self.height(node.right)
|
||
|
||
### Вращение в АВЛ-дереве
|
||
|
||
> Особенностью АВЛ-дерева является операция вращения, которая позволяет восстановить баланс узла, не влияя на порядок обхода двоичного дерева. Ины- ми словами, **вращение поворота сохраняет свойства двоичного дерева поиска и делает дерево снова сбалансированным двоичным деревом**.
|
||
>
|
||
> Узлы с абсолютным значением фактора баланса \> 1 называются несбалан- сированными узлами. В зависимости от типа несбалансированности узла опе- рации вращения делятся на четыре типа: правое; левое; сначала правое, затем левое; сначала левое, затем правое. Рассмотрим их подробнее.
|
||
|
||
##### Правое вращение
|
||
|
||
> На рис. 7.26 ниже узла указан фактор баланса. Если идти снизу вверх, в дво- ичном дереве первым несбалансированным узлом является узел 3. Рассмо- трим поддерево с этим узлом в качестве корня: обозначим этот узел как node, а его левый дочерний узел как child и выполним операцию правого вращения. После завершения операции поддерево восстанавливает баланс и сохраняет свойства двоичного дерева поиска.
|
||
|
||

|
||
|
||
> **Рис. 7.26.** Этапы правого вращения. Шаги 1--2
|
||
>
|
||
> 
|
||
|
||

|
||
|
||
> **Рис. 7.26.** *Окончание*.. Шаги 3--4
|
||
>
|
||
> Если у узла child есть правый дочерний узел (обозначим его как grand_child), необходимо добавить в правое вращение еще один шаг: сделать grand_child ле- вым дочерним узлом для node.
|
||
>
|
||
> Правое вращение -- это образное выражение, фактически оно реализуется путем изменения указателей узлов, как показано в приведенном ниже коде.
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 7.27.** Правое вращение с grand_child
|
||
>
|
||
> \# === File: avl_tree.py ===
|
||
>
|
||
> def right_rotate(self, node: TreeNode \| None) -\> TreeNode \| None: \"\"\"Правое вращение.\"\"\"
|
||
>
|
||
> child = node.left grand_child = child.right
|
||
>
|
||
> \# С использованием child в качестве опорной точки выполнить правое вращение node. child.right = node
|
||
>
|
||
> node.left = grand_child
|
||
>
|
||
> \# Обновление высоты узлов. self.update_height(node) self.update_height(child)
|
||
>
|
||
> \# Возврат корневого узла поддерева после вращения. return child
|
||
|
||
##### Левое вращение
|
||
|
||
> Соответственно, если рассмотреть зеркальное отражение вышеупомянутого несбалансированного двоичного дерева, необходимо выполнить операцию левого вращения, как показано на рис. 7.28.
|
||
|
||

|
||
|
||
> **Рис. 7.28.** Левое вращение
|
||
>
|
||
> Аналогично, если у узла child есть левый дочерний узел (обозначим его как grand_child), необходимо добавить в левое вращение еще один шаг: сделать grand_child правым дочерним узлом для node, как показано на рис. 7.29.
|
||
|
||

|
||
|
||
> **Рис. 7.29.** Левое вращение с grand_child
|
||
>
|
||
> Можно заметить, **что правое и левое вращение логически являются зеркально симметричными, и они решают две симметричные ситуации несбалансированности**. Поэтому достаточно заменить в коде реализации правого вращения все left на right и все right на left, чтобы получить код реа- лизации левого вращения.
|
||
>
|
||
> \# === File: avl_tree.py ===
|
||
>
|
||
> def left_rotate(self, node: TreeNode \| None) -\> TreeNode \| None: \"\"\"Левый поворот.\"\"\"
|
||
>
|
||
> child = node.right grand_child = child.left
|
||
>
|
||
> \# С использованием child в качестве опорной точки выполнить левый поворот node. child.left = node
|
||
>
|
||
> node.right = grand_child
|
||
>
|
||
> \# Обновление высоты узлов. self.update_height(node) self.update_height(child)
|
||
>
|
||
> \# Возврат корневого узла поддерева после поворота. return child
|
||
|
||
##### Сначала левое, затем правое вращение
|
||
|
||
> Для несбалансированного узла 3 на рис. 7.30 использование только левого или правого вращения не позволяет восстановить баланс поддерева. В этом случае необходимо сначала выполнить левое вращение для child, а затем правое вра- щение для node.
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 7.30.** Сначала левое, затем правое вращение
|
||
|
||
##### Сначала правое, затем левое вращение
|
||
|
||
> Для зеркальной ситуации вышеупомянутого разбалансированного двоичного дерева необходимо сначала выполнить правое вращение для child, а затем ле- вое вращение для node, как показано на рис. 7.31.
|
||
|
||

|
||
|
||
> **Рис. 7.31.** Сначала правое, затем левое вращение
|
||
|
||
##### Выбор типа вращения
|
||
|
||
> На рис. 7.32 изображено четыре типа несбалансированности, соответствую- щие вышеописанным случаям, для которых необходимо применять операции: правого вращения; сначала левого, затем правого вращения; сначала правого, затем левого вращения; левого вращения соответственно.
|
||
>
|
||
> Из табл. 7.3 видно, что для определения того, к какому случаю из рис. 7.32 относится несбалансированный узел, используется фактор баланса узла и знак фактора баланса дочернего узла с большей высотой.
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 7.32.** Четыре типа вращений в АВЛ-дереве
|
||
|
||
+-------------------------------------------------+----------------------+----------------------------------------+
|
||
| > **Фактор баланса несбаланси- рованного узла** | > **Фактор баланса** | > **Рекомендуемый метод вращения** |
|
||
+=================================================+======================+========================================+
|
||
| > \> 1 (левостороннее дерево) | > ≥ 0 | > Правое вращение |
|
||
+-------------------------------------------------+----------------------+----------------------------------------+
|
||
| > \> 1 (левостороннее дерево) | > \< 0 | > Сначала левое, затем правое вращение |
|
||
+-------------------------------------------------+----------------------+----------------------------------------+
|
||
| > \< -1 (правостороннее дерево) | > ≤ 0 | > Левое вращение |
|
||
+-------------------------------------------------+----------------------+----------------------------------------+
|
||
| > \< -1 (правостороннее дерево) | > \> 0 | > Сначала правое, затем левое вращение |
|
||
+-------------------------------------------------+----------------------+----------------------------------------+
|
||
|
||
> **дочернего узла**
|
||
>
|
||
> Для удобства использования операции вращения инкапсулированы в функцию. **С помощью этой функции можно выполнять вращения для различных случаев несбалансированности узла**. Ниже приведен код ре- ализации.
|
||
>
|
||
> \# === File: avl_tree.py ===
|
||
>
|
||
> def rotate(self, node: TreeNode \| None) -\> TreeNode \| None:
|
||
>
|
||
> \"\"\" Выполнение операции вращения для восстановления баланса поддерева.\"\"\" \# Получение фактора баланса узла node.
|
||
>
|
||
> balance_factor = self.balance_factor(node) \# Левостороннее дерево.
|
||
>
|
||
> if balance_factor \> 1:
|
||
>
|
||
> if self.balance_factor(node.left) \>= 0: \# Правое вращение.
|
||
>
|
||
> return self.right_rotate(node)
|
||
>
|
||
> else:
|
||
>
|
||
> \# Сначала левое, затем правое вращение. node.left = self.left_rotate(node.left) return self.right_rotate(node)
|
||
>
|
||
> \# Правостороннее дерево. elif balance_factor \< -1:
|
||
>
|
||
> if self.balance_factor(node.right) \<= 0: \# Левое вращение.
|
||
>
|
||
> return self.left_rotate(node)
|
||
>
|
||
> else:
|
||
>
|
||
> \# Сначала правое, затем левое вращение. node.right = self.right_rotate(node.right) return self.left_rotate(node)
|
||
>
|
||
> \# Сбалансированное дерево, вращение не требуется. return node
|
||
|
||
### Основные операции с АВЛ-деревом
|
||
|
||
##### Вставка узла
|
||
|
||
> Операция вставки узла в АВЛ-дерево в основном схожа с двоичным деревом поиска. Единственное отличие заключается в том, что после вставки узла в АВЛ-дерево на пути от этого узла к корню могут возникнуть несбалансиро- ванные узлы. Поэтому*,* **начиная с этого узла**, **необходимо выполнять вра- щения снизу вверх**, **чтобы восстановить баланс всех несбалансирован- ных узлов**. Ниже приведен код реализации.
|
||
>
|
||
> \# === File: avl_tree.py === def insert(self, val):
|
||
>
|
||
> \"\"\" Вставка узла.\"\"\"
|
||
>
|
||
> self.\_root = self.insert_helper(self.\_root, val)
|
||
>
|
||
> def insert_helper(self, node: TreeNode \| None, val: int) -\> TreeNode: \"\"\" Рекурсивная вставка узла (вспомогательный метод).\"\"\"
|
||
>
|
||
> if node is None:
|
||
>
|
||
> return TreeNode(val)
|
||
>
|
||
> \# 1. Поиск позиции для вставки и вставка узла. if val \< node.val:
|
||
>
|
||
> node.left = self.insert_helper(node.left, val) elif val \> node.val:
|
||
>
|
||
> node.right = self.insert_helper(node.right, val)
|
||
>
|
||
> else:
|
||
>
|
||
> \# Повторяющийся узел не вставляется, возвращается напрямую. return node
|
||
>
|
||
> \# Обновление высоты узла. self.update_height(node)
|
||
>
|
||
> \# 2. Выполнение операции вращения для восстановления баланса поддерева. return self.rotate(node)
|
||
|
||
##### Удаление узла
|
||
|
||
> Для удаления узла можно также взять метод удаления узла в двоичном дереве поиска и добавить вращения при движении снизу вверх, чтобы восстановить баланс всех несбалансированных узлов. Ниже приведен код реализации.
|
||
>
|
||
> \# === File: avl_tree.py === def remove(self, val: int):
|
||
>
|
||
> \"\"\" Удаление узла.\"\"\"
|
||
>
|
||
> self.\_root = self.remove_helper(self.\_root, val)
|
||
>
|
||
> def remove_helper(self, node: TreeNode \| None, val: int) -\> TreeNode \| None: \"\"\" Рекурсивное удаление узла (вспомогательный метод).\"\"\"
|
||
>
|
||
> if node is None:
|
||
>
|
||
> return None
|
||
>
|
||
> \# 1. Поиск узла и его удаление. if val \< node.val:
|
||
>
|
||
> node.left = self.remove_helper(node.left, val) elif val \> node.val:
|
||
>
|
||
> node.right = self.remove_helper(node.right, val)
|
||
>
|
||
> else:
|
||
>
|
||
> if node.left is None or node.right is None: child = node.left or node.right
|
||
>
|
||
> \# Количество подузлов = 0, узел node удаляется напрямую и выполня-
|
||
>
|
||
> ется возврат.
|
||
>
|
||
> if child is None:
|
||
>
|
||
> return None
|
||
>
|
||
> \# Количество подузлов = 1, узел node удаляется напрямую.
|
||
>
|
||
> else:
|
||
>
|
||
> node = child
|
||
>
|
||
> else:
|
||
>
|
||
> \# Количество подузлов = 2, следующий узел в порядке обхода удаляет- ся, а текущий узел заменяется этим узлом.
|
||
>
|
||
> temp = node.right
|
||
>
|
||
> while temp.left is not None: temp = temp.left
|
||
>
|
||
> node.right = self.remove_helper(node.right, temp.val) node.val = temp.val
|
||
>
|
||
> \# Обновление высоты узла. self.update_height(node)
|
||
>
|
||
> \# 2. Выполнение операции вращения для восстановления баланса поддерева. return self.rotate(node)
|
||
|
||
##### Поиск узла
|
||
|
||
> Операция поиска узла в АВЛ-дереве идентична поиску в двоичном дереве по- иска, поэтому здесь повторно не рассматривается.
|
||
|
||
### Типичные сценарии применения АВЛ-дерева
|
||
|
||
- Организация и хранение больших объемов данных подходит для сцена- риев с частыми поисками и редкими вставками и удалениями.
|
||
|
||
- Используется для построения индексных систем в базах данных.
|
||
|
||
- Красно-черное дерево также является распространенным видом сбалан- сированного двоичного дерева поиска. По сравнению с АВЛ-деревом ус- ловия баланса в красно-черном дереве более мягкие, что требует меньше вращений при вставке и удалении узлов и обеспечивает более высокую среднюю эффективность этих операций.
|
||
|
||
#### резюме
|
||
|
||
##### Ключевые моменты
|
||
|
||
- Двоичное (бинарное) дерево -- это нелинейная структура данных, отра- жающая логику «разделяй и властвуй». Каждый узел двоичного дерева содержит значение и два указателя, указывающих на его левый и правый дочерние узлы соответственно.
|
||
|
||
- Для любого узла в двоичном дереве его левый (правый) дочерний узел и об- разуемое им дерево называются левым (правым) поддеревом этого узла.
|
||
|
||
- Связанные с двоичным деревом понятия включают корневой узел, ли- стовой узел, уровень, степень, ребро, высоту, глубину и др.
|
||
|
||
- Инициализация двоичного дерева, вставка и удаление узлов аналогичны методам работы со списками.
|
||
|
||
- К распространенным типам двоичных деревьев относятся идеальное двоичное дерево, совершенное двоичное дерево, полное двоичное де- рево и сбалансированное двоичное дерево. Идеальное двоичное дерево является наиболее желаемым состоянием, а список -- наихудшим состо- янием после вырождения.
|
||
|
||
- Двоичное дерево может быть представлено массивом, в котором значе- ния узлов и пустые места располагаются в порядке обхода по уровням, а указатели реализуются на основе индексации между родительскими и дочерними узлами.
|
||
|
||
- Обход двоичного дерева по уровням является методом поиска в шири- ну, который отражает способ обхода по кругам, расширяющимся наружу и обычно реализуется с помощью очереди.
|
||
|
||
- Прямой, симметричный и обратный обходы относятся к методам поиска в глубину. Они демонстрируют способ обхода «сначала до конца, затем воз- врат и продолжение», обычно реализуемый с использованием рекурсии.
|
||
|
||
- Двоичное дерево поиска представляет собой эффективную структуру дан- ных для поиска элементов, где временная сложность операций поиска, вставки и удаления составляет *O*(log *n*). Когда двоичное дерево поиска вы- рождается в список, временная сложность всех операций ухудшается до *O*(*n*).
|
||
|
||
- АВЛ-дерево, также известное как сбалансированное двоичное дерево поиска, поддерживает выполнение балансировки дерева после вставки и удаления узлов с помощью операций вращения.
|
||
|
||
> 7.6. Резюме ❖ **211**
|
||
|
||
- Операции вращения в АВЛ-дереве включают: правое вращение; левое вращение; сначала левое, затем правое вращение; сначала правое, затем левое вращение. После вставки или удаления узлов АВЛ-дерево выпол- няет операции вращения снизу вверх, чтобы восстановить баланс.
|
||
|
||
##### Вопросы и ответы
|
||
|
||
> **Вопрос**. Для двоичного дерева с единственным узлом высота дерева и глубина корневого узла равны 0?
|
||
>
|
||
> **Ответ**. Да, поскольку высота и глубина обычно определяются как количе- ство пройденных ребер.
|
||
>
|
||
> **Вопрос**. Вставка и удаление в двоичном дереве обычно выполняются с по- мощью набора операций. Что подразумевается под набором операций? Мож- но ли это понимать как освобождение ресурсов дочерних узлов?
|
||
>
|
||
> **Ответ**. Возьмем, к примеру, двоичное дерево поиска: операция удаления узла требует обработки трех различных случаев, в каждом из которых необхо- димо выполнить несколько шагов операций с узлами.
|
||
>
|
||
> **Вопрос**. Почему для обхода двоичного дерева в глубину существуют три по- рядка -- прямой, симметричный и обратный, и в чем их преимущества?
|
||
>
|
||
> **Ответ**. Подобно прямому и обратному обходу массива, прямой, симметрич- ный и обратный обходы являются тремя методами обхода двоичного дерева. Они позволяют получить результат обхода в определенном порядке. Напри- мер, в двоичном дереве поиска, поскольку значения узлов удовлетворяют ус- ловию «значение левого дочернего узла \< значение корневого узла \< значение правого дочернего узла», обход дерева в порядке «левый → корень → правый» позволяет получить упорядоченную последовательность узлов.
|
||
>
|
||
> **Вопрос**. Операция правого вращения обрабатывает отношения между не- сбалансированным узлом node, дочерним узлом child и внуком grand_child. Не нужно ли поддерживать связь node с его родительским узлом?
|
||
>
|
||
> **Ответ**. Необходимо рассматривать этот вопрос с рекурсивной точки зрения. Операция правого вращения right_rotate(root) принимает корневой узел под- дерева, и в итоге возвращает child, который после вращения становится кор- невым узлом. Связь корневого узла поддерева с его родительским узлом уста- навливается после завершения функции и не входит в область поддержания операции правого вращения.
|
||
>
|
||
> **Вопрос**. В C++ функции разделяются на private и public. Какие соображения при этом нужно учитывать? Почему функции height() и updateHeight() разме- щены в public и private соответственно?
|
||
>
|
||
> **Ответ**. Это зависит от области применения метода. Если метод использу- ется только внутри класса, его следует сделать private. Например, вызов up- dateHeight() пользователем не имеет смысла, так как это лишь этап в опера- циях вставки и удаления. А height() используется для доступа к высоте узла аналогично методу vector.size(), поэтому он помечен как public для удобства использования.
|
||
>
|
||
> **Вопрос**. Как построить двоичное дерево поиска из набора входных данных?
|
||
>
|
||
> Является ли важным способ выбора корневого узла?
|
||
>
|
||
> **Ответ**. Да, метод построения дерева описан в методе build_tree() в коде дво- ичного дерева поиска. Что касается выбора корневого узла, обычно входные
|
||
>
|
||
> данные сортируются, затем средний элемент выбирается в качестве корневого узла, после чего рекурсивно строятся левые и правые поддеревья. Это позво- ляет максимально сохранить баланс дерева.
|
||
>
|
||
> **Вопрос**. Всегда ли в Java для сравнения строк нужно использовать метод
|
||
>
|
||
> equals()?
|
||
>
|
||
> **Ответ**. В Java для базовых типов данных оператор == используется для срав- нения значений двух переменных. Для ссылочных типов принцип работы этих операторов различен.
|
||
>
|
||
> ① ==: используется для сравнения того, указывают ли две переменные на один и тот же объект, т. е. совпадают ли их позиции в памяти.
|
||
|
||
- equals(): используется для сравнения значений двух объектов.
|
||
|
||
> Таким образом, если необходимо сравнить значения, следует использовать метод equals(). Однако строки, инициализированные как String a = \"hi\"; String b = \"hi\";, хранятся в пуле строковых констант и указывают на один и тот же объект, поэтому для сравнения содержимого этих строк можно использовать a == b.
|
||
>
|
||
> **Вопрос**. До достижения самого нижнего уровня при обходе в ширину коли- чество узлов в очереди равно 2*h*?
|
||
>
|
||
> **Ответ**. Да, например, для полного двоичного дерева высотой *h* = 2 общее количество узлов *n* = 7, тогда количество узлов на нижнем уровне равно 4 = 2*h* = (*n* + 1)/2.
|
||
>
|
||
> Глава 8
|