1. От массивов к иерархиям: анатомия бинарного дерева и реализация на Python
От массивов к иерархиям: анатомия бинарного дерева и реализация на Python
Если нужно быстро находить элементы, мы используем отсортированный массив и бинарный поиск, получая время . Но если в эту коллекцию требуется постоянно добавлять новые данные, динамический массив ответит штрафом в на каждую вставку из-за необходимости сдвигать элементы в памяти. Классический связный список элегантно решает проблему вставки за , но полностью убивает быстрый поиск, так как мы не можем прыгнуть в его середину, возвращая алгоритм к линейному времени . Разрешение этого конфликта требует отказа от линейных структур данных в пользу ветвящихся.
Предел линейности и рождение графов
Массивы, стеки, очереди и связные списки объединяет одно свойство: у каждого элемента (кроме крайних) есть строго один предыдущий и строго один следующий. Это одномерное пространство.
Дерево ломает это ограничение. Это структура данных, в которой каждый элемент может иметь один «предыдущий» элемент и множество «следующих». В строгих терминах информатики дерево — это связный ациклический ориентированный граф.
В алгоритмических секциях Яндекса подавляющее большинство задач связано со специфическим подтипом этой структуры — бинарным (двоичным) деревом. Правило бинарного дерева максимально простое: у любого узла может быть не более двух исходящих связей. Либо ноль, либо одна, либо две.
Анатомия бинарного дерева
Для работы с деревьями используется устоявшаяся терминология, смешивающая ботанику и генеалогию.
!Анатомия бинарного дерева и его основные компоненты
Реализация на Python: класс TreeNode
В Python бинарное дерево не является встроенной коллекцией вроде list или dict. Оно конструируется из пользовательских объектов, связанных друг с другом ссылками. Стандартный класс, который вы встретите на платформах вроде LeetCode, выглядит так:
Механика работы здесь идентична двусвязному списку, но меняется семантика. Если в списке ссылки prev и next выстраивали элементы в линию, то left и right формируют ветвление.
Создадим простое дерево из трёх узлов:
В памяти компьютера нет единого непрерывного блока «дерево». Есть три разрозненных объекта в куче (heap). Объект root хранит число 10 и два адреса памяти, указывающие на объекты node_left и node_right. Переменные node_left.left и node_left.right по умолчанию равны None, что делает узел 5 листом.
Важный нюанс: переменная root в коде — это не само дерево. Это лишь ссылка на один конкретный узел. Но поскольку через этот узел можно по цепочке ссылок добраться до всех остальных элементов, мы технически отождествляем ссылку на корень со всем деревом. Потеряете ссылку на корень — сборщик мусора Python уничтожит всю структуру.
Метрики иерархии: Глубина против Высоты
При оценке сложности алгоритмов (особенно пространственной сложности рекурсии) критически важно понимать метрики дерева. Термины «глубина» и «высота» часто путают, хотя они измеряют разные векторы.
Глубина (Depth) узла — это количество рёбер от корня до этого узла. Глубина измеряется сверху вниз.
Высота (Height) узла — это максимальное количество рёбер от этого узла до самого дальнего листа в его поддереве. Высота измеряется снизу вверх.
Рассмотрим вырожденный случай: бинарное дерево, в котором каждый узел имеет только правого ребёнка. Структурно это просто связный список. Если в нём 5 узлов, то высота корня будет 4, а глубина последнего листа — 4. В таком дереве алгоритм спустится на 4 уровня вниз.
Если же 5 узлов распределить оптимально (корень, два ребёнка, и у одного из них два своих ребёнка), максимальная глубина составит всего 2. Разница между высотой вырожденного дерева и сбалансированного — это главная причина, по которой структуру дерева нужно контролировать.
Архитектурные формы бинарных деревьев
Алгоритмические задачи часто накладывают дополнительные условия на форму дерева. Понимание этих форм помогает выбрать правильный подход к решению.
Именно завершённое дерево обладает уникальным свойством: его можно эффективно хранить в обычном одномерном массиве без использования объектов 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.4.8.4 (индекс 1). Его левый ребёнок: . Под индексом 3 лежит 11.4: . Под индексом 4 лежит null. Это означает, что у узла 4 нет правого ребёнка.Значение null в массиве выполняет роль заполнителя (placeholder). Оно необходимо, чтобы сохранить математическую связь индексов для последующих узлов. Если бы мы просто пропустили null и сдвинули массив, формула указала бы на совершенно другой узел, разрушив виртуальную иерархию.
Такой формат хранения (неявная структура данных) крайне эффективен по памяти, так как не требует хранения 64-битных указателей на объекты. Он активно используется при реализации бинарных куч (Heaps) — структур, лежащих в основе приоритетных очередей.
Переход от линейных структур к деревьям требует смены ментальной модели. В массиве мы мыслили индексами и циклами for от начала до конца. В деревьях нет чёткого «начала» и «конца» в линейном понимании. Есть корень и разветвляющиеся пути. Чтобы пройти по всем узлам дерева, цикл while становится неудобным, так как после обработки левого поддерева нужно каким-то образом вспомнить о существовании правого и вернуться к нему. Эта потребность в запоминании отложенных задач делает деревья идеальным полигоном для применения рекурсии.