Двоичное (бинарное) дерево — это иерархическая структура данных, в которой у каждого узла не больше двух потомков, причем они различаются как левый и правый. Назначение двоичного дерева — хранить данные так, чтобы на каждом шаге выбирать одну из двух ветвей: это дает быстрый поиск по ключу (дерево поиска), быструю выдачу минимума (куча) и естественное представление выражений и кодов (дерево выражения, код Хаффмана).
Содержание
Важно сразу развести три вещи. Двоичное дерево — это форма (не больше двух упорядоченных потомков). Двоичное дерево поиска — форма плюс правило порядка ключей. Двоичная куча — почти полное дерево (без пропусков по уровням) плюс другое правило: в min-куче родитель не больше потомков, в max-куче — не меньше. Ниже — виды, хранение в памяти, операции, обходы и таблица применения. Код проверен на Python 3.14.
Что такое двоичное дерево: термины
Короткий словарь, которого хватит для статьи:
- Корень — единственный узел без родителя.
- Лист — узел без потомков; остальные узлы называют внутренними.
- Поддерево — узел вместе со всеми его потомками; у каждого узла есть левое и правое поддерево (возможно, пустое).
- Высота — число уровней от корня до самого дальнего листа. Ее считают в уровнях или в ребрах; дальше в коде — в уровнях.
Двоичное дерево — не просто «дерево со степенью не больше 2». В двоичном дереве узел с единственным левым потомком и узел с единственным правым — разные деревья, а в обычном дереве такого различия нет. Общие термины и три рекурсивных обхода подробно разобраны в статье Дерево как структура данных, здесь фокус на двоичном случае.
Виды двоичных деревьев
В русских источниках названия видов путаются: «полным» называют то full, то complete, то perfect. Поэтому в таблице рядом дан английский термин и проверяемое условие.
| Вид | Английский термин | Условие | Где встречается |
|---|---|---|---|
| Строгое (полное) | full, strict | у каждого узла 0 или 2 потомка | дерево выражения, дерево Хаффмана |
| Почти полное (завершенное) | complete | все уровни заполнены, кроме последнего; последний заполнен слева направо без пропусков | двоичная куча, хранение в массиве |
| Совершенное | perfect | все внутренние узлы имеют 2 потомка, все листья на одном уровне | идеальный случай для оценок |
| Сбалансированное | balanced | высота остается O(log n): в AVL высоты поддеревьев любого узла различаются не больше чем на 1, в красно-черном самый длинный путь от узла до листа не более чем вдвое длиннее самого короткого | AVL, красно-черные деревья |
| Вырожденное | degenerate | у каждого узла не больше одного потомка | по сути связный список, худший случай |
Три полезных свойства, которые проверяются подсчетом:
- В совершенном дереве из h уровней ровно 2^h — 1 узлов и 2^(h-1) листьев: при h = 3 это 7 узлов и 4 листа.
- В строгом дереве листьев на один больше, чем внутренних узлов.
- Минимальное число уровней для n узлов — ⌈log2(n + 1)⌉: для 1000 узлов это 10 уровней, а у вырожденного дерева — все 1000.
Именно разница между 10 и 1000 уровнями объясняет, зачем нужны сбалансированные деревья.
Как двоичное дерево хранится в памяти
Есть два способа. Первый — узлы со ссылками left и right: подходит для любой формы дерева. Второй — массив без ссылок: подходит для почти полного дерева, потому что в нем нет «дыр». У узла с индексом i левый потомок лежит в 2i + 1, правый — в 2i + 2, родитель — в (i — 1) // 2.
import heapq
# Почти полное (complete) дерево в массиве: потомки узла i лежат в 2*i+1 и 2*i+2
tree = [8, 3, 10, 1, 6, 9, 14]
def children(i):
kids = [j for j in (2 * i + 1, 2 * i + 2) if j < len(tree)]
return [tree[j] for j in kids]
for i, value in enumerate(tree):
parent = tree[(i - 1) // 2] if i > 0 else None
print(f"индекс {i}: узел {value}, родитель {parent}, дети {children(i)}")
# heapq хранит двоичную кучу в обычном списке по той же схеме индексов
tasks = [(3, "отчет"), (1, "авария"), (2, "ревью")]
heapq.heapify(tasks)
print("корень кучи:", tasks[0])
print("порядок выдачи:", [heapq.heappop(tasks)[1] for _ in range(3)])
Вывод:
индекс 0: узел 8, родитель None, дети [3, 10]
индекс 1: узел 3, родитель 8, дети [1, 6]
индекс 2: узел 10, родитель 8, дети [9, 14]
индекс 3: узел 1, родитель 3, дети []
индекс 4: узел 6, родитель 3, дети []
индекс 5: узел 9, родитель 10, дети []
индекс 6: узел 14, родитель 10, дети []
корень кучи: (1, 'авария')
порядок выдачи: ['авария', 'ревью', 'отчет']
Модуль heapq — готовая двоичная куча: базовые функции работают с min-кучей, в корне (tasks[0]) всегда наименьший элемент, поэтому задача с приоритетом 1 выдается первой. С Python 3.14 в модуле есть и функции max-кучи (heapify_max, heappush_max, heappop_max); в более ранних версиях для max-кучи ключи хранят с обратным знаком. Вставка и извлечение стоят O(log n), потому что элемент «всплывает» или «тонет» только по одной ветви.
Бинарное дерево поиска: поиск, вставка, удаление
В двоичном дереве поиска (BST) для каждого узла все ключи левого поддерева меньше ключа узла, а все ключи правого — больше. Поиск и вставка идут от корня вниз: меньше — влево, больше — вправо. Сложнее всего удаление, у него три случая:
- У узла нет потомков — просто убираем его.
- Один потомок — ставим потомка на место узла.
- Два потомка — берем минимальный ключ правого поддерева (преемника), копируем его в узел и удаляем преемника из правого поддерева. Симметричный вариант — максимум левого поддерева.
Полный пример: вставка без рекурсии, удаление всех трех случаев и проверка высоты при разном порядке вставки.
import random
class Node:
def __init__(self, key):
self.key, self.left, self.right = key, None, None
def insert(root, key):
"""Итеративная вставка: не упирается в лимит рекурсии."""
new = Node(key)
if root is None:
return new
cur = root
while True:
if key < cur.key:
if cur.left is None:
cur.left = new
return root
cur = cur.left
elif key > cur.key:
if cur.right is None:
cur.right = new
return root
cur = cur.right
else:
return root # дубликат не вставляем
def delete(node, key):
if node is None:
return None # ключа нет - дерево не меняется
if key < node.key:
node.left = delete(node.left, key)
elif key > node.key:
node.right = delete(node.right, key)
else:
if node.left is None: # случаи 1 и 2: ноль или один потомок
return node.right
if node.right is None:
return node.left
succ = node.right # случай 3: два потомка
while succ.left is not None: # минимум правого поддерева
succ = succ.left
node.key = succ.key
node.right = delete(node.right, succ.key)
return node
def inorder(node):
"""Симметричный обход без рекурсии, через явный стек."""
stack, out = [], []
while stack or node:
while node:
stack.append(node)
node = node.left
node = stack.pop()
out.append(node.key)
node = node.right
return out
def height(node):
"""Высота в уровнях, обход в ширину."""
level, h = [node] if node else [], 0
while level:
h += 1
level = [c for n in level for c in (n.left, n.right) if c]
return h
root = None
for k in [8, 3, 10, 1, 6, 14, 4, 7, 13]:
root = insert(root, k)
root = delete(root, 3) # у 3 два потомка: ее место займет 4
root = delete(root, 14) # у 14 один потомок: 13 поднимется
root = delete(root, 7) # 7 - лист: просто исчезает
root = delete(root, 99) # такого ключа нет
print("после удалений:", inorder(root))
print("корень левого поддерева:", root.left.key)
n = 1000
chain = None
for k in range(1, n + 1): # ключи по возрастанию
chain = insert(chain, k)
keys = list(range(1, n + 1))
random.seed(42)
random.shuffle(keys)
mixed = None
for k in keys: # те же ключи в случайном порядке
mixed = insert(mixed, k)
print("высота, вставка по порядку:", height(chain))
print("высота, случайный порядок:", height(mixed))
Вывод:
после удалений: [1, 4, 6, 8, 10, 13]
корень левого поддерева: 4
высота, вставка по порядку: 1000
высота, случайный порядок: 21
Симметричный обход выдал ключи по возрастанию — значит, после удалений правило BST не нарушено. Число 21 получено в одном прогоне с random.seed(42), при другом порядке оно будет другим, но близкого порядка.
Главный вывод из последних двух строк: оценка O(log n) для BST верна только для сбалансированного дерева. Операции стоят O(h), где h — высота, и в худшем случае h = n. Отсортированные данные превращают BST в список. Поэтому в библиотеках используют самобалансирующиеся деревья: например, java.util.TreeMap по документации построен на красно-черном дереве. Удаление в примере рекурсивное: для учебного дерева этого достаточно, на вырожденном дереве его стоит переписать циклом так же, как вставку.
Обходы двоичного дерева: в глубину и в ширину
Обход — посещение каждого узла ровно один раз. Порядки различаются моментом, когда обрабатывается сам узел.
| Обход | Порядок | Чем реализуют | Типичная задача |
|---|---|---|---|
| Прямой (pre-order) | узел -> левое -> правое | рекурсия или стек | копирование и сериализация дерева, префиксная запись |
| Симметричный (in-order) | левое -> узел -> правое | рекурсия или стек | ключи BST по возрастанию |
| Обратный (post-order) | левое -> правое -> узел | рекурсия или стек | вычисление выражения, удаление дерева, размеры поддеревьев |
| В ширину (level-order) | по уровням, слева направо | очередь | кратчайший путь до узла, печать по уровням |
Первые три — обходы в глубину. Обход в ширину использует очередь: узел извлекается из начала, его потомки добавляются в конец. Удобный пример, где порядок обхода меняет смысл, — дерево выражения:
from collections import deque
class Node:
def __init__(self, value, left=None, right=None):
self.value, self.left, self.right = value, left, right
# Дерево выражения (2 + 3) * (7 - 4): операции во внутренних узлах, числа в листьях
expr = Node("*", Node("+", Node(2), Node(3)), Node("-", Node(7), Node(4)))
def preorder(n):
return [] if n is None else [n.value] + preorder(n.left) + preorder(n.right)
def postorder(n):
return [] if n is None else postorder(n.left) + postorder(n.right) + [n.value]
def by_levels(root):
out, queue = [], deque([root])
while queue:
n = queue.popleft()
out.append(n.value)
queue.extend(c for c in (n.left, n.right) if c)
return out
def evaluate(n):
"""Вычисление = обратный обход: сначала операнды, потом операция."""
if n.left is None and n.right is None:
return n.value
a, b = evaluate(n.left), evaluate(n.right)
return {"+": a + b, "-": a - b, "*": a * b}[n.value]
print("прямой: ", " ".join(map(str, preorder(expr))))
print("обратный:", " ".join(map(str, postorder(expr))))
print("в ширину:", " ".join(map(str, by_levels(expr))))
print("значение:", evaluate(expr))
Вывод:
прямой: * + 2 3 - 7 4
обратный: 2 3 + 7 4 - *
в ширину: * + - 2 3 7 4
значение: 15
Прямой обход дал польскую (префиксную) запись, обратный — обратную польскую, которую вычисляют стеком. Это дерево строгое: у каждой операции ровно два операнда. Симметричный обход здесь дал бы 2 + 3 * 7 - 4 — без скобок он теряет порядок действий, поэтому для вывода выражения скобки добавляют при обходе.
Назначение и применение двоичных деревьев
Выбор структуры идет от задачи, а не от названия дерева:
| Задача | Что взять | Пример |
|---|---|---|
| Быстро доставать минимум или максимум | двоичная куча | heapq в Python, PriorityQueue в Java, планировщики задач |
| Хранить ключи упорядоченно, искать диапазоны | сбалансированное BST | TreeMap и TreeSet в Java, std::map в C++ (обычно красно-черное дерево) |
| Разбирать и вычислять выражения | дерево выражения | калькуляторы, компиляторы (синтаксическое дерево бывает и не двоичным) |
| Сжимать данные префиксным кодом | дерево Хаффмана | переход влево — 0, вправо — 1; используется в DEFLATE (zip, gzip, PNG) |
| Принимать решения по признакам | дерево решений | алгоритм CART строит двоичные разбиения |
Если ключи лежат на диске, а не в памяти, двоичное дерево обычно проигрывает: индексы СУБД строят на B-деревьях, у которых у узла много потомков и меньше чтений с диска. Для точного поиска по ключу без порядка чаще хватает хеш-таблицы (dict), дерево нужно, когда важны порядок, минимум или диапазоны.
Если не получилось
Типичные симптомы и причины:
- RecursionError на большом дереве. Рекурсивный обход вырожденного дерева уходит на глубину n.
- Ключи теряются при вставке. Нет ветки для равных ключей или не возвращается корень из рекурсивной функции.
- После удаления пропало поддерево. В случае с двумя потомками узел удален целиком вместо замены преемником.
Первый симптом легко воспроизвести — рекурсивный симметричный обход цепочки из 5000 узлов:
import sys
class Node:
def __init__(self, key):
self.key, self.left, self.right = key, None, None
def inorder_rec(node, out):
if node:
inorder_rec(node.left, out)
out.append(node.key)
inorder_rec(node.right, out)
root = cur = Node(1)
for k in range(2, 5001): # вырожденное дерево-«цепочка» из 5000 узлов
cur.right = Node(k)
cur = cur.right
print("лимит рекурсии:", sys.getrecursionlimit())
out = []
inorder_rec(root, out)
Результат на Python 3.14 (фрагмент):
лимит рекурсии: 1000
Traceback (most recent call last):
...
RecursionError: maximum recursion depth exceeded
Исправление — не поднимать лимит, а обходить итеративно: функция inorder со стеком из раздела про BST обработает те же 5000 узлов без ошибки. Лучшая защита — не допускать вырождения: минимальная высота для 5000 узлов — 13 уровней, и у AVL или красно-черного дерева она того же порядка, а не 5000.
Выводы
- Двоичное дерево — узлы с не более чем двумя упорядоченными потомками; дерево поиска и куча — это двоичные деревья с дополнительным правилом порядка.
- Названия видов путаются, поэтому проверяйте условие: строгое (0 или 2 потомка), почти полное (без дыр по уровням), совершенное, сбалансированное.
- Почти полное дерево удобно хранить в массиве по индексам 2i + 1 и 2i + 2 — так устроен
heapq. - Операции BST стоят O(h): при балансе это O(log n), при отсортированной вставке высота растет до n.
- Порядок обхода выбирают под задачу: in-order для сортировки ключей, post-order для вычисления выражений, обход в ширину — по уровням через очередь.
Где применяется / связь с практикой
Освойте тему на практике
Двоичные деревья — база для задач на собеседованиях и для понимания стандартных библиотек: очереди с приоритетом, упорядоченных словарей, парсеров и сжатия. Следующий шаг после этой статьи — самобалансирующиеся деревья (AVL, красно-черные) и оценка сложности в худшем случае. Системно эти темы с задачами и разбором кода проходят на курсе «Алгоритмы и структуры данных». Отдельные темы можно посмотреть на открытых уроках Otus.
FAQ
Чем двоичное дерево отличается от двоичного дерева поиска?
Двоичное дерево ограничивает только число потомков. В дереве поиска дополнительно действует правило: слева меньшие ключи, справа большие, поэтому по нему можно искать за O(h).
Можно ли хранить в BST одинаковые ключи?
Можно, если заранее договориться: считать равные ключи правыми (или левыми) потомками либо хранить в узле счетчик повторов. В примере выше дубликаты просто игнорируются.
Куча — это дерево поиска?
Нет. В куче известно только, что родитель не больше потомков, порядок между левым и правым поддеревом не задан, поэтому искать произвольный ключ в ней приходится перебором.



