Деревья, кучи и графы

Что это такое

На вопрос «что лежит под этим ключом» отвечают плоские коллекции, а деревья, кучи и графы — на два других: что упорядочено и что с чем связано. Дерево связывает каждое значение с родителем и потомками, куча держит сверху самое важное значение, а граф позволяет любому узлу указывать на любой другой — из этих структур и состоят отсортированные индексы, очереди задач и поиск маршрутов.

Почему это важно

Именно на упорядоченных и связанных данных наивный код спотыкается первым: вставка в отсортированный массив порядок сохраняет, зато копирует хвост, а просьба к связному списку «дай наименьшее» оборачивается обходом всего списка. Сбалансированное дерево даёт вставку и поиск с одинаковой растущей стоимостью, поэтому индексы баз данных и упорядоченные отображения устроены как деревья, а куча выдаёт «следующую самую срочную задачу» за фиксированную стоимость, поэтому её берут планировщики и ограничители скорости. Графы описывают всё, у чего есть маршруты, зависимости или отношения, и неверный обход как раз и заставляет краулер зациклиться навсегда.

Ключевые идеи

Дерево упорядочивает структурой, а не позицией

Двоичное дерево поиска хранит в левом поддереве значения меньше корня, а в правом — больше. Поиск, вставка и удаление идут по одному пути от корня к листу, поэтому стоимость растёт с высотой дерева, а не с числом значений, — но лишь пока дерево остаётся сбалансированным. Вставьте в несбалансированное дерево отсортированный вход, и оно выродится в связный список; поэтому на практике вам встречаются самобалансирующиеся варианты.

        50
      /    \
    30      70
   /  \    /  \
  20   40 60   80

поиск 40: 40 < 50 -> влево; 40 > 30 -> вправо; найдено

Куча держит одно значение дешёвым

Куча — это дерево, хранимое в массиве, с одним правилом: каждый родитель главнее своих потомков. Это правило ставит минимум или максимум на нулевой индекс, а перестроение после добавления или извлечения стоит глубины дерева; остальное куча не сортирует — осмыслен только верх, — и как раз этого и требует очередь с приоритетом.

добавить 7 в [3, 5, 9, 8]: поставить в конец, затем менять
с родителем, пока меньше его.  извлечение: перенести последний
элемент в корень, затем менять вниз.  Оба стоят O(log n).

Графы — общий случай

Граф состоит из узлов и рёбер, может содержать циклы и несвязные куски, поэтому обходу нужна запись того, где он уже был. Поиск в ширину исследует кольцами от старта и находит кратчайшие пути в невзвешенном графе, поиск в глубину ныряет и подходит для достижимости, обнаружения циклов и топологического порядка, — а устроены оба одинаково, и различает их только то, что служит фронтом: очередь или стек.

visited = set()
queue = [start]
while queue not empty:
  node = queue.pop front
  if node in visited: continue
  visited.add(node)
  queue.extend(node.neighbours)

Форма данных решает обход

Прежде чем писать цикл, спросите, что означают рёбра: граф зависимостей направленный и цикла содержать не должен, дорожная сеть взвешенная, поэтому кратчайшему пути нужны веса, а файловая система — дерево, у которого циклов нет, так что рекурсивный обход здесь безопасен. Стоит назвать форму первым делом — и расплывчатый поиск превращается в конкретный алгоритм.

Практика

Постройте индекс вручную

Возьмите список из пятидесяти записей с числовым ключом. Храните их в сбалансированном двоичном дереве поиска, которое реализуете сами, и обеспечьте поиск и упорядоченный вывод.

Готово, когда: поиск задевает не больше нескольких узлов на запрос, а вывод обходит дерево по порядку.

Найдите кратчайший маршрут в небольшом графе

Смоделируйте пять остановок и связи между ними. Запустите поиск в ширину из одной остановки и напечатайте расстояние до каждой из остальных.

Готово, когда: расстояния верны, и вы можете объяснить, почему первый визит в узел — кратчайший.

Частые ошибки

  • Вставка отсортированных данных в наивное дерево поиска — дерево становится связным списком, и каждый поиск деградирует; исправить: возьмите самобалансирующееся дерево или перемешайте вход.
  • Забытое множество посещённых — граф с циклом гоняет обход по кругу вечно; исправить: записывайте каждый узел в момент попадания во фронт, а не во время обработки.
  • Ожидание, что куча отсортирована — гарантирован только верхний элемент, поэтому чтение остальных по порядку — ошибка; исправить: извлекайте по одному, если нужен полный порядок.
  • Обращение к графу, когда данные — дерево — лишняя обработка циклов добавляет код и прячет инвариант; исправить: назовите форму в типе или имени, прежде чем выбирать структуру.
  • Рекурсивный обход большого графа — глубокая рекурсия исчерпывает стек вызовов на реальных данных; исправить: используйте явную очередь или стек для всего, что может быть большим.

Что читать дальше