Алгоритмы и структуры данных · 4
Обходы, деревья поиска и балансировка, B-деревья в базах данных, кучи и очереди с приоритетом, trie. Как они устроены, где живут в ОС, базах и стандартных библиотеках и как узнать их в задаче на собеседовании.
Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и узлы деревьев.
Дерево — иерархия без циклов: один корень, у каждого узла ровно один родитель. DOM, AST, файловая система, JSON, оргструктура, план SQL-запроса — всё деревья.
Почти любая операция над ними — обход, и вопрос только в порядке: родитель до детей, после детей или по уровням. Выберите пример и операцию: цифры — порядок посещения узлов.
find.du, rm -r, вычисление выражений, освобождение памяти.DFS хранит путь от корня — стек глубиной h. BFS хранит фронт — очередь шириной до n/2 узлов. Выберите порядок и жмите «Шаг».
Рекурсивный обход использует системный стек: кадр на уровень. У сбалансированного дерева глубина ~log₂ n, у вырожденного — n, и стек кончается. Явный стек живёт в куче и ограничен только памятью.
# рекурсия: глубина ограничена стеком
def dfs(node):
if node:
yield node.val
yield from dfs(node.left)
yield from dfs(node.right)
# явный стек: глубина любая
def dfs_iter(root):
stack = [root] if root else []
while stack:
node = stack.pop()
yield node.val
if node.right: stack.append(node.right)
if node.left: stack.append(node.left)
Лимиты: CPython — 1000 кадров (sys.getrecursionlimit()), Node.js — ~10 тыс., Java — от тысяч до десятков тысяч при стеке 512 КБ–1 МБ, у горутины Go стек растёт до 1 ГБ. Подробнее — в «Стеке и куче».
В двоичное дерево поиска вставили 5000 ключей по возрастанию (id из базы). Что сделает рекурсивный dfs на CPython?
sys.setrecursionlimit(10**6) спасает не всегда: кончится уже стек потока на C, и тогда действительно будет segfault. Надёжно — явный стек или сбалансированное дерево.Слева от узла — меньшие ключи, справа — большие. Поиск, вставка и удаление идут по одному пути от корня — O(h). В сбалансированном дереве h ≈ log₂ n: 20 шагов на миллион ключей.
Поворот поднимает узел на место родителя и перевешивает одно поддерево. Порядок ключей не меняется, а высота — меняется. Все самобалансирующиеся деревья держат форму только поворотами — за O(1) каждый.
Нажмите на узел, чтобы поднять его над родителем. Превратите цепочку 1…7 в дерево высоты 3. Подсказка: начните с середины.
Ключи по возрастанию — самый частый вход в жизни: автоинкрементные id, время, имена из отсортированного файла. Обычное дерево поиска превращается в список, и все операции — O(n).
TreeMap, TreeSet, std::map, std::set — красно-чёрные деревья. floorKey, ceilingKey, subMap, lower_bound — поиск соседей и диапазонов за O(log n).
Если в одной корзине набралось 8 коллизий (и таблица не меньше 64), цепочка превращается в красно-чёрное дерево — защита от атаки подобранными ключами.
Планировщик CFS и сменивший его в 6.6 EEVDF держат готовые задачи в КЧ-дереве — следующая берётся с левого края. В КЧ-деревьях лежат дескрипторы epoll и таймеры hrtimer.
Области памяти процесса (VMA) переехали из КЧ-дерева в maple tree — разновидность B-дерева: меньше промахов кэша. Об этих областях — в «Виртуальной памяти».
Таймеры соединений в nginx — КЧ-дерево: ближайший таймаут — самый левый узел.
Встроенного упорядоченного словаря нет. Python — sortedcontainers (не дерево, а список списков с bisect), Go — google/btree, tidwall/btree. Часто хватает сортировки и bisect.
Атакующий отправил в Java-сервис 10 000 строковых ключей с одинаковым hashCode(), и все они легли в HashMap. Сколько стоит поиск одного ключа в Java 8+?
Диск читает страницами по 4–16 КБ, и случайное чтение стоит как тысячи сравнений в памяти. Поэтому в узел B-дерева кладут сотни ключей: узел = страница, ветвление — сотни, высота — 3–4 даже для миллиардов строк.
Верхние уровни занимают мегабайты и всегда в кэше. Даже в RAM B-дерево обгоняет двоичное: узел — несколько кэш-линий подряд, а не промах на каждом шаге. Двигайте ползунки. B-дерево в Postgres — подробно в «PostgreSQL изнутри».
LSM-дерево (RocksDB, Cassandra) отвечает на другой вопрос — как быстрее писать: копит записи в памяти и сливает отсортированные файлы. О нём — в «Вероятностных структурах» вместе с фильтрами Блума.
Полное двоичное дерево, где родитель не больше детей. Хранится в массиве без указателей: дети i — 2i+1 и 2i+2, родитель — (i−1)//2. Минимум всегда в a[0].
Куча не отсортирована: она знает только минимум. Зато вставка дешевле, чем в дерево поиска, и нет ни одного указателя — данные лежат подряд.
heapq.merge, внешняя сортировка и слияние сегментов в LSM-базах.time.Sleep, time.After, контексты с дедлайном). В libuv и Node.js — двоичная куча: цикл событий спит до срока a[0]. В asyncio call_later кладёт таймер в heapq.PriorityQueue и DelayQueue в Java, отложенные задачи в очередях.Удалить из кучи произвольный элемент дорого: сначала его нужно найти за O(n). Поэтому отменённые таймеры только помечают и выбрасывают при извлечении — так делают asyncio и Go runtime.
import heapq
tasks = [] # куча — это обычный list
# кортежи сравниваются по первому полю,
# seq разрешает ничьи и сохраняет порядок
heapq.heappush(tasks, (deadline, seq, job))
deadline, _, job = heapq.heappop(tasks)
heapq.heapify(data) # O(n), на месте
heapq.nlargest(10, rows, key=lambda r: r.ms)
for line in heapq.merge(log1, log2, log3):
... # слияние за O(n log k)
# max-куча: кладите -x
# (в Python 3.14+ есть heapq.heappush_max)
Лог на 10 млрд строк читается потоком. Нужны 100 самых медленных запросов, памяти — несколько мегабайт. Какая структура?
heapq.nlargest(100, ...).Строки хранятся по символам: путь от корня — префикс, общие префиксы хранятся один раз. Найти слово или все слова с префиксом — O(длины префикса), сколько бы слов ни было в словаре.
Печатайте в поле — подсказки обновляются на лету. Добавьте своё слово.
Маршрутизатор выбирает самый длинный подходящий префикс: 10.1.2.0/24 важнее 10.0.0.0/8, а 0.0.0.0/0 — маршрут по умолчанию. Это trie по битам адреса: идём от старшего бита и запоминаем последний совпавший маршрут.
В Linux маршруты IPv4 лежат в сжатом LC-trie (/proc/net/fib_trie), в аппаратных маршрутизаторах — в TCAM-памяти. Тот же поиск делают файрволы и GeoIP. Выберите адрес и жмите «Шаг».
Нужна сумма или минимум на отрезке массива, который меняется. Префиксные суммы отвечают за O(1), но обновление стоит O(n). Дерево Фенвика и дерево отрезков делают и то и другое за O(log n).
i хранит сумму блока длиной в младший бит i. Для сумм и любых обратимых операций.ZRANK) держится на skip list. На собеседованиях — в задачах hard.class Fenwick: # индексы с 1
def __init__(self, n):
self.t = [0] * (n + 1)
def add(self, i, delta): # a[i] += delta
while i < len(self.t):
self.t[i] += delta
i += i & -i # следующий блок с i
def prefix(self, i): # a[1] + … + a[i]
s = 0
while i > 0:
s += self.t[i]
i -= i & -i # снять младший бит
return s
# сумма на [l, r]: prefix(r) - prefix(l - 1)
len(q) в начале итерации#) или BFSСначала уточните: двоичное ли дерево, дерево ли поиска, бывают ли дубликаты, может ли оно быть пустым и насколько глубоким (рекурсия или стек). Сложность почти всегда O(n) по времени и O(h) по памяти — проговорите это.
Классическая ловушка — проверять только «левый ребёнок меньше, правый больше». Нужно передавать вниз границы: всё левое поддерево меньше узла, а не только его ребёнок.
inf = float('inf')
def valid(node, lo=-inf, hi=inf):
if not node:
return True
if not lo < node.val < hi:
return False
return (valid(node.left, lo, node.val) and
valid(node.right, node.val, hi))
Переключайте задачи и жмите «Шаг»: первые две идут на дереве с ловушкой, две другие — на правильном BST.
heapq, bisect, sortedcontainers, TreeMap, container/heap.# AST и байткод Python
echo 'x = a + b * 2' | python3 -m ast
echo 'x = a + b * 2' | python3 -m dis
# Linux: trie маршрутов и longest prefix match
head -30 /proc/net/fib_trie
ip route get 10.1.2.7
# очереди планировщика (КЧ-деревья задач)
sudo cat /sys/kernel/debug/sched/debug | less
# Postgres: высота B-дерева индекса
CREATE EXTENSION pageinspect;
SELECT level, fastroot FROM bt_metap('orders_pkey');
# DOM в консоли браузера: обход в глубину
document.querySelectorAll('*').length
Видите иерархию — ищите обход. Видите «k лучших» — берите кучу. Видите префиксы — стройте trie.