Алгоритмы с нуля
Алгоритм Дейкстры: кратчайшие пути со взвешенными рёбрами
В прошлом уроке 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 Переверни чтобы получить естественное направление источник-к-цели.
Запуск на примере графа
Замечание об очереди с приоритетом
Для ясности ты мог бы заменить двоичную кучу на 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 раз и обнаруживает отрицательные циклы). Дейкстра меняет ту общность на скорость, и цена это требование неотрицательности.
Что Дейкстра использует вместо 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.