awesome-everything EN
↑ Обратно к восхождению

Алгоритмы с нуля

Алгоритм Дейкстры: кратчайшие пути со взвешенными рёбрами

Суть Дейкстра находит кратчайшие пути от источника в графах с неотрицательными весами. Min-куча по расстоянию извлекает ближайшую неустановленную вершину, устанавливает её и релаксирует рёбра. Ленивое удаление заменяет decrease-key. O((V+E) log V). Отрицательным — Bellman-Ford.
◷ 30 min

В прошлом уроке BFS давал тебе кратчайшие пути бесплатно — но только потому что каждое ребро считалось одинаково. Повесь ценник на каждое ребро (эта дорога стоит 1, та дорога стоит 7) и BFS ломается: он минимизирует число рёбер, а не суммарную стоимость. Маршрут из 2 рёбер может стоить намного больше чем обход из 3 рёбер. Алгоритм Дейкстры это исправляет. Вместо обычной очереди, что извлекает вершины в порядке прибытия, он использует очередь с минимальным приоритетом, что всегда извлекает вершину с наименьшим предварительным расстоянием на текущий момент. Каждое извлечение «зафиксирует» вершину — фиксирует её итоговое кратчайшее расстояние — а потом «релаксирует» её исходящие рёбра, понижая предварительные расстояния её соседей. Это BFS, улучшенный для весов, и он движет реальной маршрутизацией: карты, сети, всё где перемещение стоит по-разному.

Цель

После этого урока ты можешь реализовать алгоритм Дейкстры с очередью с минимальным приоритетом на двоичной куче. Ты понимаешь две основные операции: зафиксировать (извлечь ближайшую незафиксированную вершину и зафиксировать её расстояние как итоговое) и релаксировать (если dist[u] + w(u,v) < dist[v], понизить dist[v] и записать parent[v] = u). Ты ведёшь dist[] и parent[] так что можешь восстановить конкретный самый дешёвый путь. Ты знаешь приём ленивого удаления: у двоичной кучи нет decrease-key, так что ты добавляешь дублирующие записи и пропускаешь устаревшие извлечения. Ты понимаешь — и можешь объяснить почему — Дейкстра корректна только с неотрицательными весами, и что для отрицательных рёбер вместо неё требуется Bellman-Ford. Ты знаешь что BFS это частный случай где все веса равны 1. Стоимость: O((V+E) log V) по времени, O(V) по памяти.

Идея

Вопрос о кратчайшем взвешенном пути

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

Почему BFS недостаточно

BFS извлекает вершины в порядке прибытия (FIFO), что равно порядку расстояния только когда все рёбра стоят одинаково. При смешанных весах вершина, что в нескольких рёбрах, может быть дорогой, а вершина во многих рёбрах может быть дешёвой. Нам нужно всегда расширять самый дешёвый фронт следующим, не ближайший-по-рёбрам фронт. Это единственное изменение, что делает Дейкстра: заменить FIFO-очередь на очередь с минимальным приоритетом с ключом по предварительному расстоянию.

Установить и релаксировать: две операции

Дейкстра ведёт предварительное расстояние dist[v] для каждой вершины (начинается с бесконечности, кроме dist[source] = 0). Он повторяет два шага пока очередь не пуста:

  • Установить: извлеки неустановленную вершину u с наименьшим dist[u]. Её расстояние теперь итоговое — более дешёвого маршрута к u существовать не может. Пометь её установленной.
  • Релаксировать: для каждого исходящего ребра u -> v веса w проверь, обыгрывает ли путь через u лучший известный маршрут к v. Если dist[u] + w < dist[v], то обнови dist[v] = dist[u] + w, задай parent[v] = u и добавь v в очередь с её новым ключом.

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

Пример графа (ориентированный, взвешенный)

Adjacency list (vertex -> [neighbor, weight]):
A: [B:4, C:1]
B: [E:4]
C: [B:2, D:5]
D: [E:2]
E: [F:3]
F: []

Visual (arrows show direction, numbers are weights):

        4
   A ------> B
   |         |
  1|         |4
   v   2     v   3
   C ------> E ------> F
   |         ^
  5|        2|
   v         |
   D --------+

Заметь ловушку, что ставит прямое ребро A->B: оно стоит 4, но обход A->C->B стоит 1+2 = 3. Дейкстра должна обнаружить более дешёвый маршрут через C.

Разобранная трассировка релаксации (источник = A)

Начни с dist[A]=0, всё остальное бесконечность. Min-куча держит пары (distance, vertex).

куча = [(0,A)]          dist: A=0  B=inf C=inf D=inf E=inf F=inf

Извлекаем (0,A). Фиксируем A. Релаксируем рёбра A:
  A->B (4): 0+4=4 < inf  -> dist[B]=4, parent[B]=A, push (4,B)
  A->C (1): 0+1=1 < inf  -> dist[C]=1, parent[C]=A, push (1,C)
куча = [(1,C),(4,B)]    dist: A=0 B=4 C=1 D=inf E=inf F=inf

Извлекаем (1,C). Фиксируем C. Релаксируем рёбра C:
  C->B (2): 1+2=3 < 4    -> dist[B]=3, parent[B]=C, push (3,B)   (дешевле!)
  C->D (5): 1+5=6 < inf  -> dist[D]=6, parent[D]=C, push (6,D)
куча = [(3,B),(4,B),(6,D)]   dist: A=0 B=3 C=1 D=6 E=inf F=inf

Извлекаем (3,B). Фиксируем B. Релаксируем рёбра B:
  B->E (4): 3+4=7 < inf  -> dist[E]=7, parent[E]=B, push (7,E)
куча = [(4,B),(6,D),(7,E)]   dist: A=0 B=3 C=1 D=6 E=7 F=inf

Извлекаем (4,B). B уже зафиксирована -> УСТАРЕВШАЯ, пропускаем. (ленивое удаление)
куча = [(6,D),(7,E)]

Извлекаем (6,D). Фиксируем D. Релаксируем рёбра D:
  D->E (2): 6+2=8 < 7?  Нет (8 >= 7) -> нет обновления
куча = [(7,E)]

Извлекаем (7,E). Фиксируем E. Релаксируем рёбра E:
  E->F (3): 7+3=10 < inf -> dist[F]=10, parent[F]=E, push (10,F)
куча = [(10,F)]

Извлекаем (10,F). Фиксируем F. Нет исходящих рёбер.
куча = []  Готово.

Итоговые расстояния: A=0  B=3  C=1  D=6  E=7  F=10
родитель: A=null B=C C=A D=C E=B F=E

Заметь две вещи. Первое, B был релаксирован дважды: предварительно 4 через A, потом улучшен до 3 через C. Устаревшая запись (4,B) осталась в куче и была пропущена при извлечении. Второе, прямое ребро A->B (стоимость 4) проиграло обходу A->C->B (стоимость 3) — релаксация нашла более дешёвый маршрут.

Восстановление конкретного пути A -> F

Иди по parent[] назад от цели, потом переверни:

start at F        path = [F]              parent[F]=E
go to E           path = [F, E]           parent[E]=B
go to B           path = [F, E, B]        parent[B]=C
go to C           path = [F, E, B, C]     parent[C]=A
go to A           path = [F, E, B, C, A]  parent[A]=null -> stop
reverse           path = [A, C, B, E, F]

Самый дешёвый путь это A -> C -> B -> E -> F, суммарная стоимость 10, совпадает с dist[F]=10.

BFS это Дейкстра со всеми весами = 1

Если каждый вес ребра равен 1, «наименьшее предварительное расстояние» равно «наименьшему числу рёбер», и очередь с минимальным приоритетом вырождается в FIFO-порядок BFS. Так что BFS это частный случай Дейкстры с единичным весом. Дейкстра это обобщение, что обрабатывает произвольные неотрицательные веса.

Код

Min-куча (двоичная куча) для очереди с приоритетом

Нам нужна структура, что быстро возвращает наименьшее предварительное расстояние. Двоичная min-куча даёт O(log n) на push и pop. У неё нет эффективного decrease-key, так что вместо него мы будем использовать ленивое удаление.

1 class MinHeap {
2 constructor() { this.items = []; }
3 size() { return this.items.length; }
4
5 push(item) { // item = [distance, vertex]
6 const a = this.items;
7 a.push(item);
8 let i = a.length - 1;
9 while (i > 0) { // sift up to keep min at the root
10 const parent = (i - 1) >> 1;
11 if (a[parent][0] <= a[i][0]) break;
12 [a[parent], a[i]] = [a[i], a[parent]];
13 i = parent;
14 }
15 }
16
17 pop() { // remove and return the smallest
18 const a = this.items;
19 const top = a[0];
20 const last = a.pop();
21 if (a.length > 0) {
22 a[0] = last;
23 let i = 0; const n = a.length;
24 while (true) { // sift down to restore heap order
25 let smallest = i;
26 const l = 2 * i + 1, r = 2 * i + 2;
27 if (l < n && a[l][0] < a[smallest][0]) smallest = l;
28 if (r < n && a[r][0] < a[smallest][0]) smallest = r;
29 if (smallest === i) break;
30 [a[smallest], a[i]] = [a[i], a[smallest]];
31 i = smallest;
32 }
33 }
34 return top;
35 }
36 }
  • L5 Каждый элемент это [distance, vertex]; мы упорядочиваем по item[0], расстоянию.
  • L9 Просеивание вверх: всплывай новый элемент к корню пока его родитель не станет меньше.
  • L18 pop() всегда возвращает пару с наименьшим расстоянием: ближайшую вершину фронта.
  • L24 Просеивание вниз: спускай перемещённый последний элемент назад на его правильный уровень.
  • L27 Сравни обоих детей и поменяй с меньшим, повторяя пока не установится.

Дейкстра с кучей, dist[] и parent[]

1 function dijkstra(source, adj) {
2 const dist = {};
3 const parent = {};
4 for (const v in adj) dist[v] = Infinity; // unknown = infinity
5 dist[source] = 0; // source is 0 from itself
6 parent[source] = null;
7
8 const settled = new Set();
9 const heap = new MinHeap();
10 heap.push([0, source]);
11
12 while (heap.size() > 0) {
13 const [d, u] = heap.pop(); // closest unsettled vertex
14 if (settled.has(u)) continue; // stale entry: skip (lazy deletion)
15 settled.add(u); // u's distance is now FINAL
16
17 for (const [v, w] of adj[u]) { // relax each outgoing edge
18 if (dist[u] + w < dist[v]) {
19 dist[v] = dist[u] + w; // cheaper route to v found
20 parent[v] = u; // remember the breadcrumb
21 heap.push([dist[v], v]); // push, do NOT decrease-key
22 }
23 }
24 }
25 return { dist, parent };
26 }
  • L4 Каждая вершина начинается с бесконечности: маршрут пока неизвестен.
  • L5 Источник стоит 0 чтобы достичь его от самого себя.
  • L8 settled = вершины, чьё итоговое расстояние зафиксировано. Управляет пропуском ниже.
  • L13 Если u уже установлена, это устаревший дубликат; игнорируй его.
  • L14 Установка u фиксирует dist[u] как итоговое: инвариант установки.
  • L17 Тест релаксации: обыгрывает ли маршрут через u лучший известный к v?
  • L18 Понижай dist[v] только когда строго дешевле, так мы никогда не раздуваем расстояние.
  • L20 Нет decrease-key: добавь свежую (меньшую) запись; старая становится устаревшей.

Восстановление конкретного пути

1 function reconstructPath(target, parent) {
2 const path = [];
3 let cur = target;
4 while (cur !== null && cur !== undefined) {
5 path.push(cur); // collect target back to source
6 cur = parent[cur]; // step to the predecessor
7 }
8 path.reverse(); // flip to read source -> target
9 return path;
10 }
  • L4 null помечает источник; undefined означает что цель не была достигнута.
  • L5 Мы собираем вершины в обратном порядке: цель сначала, источник последним.
  • L8 Переверни чтобы получить естественное направление источник-к-цели.

Запуск на примере графа

Output
 

Замечание об очереди с приоритетом

Для ясности ты мог бы заменить двоичную кучу на PQ на отсортированном массиве (сканируй на минимум при каждом извлечении). Её проще читать но она даёт O(V²) в целом, годится для маленьких графов. Двоичная куча это то, что даёт границу O((V+E) log V), указанную ниже; production-код часто использует кучу Фибоначчи (O(V log V + E)) или d-арную кучу, но двоичная куча это стандартный практический выбор.

Пошаговый разбор
1 adj = { A:[B4,C1], B:[E4], C:[B2,D5], D:[E2], E:[F3], F:[] };
2 dist[A]=0; rest=inf; heap=[(0,A)];
3 pop min -> settle -> relax outgoing edges;

Сложность

Сложность по времени: O((V + E) log V)

Каждая вершина устанавливается ровно раз. Чтобы установить, мы извлекаем из кучи; с ленивым удалением куча может держать до одной записи на релаксацию, так что до O(E) записей, но её размер ограничен числом добавлений. Каждый push и каждый pop стоит O(log от размера кучи) = O(log V) (куча никогда не держит больше чем O(E) записей, и log E = O(log V) потому что E < V²). В сумме:

  • Установка всех вершин: V извлечений, каждое O(log V) = O(V log V).
  • Релаксация всех рёбер: каждая релаксация ребра может добавить раз, E добавлений, каждое O(log V) = O(E log V).

Итого: O((V + E) log V). На связном графе E >= V - 1, так что это часто записывают как O(E log V).

sorted-array PQ : O(V^2)            -> good for dense graphs (E ~ V^2)
binary heap     : O((V+E) log V)    -> good for sparse graphs (E ~ V)
Fibonacci heap  : O(V log V + E)    -> best asymptotic, rarely needed

Сложность по памяти: O(V)

dist[] держит до V записей, parent[] до V, множество settled до V. Куча с ленивым удалением может временно держать до O(E) устаревших записей, но служебные массивы это O(V). Доминирующий рабочий набор это O(V) (куча это O(E) в худшем случае, что равно O(V) на разреженных графах).

Почему требуются неотрицательные веса (иначе ломается инвариант установки)

Корректность Дейкстры опирается на это утверждение: когда вершина u извлечена как минимум кучи, dist[u] итоговое. Аргумент: любой другой путь к u должен покинуть установленную область через какую-то неустановленную вершину x, чьё предварительное расстояние >= dist[u] (иначе x была бы извлечена первой). Поскольку рёбра только добавляют стоимость, полный путь через x стоит как минимум dist[x] >= dist[u]. Так что более дешёвого маршрута к u не существует.

Этот аргумент молча предполагает что рёбра никогда не уменьшают текущую сумму. Отрицательное ребро это ломает:

A --1--> B
A --4--> C
B --(-3)--> C       (negative edge!)

Dijkstra from A:
  Settle A. Relax: dist[B]=1, dist[C]=4.
  Pop (1,B) -> settle B (LOCKED). Relax B->C: 1 + (-3) = -2 < 4
      -> dist[C] = -2.
  Pop next... but C might already be settled at the wrong value
      in a larger graph, and B's "final" distance can itself be
      undercut later by a negative edge feeding back.

True shortest A->C = A->B->C = 1 + (-3) = -2, but Dijkstra's
settle step can commit a vertex BEFORE a later negative edge
would have lowered it. The invariant fails.

Установленная вершина больше не может быть улучшена — но отрицательное ребро это именно улучшение, приходящее после установки. Так что Дейкстра может вернуть неправильные ответы на отрицательных весах. Для графов с отрицательными рёбрами используй Bellman-Ford (O(V*E), релаксирует все рёбра V-1 раз и обнаруживает отрицательные циклы). Дейкстра меняет ту общность на скорость, и цена это требование неотрицательности.

Практика 0 / 6

Что Дейкстра использует вместо FIFO-очереди BFS, и почему?

Каков шаг релаксации для ребра u -> v веса w?

У двоичной кучи нет эффективного decrease-key. Как с этим справляется стандартная реализация Дейкстры?

Почему Дейкстра требует неотрицательные веса рёбер?

Как BFS связан с Дейкстрой?

В графе урока (A:[B4,C1], B:[E4], C:[B2,D5], D:[E2], E:[F3]) чему равно dist[F], самая дешёвая суммарная стоимость от A до F?

Почему это работает

Почему установить, потом релаксировать, в этом порядке? Установка вершины это акт доверия её предварительному расстоянию как итоговому. Это доверие действительно только для текущего минимума кучи, потому что каждый альтернативный маршрут должен проходить через равное-или-большее предварительное расстояние (неотрицательные веса это гарантируют). Как только u получила доверие, релаксация её рёбер распространяет это итоговое значение наружу к соседям. Релаксация до установки позволила бы незавершённым, возможно-неправильным расстояниям распространиться.

Частая ошибка

Релаксация или повторная установка устаревшего извлечения. С ленивым удалением куча держит дубликаты. Если ты забудешь защиту if (settled.has(u)) continue;, ты обработаешь устаревшую запись: повторно установишь уже-итоговую вершину и релаксируешь её рёбра с большим, устаревшим расстоянием. Это тратит работу и — если совместить с перезаписью dist — может испортить результаты. Всегда пропускай извлечение, чья вершина уже установлена.

Граничные случаи

Недостижимые вершины. Если вершина не лежит ни в одном пути от источника, она никогда не добавляется, так что её dist остаётся Infinity и у неё нет parent. Восстановление из такой цели останавливается немедленно (cur это undefined) и возвращает список, что не начинается с источника. Проверяй dist[target] !== Infinity перед тем как сообщить путь, и говори «нет пути» иначе.

Ещё практика

Отрицательные веса -> Bellman-Ford. В тот момент когда любое ребро может быть отрицательным, отбрось Дейкстру. Bellman-Ford релаксирует каждое ребро V-1 раз за O(V*E) и, на V-м проходе, обнаруживает отрицательные циклы (где «кратчайший путь» не определён потому что можно зациклиться до -бесконечности). Он медленнее но терпит отрицательные. Реальные системы выбирают Дейкстру когда стоимости физические и неотрицательные (расстояние, задержка, деньги) и Bellman-Ford когда стоимости могут быть кредитами или возвратами.

Проверь себя
Викторина

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

Итог

Алгоритм Дейкстры находит кратчайшие пути от источника во взвешенном графе с неотрицательными весами рёбер. Он обобщает BFS, заменяя FIFO-очередь на очередь с минимальным приоритетом с ключом по предварительному расстоянию.

Две операции: установи извлечённый минимум (его dist теперь итоговое), потом релаксируй его исходящие рёбра — if dist[u] + w < dist[v], понизь dist[v] и задай parent[v] = u.

Служебные данные: dist[] для стоимостей, parent[] для следа из хлебных крошек. Иди по parent[] назад от цели и переверни чтобы восстановить конкретный самый дешёвый путь.

Ленивое удаление: у двоичной кучи нет decrease-key, так что добавляй дублирующие записи при каждом улучшении и пропускай любое извлечение, чья вершина уже установлена.

Время: O((V + E) log V). Память: O(V).

Граница: только неотрицательные веса. Установка фиксирует расстояние как итоговое, но отрицательное ребро могло бы понизить его после — используй Bellman-Ford для отрицательных. BFS это частный случай где каждый вес равен 1.

Продолжить восхождение ↑Union-Find (система непересекающихся множеств): сжатие путей и объединение по рангу
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.