# теория · шаг 2 из 7
Алгоритм Дейкстры: жадность, которая оказывается точной
Коротко
Поправка к обходу в ширину проста: брать не «кто раньше попал в очередь», а вершину с наименьшей
текущей оценкой dist. Такая вершина уже не улучшится, её оценку можно объявить окончательной,
релаксировать её рёбра и больше к ней не возвращаться. Это и есть алгоритм Дейкстры: V раз
«извлечь минимум» и до E раз «улучшить оценку соседа» - ровно контракт очереди с приоритетами из
раздела про кучи. На двоичной куче это O((V + E)·log V), на куче Фибоначчи O(E + V·log V), а на
плотном графе быстрее всего простой перебор массива - O(V²). Жадный выбор здесь не эвристика: он
ДОКАЗУЕМО даёт точный ответ - но только пока веса неотрицательны.
Если ты помнишь алгоритм, умеешь доказать, почему извлечённая вершина уже окончательна, и знаешь три реализации с их ценой, листай до «Теперь сам».
Как думаешь, когда оценку можно считать окончательной
Мы держим оценки сверху dist и улучшаем их релаксациями. Прежде чем читать дальше, ответь: в
какой момент про вершину можно сказать «всё, дешевле уже не будет» и больше её не трогать? И чем
для этого ответа важно, что все веса неотрицательны?
На что это похоже
Пожар, расходящийся по сухой траве. Огонь занимает сначала ближайшие участки, потом следующие по удалённости, и к каждому участку приходит по кратчайшему пути - потому что горит он равномерно и всегда идёт от ближайшей границы. Дейкстра - это такой же фронт: вершины «загораются» в порядке возрастания расстояния, и как только вершина загорелась, её время прихода уже не изменится.
Механизм: очередь с приоритетами и ленивое удаление
import heapq # стандартная куча Python
def dijkstra(start):
dist = [INF] * V # INF - заведомо большое число (см. прошлый урок)
prev = [-1] * V # откуда пришли; -1 значит «неизвестно»
dist[start] = 0
q = [(0, start)] # (оценка, вершина) - min-куча
while q:
d, v = heapq.heappop(q)
if d > dist[v]:
continue # устаревшая запись - пропускаем
for u, w in adj[v]: # сосед и вес ребра
if dist[v] + w < dist[u]:
dist[u] = dist[v] + w
prev[u] = v
heapq.heappush(q, (dist[u], u)) # новая запись, старая станет мусором
return dist, prev
Про листинг: это обычный Python, heapq и V/adj - куча из стандартной библиотеки и данные
графа (число вершин и список смежности), которые заводятся снаружи функции. В ЗАДАЧАХ курса
импортов нет и кучи тоже: там вершину с наименьшей оценкой находят перебором массива - это третья
строка таблицы ниже, O(V²), и на графах из условий она быстрее.
Это ЛЕНИВЫЙ вариант из главы про очередь с приоритетами: вместо того чтобы искать вершину в куче и
менять её ключ, мы кладём новую запись, а устаревшие отбрасываем при извлечении по проверке
d > dist[v]. Записей в куче становится до E, но код остаётся коротким, и на практике так и
пишут.
Три реализации и их цена:
двоичная куча, ленивая : O((V + E)·log V) ← обычный выбор
куча Фибоначчи с
уменьшением ключа : O(E + V·log V) ← лучшая ОЦЕНКА из трёх
массив без кучи
(каждый раз ищем min) : O(V² + E) = O(V²) ← простой выбор при E ≈ V²
Читать эту таблицу надо так. На разреженном графе (E ≈ V) двоичная куча выигрывает у перебора
массива: V·log V против V². На плотном (E ≈ V²) - наоборот: перебор даёт V², а куча
V²·log V. Куча Фибоначчи лучше ДВОИЧНОЙ кучи на любом графе (E + V·log V никогда не хуже
(V + E)·log V), а на плотном графе она даёт те же V², что и перебор массива. Но множитель у неё
большой, а код длинный, поэтому на практике берут либо двоичную кучу, либо, на плотном графе,
перебор массива - он и короче, и без единой лишней структуры.
Правило
Дейкстра: держим оценки dist, повторно берём НЕОБРАБОТАННУЮ вершину с наименьшей оценкой,
объявляем её окончательной и релаксируем её рёбра. Требует НЕОТРИЦАТЕЛЬНЫХ весов. Цена -
O((V + E)·log V) на двоичной куче с ленивым удалением, O(E + V·log V) с уменьшением ключа,
O(V²) перебором массива на плотном графе. Маршрут восстанавливается по prev.
Разбор: считаем по шагам
Граф (ориентированный): s→a = 4, s→b = 1, b→a = 2, a→t = 1, b→t = 7.
старт: dist = {s:0, a:∞, b:∞, t:∞}, куча [(0,s)]
извлекаем (0, s) → фиксируем s = 0
релаксируем s→a: dist[a] = 4, кладём (4,a)
релаксируем s→b: dist[b] = 1, кладём (1,b)
извлекаем (1, b) → фиксируем b = 1 ← минимум, а не «кто раньше пришёл»
релаксируем b→a: 1 + 2 = 3 < 4 → dist[a] = 3, кладём (3,a)
релаксируем b→t: 1 + 7 = 8 < ∞ → dist[t] = 8, кладём (8,t)
извлекаем (3, a) → фиксируем a = 3
релаксируем a→t: 3 + 1 = 4 < 8 → dist[t] = 4, кладём (4,t)
извлекаем (4, t) → фиксируем t = 4 ← ключ тот же, что у (4,a): кучу выбор не волнует
извлекаем (4, a) → d=4 > dist[a]=3 → УСТАРЕВШАЯ запись, пропускаем
извлекаем (8, t) → d=8 > dist[t]=4 → устаревшая, пропускаем
Ответ: dist = {s:0, b:1, a:3, t:4}, маршрут s → b → a → t. Обрати внимание на три вещи. Первая:
вершины фиксировались в порядке 0, 1, 3, 4 - по возрастанию расстояния, а не по порядку появления.
Вторая: в куче осталось два мусорных элемента, и они были отброшены проверкой одной строкой -
именно так работает ленивое удаление. Третья: записи (4, t) и (4, a) имеют ОДИНАКОВЫЙ ключ, и
какую из них куча выдаст первой - её внутреннее дело (Python сравнит вторые элементы пары и выдаст
(4, a), другая реализация может выдать (4, t)). На ответ это не влияет: a уже зафиксирована с
оценкой 3, её запись всё равно будет отброшена как устаревшая. Правило простое: там, где ключи
равны, порядок фиксации не определён, и алгоритм не должен от него зависеть.
Разбор: почему извлечённая вершина уже окончательна
Это главное утверждение алгоритма, и доказательство короткое. Пусть все веса неотрицательны, и
пусть мы извлекаем из кучи вершину u с наименьшей оценкой среди необработанных. Докажем, что
dist[u] уже равно истинному кратчайшему расстоянию.
От противного: пусть есть путь из s в u короче, чем dist[u]. Идём по этому пути от начала.
Стартовая вершина обработана, u - нет, значит где-то на пути есть первое ребро x → y, где x
уже обработана, а y ещё нет. Когда мы обрабатывали x, мы релаксировали это ребро, значит
dist[y] <= dist[x] + w(x, y), а это - длина куска пути от s до y.
Дальше решает НЕОТРИЦАТЕЛЬНОСТЬ весов: оставшийся кусок пути от y до u имеет длину >= 0,
поэтому длина всего пути >= dist[y]. Мы предположили, что весь путь короче dist[u], значит
dist[y] < dist[u]. Но y не обработана, а u мы выбрали как необработанную вершину с
НАИМЕНЬШЕЙ оценкой - противоречие.
Вот и вся доказательная работа. И сразу видно, где она сломается: если бы существовали
отрицательные рёбра, «оставшийся кусок пути >= 0» перестало бы выполняться, и вершина с
маленькой оценкой могла бы подешеветь ПОСЛЕ фиксации. Контрпример на три вершины будет в следующем
уроке.
Что ломается без этого
Первое: брать вершину «по порядку очереди», а не по минимуму - получится обход в ширину, который на
весах врёт. Второе: забыть проверку if d > dist[v]: continue в ленивом варианте - вершина будет
обрабатываться по нескольку раз, и хотя ответ обычно останется верным, время вырастет, а на
плохих графах заметно. Третье: класть в кучу вершину без её оценки (heappush(q, v)) - куча начнёт
сравнивать номера вершин вместо расстояний, и алгоритм превратится в мусор. Четвёртое: применять
Дейкстру к отрицательным весам «потому что обычно работает» - именно так и появляются баги, которые
всплывают на редких данных. Пятое: на плотном графе тащить кучу, когда перебор массива и проще, и
быстрее.
Зачем это в работе
Дейкстра - рабочая лошадь навигации и маршрутизации: маршруты в картах (с весом «время с учётом
пробок»), протоколы состояния канала в сетях (каждый маршрутизатор знает всю карту сети - вершины это
маршрутизаторы, рёбра это каналы с «метрикой» вместо длины - и считает по ней кратчайшие пути;
так работают OSPF и IS-IS),
планирование перемещений роботов и юнитов в играх, поиск самой дешёвой цепочки преобразований -
от конвертации валют до подбора последовательности операций. На огромных дорожных графах его
дополняют предобработкой (иерархии сокращений, contraction hierarchies) и двунаправленным
поиском, но ядро остаётся тем же. И отдельный практический вывод: если веса одинаковые, не зови
Дейкстру - обход в ширину даст тот же ответ и без кучи; если веса маленькие целые (0 и 1),
существует отдельный дешёвый трюк - дека вместо кучи, об этом в конце главы.
Теперь сам
Граф: s→a = 2, s→b = 5, a→b = 1, a→c = 7, b→c = 1, c→t = 2, b→t = 9. Проведи
Дейкстру из s и выпиши, в каком порядке фиксируются вершины и каким получится dist[t].
Ответ. Старт: dist[s] = 0, остальные ∞, куча [(0,s)].
Извлекаем (0, s) → фиксируем s = 0; релаксации: dist[a] = 2, dist[b] = 5.
Извлекаем (2, a) → фиксируем a = 2; релаксации: a→b: 2 + 1 = 3 < 5 → dist[b] = 3;
a→c: 2 + 7 = 9 < ∞ → dist[c] = 9.
Извлекаем (3, b) → фиксируем b = 3; релаксации: b→c: 3 + 1 = 4 < 9 → dist[c] = 4;
b→t: 3 + 9 = 12 < ∞ → dist[t] = 12.
Извлекаем (4, c) → фиксируем c = 4; релаксация c→t: 4 + 2 = 6 < 12 → dist[t] = 6.
Извлекаем (5, b) - устаревшая запись (5 > dist[b] = 3), пропускаем.
Извлекаем (6, t) → фиксируем t = 6. Дальше в куче только мусор ((9,c), (12,t)).
Порядок фиксации: s, a, b, c, t - строго по возрастанию расстояния (0, 2, 3, 4, 6), и
dist[t] = 6 по маршруту s → a → b → c → t. Заметь, что прямое ребро b→t весом 9 и ребро
a→c весом 7 в ответ не вошли вовсе: обе оценки, которые они дали, были позже улучшены. И ещё:
вершина b побывала в куче дважды - с оценками 5 и 3; отработала запись с меньшей, а вторая была
отброшена проверкой.
Главное
Алгоритм Дейкстры - обход в ширину, у которого очередь заменена на очередь с ПРИОРИТЕТАМИ: каждый
раз берём необработанную вершину с наименьшей оценкой dist, объявляем её окончательной и
релаксируем её рёбра. Корректность доказывается от противного: если бы существовал более короткий
путь до извлечённой вершины u, он на границе обработанного множества дал бы вершине y оценку
dist[y] < dist[u], а мы выбрали u как наименьшую - противоречие. Ключевой шаг опирается на
НЕОТРИЦАТЕЛЬНОСТЬ весов: остаток пути не может уменьшить длину. Реализация - ленивая двоичная
куча: кладём новую запись при каждом улучшении и отбрасываем устаревшие проверкой d > dist[v];
цена O((V + E)·log V). С уменьшением ключа на куче Фибоначчи - O(E + V·log V), а на плотном
графе (E ≈ V²) быстрее всего простой перебор массива за O(V²). Маршрут - по массиву prev.
При равных весах Дейкстра не нужна: обход в ширину даст то же самое дешевле.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий