1. Теория. Графы
«В теории графов нет никакой мистики: есть лишь вершины, соединяющие их рёбра и искусство не блуждать по кругу».
— Эдсгер Вибе Дейкстра
Граф — наиболее общая и выразительная модель данных в Computer Science. Если списки и массивы моделируют последовательности, а деревья — строгие иерархии «сверху вниз», то графы моделируют произвольные взаимосвязи между объектами реального мира.
В архитектуре современных высоконагруженных систем графы окружают инженера повсюду:
- Распределённая трассировка (Distributed Tracing): граф вызовов микросервисов в OpenTelemetry / Jaeger с передачей
TraceIDиSpanID. - Сетевая маршрутизация: построение кратчайших путей в протоколах OSPF, BGP и mesh-сетях дата-центров.
- Менеджеры зависимостей: построение направленного графа зависимостей пакетов в
go modи графа задач в системах сборки (Makefile, Bazel). - Обнаружение Deadlock'ов: поиск циклов ожидания ресурсов в транзакционных СУБД (PostgreSQL, MySQL InnoDB).
- Сборка мусора в Go (GC): трёхцветный алгоритм пометки живых объектов (Tri-color Mark & Sweep) — это не что иное, как обход направленного графа ссылок в куче, начиная от корней (Root Set).
1. Математическая анатомия графа
Формально граф $G$ определяется парой множеств: $G = (V, E)$, где:
- $V$ (Vertices / Вершины) — множество узлов системы (пользователи, серверы, задачи).
- $E$ (Edges / Рёбра) — множество пар вершин, описывающих взаимосвязи.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
A["Вершина A"]:::entry -->|"Ориентированное<br>ребро (вес 5)"| B["Вершина B"]:::process
B -->|"Вес 2"| C["Вершина C"]:::accent
C -->|"Вес 1"| A
B ---|"Неориентированное<br>ребро"| D["Вершина D"]:::success
Классификация графов
- По направленности рёбер:
- Неориентированные (Undirected): рёбра двунаправлены ($A - B \iff B - A$). Пример: сетевой кабель или взаимная дружба в соцсети.
- Ориентированные (Directed / Digraph): рёбра имеют строгое направление ($A \rightarrow B$). Пример: зависимости пакетов, подписки в Twitter, HTTP-вызовы.
- По наличию весов:
- Невзвешенные (Unweighted): стоимость каждого перехода равна $1$. Кратчайший путь находится классическим волновым BFS (1. Теория. BFS).
- Взвешенные (Weighted): каждому ребру сопоставлен вес $w(u, v)$ (задержка сети в мс, стоимость перелёта). Кратчайший путь требует алгоритма Дейкстры или Беллмана-Форда.
- По наличию циклов:
- Ациклические (DAG): не содержат путей, возвращающихся в исходную точку. Критически важны для топологической сортировки.
- Циклические: содержат замкнутые маршруты, требующие строгого контроля посещённости (
visited).
2. Представление графа в памяти Go: Память и Кэш-линии
Аппаратная архитектура процессора оперирует непрерывными массивами оперативной памяти. Существует три классических формата хранения графов в RAM:
1. Матрица смежности (Adjacency Matrix)
Квадратная таблица размером $V \times V$, где ячейка matrix[u][v] хранит вес ребра от вершины $u$ к вершине $v$ (или $0/1$):
matrix := [][]int{
{0, 1, 0, 0}, // Ребро 0 -> 1
{1, 0, 1, 1}, // Ребра 1 -> 0, 1 -> 2, 1 -> 3
{0, 1, 0, 0},
{0, 1, 0, 0},
}
- Проверка наличия ребра $(u, v)$: мгновенно за $O(1)$.
- Потребление памяти: строго $O(V^2)$.
- Инженерный вердикт: применима только для плотных графов (Dense Graphs), где $E \approx V^2$. Для графа из $100\,000$ пользователей матрица займет $100\,000 \times 100\,000 \times 8 \text{ байт} \approx 80 \text{ ГБ}$ памяти, из которых $99.9\%$ будут пустыми нулями. Гарантированный OOM-сбой.
2. Список смежности (Adjacency List — Золотой стандарт)
Для каждой вершины хранится срез её непосредственных соседей. Применяется в 95% задач:
// Вариант А: Разреженные идентификаторы (UUID, строки)
graphMap := make(map[string][]string)
// Вариант Б: Плотные целочисленные идентификаторы 0..V-1 (High-Performance)
graph := make([][]int, numVertices)
graph[0] = []int{1, 2}
graph[1] = []int{0, 3}
- Потребление памяти: оптимальное $O(V + E)$.
- Итерация по соседям: $O(\text{deg}(u))$ — мы сканируем только реальные рёбра.
3. Mechanical Sympathy: Срез срезов [][]int против map[int][]int
На собеседовании уровня Senior кандидата обязательно спросят:
«Почему представление [][]int работает на порядок быстрее, чем map[int][]int, если их алгоритмическая сложность формально одинакова — $O(V + E)$?»
Ответ раскрывает глубинное понимание рантайма Go и микроархитектуры процессора:
- Накладные расходы хеширования:
Срез[][]int— это монолитный блок 24-байтовых дескрипторовSliceHeader. Обращениеgraph[u]компилируется в одну инструкцию сложенияbase + u * 24за 1 такт CPU.
Доступ кmap[u]требует вычисления хеша ключа, наложения битовой маски бакета, проверкиtop-hash, перехода по цепочкам переполнения и разыменования указателей — это сотни инструкций процессора. - Кэш-локальность L1/L2:
Ячейки среза лежат в памяти последовательно. Предвыборщик процессора (Hardware Prefetcher) подтягивает соседние записи в 64-байтную кэш-линию L1d с нулевой задержкой.
Хеш-таблицаruntime.hmapразбрасывает бакеты по куче псевдослучайно, порождая постоянные промахи кэша (Cache Misses с простоем в 150–200 тактов на каждое обращение).
4. Список рёбер (Edge List)
Массив плоских структур, представляющих каждое ребро:
type Edge struct {
From int
To int
Weight int
}
edges := []Edge{{From: 0, To: 1, Weight: 10}}
- Потребление памяти: минимальное $O(E)$.
- Применение: алгоритм Крускала (поиск минимального остовного дерева MST с сортировкой рёбер), алгоритм Беллмана-Форда (поиск кратчайших путей с отрицательными рёбрами).
5. Ловушка зацикливания: почему графам необходим visited
В бинарном дереве рекурсия гарантированно завершалась в листьях (node == nil). В графах наличие циклов ($A \rightarrow B \rightarrow C \rightarrow A$) превращает наивный обход в бесконечную рекурсию и панику по переполнению стека (stack overflow).
Для разрыва циклов в алгоритм обязательно вводится структура учёта посещённых вершин:
- В неориентированных графах: булев массив
visited := make([]bool, V). - В ориентированных графах: метод трёх цветов (White-Gray-Black), подробно рассматриваемый в следующей главе.
Резюме для интервью
| Представление | Память | Проверка ребра $(u, v)$ | Перебор соседей вершины $u$ | Где применяется |
|---|---|---|---|---|
| Матрица смежности | $O(V^2)$ | $O(1)$ | $O(V)$ | Плотные графы ($E \approx V^2$), алгоритм Флойда-Уоршелла |
Список смежности ([][]int) |
$O(V + E)$ | $O(\text{deg}(u))$ | $O(\text{deg}(u))$ | 95% задач на DFS/BFS, Дейкстру, топосорт |
| Список рёбер | $O(E)$ | $O(E)$ | $O(E)$ | Алгоритмы Крускала, Беллмана-Форда |
В следующей статье мы разберем две важнейшие фундаментальные операции над графами — выделение компонент связности и детектирование циклов: 2. Компоненты и циклы.