От массивов к иерархиям: анатомия бинарного дерева и реализация на Python
От массивов к иерархиям: анатомия бинарного дерева и реализация на Python
Если нужно быстро находить элементы, мы используем отсортированный массив и бинарный поиск, получая время . Но если в эту коллекцию требуется постоянно добавлять новые данные, динамический массив ответит штрафом в на каждую вставку из-за необходимости сдвигать элементы в памяти. Классический связный список элегантно решает проблему вставки за , но полностью убивает быстрый поиск, так как мы не можем прыгнуть в его середину, возвращая алгоритм к линейному времени . Разрешение этого конфликта требует отказа от линейных структур данных в пользу ветвящихся.
Предел линейности и рождение графов
Массивы, стеки, очереди и связные списки объединяет одно свойство: у каждого элемента (кроме крайних) есть строго один предыдущий и строго один следующий. Это одномерное пространство.
Дерево ломает это ограничение. Это структура данных, в которой каждый элемент может иметь один «предыдущий» элемент и множество «следующих». В строгих терминах информатики дерево — это связный ациклический ориентированный граф.
- Граф, потому что состоит из узлов, соединённых связями.
- Ориентированный, потому что связи имеют направление (сверху вниз).
- Ациклический, потому что, двигаясь по направлениям связей, невозможно вернуться в узел, в котором вы уже были.
- Связный, потому что от вершины можно добраться до любого узла.
В алгоритмических секциях Яндекса подавляющее большинство задач связано со специфическим подтипом этой структуры — бинарным (двоичным) деревом. Правило бинарного дерева максимально простое: у любого узла может быть не более двух исходящих связей. Либо ноль, либо одна, либо две.
Анатомия бинарного дерева
Для работы с деревьями используется устоявшаяся терминология, смешивающая ботанику и генеалогию.
- Узел (Node) — базовая единица дерева. Хранит полезное значение (число, строку, объект) и ссылки на дочерние узлы.
- Корень (Root) — единственный узел в дереве, у которого нет родителя. Это точка входа в структуру. Любой обход дерева начинается с корня.
- Родитель (Parent) и Ребёнок (Child) — относительные термины. Узел, имеющий исходящую ссылку, является родителем для узла, на который эта ссылка указывает. В бинарном дереве дети строго позиционированы: есть левый ребёнок и правый ребёнок.
- Лист (Leaf) — узел, у которого нет детей (обе ссылки указывают на пустоту). Листья образуют нижние границы дерева.
- Братья/Сёстры (Siblings) — узлы, имеющие общего родителя.
- Поддерево (Subtree) — любой узел дерева вместе со всеми его потомками образует полноценное дерево. Это свойство рекурсивной вложенности — фундамент для всех алгоритмов обхода. Левый ребёнок корня является корнем всего левого поддерева.
Реализация на Python: класс TreeNode
В Python бинарное дерево не является встроенной коллекцией вроде list или dict. Оно конструируется из пользовательских объектов, связанных друг с другом ссылками. Стандартный класс, который вы встретите на платформах вроде LeetCode, выглядит так:
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
Механика работы здесь идентична двусвязному списку, но меняется семантика. Если в списке ссылки prev и next выстраивали элементы в линию, то left и right формируют ветвление.
Создадим простое дерево из трёх узлов:
# Создаем изолированные узлы в памяти
root = TreeNode(10)
node_left = TreeNode(5)
node_right = TreeNode(15)
# Выстраиваем иерархию через ссылки
root.left = node_left
root.right = node_right
В памяти компьютера нет единого непрерывного блока «дерево». Есть три разрозненных объекта в куче (heap). Объект root хранит число 10 и два адреса памяти, указывающие на объекты node_left и node_right. Переменные node_left.left и node_left.right по умолчанию равны None, что делает узел 5 листом.
Важный нюанс: переменная root в коде — это не само дерево. Это лишь ссылка на один конкретный узел. Но поскольку через этот узел можно по цепочке ссылок добраться до всех остальных элементов, мы технически отождествляем ссылку на корень со всем деревом. Потеряете ссылку на корень — сборщик мусора Python уничтожит всю структуру.
Метрики иерархии: Глубина против Высоты
При оценке сложности алгоритмов (особенно пространственной сложности рекурсии) критически важно понимать метрики дерева. Термины «глубина» и «высота» часто путают, хотя они измеряют разные векторы.
Глубина (Depth) узла — это количество рёбер от корня до этого узла. Глубина измеряется сверху вниз.
- Глубина корня всегда равна 0.
- Глубина детей корня равна 1.
- Это показатель того, насколько глубоко узел «погружён» в структуру.
Высота (Height) узла — это максимальное количество рёбер от этого узла до самого дальнего листа в его поддереве. Высота измеряется снизу вверх.
- Высота любого листа всегда равна 0.
- Высота дерева в целом — это высота его корня.
- Это показатель того, насколько длинная цепочка вызовов потребуется, чтобы добраться до самого дна.
Рассмотрим вырожденный случай: бинарное дерево, в котором каждый узел имеет только правого ребёнка. Структурно это просто связный список. Если в нём 5 узлов, то высота корня будет 4, а глубина последнего листа — 4. В таком дереве алгоритм спустится на 4 уровня вниз.
Если же 5 узлов распределить оптимально (корень, два ребёнка, и у одного из них два своих ребёнка), максимальная глубина составит всего 2. Разница между высотой вырожденного дерева и сбалансированного — это главная причина, по которой структуру дерева нужно контролировать.
Архитектурные формы бинарных деревьев
Алгоритмические задачи часто накладывают дополнительные условия на форму дерева. Понимание этих форм помогает выбрать правильный подход к решению.
-
Полное бинарное дерево (Full Binary Tree). Правило: у каждого узла либо 0, либо 2 ребёнка. Узлов с ровно одним ребёнком быть не должно. Такая структура часто возникает при парсинге математических выражений, где оператор (корень) всегда требует двух операндов (листьев).
-
Идеальное бинарное дерево (Perfect Binary Tree). Правило: все внутренние узлы имеют по два ребёнка, и абсолютно все листья находятся на одной глубине. Это максимально плотная упаковка узлов. Количество узлов на каждом уровне удваивается: 1, 2, 4, 8, 16. Общее число узлов в таком дереве высоты строго равно .
-
Завершённое бинарное дерево (Complete Binary Tree). Правило: все уровни, кроме, возможно, последнего, заполнены полностью. Последний уровень заполняется строго слева направо, без пропусков.
Именно завершённое дерево обладает уникальным свойством: его можно эффективно хранить в обычном одномерном массиве без использования объектов TreeNode и ссылок left/right.
Массивы под прикрытием: как LeetCode хранит деревья
Когда вы решаете задачу на LeetCode, на вход вашей функции подаётся объект TreeNode. Но в описании задачи (и в тестовых кейсах) дерево представлено в виде массива: [5, 4, 8, 11, null, 13, 4].
Это представление основано на обходе дерева по уровням (Level-order traversal) и математических свойствах индексов завершённого дерева.
Если мы поместим корень дерева в массив под индексом , то для любого узла, находящегося по индексу , справедливы следующие формулы:
- Индекс его левого ребёнка:
- Индекс его правого ребёнка:
- Индекс его родителя: (целочисленное деление)
Проверим на массиве [5, 4, 8, 11, null, 13, 4]:
- Корень
5имеет индекс 0. - Его левый ребёнок: . Под индексом 1 лежит
4. - Его правый ребёнок: . Под индексом 2 лежит
8. - Возьмём узел
4(индекс 1). Его левый ребёнок: . Под индексом 3 лежит11. - Правый ребёнок узла
4: . Под индексом 4 лежитnull. Это означает, что у узла4нет правого ребёнка.
Значение null в массиве выполняет роль заполнителя (placeholder). Оно необходимо, чтобы сохранить математическую связь индексов для последующих узлов. Если бы мы просто пропустили null и сдвинули массив, формула указала бы на совершенно другой узел, разрушив виртуальную иерархию.
Такой формат хранения (неявная структура данных) крайне эффективен по памяти, так как не требует хранения 64-битных указателей на объекты. Он активно используется при реализации бинарных куч (Heaps) — структур, лежащих в основе приоритетных очередей.
Переход от линейных структур к деревьям требует смены ментальной модели. В массиве мы мыслили индексами и циклами for от начала до конца. В деревьях нет чёткого «начала» и «конца» в линейном понимании. Есть корень и разветвляющиеся пути. Чтобы пройти по всем узлам дерева, цикл while становится неудобным, так как после обработки левого поддерева нужно каким-то образом вспомнить о существовании правого и вернуться к нему. Эта потребность в запоминании отложенных задач делает деревья идеальным полигоном для применения рекурсии.