open atlas
← Все проекты

algorithms · intermediate · 8d

Движок поиска маршрутов

Четыре алгоритма поиска — одна общая задача: добраться из A в B по взвешенной сетке. Ты построишь BFS, DFS, Dijkstra и A* на одной модели графа, а затем запустишь их бок о бок и увидишь, как они расходятся: DFS бросается в тупик, BFS равномерно растекается, Dijkstra ползёт наружу по стоимости, A* тянется к цели. Именно здесь раздел про алгоритмы перестаёт быть набором фактов и становится инструментами, между которыми ты выбираешь осознанно.

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

Результат

CLI, который загружает сетку (стены и взвешенные клетки), запускает любой из BFS/DFS/Dijkstra/A* между двумя точками, печатает путь вместе с числом раскрытых узлов и стоимостью пути и рисует исследованную границу в ASCII, чтобы ты видел, как ищет каждый алгоритм.

Этапы

0/5 · 0%
  1. 01Смоделируй мир как граф

    Прежде чем что-то искать, нужно то, в чём искать. Представь сетку так, чтобы каждый алгоритм мог задать одни и те же два вопроса: «кто мои соседи?» и «сколько стоит шаг туда?». Осознанно реши, хранишь ли ты явный список смежности или вычисляешь соседей на лету из (row, col) — для плотной сетки неявная форма экономнее, но написание явной функции `neighbours(node)` заставляет в одном честном месте обработать стены, выбор между четырьмя и восемью направлениями и выход за границы. Взвешенные клетки (скажем, грязь стоит 3, дорога — 1) — это то, что отличает настоящий движок маршрутов от игрушечного лабиринта, поэтому закладывай стоимость в ребро с самого начала, а не прикручивай её потом.

    Критерии готовности
    • Функция `neighbours(node)` возвращает только клетки в границах и без стен вместе со стоимостью шага.
    • Небольшая сетка (хотя бы с одной стеной и одной взвешенной областью) загружается из текста или массива и корректно сохраняется обратно.
  2. 02BFS и DFS: неинформированная пара

    Реализуй поиск в ширину и в глубину и позволь структуре данных стать всем уроком: очередь даёт тебе BFS и кратчайший путь по числу шагов; замени её на стек — и тот же самый код превращается в DFS, который находит *какой-то* путь, но редко кратчайший. По ходу веди множество `visited` и карту `parent`, ведь именно карта parent позволяет восстановить настоящий маршрут после достижения цели — сам поиск лишь сообщает, что цель достижима. Запусти оба на сетке со стеной посередине и понаблюдай, как DFS ныряет вглубь одного коридора, а BFS расширяется кольцами; этот контраст — интуиция, на которой строится весь остальной проект.

    Критерии готовности
    • BFS возвращает путь с минимальным числом шагов на невзвешенной сетке; DFS возвращает корректный (не обязательно кратчайший) путь.
    • Оба восстанавливают путь из карты parent и возвращают его упорядоченным списком клеток либо аккуратно сообщают «пути нет», когда цель отгорожена стеной.
  3. 03Dijkstra: поиск, уважающий стоимость

    BFS считает шаги, но в твоей сетке есть взвешенные клетки, поэтому путь с наименьшим числом шагов и самый дешёвый путь — уже не одно и то же. Dijkstra решает это, всегда раскрывая самый дешёвый на данный момент узел, а значит обычной очереди не хватит — нужна очередь с приоритетом по накопленной стоимости. Реализуй её аккуратно: классическая ошибка — считать узел финальным в момент, когда ты его *увидел*, а не когда *достал* из очереди, что тихо даёт неверные стоимости на графах, где маршрут с большим числом шагов дешевле. Веди карту `dist` и обновляй соседа только тогда, когда нашёл строго более дешёвый путь к нему. Когда всё заработает, объезд по дороге должен побеждать прямую линию через грязь, а напечатанная стоимость пути это докажет.

    Критерии готовности
    • На взвешенной сетке Dijkstra возвращает путь минимальной стоимости, который отличается от пути BFS по числу шагов, когда веса различаются.
    • Узлы финализируются при извлечении из очереди, а не при первом обнаружении, и сообщаемая стоимость пути равна сумме весов рёбер вдоль него.
  4. 04A*: целься с помощью эвристики

    Dijkstra корректен, но слеп — он раскрывается во все стороны одинаково, тратя работу на клетки, ведущие прочь от цели. A* — это Dijkstra плюс подсказка: в каждом узле он минимизирует `g + h`, где `g` — стоимость пройденного пути, а `h` — оценка оставшейся стоимости до цели. На сетке естественная эвристика для движения по четырём направлениям — манхэттенское расстояние. Незыблемое правило: `h` должна быть *допустимой* — она никогда не должна переоценивать настоящую оставшуюся стоимость, иначе A* может вернуть неверный путь; это тот режим отказа, который нужно намеренно проверять, а не просто прочитать о нём. Когда всё работает, ты увидишь, как поиск раскрывает узкий конус к цели вместо полного диска, и на той же карте A* должен раскрыть строго меньше узлов, чем Dijkstra, вернув ту же оптимальную стоимость.

    Критерии готовности
    • A* возвращает тот же оптимальный по стоимости путь, что и Dijkstra, на взвешенных сетках при допустимой эвристике.
    • Хотя бы на одной карте A* раскрывает ощутимо меньше узлов, чем Dijkstra, а тест с недопустимой эвристикой явно ломает оптимальность.
  5. 05Один CLI, четыре поиска, честные цифры

    Подключи все четыре алгоритма к одной команде так, чтобы между запусками менялся только выбранный поиск. Каждый запуск должен печатать одни и те же три факта — путь, стоимость пути и число раскрытых узлов — плюс ASCII-визуализацию исследованной границы, чтобы различия были видны, а не только выражены числами. Именно здесь проект окупается: на одной карте BFS и Dijkstra совпадают, потому что веса плоские, на другой расходятся, а A* повторяет стоимость Dijkstra, затронув меньше клеток. Не вшивай сетку в код — читай её из файла, чтобы создавать каверзные карты (длинный объезд, обманчивая стена), на которых характер каждого алгоритма становится очевидным.

    Критерии готовности
    • `engine <algo> <map> <start> <goal>` запускает любой из четырёх и единообразно печатает путь, стоимость и число раскрытых узлов.
    • Существуют хотя бы две карты, на которых алгоритмы дают заметно разные границы или стоимости, что видно в выводе.

Стартер

  • README.md
  • src/pathfind.ts
  • test/pathfind.test.ts
Скачать стартер (.zip)

Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test

Рубрика

Джуниор Миддл Сеньор
Корректность очереди с приоритетом Использует встроенную сортировку или линейный проход для нахождения узла с минимальной стоимостью на каждом шаге; Dijkstra и A* дают верные пути на небольших сетках. Реализует или оборачивает бинарную кучу с O(log n) insert и extract-min; финализирует узлы при извлечении (а не при первом обнаружении), чтобы стоимости были верны даже при обнаружении более дешёвого пути к уже виденному узлу. Умеет продемонстрировать классический баг pop-vs-visit на конкретном графе: узел, увиденный со стоимостью 10 по одному пути и 8 по более позднему — если финализировать при посещении, неоптимальная стоимость фиксируется. Знает, когда куча с decrease-key (куча Фибоначчи) снизила бы асимптотику с O((V+E) log V) до O(E + V log V), и умеет объяснить, почему на практике она редко выигрывает из-за константных накладных расходов.
Допустимость эвристики Использует манхэттенское расстояние как эвристику A* и наблюдает, что A* раскрывает меньше узлов, чем Dijkstra, на открытых картах. Умеет сформулировать требование допустимости (h(n) никогда не должна превышать истинную оставшуюся стоимость) и имеет тест, где недопустимая эвристика (например, манхэттенская * 1,5) даёт неоптимальный путь на конкретной карте, где переоценка заставляет раскрыть неверный узел первым. Объясняет согласованность (монотонная эвристика): h(n) <= cost(n, n') + h(n') для всех рёбер, что гарантирует оптимальность стоимости при первом раскрытии узла — позволяя использовать ленивое обнаружение дублей вместо закрытого множества. Умеет построить сетку, где манхэттенская эвристика допустима, но не согласована (это невозможно для стандартных 4-направленных сеток, но возможно при переменных стоимостях рёбер), и объяснить следствие.
Компромисс представления графа Хранит сетку как двумерный массив и вычисляет соседей по требованию с жёстко закодированными дельтами направлений. Отделяет абстракцию сетки от алгоритма поиска через интерфейс `neighbours(node) -> [(node, cost)]`, позволяя подключить несеточный граф (например, список смежности дорожной сети) без изменения кода поиска. Умеет обосновать, когда явный список смежности выигрывает у неявного вычисления соседей на лету: плотные сетки с постоянным коэффициентом ветвления (4 или 8) предпочитают неявный вариант (нет аллокации на узел); разреженные графы с переменным out-degree или неоднородными метаданными рёбер — явный. Умеет оценить память: сетка 1000×1000 с 4-связностью хранит 4M рёбер неявно как арифметику против 4M явных указателей (~32 МБ при 8 байтах каждый).
Эталонный разбор (спойлер)

Почему узлы финализируются при извлечении, а не при посещении: во взвешенном графе первая встреча с узлом может быть по более длинному маршруту. Более поздний путь через очередь с приоритетом может быть дешевле. Если пометить узел завершённым при первом обнаружении, фиксируется неверная стоимость. Инвариант, делающий Dijkstra корректным: когда узел извлечён из мин-кучи, его известная стоимость является глобальным минимумом стоимости достижения — это выполняется, потому что все непосещённые стоимости >= извлечённой стоимости.

Условие оптимальности A*: A* возвращает оптимальный путь тогда и только тогда, когда эвристика допустима (никогда не переоценивает). Недопустимая эвристика может заставить A* раскрыть субоптимальный путь первым, достичь цели по этому пути и вернуть не минимальное решение по стоимости. Отказ не приводит к краху — путь корректен, просто не самый дешёвый, — что делает это тихим багом корректности, проявляющимся только на картах, где переоценка существенна.

Бинарная куча против линейного прохода для очереди с приоритетом: наивная реализация, сканирующая все открытые узлы на каждом шаге, даёт O(V^2) суммарно; бинарная куча делает каждый insert и extract-min O(log V), давая O((V+E) log V) в целом. Для сетки 1000×1000 с ~4M рёбрами подход с кучей исследует ~1M узлов с ~20 сравнениями каждый (log2(1M) ≈ 20) против ~1M узлов с ~500K сравнениями каждый при наивном проходе — примерно в 25000 раз меньше сравнений.

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

Сделай по-сеньорски

  • Добавь двунаправленный поиск: расти две границы — одну от старта, другую от цели — и останавливайся, когда они встретятся. Выигрыш реален: поиск до радиуса r с обоих концов затрагивает гораздо меньше узлов, чем поиск до радиуса 2r с одного — но условие встречи и сшивание пути коварны, особенно для взвешенного варианта и A*, где «они встретились» не равно «оптимальный путь найден».
  • Сделай настоящий бенчмарк: сгенерируй случайные карты нескольких размеров и плотностей препятствий, прогони все алгоритмы под таймингом и выведи число раскрытых узлов и время по часам в виде таблицы. Цель — сделать учебные утверждения проверяемыми: покажи, где преимущество эвристики A* тает (открытые карты, слабые эвристики) и где лишняя работа Dijkstra — это цена отсутствия подсказки.
  • Сделай эвристику подключаемой и проверь теорию на практике: сравни манхэттенскую, евклидову и намеренно недопустимую (переоценивающую) эвристики на одних и тех же картах и покажи, как недопустимая меняет оптимальность на скорость — иногда приемлемо, иногда нет.

Навыки

modeling a grid as an adjacency-based weighted graphimplementing BFS and DFS with explicit frontiersDijkstra with a priority queueA* with an admissible heuristicreconstructing a path from a parent mapinstrumenting and benchmarking search algorithms

Рекомендуемый стек

TypeScript or Pythona binary-heap priority queue (hand-rolled or stdlib)node:test / pytesta benchmarking harness (hyperfine or a timing loop)