Перейти к содержимому

Графовые алгоритмы (BFS, DFS, Dijkstra, MST, SCC)

Графовые алгоритмы — это фундамент computer science, применимый везде: от социальных сетей и интернет-маршрутизации до компиляторов и планирования задач. Граф — это набор вершин и рёбер; рёбра могут быть ориентированными или неориентированными, взвешенными или невзвешенными. От этих свойств зависит выбор алгоритма. Эта секция собирает «must-know» алгоритмы для собеса: два базовых обхода, четыре алгоритма кратчайших путей, топологическая сортировка, MST и SCC.

  • BFS — кратчайший путь в невзвешенном графе или графе с весами 0/1 (через 0-1 BFS), уровни, level-order обходы.
  • DFS — топологическая сортировка, поиск циклов, SCC (Tarjan/Kosaraju), мосты, точки сочленения, исчерпывающее исследование путей.
  • Dijkstra — кратчайший путь от одной вершины при неотрицательных весах.
  • Bellman-Ford — графы с отрицательными весами и детект отрицательных циклов.
  • Floyd-Warshall — all-pairs shortest path для малых графов (V ≤ 400).
  • Topological sort — DAG, dependency resolution, build systems, расписание курсов.
  • MST (Kruskal/Prim) — минимальное соединение всех вершин (сети, кластеризация).
  • SCC (Kosaraju/Tarjan) — анализ циклов, конденсация графа, 2-SAT.

BFS обходит граф по слоям, используя очередь, и находит кратчайший путь в невзвешенном графе за O(V + E). DFS — обход в глубину через стек/рекурсию, применим для поиска циклов, компонент связности, топосорта.

Для взвешенных графов BFS не работает. Dijkstra — жадный алгоритм с min-heap, O((V+E) log V) при неотрицательных весах. Если веса могут быть отрицательными — Bellman-Ford, O(V·E). Для всех пар на малых графах — Floyd-Warshall O(V³).

Топологическая сортировка — линейный порядок вершин DAG, чтобы все рёбра шли «слева направо». Метод 1: DFS с post-order стеком. Метод 2: Kahn’s algorithm — BFS по in-degree.

MST для неориентированного связного взвешенного графа — дерево минимального веса. Kruskal: сортировка рёбер + DSU, O(E log E). Prim: как Dijkstra, но минимизируем вес ребра к дереву, O((V+E) log V).

SCC: максимальные подграфы, где из любой вершины достижима любая другая. Kosaraju: два DFS — прямой и на транспонированном графе. Tarjan: один DFS с low-link. Оба O(V + E).

Задача (Network Delay Time): дан граф из n узлов, массив times[i] = [u, v, w] (ребро u→v весом w), стартовая вершина k. Найти время, за которое сигнал достигнет всех узлов; если невозможно — -1.

class MinHeap<T> {
private heap: T[] = [];
constructor(private compare: (a: T, b: T) => number) {}
push(val: T): void {
this.heap.push(val);
this.bubbleUp(this.heap.length - 1);
}
pop(): T | undefined {
if (this.heap.length === 0) return undefined;
if (this.heap.length === 1) return this.heap.pop();
const min = this.heap[0];
this.heap[0] = this.heap.pop()!;
this.bubbleDown(0);
return min;
}
private bubbleUp(idx: number): void {
while (idx > 0) {
const parent = (idx - 1) >> 1;
if (this.compare(this.heap[idx], this.heap[parent]) < 0) {
[this.heap[idx], this.heap[parent]] = [this.heap[parent], this.heap[idx]];
idx = parent;
} else break;
}
}
private bubbleDown(idx: number): void {
const len = this.heap.length;
while (true) {
let smallest = idx;
const left = 2 * idx + 1;
const right = 2 * idx + 2;
if (left < len && this.compare(this.heap[left], this.heap[smallest]) < 0) smallest = left;
if (right < len && this.compare(this.heap[right], this.heap[smallest]) < 0) smallest = right;
if (smallest !== idx) {
[this.heap[idx], this.heap[smallest]] = [this.heap[smallest], this.heap[idx]];
idx = smallest;
} else break;
}
}
get size(): number { return this.heap.length; }
}
function networkDelayTime(times: number[][], n: number, k: number): number {
const graph: number[][][] = Array.from({ length: n + 1 }, () => []);
for (const [u, v, w] of times) graph[u].push([v, w]);
const dist = Array(n + 1).fill(Infinity);
dist[k] = 0;
const heap = new MinHeap<[number, number]>((a, b) => a[0] - b[0]);
heap.push([0, k]);
while (heap.size > 0) {
const [d, u] = heap.pop()!;
if (d > dist[u]) continue; // ленивое удаление устаревших записей
for (const [v, w] of graph[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
heap.push([dist[v], v]);
}
}
}
let maxDist = 0;
for (let i = 1; i <= n; i++) {
if (dist[i] === Infinity) return -1;
maxDist = Math.max(maxDist, dist[i]);
}
return maxDist;
}
Init: dist=[∞,∞,0,∞,∞], heap=[(0,2)]
pop(0,2): обновляем dist[1]=1, dist[3]=1; heap=[(1,1),(1,3)]
pop(1,1): нет исходящих; heap=[(1,3)]
pop(1,3): dist[4]=2; heap=[(2,4)]
pop(2,4): нет исходящих; heap=[]
dist=[∞,1,0,1,2]; max=2 → ответ 2
  • Время: O((V + E) log V) — каждое ребро релаксируется один раз, операции с heap — O(log E ≤ 2 log V).
  • Память: O(V + E) для adjacency list + O(V) для dist/heap.

Релаксируем все рёбра V-1 раз. На V-й итерации, если можем ещё релаксировать — есть отрицательный цикл.

function bellmanFord(edges: number[][], V: number, src: number): number[] | null {
const dist = Array(V).fill(Infinity);
dist[src] = 0;
for (let i = 0; i < V - 1; i++) {
for (const [u, v, w] of edges) {
if (dist[u] !== Infinity && dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
}
}
}
for (const [u, v, w] of edges) {
if (dist[u] !== Infinity && dist[u] + w < dist[v]) {
return null; // отрицательный цикл
}
}
return dist;
}

Применения: графы с отрицательными весами, валютный арбитраж (цикл с положительным произведением курсов = отрицательный цикл в логарифмах).

DP для all-pairs shortest paths. dp[k][i][j] = min(dp[k-1][i][j], dp[k-1][i][k] + dp[k-1][k][j]). Можно сжать k-измерение.

function floydWarshall(dist: number[][]): number[][] {
const n = dist.length;
const d = dist.map((row) => row.slice());
for (let k = 0; k < n; k++) {
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
if (d[i][k] + d[k][j] < d[i][j]) {
d[i][j] = d[i][k] + d[k][j];
}
}
}
}
return d;
}

Сложность O(V³). Для V ≤ 400 практически приемлемо.

function topologicalSort(graph: number[][], V: number): number[] | null {
const inDegree = Array(V).fill(0);
for (let u = 0; u < V; u++) {
for (const v of graph[u]) inDegree[v]++;
}
const queue: number[] = [];
for (let i = 0; i < V; i++) {
if (inDegree[i] === 0) queue.push(i);
}
const result: number[] = [];
while (queue.length) {
const u = queue.shift()!;
result.push(u);
for (const v of graph[u]) {
if (--inDegree[v] === 0) queue.push(v);
}
}
return result.length === V ? result : null; // null если цикл
}

Сортируем рёбра по весу, итерируем; для каждого ребра (u, v): если в разных компонентах (DSU), добавляем в MST и объединяем. Останавливаемся, когда взяли V-1 рёбер.

class DSU {
private parent: number[];
private rank: number[];
constructor(n: number) {
this.parent = Array.from({ length: n }, (_, i) => i);
this.rank = Array(n).fill(0);
}
find(x: number): number {
if (this.parent[x] !== x) {
this.parent[x] = this.find(this.parent[x]);
}
return this.parent[x];
}
union(x: number, y: number): boolean {
const rx = this.find(x);
const ry = this.find(y);
if (rx === ry) return false;
if (this.rank[rx] < this.rank[ry]) this.parent[rx] = ry;
else if (this.rank[rx] > this.rank[ry]) this.parent[ry] = rx;
else { this.parent[ry] = rx; this.rank[rx]++; }
return true;
}
}
function kruskal(V: number, edges: number[][]): number {
edges.sort((a, b) => a[2] - b[2]);
const dsu = new DSU(V);
let mstWeight = 0;
let edgesUsed = 0;
for (const [u, v, w] of edges) {
if (dsu.union(u, v)) {
mstWeight += w;
if (++edgesUsed === V - 1) break;
}
}
return edgesUsed === V - 1 ? mstWeight : -1;
}

Два DFS. Первый: обход всех вершин, post-order в стек. Второй: на транспонированном графе, обход в обратном порядке стека — каждое DFS-дерево — это SCC.

function kosaraju(graph: number[][], V: number): number[][] {
const visited = Array(V).fill(false);
const stack: number[] = [];
const dfs1 = (u: number): void => {
visited[u] = true;
for (const v of graph[u]) if (!visited[v]) dfs1(v);
stack.push(u);
};
for (let i = 0; i < V; i++) if (!visited[i]) dfs1(i);
const transposed: number[][] = Array.from({ length: V }, () => []);
for (let u = 0; u < V; u++) {
for (const v of graph[u]) transposed[v].push(u);
}
visited.fill(false);
const sccs: number[][] = [];
const dfs2 = (u: number, component: number[]): void => {
visited[u] = true;
component.push(u);
for (const v of transposed[u]) if (!visited[v]) dfs2(v, component);
};
while (stack.length) {
const u = stack.pop()!;
if (!visited[u]) {
const component: number[] = [];
dfs2(u, component);
sccs.push(component);
}
}
return sccs;
}
АлгоритмВремяПамять
BFS / DFSO(V + E)O(V)
Dijkstra (heap)O((V + E) log V)O(V + E)
Bellman-FordO(V · E)O(V)
Floyd-WarshallO(V³)O(V²)
Kahn’s topologicalO(V + E)O(V)
KruskalO(E log E)O(V + E)
Prim (heap)O((V + E) log V)O(V + E)
Kosaraju / TarjanO(V + E)O(V + E)

Почему Dijkstra не работает с отрицательными весами

Заголовок раздела «Почему Dijkstra не работает с отрицательными весами»
  • Забытый visited — бесконечный цикл в обходе.
  • Неправильная инициализация dist: Infinity для всех, кроме старта.
  • Dijkstra на отрицательных весах — даёт неправильный ответ.
  • Heap без ленивого удаления — устаревшие записи дают неверный результат, либо нужен decrease-key.
  • Off-by-one в индексах: 0-indexed vs 1-indexed.
  • Несвязные компоненты — нужен внешний цикл по всем непосещённым.
  • DAG vs циклический граф — топосорт работает только для DAG.

Edge cases: изолированные вершины, петли, мультирёбра, отрицательные циклы.

  • В чём разница между BFS и Dijkstra? BFS — простая очередь (FIFO), невзвешенный граф, O(V+E). Dijkstra — min-heap, взвешенный граф с неотрицательными весами, O((V+E) log V). BFS — это Dijkstra для весов = 1.

  • Когда использовать Bellman-Ford вместо Dijkstra? При отрицательных весах или для детекта отрицательных циклов. Медленнее (O(V·E) vs O((V+E) log V)), но корректен.

  • Как определить цикл в неориентированном графе? DFS: если встретили посещённую вершину, не являющуюся родителем в DFS-дереве — цикл. Альтернатива: DSU — если union возвращает false, цикл.

  • Кратчайший путь в графе с весами 0 и 1? 0-1 BFS через deque: вес 0 — push_front, вес 1 — push_back. O(V+E), быстрее Dijkstra.

  • Найти мосты в графе. DFS с tin[u] (время входа) и low[u] (минимальное tin, достижимое из поддерева). Ребро (u, v) — мост, если low[v] > tin[u]. O(V + E).

  • Dijkstra с лимитом k остановок? Состояние (dist, node, stops), priority queue по dist. При релаксации проверяем лимит. Альтернатива: Bellman-Ford с k+1 итерациями (LeetCode 787).

  • Сразу определи: ориентирован/нет, взвешен/нет, есть ли отрицательные веса.
  • Выбирай представление: adjacency list для разреженных, matrix для плотных или V ≤ 400.
  • Не забудь visited и обработку несвязных компонент.
  • Для Dijkstra используй ленивое удаление — это упрощает priority queue.
  • Для топосорта проверяй, что вышло V вершин, иначе цикл.
  • MST: Kruskal — короче кодом (если есть DSU); Prim — если граф плотный.