Вот вы, родственная душа, структурирующая данные, которая, наверное, много слышала об этих жутких👹 самобалансирующихся деревьях, которые способны автоматически перебалансировать себя. Звучит как шарм, не так ли?🤩
Давайте узнаем, из чего они сделаны и как это сделать🧑💻
Оглавление:
- Введение о структурах данных
- Проблемы с деревьями
- Введение в AVL-деревья
- фактор баланса: баланс && высота
- вращения
4. Кодовая часть
5. Окончание
Коротко о деревьях
Есть много видов деревьев, которые позволяют вам использовать их сильные стороны, если вам нужно хранить📂 и искать данные. Назвать несколько:
- Обычные бинарные деревья
- Двоичные деревья поиска (также известные как BST)
- Кучи (мин./макс. кучи, кучи Фибоначчи и т. д.)
- Trie (произносится как t-r-y)
- Самобалансирующиеся деревья (наиболее известные из них AVL, Red-Black): они происходят от BST.
Зачем они нам нужны?
Например, вам нужно хранить данные и искать их. Какие есть альтернативы?
- массив: поиск выполняется в среднем за O(n), когда мы просматриваем всю структуру, но все упорядочено
- хэш-таблица: поиск выполняется в среднем за O(1), поскольку данные находятся внутри слотов памяти, которые не так называемые взаимные ” те. Но это не по заказу.
- график:сложно/требует использования различных методов и& не идеально подходит для задач, где нужен быстрый поиск
- наборы: неупорядочены как хэш-таблица. С моей точки зрения, они представляют собой смесь хеш-таблицы и массива, поскольку в них нет пар ключ-значение, как в первом, но они неупорядочены, как в последнем. Однако поиск выполняется за O(1), если вам нужно быстро проверить присутствие.
- связные списки (двойные и одиночные):хороши для хранения данных, если вам нужен доступ к первому или последнему элементу. Кроме того, помимо массива, где вставка за O(n), так как вам нужно перестроить ссылки в памяти, здесь это намного быстрее, так как вы находите элемент и меняете ссылки с prev и next
Следовательно, что, если нам нужна структура данных для хранения элементов и быстрого их поиска? Деревья — вот ответ. Особенно, когда мы говорим о BST. Поиск/вставка/удаление выполняется за O(log n): мы уменьшаем вдвое поиск на каждом шаге, как и в бинарном поиске.
Примечание: начиная с python 3.6 реализация хеш-таблицы, известной как словарь, упорядочена, но это скорее исключение, чем правило.
Проблемы с деревьями
Получается, нам вообще не нужна модификация BST? Увы, все намного сложнее😵
Познакомьтесь с асимметрией. Проще говоря, проблема с перекосом дерева в ту или иную сторону, что разрушает его красоту с поиском O(log n). На самом деле он деградирует до O(n).
n
/ \
n n
/
n
/
n
Итак, что мы можем с этим поделать? В общем, ничего🤷♂️ Но есть другая структура данных, или, скажем так, измененная, известная как самобалансирующаяся BST.
В этом посте я хотел бы сосредоточиться на AVL-деревьях как на решении вышеупомянутой проблемы.
АВЛ-деревья
Есть две вещи, о которых нужно помнить: баланс и вращения. Познакомимся с ними поближе.
Баланс
Tl — левое поддерево, а Tr — правое поддерево.
Формула для роста: H = max(H(Tl), H(Tr)) + 1
Формула для баланса: B(n) = H(Tl) — H(Tr)
Для обучения рассмотрим приведенный ниже пример и рассчитаем. высота и баланс.
Важный момент🤚🤚: долгое время я не мог понять отношения между высотой и балансом. На самом деле они взаимосвязаны. Как? -› Продолжим
Имейте в виду, что высота одного узла равна 0, а отсутствие узла равно -1.
3
/ \
1 4
/ \
0 2
Высота:
- Н(0) = макс(-1,-1) + 1 = 0
- Н(2) = макс(-1,-1) + 1 = 0
- Н(3) = макс(0,0) + 1 = 1
- Н(4) = макс(-1,-1) + 1 = 0
- Н(3) = макс(1,0) + 1 = 2
Баланс:
- B(0) = -1-(-1) = 0
- B(2) = -1-(-1)= 0
- B(1)=0–0 = 0. Возьмите высоту 0 и высоту 2.
- B(4) = -1-(-1)=0
- В(3) = 1–0=0. Возьмите высоту 3 и высоту 4.
У нас сбалансированное дерево. Следующим шагом является наблюдение за несбалансированным:
4
/
3
/
2
/ \
1 0
Высота:
- Н(1) = макс(-1,-1) + 1 = 0
- Н(0) = макс(-1,-1) + 1 = 0
- Н(2) = макс(0,0) + 1 = 1
- Н(3) = макс(1,-1) + 1 = 2
- Н(4) = макс(2,-1) + 1 = 3
После быстрого «глазного яблока» мы можем сделать вывод:
- B(1) = -1-(-1) = 0
- B(0) = -1 -(-1) = 0
- B(2) = 0-0 = 0. Возьмем высоту 1 и 0.
- B(3) = 1 -(-1) = 2. Возьмем высоту 2
- B(4) = 2-(-1) = 3. Возьмем высоту 3
Итак, это 3. Что это все-таки значит? Как мы можем это использовать?
Коэффициент баланса
Как правило, определенный баланс равен абс (1). Это означает, что если какая-то часть больше 1 или меньше -1 =>
асимметрия. Но почему у нас вообще есть положительный и отрицательный коэффициент баланса? Это связано с так называемой тяжестью.
Дерево с левым и правым — это дерево, поддерево которого наклонено в сторону одного из поддеревьев. Под поддеревьями я подразумеваю не root
, а любые node
с поддеревьями. т.е. на картинке выше узел 3 можно считать узлом с левым весом, а не только 4. Имейте это в виду.
Слева жирный: коэффициент положительного баланса больше 1
Справа жирный: отрицательный коэффициент баланса меньше -1
Вращения
Очевидный шаг — каким-то образом перебалансировать дерево, чтобы снова сбалансировать его, для этого мы здесь и собрались. Есть 4 основных способа сделать это:
- Левое вращение (также известное как лево-левое)
- Правое вращение (также известное как правое право)
- Вращение влево-вправо
- Вращение вправо-влево
Они могут показаться странными и непонятными, когда расставлены таким образом, поэтому я хотел бы их сгруппировать. Вспомните тяжесть из приведенного выше.
Левое тяжелое дерево: правое вращение && вращение влево-вправо
Правое тяжелое дерево: вращение влево и вращение вправо-влево
После того, как мы определили их подгруппы, давайте углубимся в конкретные случаи их применения.
Правильный поворот
У нас есть следующее дерево, которое явно несбалансировано, 2 — это баланс, и мы хотим это исправить.
3 2
/ / \
2 => 1 3
/
1
Позвольте мне показать вам одну интересную схему:
x / \ y purple / \ red blue
Вот способ обращения с деревьями, который мне помог. Смотри, 3 — это х, 2 — это у, а 1 — красный. Вы знаете, как они будут выглядеть после правильного поворота?
y / \ red x / \ blue purple
Почему так? =>
напомню, что это форма BST, где левый угол меньше узла выше, а правый больше/равен. синий больше y, но меньше x. red меньше, чем x и y. фиолетовый больше x. Мы тянем y выше, помещаем x в правую часть, кладем синий слева от x, фиолетовый опускается справа от x, а красный тянет вверх слева от y (точно, это мало что меняет).
Следовательно, в приведенном выше примере мы тянем 2 вверх (это y), помещаем x вправо, красный, равный 1, остается прежним. Если справа от 2 было 2, куда оно пойдет? =>
сначала справа от 2, а затем слева от 3.
Поворот влево-вправо
3 3 2
/ / / \
1 => 2 => 1 3
\ /
2 1
Здесь мы не можем просто сделать правильное вращение. Почему? Давай попробуем:
3 1 / \ 1 => 3 \ / 2 2
Совсем не сбалансирован. Вот почему нам нужно сделать трюк и сначала сделать левое вращение для 1 и 2. После этого сделать правое вращение для всего дерева. Вы можете увидеть это на одну картинку раньше.
Не бойтесь, если вы не знаете, я предоставлю отрывок кода
Вращение влево
1 2
\ / \
2 => 1 3
\
3
Похоже на справа, но в зеркальном отображении.
До:
x / \ purple y / \ red blue
После:
y / \ x blue / \ purple red
Почему так? =>
фиолетовый — самый маленький. red больше, чем x, но меньше, чем y. синий — больше, чем x && y. После поворота все выглядит как справа.
Вращение вправо-влево
1 1 2
\ \ / \
3 => 2 => 1 3
/ \
2 3
Упражнение: попробуйте применить логику поворота влево-вправо, чтобы понять, почему простое влево нас здесь не спасет.
Если у вас что-то не получается, напишите сообщение в комментариях, и я смогу вам помочь!🙌
Я знаю, что это было тяжело😓. Отдохните немного и посмотрите на следующую картинку…
Кодовая часть
Вы можете перейти по следующей ссылке на мой репозиторий, если хотите увидеть весь код, написанный на Python и Kotlin.
Важно: не забывайте, что ребалансировка происходит не только
через корень всего дерева, но и через поддеревья
Представьте, что у нас есть следующее дерево, и 7
— это наш недавно добавленный узел.
13 / \ 11 21 / 8 / 7
Ясно, что дерево несбалансированное и тяжелое слева. Вспомните примечание выше: не только root
служит x
из схем в предыдущем секторе. В нашем случае это будет 11
, что заметит разницу.
Этапы обработки метода:
Листовой узел — это узел, у которого нет левого или правого дочернего элемента.
1. Вставьте в правильную позицию новое значение в качестве узла LEAF
2. Обновите высоту
3. Проверьте баланс
4. По мере рекурсивного входа в дерево мы
возвращайтесь слой за слоем, обновляя высоту и проверяя
баланс. Следовательно, в один момент мы можем поймать несбалансированный материал
И мы поймали его в 11
🧐
Подожди, подожди, подожди... Как ты это заметил? Мне не дали ни копейки, как это работает😩
Отступление:
- Базовый случай: если
node
равно None, мы возвращаем наш новый узел. - Прикрепляем его либо к левому, либо к правому дочернему элементу. В нашем случае:
8.left = Node(7)
3. Увеличиваем height на 1 и проверяем, что с балансом все в порядке
4. Снова возвращаемся на предыдущий слой на return root
и здесь:
11.left = Node(8)
Повторите шаги, описанные выше, и здесь мы обнаружим проблему с тяжелостью слева.
Возвращаясь из отступления
- Мы видим, что дерево смещено влево, поэтому мы вводим
curr_balance > 1
root
в данном случае11
. Нам нужно выяснить:
является ли наш новыйNode(7)
> || <
чем.left/.right
изroot
.
Как видим, он меньше, поэтому делаем только_right_rotate()
. Обратите внимание на это ниже- Для пояснения давайте воспользуемся нашими цифрами, чтобы облегчить жизнь. Мы вводим этот метод с
11
равнымnode
8 = 11.left None = 8.right # perform rotation 8.right = 11 11.left = None
4. Затем верните y_node
в качестве нового корня для поддерева, а 11
переместится вправо от этого корня. Все как было описано в схемах.
Как вы видите, 8 выскакивает на один слой выше, увлекая за собой 7,
в то время как 11 добирается до.right
перетягивания.right
из 8, если такое
существует (в нашем случае это None)
Что, если...😲😲😲 мы хотим проиллюстрировать вращение влево-вправо.
Other nodes / 11 / 8 \ 9
Здесь вместо 7 у нас Node(9)
Весь процесс с размещением узлов такой же. И мы даже снова замечаем проблему в 11
. Как? Так:
- На первой высоте:
1. H(9) = max(-1,-1) + 1 = 0
2. H(8) = max(-1, 0) + 1 = 1
3. H(11) = max(1,-1) + 1 = 2 - Далее баланс:
1. B(8) = -1–0 = 0
2. B(11) = 1-(-1)=2
И теперь вам не терпится увидеть все на схеме, чтобы прояснить ситуацию в голове:
Вы можете еще раз проверить в части влево-вправо, почему простое вращение влево не работает.
Сначала мы делаем _left_rotate()
с 11.left
, представленным как root
. Обратите внимание на метод ниже:
Сравним рисунки и схемы:
9 = 8.right None = 9.left # perform rotation 9.left = 8 8.right = None
Это будет выглядеть примерно так:
11 / 9 / 8 \ None
Затем мы возвращаем 9
как новый .left
в 11
. Далее мы выполняем _right_rotate()
как описано ранее.
Для персональных тренировок: посмотрите на вращение
left
&& вращениеright-left
Очень сложная штука👺…
Еще один перерыв перед последним чанком🙌🏻
Удалить узел
Наконец, что, если мы хотим удалить узел? Как дереву удается все держать под контролем?🕵🏻♂️
Пример дерева:
# Some nodes / 8 \ 10 / \ 9 11
Представьте, 8
нужно удалить❌ Код ниже и после этого позвольте мне провести вас через него.
Что нужно сделать:
- Перемещайтесь рекурсивно, пока не наткнетесь на
else
в верхней части метода. Это означает, что мы нашли цельnode
- Мы видим, что
.left
&&.right
не пусты, поэтому снова введитеelse
- вызовите
._get_min_value_node(node.right)
, где мы попытаемся найти наименьшее значение в правом поддереве из текущего удаляемого узла. - Так как мы входим в этот метод с
10
корневым, проверка сначала будет основываться на нем:10 != None
и10.left != None
=> снова вызовите этот метод с10.left
, что означает, что9
будетroot
для следующего вызова. 9 != None
НО9.left == None
=› вернуть9
на верхний уровень- Вернитесь к
delete_node()
и поставьте вместоnode.value
8
-›9
- Далее нам нужно удалить
9
из этого правого поддерева. Собственно, те же действия, что и выше:
- Обратите внимание, что мы вызываем
delete_node
наnode.right
(9.right
который был8.right
) node.left == None
вdelete_node()
— это то место, где мы нажимаем и возвращаем None, поскольку9.right
— это None
8. Пересчитывайте баланс && рост для каждого root
Помните, что корень — это каждый узел с поддеревьями.
9. Но здесь не простое левое-тяжелое/правое-тяжелое, нам нужно проверить, все ли узлы в поддеревьях также сбалансированы, поэтому логика немного, но сложнее.
Outro
Надеюсь, вы пережили это нелегкое путешествие🌊 Оно было определенно сложным, и я уверен, что у вас раскалывается голова🤯
Не торопитесь, прочтите пару раз и обязательно сделайте пометки проверенным методом ручки и карандаша📝. В случае возникновения каких-либо вопросов, не стесняйтесь писать в комментариях или обращаться ко мне в любом из мест ниже:
- LinkedIn: www.linkedin.com/in/sleeplesschallenger
- GitHub: https://github.com/SleeplessChallenger
- Литкод: https://leetcode.com/SleeplessChallenger/
- Телеграмма: @SleeplessChallenger
PS: большое спасибо -›
- Видео Back2Back SWE: https://youtu.be/vRwi_UcZGjU
- Код GeeksForGeeks: https://www.geeksforgeeks.org/avl-tree-set-1-insertion/