14 KiB
Задача 12 — Дейкстра на priority_queue (C++)
long long shortest_path(int n,
const std::vector<std::tuple<int,int,int>>& edges, // (u, v, w)
int src, int dst);
Требования:
- рёбра ориентированные, вес
w >= 0, self-loop допустим; вершины0..n-1; - вернуть длину кратчайшего пути
src -> dst(long long, веса суммируются до 10^9); - недостижимость →
-1;src == dst→0; - вес рёбер 0 допустим (условие применимости Дейкстры — неотрицательные веса);
- 100 000 вершин и 200 000 рёбер: быстрее 2 секунд. Значит нужна
std::priority_queue(O((V+E) log V)), а не O(V²) перебор минимума.
Проверка: python3 grade.py 12. Критерий: все ok, сборка без предупреждений.
Факты для карточек
- base | Что возвращает функция при
src == dst? — 0 - base | Что возвращает функция при недостижимости
dst? — -1 - base | В каком типе суммируются веса и почему не
int? —long long; веса суммируются до 10^9,intможет переполниться на длинном пути
Почему дальше: чтобы уложиться в 2 секунды на 100 000 вершин и 200 000 рёбер, важно понять, почему наивный перебор минимума не подходит и что именно даёт куча.
Механизм: куча против наивного перебора
Наивная Дейкстра. На каждом из V шагов алгоритм сканирует массив dist[] целиком,
чтобы найти непосещённую вершину с минимальным расстоянием — это O(V) на шаг. Суммарно
V шагов по O(V) — O(V²), плюс O(E) на релаксацию рёбер (эта часть не доминирует).
Подходит, если граф плотный (E ~ V²), но не в этой задаче.
Дейкстра с priority_queue. Вместо линейного скана используется min-куча по текущему
известному расстоянию. При релаксации ребра (u, v, w), если найден более короткий путь до
v, в кучу добавляется новая запись (dist, v) — push стоит O(log размер_кучи).
Так как для одной вершины может накопиться несколько записей (по одной на каждое улучшение
расстояния), размер кучи ограничен числом релаксаций, то есть O(V + E), и log от этой
величины по порядку — тот же O(log V) (log(V²) = 2 log V, то есть смена основания
не меняет асимптотику). Суммарно: O((V + E) log V).
Числа задачи. V = 100 000, E = 200 000. Наивный O(V²) = 100 000² = 10^10 операций
— в 2 секунды не укладывается ни при каких обстоятельствах. Куча: (V+E)·log₂V ≈ 300 000 · 16,6 ≈ 5·10^6 операций — на 3–4 порядка меньше, укладывается с большим запасом.
«Ленивое» удаление устаревших записей. std::priority_queue не умеет decrease-key
за O(log n) (не тот интерфейс), поэтому вместо обновления существующей записи в куче
для вершины v просто добавляется новая пара (dist, v) при каждом улучшении. В куче
одновременно может лежать несколько устаревших записей для одной вершины. При извлечении
самой записи с минимальным dist проверяется, актуальна ли она:
auto [d, v] = pq.top(); pq.pop();
if (d != dist[v]) continue; // запись устарела — лучшая уже обработана раньше
Это дешевле, чем поддерживать структуру с настоящим decrease-key (например, indexed
heap), и корректность не страдает — устаревшая запись всегда хуже уже найденного dist[v]
и просто пропускается.
Факты для карточек
- base | Сложность наивной Дейкстры (перебор минимума по массиву)? — O(V²)
- base | Сложность Дейкстры с бинарной кучей? — O((V+E) log V)
- core | При V=100 000, E=200 000, почему наивный O(V²) не укладывается в 2 секунды? — 100 000² = 10^10 операций против ≈5·10^6 у варианта с кучей — разница на 3–4 порядка
- core | Что означает «ленивое удаление» устаревших записей в очереди? — вместо decrease-key при каждом улучшении расстояния в кучу пушится новая пара (dist, v); при извлечении запись с
d != dist[v]пропускается как устаревшая - deep | Почему
std::priority_queueне используют с decrease-key напрямую? — у контейнера-адаптера нет интерфейса для обновления произвольного элемента за O(log n); дешевле каждый раз пушить новую запись и лениво отбрасывать устаревшие при pop
Почему дальше: скорость решена кучей, но у Дейкстры есть жёсткое условие корректности — неотрицательные веса. Нужно понять механизм, почему это условие обязательно.
Механизм: почему нужны только неотрицательные веса
Дейкстра — жадный алгоритм: когда вершина v извлекается из очереди с минимальным на
данный момент расстоянием, алгоритм считает dist[v] окончательным и больше не
пересматривает эту вершину. Это верно только если все ещё не обработанные пути до v не
могут оказаться короче — а это гарантировано лишь при неотрицательных весах: любой другой
путь до v проходит через вершины с расстоянием ≥ dist[v] и добавляет ребро ≥ 0, то
есть не может дать сумму меньше dist[v].
Если в графе есть отрицательное ребро, эта гарантия ломается: путь через уже «финализированную» вершину может позже пройти по отрицательному ребру и дать меньшую сумму, но алгоритм эту вершину уже не пересматривает — результат будет неверным без явного падения или ошибки, то есть тихо неправильным.
Для графов с отрицательными весами (без отрицательных циклов) используется
Беллман-Форд: V-1 раз релаксируются все E рёбер, O(V·E). Дополнительно на V-м
проходе можно проверить, продолжает ли что-то релаксироваться — если да, в графе
отрицательный цикл, кратчайший путь не определён (можно уменьшать бесконечно).
Self-loop с весом w ≥ 0 никогда не уменьшает кратчайший путь (добавление неотрицательного
веса к текущему расстоянию не улучшает его), поэтому не требует отдельной обработки —
алгоритм просто никогда не выберет такое ребро для релаксации выгодно.
Факты для карточек
- core | Почему Дейкстра ломается на отрицательных рёбрах? — алгоритм считает расстояние до извлечённой из очереди вершины окончательным; отрицательное ребро может позже уменьшить это расстояние, но вершина уже не пересматривается
- base | Какой алгоритм нужен при отрицательных весах без отрицательных циклов? — Беллман-Форд, O(V·E)
- core | Как Беллман-Форд обнаруживает отрицательный цикл? — если рёбра продолжают релаксироваться на V-м проходе (после V-1 гарантированно достаточных проходов), в графе есть отрицательный цикл
- base | Почему self-loop с w≥0 не требует отдельной обработки? — добавление неотрицательного веса к текущему расстоянию никогда не уменьшает его, значит такое ребро никогда не выигрывает релаксацию
Ловушки
- Использовать
intвместоlong longдля накопленной суммы весов → при весах рёбер до 10^9 и длинном пути сумма переполняетint→ тихо неверный (отрицательный или «случайный») результат без явного падения. - Забыть проверку
if (d != dist[v]) continueпри извлечении из очереди → обрабатываются устаревшие записи повторно → не влияет на корректность, но раздувает число операций и на 200 000 рёбер может вывести время за лимит 2 секунды. - Запустить алгоритм на графе с отрицательным ребром без проверки условия применимости → результат тихо неверный (нет явной ошибки времени выполнения) — Дейкстра не обнаруживает нарушение своего предположения сама.
- Перепутать направление рёбер (граф ориентированный) и релаксировать в обе стороны →
находится путь, которого нет в графе условия задачи,
shortest_pathвозвращает заниженное значение.