← назад к разделу

Многие задачи из реального мира — это связи: города и дороги, люди и знакомства, задачи и их зависимости. Структура, которая моделирует связи напрямую, называется граф. В отличие от деревьев (которые, кстати, частный случай графа), в графе нет корня и иерархии.

Вершины и рёбра

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

Как хранить граф

Граф представляют одним из двух способов.

как хранить матрица V×V почти все пары связаны списки соседей рёбер немного

Два способа хранить один и тот же граф: таблица на все пары вершин или список соседей у каждой вершины; выбор решает плотность графа.

Матрица смежности — квадратная таблица размера «вершины × вершины», где ячейка на пересечении i и j показывает, есть ли ребро между вершинами i и j. Проверить связь двух вершин — мгновенно, но память растёт как квадрат числа вершин, даже если рёбер мало.

Список смежности — для каждой вершины хранят список её соседей. Экономно, когда рёбер немного (разреженный граф), а таких большинство — это самый частый способ. Граница между ними считается: матрица занимает V² ячеек, список V + E. У соцсети на три миллиона человек по сорок друзей это девять триллионов ячеек против ста двадцати миллионов записей; матрица окупается, только когда рёбер сравнимо с V², то есть почти каждая пара связана, а таких графов в жизни единицы: небольшие полные графы расстояний, таблицы «кто с кем совместим».

Два способа обойти граф

Основная операция над графом — обход: планомерно посетить все вершины, двигаясь по рёбрам. Способов два.

Обход в глубину (DFS, depth-first search). Идём по одному пути как можно дальше, пока не упрёмся в тупик (вершину без непосещённых соседей), затем возвращаемся на шаг назад и пробуем другой путь. Это поведение естественно выражается стеком или рекурсией — как прохождение лабиринта с правилом «держись одной стены».

Обход в ширину (BFS, breadth-first search). Наоборот, исследуем граф «слоями»: сначала соседей стартовой вершины, потом соседей соседей, и так волной — это очередь. BFS находит вершины в порядке удалённости от старта, поэтому им ищут кратчайший путь по числу рёбер в невзвешенном графе.

Стоят оба обхода одинаково — O(V + E), где V — число вершин, а E — число рёбер: каждую вершину посещают ровно раз и каждое ребро просматривают ровно раз (в неориентированном графе — дважды, с обоих концов). Цифра важнее, чем кажется. Соцсеть на три миллиона человек, у каждого сорок друзей, — это три миллиона вершин и шестьдесят миллионов дружб; каждая записана у обоих, поэтому обход пройдёт по ним сто двадцать миллионов раз. Сотни миллионов шагов — это секунды, а не вечность. Именно потому, что работа пропорциональна размеру графа, а не квадрату числа вершин.

старт уровень 1 уровень 2

BFS обходит уровнями: сначала старт, потом всех его соседей, потом соседей соседей. Так находится кратчайший путь по числу рёбер.

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

живой пример

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

public class GraphWalk {
    static final Map<String, List<String>> GRAPH = Map.of(
            "A", List.of("B", "C"), "B", List.of("D", "E"), "C", List.of("E", "F"),
            "D", List.of(), "E", List.of(), "F", List.of());

    static List<String> walk(String start, boolean depthFirst) {
        List<String> order = new ArrayList<>();
        Set<String> seen = new HashSet<>();
        Deque<String> pending = new ArrayDeque<>();
        pending.add(start);
        while (!pending.isEmpty()) {
            String node = depthFirst ? pending.pollLast() : pending.pollFirst();
            if (!seen.add(node)) continue;
            order.add(node);
            pending.addAll(GRAPH.get(node));
        }
        return order;
    }

    public static void main(String[] args) {
        System.out.println("в ширину:  " + walk("A", false));
        System.out.println("в глубину: " + walk("A", true));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import "fmt"

var graph = map[string][]string{
	"A": {"B", "C"}, "B": {"D", "E"}, "C": {"E", "F"},
	"D": {}, "E": {}, "F": {},
}

func walk(start string, depthFirst bool) []string {
	var order []string
	seen := map[string]bool{}
	pending := []string{start}
	for len(pending) > 0 {
		var node string
		if depthFirst {
			node, pending = pending[len(pending)-1], pending[:len(pending)-1]
		} else {
			node, pending = pending[0], pending[1:]
		}
		if seen[node] {
			continue
		}
		seen[node] = true
		order = append(order, node)
		pending = append(pending, graph[node]...)
	}
	return order
}

func main() {
	fmt.Println("в ширину: ", walk("A", false))
	fmt.Println("в глубину:", walk("A", true))
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const GRAPH = new Map([
  ["A", ["B", "C"]], ["B", ["D", "E"]], ["C", ["E", "F"]],
  ["D", []], ["E", []], ["F", []],
]);

function walk(start, depthFirst) {
  const order = [];
  const seen = new Set();
  const pending = [start];
  while (pending.length > 0) {
    const node = depthFirst ? pending.pop() : pending.shift();
    if (seen.has(node)) continue;
    seen.add(node);
    order.push(node);
    pending.push(...GRAPH.get(node));
  }
  return order;
}

console.log("в ширину: ", walk("A", false));
console.log("в глубину:", walk("A", true));
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

from collections import deque

GRAPH = {
    "A": ["B", "C"], "B": ["D", "E"], "C": ["E", "F"],
    "D": [], "E": [], "F": [],
}


def walk(start: str, depth_first: bool) -> list[str]:
    order: list[str] = []
    seen: set[str] = set()
    pending = deque([start])
    while pending:
        node = pending.pop() if depth_first else pending.popleft()
        if node in seen:
            continue
        seen.add(node)
        order.append(node)
        pending.extend(GRAPH[node])
    return order


print("в ширину: ", walk("A", False))
print("в глубину:", walk("A", True))
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

В ширину получается A, B, C, D, E, F — ровно уровни с картинки. В глубину — A, C, F, E, B, D: стек отдаёт последнего добавленного соседа, поэтому обход уходит по ветке C до конца и лишь потом возвращается за B. Проверка по множеству посещённых нужна обоим: без неё цикл в графе закрутит обход навсегда.

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

живой пример

import java.util.*;

public class BfsPath {
    static final Map<String, List<String>> GRAPH = Map.of(
            "A", List.of("B", "C"), "B", List.of("D"), "C", List.of("D", "E"),
            "D", List.of("F"), "E", List.of("F"), "F", List.of());

    static List<String> path(String from, String to) {
        Map<String, String> cameFrom = new HashMap<>();
        Deque<String> queue = new ArrayDeque<>(List.of(from));
        cameFrom.put(from, null);
        while (!queue.isEmpty()) {
            String node = queue.poll();
            if (node.equals(to)) break;
            for (String next : GRAPH.get(node)) {
                if (!cameFrom.containsKey(next)) {
                    cameFrom.put(next, node);
                    queue.add(next);
                }
            }
        }
        if (!cameFrom.containsKey(to)) return List.of();
        LinkedList<String> path = new LinkedList<>();
        for (String v = to; v != null; v = cameFrom.get(v)) path.addFirst(v);
        return path;
    }

    public static void main(String[] args) {
        System.out.println(path("A", "F"));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"fmt"
	"slices"
)

var graph = map[string][]string{
	"A": {"B", "C"}, "B": {"D"}, "C": {"D", "E"},
	"D": {"F"}, "E": {"F"}, "F": {},
}

func path(from, to string) []string {
	cameFrom := map[string]string{from: ""}
	queue := []string{from}
	for len(queue) > 0 {
		node := queue[0]
		queue = queue[1:]
		if node == to {
			break
		}
		for _, next := range graph[node] {
			if _, seen := cameFrom[next]; !seen {
				cameFrom[next] = node
				queue = append(queue, next)
			}
		}
	}
	if _, ok := cameFrom[to]; !ok {
		return nil
	}
	var path []string
	for v := to; v != ""; v = cameFrom[v] {
		path = append(path, v)
	}
	slices.Reverse(path)
	return path
}

func main() {
	fmt.Println(path("A", "F"))
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const GRAPH = new Map([
  ["A", ["B", "C"]], ["B", ["D"]], ["C", ["D", "E"]],
  ["D", ["F"]], ["E", ["F"]], ["F", []],
]);

function path(from, to) {
  const cameFrom = new Map([[from, null]]);
  const queue = [from];
  let head = 0;
  while (head < queue.length) {
    const node = queue[head++];
    if (node === to) break;
    for (const next of GRAPH.get(node)) {
      if (!cameFrom.has(next)) {
        cameFrom.set(next, node);
        queue.push(next);
      }
    }
  }
  if (!cameFrom.has(to)) return [];
  const path = [];
  for (let v = to; v !== null; v = cameFrom.get(v)) path.unshift(v);
  return path;
}

console.log(path("A", "F"));
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

from collections import deque

GRAPH = {
    "A": ["B", "C"], "B": ["D"], "C": ["D", "E"],
    "D": ["F"], "E": ["F"], "F": [],
}


def path(start: str, goal: str) -> list[str]:
    came_from = {start: None}
    queue = deque([start])
    while queue:
        node = queue.popleft()
        if node == goal:
            break
        for nxt in GRAPH[node]:
            if nxt not in came_from:
                came_from[nxt] = node
                queue.append(nxt)
    if goal not in came_from:
        return []
    result = []
    v = goal
    while v is not None:
        result.append(v)
        v = came_from[v]
    return result[::-1]


print(path("A", "F"))
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

Когда известны обе вершины и граф большой, обычный BFS из одной вершины разворачивает пол-графа: волна на глубине d при сорока соседях у каждого это 40ᵈ вершин. Двусторонний поиск пускает волну с обоих концов и останавливается, когда они встретились: две волны по глубине d/2 это 2·40^(d/2), для цепочки знакомств длиной шесть это тысячи вершин вместо миллиардов. Так ищут «через сколько рукопожатий» в графах на миллионы вершин, и это правильный ответ там, где старт и цель заданы оба.

Что дают обходы

На обходах строится многое. Обход из вершины посещает всё, до чего можно дойти, так проверяют, связен ли граф и на какие компоненты он распадается: запускают обход из каждой ещё не посещённой вершины и считают запуски.

int components(List<List<Integer>> adj) {
    boolean[] seen = new boolean[adj.size()];
    int count = 0;
    for (int start = 0; start < adj.size(); start++) {
        if (seen[start]) continue;
        count++;
        Deque<Integer> pending = new ArrayDeque<>(List.of(start));
        while (!pending.isEmpty()) {
            int u = pending.pop();
            if (seen[u]) continue;
            seen[u] = true;
            for (int v : adj.get(u)) if (!seen[v]) pending.push(v);
        }
    }
    return count;
}
func components(adj [][]int) int {
	seen := make([]bool, len(adj))
	count := 0
	for start := range adj {
		if seen[start] {
			continue
		}
		count++
		pending := []int{start}
		for len(pending) > 0 {
			u := pending[len(pending)-1]
			pending = pending[:len(pending)-1]
			if seen[u] {
				continue
			}
			seen[u] = true
			for _, v := range adj[u] {
				if !seen[v] {
					pending = append(pending, v)
				}
			}
		}
	}
	return count
}
function components(adj) {
  const seen = new Array(adj.length).fill(false);
  let count = 0;
  for (let start = 0; start < adj.length; start++) {
    if (seen[start]) continue;
    count++;
    const pending = [start];
    while (pending.length > 0) {
      const u = pending.pop();
      if (seen[u]) continue;
      seen[u] = true;
      for (const v of adj[u]) if (!seen[v]) pending.push(v);
    }
  }
  return count;
}
def components(adj: list[list[int]]) -> int:
    seen = [False] * len(adj)
    count = 0
    for start in range(len(adj)):
        if seen[start]:
            continue
        count += 1
        pending = [start]
        while pending:
            u = pending.pop()
            if seen[u]:
                continue
            seen[u] = True
            pending.extend(v for v in adj[u] if not seen[v])
    return count

Каждый запуск закрашивает одну компоненту целиком, и число запусков это число компонент; так находят изолированные кластеры пользователей, острова в сети сервисов и осиротевшие записи. Для потока «пришло новое ребро, в одной ли компоненте эти двое» тот же вопрос дешевле решает система непересекающихся множеств из статьи про взвешенные графы. Рёбра, по которым прошёл обход, образуют дерево, связывающее все вершины без циклов, «скелет» графа, его называют остовным (spanning tree). А в направленном графе без циклов, где «задача A перед задачей B», обход выстраивает вершины в порядок, где каждая зависимость идёт раньше зависящего; это топологическая сортировка, и так планировщики определяют порядок сборки и выполнения задач.

Последний случай встречается чаще прочих, поэтому чуть подробнее. Самый понятный способ (алгоритм Кана) работает как разбор завала: для каждой вершины считают, сколько стрелок в неё входит; те, у кого ноль, — задачи без незакрытых зависимостей, их складывают в очередь. Достали задачу из очереди — записали в ответ и у всех, кто её ждал, уменьшили счётчик на единицу; у кого он обнулился — тот отправляется в очередь следом. Стоит это те же O(V + E).

Условие применимости здесь не формальность: порядок существует только в графе без циклов. Если задача A ждёт B, B ждёт C, а C ждёт A, начать нечего — и отдельной проверки на цикл не нужно. Алгоритм Кана обнаруживает его сам: очередь опустела, а в ответе оказалось меньше вершин, чем в графе, — значит, остаток замкнут в кольцо.

Топологическая сортировка: Кан и обход в глубину

Миграции базы, у каждой список «после каких», задачи сборки, зависимости сервисов при старте: везде нужен порядок, в котором каждая вещь идёт после всех, от кого зависит. Это топологическая сортировка направленного графа без циклов, и для бекенда это самый частый графовый алгоритм.

Алгоритм Кана идёт от очевидного: то, от чего никто не зависит, можно выполнять первым. Считаем для каждой вершины число входящих рёбер, кладём в очередь вершины с нулём, забираем по одной, «удаляем» её рёбра, уменьшая счётчики соседей, и ставим в очередь тех, у кого счётчик стал нулём.

List<Integer> kahn(List<List<Integer>> adj) {
    int n = adj.size();
    int[] indeg = new int[n];
    for (List<Integer> edges : adj) for (int v : edges) indeg[v]++;
    Deque<Integer> ready = new ArrayDeque<>();
    for (int v = 0; v < n; v++) if (indeg[v] == 0) ready.add(v);
    List<Integer> order = new ArrayList<>();
    while (!ready.isEmpty()) {
        int u = ready.poll();
        order.add(u);
        for (int v : adj.get(u)) if (--indeg[v] == 0) ready.add(v);
    }
    if (order.size() < n) throw new IllegalStateException("в зависимостях цикл");
    return order;
}
func kahn(adj [][]int) ([]int, error) {
	n := len(adj)
	indeg := make([]int, n)
	for _, edges := range adj {
		for _, v := range edges {
			indeg[v]++
		}
	}
	var ready []int
	for v := 0; v < n; v++ {
		if indeg[v] == 0 {
			ready = append(ready, v)
		}
	}
	order := make([]int, 0, n)
	for len(ready) > 0 {
		u := ready[0]
		ready = ready[1:]
		order = append(order, u)
		for _, v := range adj[u] {
			indeg[v]--
			if indeg[v] == 0 {
				ready = append(ready, v)
			}
		}
	}
	if len(order) < n {
		return nil, errors.New("в зависимостях цикл")
	}
	return order, nil
}
function kahn(adj) {
  const n = adj.length;
  const indeg = new Array(n).fill(0);
  for (const edges of adj) for (const v of edges) indeg[v]++;
  const ready = [];
  for (let v = 0; v < n; v++) if (indeg[v] === 0) ready.push(v);
  const order = [];
  let head = 0;
  while (head < ready.length) {
    const u = ready[head++];
    order.push(u);
    for (const v of adj[u]) if (--indeg[v] === 0) ready.push(v);
  }
  if (order.length < n) throw new Error("в зависимостях цикл");
  return order;
}

В стандартной библиотеке Python алгоритм Кана уже есть: graphlib.TopologicalSorter с версии 3.9 принимает словарь зависимостей и бросает CycleError, если порядок не собрать. Руками он выглядит так:

from collections import deque


def kahn(adj: list[list[int]]) -> list[int]:
    n = len(adj)
    indeg = [0] * n
    for edges in adj:
        for v in edges:
            indeg[v] += 1
    ready = deque(v for v in range(n) if indeg[v] == 0)
    order: list[int] = []
    while ready:
        u = ready.popleft()
        order.append(u)
        for v in adj[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                ready.append(v)
    if len(order) < n:
        raise ValueError("в зависимостях цикл")
    return order

Проверка на цикл встроена: если в порядок попали не все вершины, оставшиеся зависят друг от друга по кругу, и упорядочить их нельзя. Второй способ, обход в глубину: вершину добавляют в результат после того, как обошли всех её потомков, а в конце список разворачивают. Оба линейны, O(V + E); Кан удобнее, когда нужен явный список циклических вершин или порядок «по волнам» для параллельного запуска.

счётчики сколько стрелок входит нули в очередь зависимостей нет берём вершину пишем в ответ минус соседям счётчики уменьшили новый ноль снова в очередь очередь пуста не все вошли, значит цикл

Кан разбирает завал по слоям: в очереди лежат только вершины без незакрытых зависимостей, и если в ответ вошли не все, остаток замкнут в кольцо.

Поиск цикла: три цвета

В направленном графе цикл ловят тем же обходом в глубину, если помнить не «был или не был», а три состояния вершины: белая ещё не тронута, серая начата и не завершена, чёрная обойдена целиком. Ребро в серую вершину и есть цикл: мы вернулись туда, откуда ещё не выбрались.

boolean hasCycle(int u, List<List<Integer>> adj, int[] color) {
    color[u] = 1;                       // серая
    for (int v : adj.get(u)) {
        if (color[v] == 1) return true; // ребро назад в незавершённую вершину
        if (color[v] == 0 && hasCycle(v, adj, color)) return true;
    }
    color[u] = 2;                       // чёрная
    return false;
}
func hasCycle(u int, adj [][]int, color []int) bool {
	color[u] = 1 // серая
	for _, v := range adj[u] {
		if color[v] == 1 { // ребро назад в незавершённую вершину
			return true
		}
		if color[v] == 0 && hasCycle(v, adj, color) {
			return true
		}
	}
	color[u] = 2 // чёрная
	return false
}
function hasCycle(u, adj, color) {
  color[u] = 1;                        // серая
  for (const v of adj[u]) {
    if (color[v] === 1) return true;   // ребро назад в незавершённую вершину
    if (color[v] === 0 && hasCycle(v, adj, color)) return true;
  }
  color[u] = 2;                        // чёрная
  return false;
}
def has_cycle(u: int, adj: list[list[int]], color: list[int]) -> bool:
    color[u] = 1                          # серая
    for v in adj[u]:
        if color[v] == 1:                 # ребро назад в незавершённую вершину
            return True
        if color[v] == 0 and has_cycle(v, adj, color):
            return True
    color[u] = 2                          # чёрная
    return False

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

Как это сделано в стандартной библиотеке

У остальных структур курса в стандартной библиотеке есть готовый класс. Граф — исключение: класса Graph в Java нет, собирать придётся самому. Не с нуля, а из готовых коллекций — и от того, какие взять, зависит реальная сложность обхода.

Список смежности — это Map из вершины в список её соседей; список для новой вершины заводит computeIfAbsent. HashMap находит список соседей за O(1) в среднем, ArrayList внутри — плотный массив, по которому цикл идёт без промахов кэша. Если вершины пронумерованы подряд (0…N−1), Map вообще не нужен: берут массив списков, и поиск соседей превращается в обращение по индексу.

Матрица смежности — это boolean[][]; каждая ячейка занимает байт, поэтому на ста тысячах вершин матрица попросит около десяти гигабайт. Отсюда и правило: матрица — только для маленьких плотных графов.

Оба обхода держатся на ArrayDeque: в ширину это очередь (addLast в конец, pollFirst с начала), в глубину — тот же класс как стек (push и pop). Внутри кольцевой массив, обе операции амортизированно O(1). LinkedList формально умеет то же самое, но платит отдельным объектом-узлом за каждый элемент. Stack не берут вообще: внутри у него синхронизированный Vector из первых версий Java.

Посещённые вершины держат в HashSet (O(1) в среднем), а при пронумерованных вершинах — в boolean[]: быстрее и без объектов-обёрток.

Грабля тут одна, зато обидная. Если вершина — ваш собственный класс без equals и hashCode, HashMap сравнивает вершины по ссылке. Две «одинаковые» вершины станут разными ключами, граф тихо распадётся на куски, а обход найдёт меньше, чем должен, — без исключения, просто неверный ответ. Вторая мелочь оттуда же: соседи хранят порядок добавления, а сами вершины при переборе Map выдаются в порядке корзин — когда важна воспроизводимость обхода, ключи держат в LinkedHashMap.

У остальных структур курса в стандартной библиотеке есть готовый тип. Граф — исключение: типа Graph в Go нет, собирать придётся самому. Не с нуля, а из среза и map — и от того, как их взять, зависит реальная сложность обхода.

Список смежности — это map[string][]string из вершины в срез её соседей; append к отсутствующему ключу работает сразу, потому что нулевой срез это nil, к которому можно добавлять. map находит соседей за O(1) в среднем, срез внутри — плотный массив, по которому цикл идёт без промахов кеша. Если вершины пронумерованы подряд (0…N−1), map вообще не нужен: берут [][]int, и поиск соседей превращается в обращение по индексу, как во фрагментах выше.

Матрица смежности — это [][]bool или один плоский []bool длиной N·N; каждая ячейка занимает байт, поэтому на ста тысячах вершин матрица попросит около десяти гигабайт. Отсюда и правило: матрица — только для маленьких плотных графов.

Оба обхода держатся на срезе: в глубину — как стек (append и s[:len(s)-1]), в ширину — как очередь с индексом головы или q[1:]. Обе операции амортизированно O(1). container/list формально умеет то же самое, но платит отдельным объектом-узлом и упаковкой в any за каждый элемент. Рекурсивный обход в глубину в Go безопаснее, чем в других языках, потому что стек горутины растёт, но на графе из пользовательских данных его всё равно переписывают циклом.

Посещённые вершины держат в map[string]bool или map[string]struct{} (O(1) в среднем), а при пронумерованных вершинах — в []bool: быстрее и без хеширования.

Грабля тут одна, зато обидная. Если вершина — ваша структура, ключом в map она годится, только пока все её поля сравнимы, и сравнивается по значению: две структуры с одинаковыми полями — один ключ. А вот map[*Vertex] сравнивает по адресу: две «одинаковые» вершины, созданные в разных местах, станут разными ключами, граф тихо распадётся на куски, а обход найдёт меньше, чем должен, — без ошибки, просто неверный ответ. Вторая мелочь оттуда же: соседи в срезе хранят порядок добавления, а сами вершины при переборе map выдаются в случайном порядке, каждый запуск в новом — когда важна воспроизводимость обхода, стартовые вершины сортируют, slices.Sorted(maps.Keys(graph)).

У остальных структур курса в стандартной библиотеке есть готовый класс. Граф — исключение: класса Graph в JavaScript нет, собирать придётся самому. Не с нуля, а из Map, Set и массивов — и от того, какие взять, зависит реальная сложность обхода.

Список смежности — это Map из вершины в массив её соседей; массив для новой вершины заводят при первом ребре. Map находит соседей за O(1) в среднем, массив внутри — плотный, пока в нём только числа или строки. Если вершины пронумерованы подряд (0…N−1), Map вообще не нужна: берут массив массивов, и поиск соседей превращается в обращение по индексу, как во фрагментах выше.

Матрица смежности — это массив массивов или один плоский Uint8Array длиной N·N; каждая ячейка занимает байт, поэтому на ста тысячах вершин матрица попросит около десяти гигабайт, больше, чем куча Node позволит. Отсюда и правило: матрица — только для маленьких плотных графов.

Оба обхода держатся на массиве, но с разной ценой: в глубину — как стек, push и pop, амортизированно O(1); в ширину — как очередь, и здесь shift стоит O(N), потому что сдвигает весь массив. На графе из десятка вершин, как в примере, это незаметно, на графе из миллиона обход становится квадратичным. Честная очередь — индекс головы по тому же массиву, как во фрагменте с путём выше, или пакет denque. Рекурсивный обход в глубину упирается в стек V8 на десятке тысяч вершин, поэтому на больших графах его переписывают циклом.

Посещённые вершины держат в Set (O(1) в среднем), а при пронумерованных вершинах — в массиве или Uint8Array: быстрее и без хеширования.

Грабля тут одна, зато обидная. Map и Set сравнивают объекты по ссылке: если вершина — ваш объект, две «одинаковые» вершины, созданные в разных местах, станут разными ключами, граф тихо распадётся на куски, а обход найдёт меньше, чем должен, — без исключения, просто неверный ответ. Ключом делают примитив: идентификатор или строку. Вторая мелочь: обычный объект {} вместо Map тянет прототип, и вершина с именем constructor «найдётся» в графе, куда её не клали; словарь вершин делают через Map или Object.create(null). Зато порядок обхода воспроизводим сам собой: и Map, и Set перебираются в порядке вставки.

У остальных структур курса в стандартной библиотеке есть готовый тип. Граф — исключение: класса Graph в Python нет, собирать придётся самому. Не с нуля, а из dict, list и set — и от того, какие взять, зависит реальная сложность обхода.

Список смежности — это dict из вершины в список её соседей; список для новой вершины сам заводит defaultdict(list). dict находит соседей за O(1) в среднем, список внутри — плотный массив ссылок. Если вершины пронумерованы подряд (0…N−1), dict вообще не нужен: берут список списков, и поиск соседей превращается в обращение по индексу, как во фрагментах выше.

Матрица смежности — это список списков или, чтобы не платить по 8 байт за ссылку на True, bytearray длиной N·N; каждая ячейка занимает байт, поэтому на ста тысячах вершин матрица попросит около десяти гигабайт. Отсюда и правило: матрица — только для маленьких плотных графов.

Оба обхода держатся на разных структурах: в глубину — обычный list как стек, append и pop(); в ширину — collections.deque с popleft, потому что list.pop(0) сдвигает весь список за O(N). Обе операции у своих структур амортизированно O(1). Рекурсивный обход в глубину в Python упирается в предел на тысяче вложенных вызовов, RecursionError, поэтому на графе больше нескольких сотен вершин его пишут циклом со своим стеком.

Посещённые вершины держат в set (O(1) в среднем), а при пронумерованных вершинах — в списке bool: быстрее и без хеширования.

Грабля тут одна, зато обидная. Если вершина — ваш собственный класс без __eq__ и __hash__, dict и set сравнивают вершины по адресу. Две «одинаковые» вершины станут разными ключами, граф тихо распадётся на куски, а обход найдёт меньше, чем должен, — без исключения, просто неверный ответ. Лечится @dataclass(frozen=True), который делает оба метода по полям. Зато порядок обхода воспроизводим почти сам собой: dict перебирается в порядке вставки; только set порядка не хранит, и для воспроизводимости стартовые вершины берут из dict, а не из set.

И одна готовая вещь всё-таки есть: топологическую сортировку по Кану стандартная библиотека умеет сама, graphlib.TopologicalSorter с версии 3.9, а для настоящей работы с графами берут networkx, где написаны и обходы, и кратчайшие пути, и компоненты.

Коротко

  • Граф — вершины и рёбра между ними; моделирует связи (сети, маршруты, зависимости). Бывает направленным/ненаправленным и взвешенным/невзвешенным.
  • Хранят двумя способами: матрица смежности (быстрый ответ о связи, но память ~вершин²) и список смежности (экономно для разреженных графов).
  • Обход в глубину (DFS) ныряет вглубь и откатывается — стек или рекурсия.
  • Обход в ширину (BFS) расходится волнами — очередь; находит кратчайший путь по числу рёбер.
  • Оба обхода стоят O(V + E) — пропорционально размеру графа, а не квадрату числа вершин.
  • На обходах строят проверку связности, остовное дерево и топологическую сортировку зависимостей — последняя работает только в графе без циклов и сама же цикл и обнаруживает.
  • В Java класса Graph нет: список смежности собирают на HashMap, обход — на ArrayDeque, посещённые — в HashSet. Вершина-ключ без equals и hashCode тихо рвёт граф на куски.
  • В Go типа Graph нет: список смежности это map[string][]string или [][]int, обходы на срезе, посещённые в map или []bool; ключ-указатель сравнивается по адресу и рвёт граф, а обход map идёт в случайном порядке.
  • В JavaScript класса Graph нет: список смежности на Map, стек на массиве, очередь с индексом головы (shift это O(N)), посещённые в Set; объект-вершина сравнивается по ссылке, ключом делают идентификатор.
  • В Python класса Graph нет: defaultdict(list), стек на list, очередь на deque, посещённые в set; вершина без __eq__ и __hash__ рвёт граф, рекурсивный обход упирается в предел тысячи вызовов; Кан готов в graphlib.
  • Порядок по зависимостям даёт топологическая сортировка: Кан со счётчиками входящих рёбер или обход в глубину с разворотом; не все вершины вошли, значит есть цикл.
  • Цикл в направленном графе ловят тремя цветами: ребро в серую (незавершённую) вершину.
  • Матрица это V² ячеек, список V + E: матрица окупается лишь на почти полных графах. Сам путь из BFS восстанавливают по карте «откуда пришли», а между двумя заданными вершинами пускают волну с обоих концов; компоненты считают запусками обхода.

Что почитать дальше