Вот вы, родственная душа, структурирующая данные, которая, наверное, много слышала об этих жутких👹 самобалансирующихся деревьях, которые способны автоматически перебалансировать себя. Звучит как шарм, не так ли?🤩
Давайте узнаем, из чего они сделаны и как это сделать🧑💻
Оглавление:
- Введение о структурах данных
- Проблемы с деревьями
- Введение в 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.value8-›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/