1. Представление графов
Мы начинаем один из самых масштабных, красивых и практически важных разделов в компьютерных науках — Графы.
Если деревья (см. раздел 04) помогают нам строить строгие и предсказуемые иерархии (файловые системы, синтаксические деревья компиляторов, B-деревья в СУБД), то графы описывают реальный мир. А в реальном мире связи между объектами хаотичны, цикличны, многонаправленны и порой запутаны. Компьютерные сети, топология микросервисных архитектур (Service Mesh), вычисление зависимостей пакетов в Go modules, обнаружение взаимных блокировок (deadlock detection), социальные графы и графы объектов в Garbage Collector — всё это дышит графовыми алгоритмами.
Прежде чем разворачивать обходы в ширину, искать циклы или прокладывать кратчайшие маршруты, инженер обязан ответить на фундаментальный вопрос: как компактно и производительно разместить граф в оперативной памяти? Выбор структуры данных определяет всё: превратится ли обход ваших микросервисов в миллисекундный полет по L1-кэшу или в часы мучительного ожидания с бесконечными Cache Miss и метаниями сборщика мусора.
Анатомия графа: вершины, ребра и их природа
Формально любой граф $G = (V, E)$ состоит из двух фундаментальных множеств:
- $V$ (Vertices / Узлы / Вершины): Дискретные объекты предметной области (серверы, пользователи, функции в стеке вызовов, перекрестки дорог).
- $E$ (Edges / Ребра / Дуги): Отношения и связи между узлами.
Ребра бывают:
- Неориентированными (Undirected): Связь взаимна (двустороннее шоссе, друзья в социальной сети). Если есть ребро $(u, v)$, то существует и $(v, u)$.
- Направленными (Directed / Digraph): Односторонняя связь (подписка в Twitter/X, HTTP-запрос от сервиса A к сервису B, вызов функции).
- Взвешенными (Weighted): Ребру сопоставлено числовое значение — задержка (RTT) в миллисекундах, пропускная способность канала, стоимость проезда или физическое расстояние.
flowchart LR
subgraph Undir["Неориентированный граф"]
direction LR
U0["0"] --- U1["1"]
U0 --- U2["2"]
U1 --- U2
U2 --- U3["3"]
end
subgraph Dir["Ориентированный взвешенный граф"]
direction LR
D0["0"] -->|"12ms"| D1["1"]
D0 -->|"4ms"| D2["2"]
D1 -->|"8ms"| D2
D2 -->|"15ms"| D3["3"]
end
classDef default fill:#131d33,stroke:#38bdf8,stroke-width:1.5px,color:#f8fafc;
Рассмотрим три классических способа представления графа в памяти на языке Go. У каждого из них своя ниша, свои алгоритмические компромиссы и свои отношения с иерархией процессорного кэша.
1. Матрица смежности (Adjacency Matrix)
Матрица смежности — это концептуально простейший подход: двумерная таблица размером $V \times V$, где ячейка [i][j] содержит флаг наличия ребра (или его числовой вес) между вершиной $i$ и вершиной $j$.
flowchart LR
0["0"] --- 1["1"]
0 --- 2["2"]
1 --- 2["2"]
2 --- 3["3"]
classDef default fill:#131d33,stroke:#38bdf8,stroke-width:1.5px,color:#f8fafc;
Для неориентированного графа выше матрица смежности симметрична относительно главной диагонали:
| Вершина | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 0 | 0 | 1 | 1 | 0 |
| 1 | 1 | 0 | 1 | 0 |
| 2 | 1 | 1 | 0 | 1 |
| 3 | 0 | 0 | 1 | 0 |
Наивная реализация на Go
Частая ошибка инженеров, пришедших из языков с динамическими списками — создавать матрицу смежности как «слайс слайсов» [][]int или [][]bool:
matrix := make([][]int, V)
for i := range matrix {
matrix[i] = make([]int, V)
}
// Проверка наличия ребра за O(1)
hasEdge := matrix[u][v] == 1
Слайс слайсов [][]bool (Фрагментированная память, Pointer Chasing):
[ &row0 | &row1 | &row2 ] (Внешний слайс в куче)
│ │ │
│ │ └───> [ 0 | 1 | 0 ] (Память по адресу 0x1040) -> Cache Miss
│ └───────────> [ 1 | 0 | 1 ] (Память по адресу 0x2080) -> Cache Miss
└───────────────────> [ 0 | 1 | 1 ] (Память по адресу 0x08a0) -> Cache Miss
Плоский массив []bool (Непрерывный буфер, Spatial Locality):
[ 0, 1, 1, 1, 0, 1, 0, 1, 0 ] (Единый кусок памяти, загрузка в L1-кэш линиями по 64 байта)
Idiomatic & Mechanical Sympathy реализация (Плоский массив)
Правильный инженерный путь — «расплющить» (flatten) 2D-матрицу в единый непрерывный одномерный слайс размером $V \times V$. Индекс элемента вычисляется элементарной арифметикой: row * width + col.
package main
// DenseGraph представляет граф в виде плоской матрицы смежности.
type DenseGraph struct {
vertices int
matrix []bool // Используем bool для экономии памяти (или int для веса)
}
// NewDenseGraph создает граф с единой непрерывной аллокацией памяти.
func NewDenseGraph(v int) *DenseGraph {
return &DenseGraph{
vertices: v,
// ОДНА аллокация памяти. Идеальная пространственная локальность.
matrix: make([]bool, v*v),
}
}
// AddEdge добавляет неориентированное ребро
func (g *DenseGraph) AddEdge(u, v int) {
// Формула вычисления индекса: строка * ширина + столбец
g.matrix[u*g.vertices+v] = true
g.matrix[v*g.vertices+u] = true
}
// HasEdge проверяет связь за константное O(1) время
func (g *DenseGraph) HasEdge(u, v int) bool {
return g.matrix[u*g.vertices+v]
}
Плюсы матрицы смежности:
- Проверка наличия ребра
(u, v)занимает гарантированное $O(1)$. - Добавление и удаление ребер — строго $O(1)$.
- Идеально для плотных графов (Dense Graphs), где количество ребер стремится к максимуму: $E \approx V^2$.
Минусы матрицы смежности:
- Потребление памяти строго $O(V^2)$. Если у вас 1 миллион пользователей в базе, матрица смежности потребует $1\,000\,000^2 \text{ байт} \approx 1 \text{ ТБ}$ оперативной памяти, даже если у каждого пользователя всего по два друга!
- Итерация по всем соседям конкретной вершины занимает $O(V)$, так как нужно просканировать всю строку матрицы, даже если она почти целиком заполнена нулями.
2. Список смежности (Adjacency List)
В реальных бэкенд-системах подавляющее большинство графов являются разреженными (Sparse Graphs): количество ребер несоизмеримо меньше теоретического максимума ($E \ll V^2$). Каждая веб-страница ссылается на десятки других, а не на весь Интернет; у пользователя сотни контактов, а не миллиарды.
Список смежности решает проблему памяти радикально: для каждой вершины мы храним только коллекцию ее реальных непосредственных соседей.
flowchart TD
subgraph AdjList["Список смежности (Слайс соседей)"]
0["Узел 0"] --> N0["[ 1, 2 ]"]
1["Узел 1"] --> N1["[ 0, 2 ]"]
2["Узел 2"] --> N2["[ 0, 1, 3 ]"]
3["Узел 3"] --> N3["[ 2 ]"]
end
classDef default fill:#131d33,stroke:#38bdf8,stroke-width:1.5px,color:#f8fafc;
Реализация через Map (Хеш-таблицу)
Когда вершины графа представлены произвольными бизнес-ключами (строки, UUID, доменные имена), разработчики инстинктивно тянутся к map:
type StringGraph struct {
adj map[string][]string
}
func (g *StringGraph) AddEdge(u, v string) {
g.adj[u] = append(g.adj[u], v)
}
Production-Ready реализация (Трансляция ID в Слайсы)
Золотой стандарт высокопроизводительных систем: на входном рубеже сервиса (Ingress/API) мы транслируем строковые или UUID-идентификаторы во внутренние плотные целочисленные индексы от $0$ до $V-1$ (интернирование / ID mapping). Внутри ядра алгоритма граф хранится как слайс слайсов, где внешний индекс массива — это внутренний компактный ID вершины.
package main
// SparseGraph реализует список смежности на слайсах.
// Индекс во внешнем слайсе - это ID вершины (от 0 до V-1).
type SparseGraph struct {
adj [][]int
}
// NewSparseGraph выделяет внешний слайс фиксированной длины.
func NewSparseGraph(vertices int) *SparseGraph {
return &SparseGraph{
// Предварительная аллокация для внешнего слайса
adj: make([][]int, vertices),
}
}
// AddEdge добавляет направленное ребро
func (g *SparseGraph) AddEdge(u, v int) {
// append может вызвать реаллокацию внутреннего слайса,
// но доступ к списку соседей по индексу g.adj[u] - это мгновенное O(1).
g.adj[u] = append(g.adj[u], v)
}
// GetNeighbors возвращает всех соседей узла u
func (g *SparseGraph) GetNeighbors(u int) []int {
return g.adj[u]
}
Плюсы списка смежности:
- Расход памяти строго пропорционален объему данных: $O(V + E)$.
- Итерация по всем соседям конкретной вершины занимает $O(\text{deg}(u))$ — мы читаем только существующие ребра.
- Внутренний слайс
[]intчитается последовательно, эффективно укладываясь в 64-байтные строки L1-кэша процессора (Spatial Locality).
Минусы списка смежности:
- Проверка существования ребра
(u, v)требует времени $O(K)$, где $K$ — степень вершины (количество соседей), поскольку нам необходимо выполнить линейный поиск (см. 1. Линейный поиск) по внутреннему слайсу.
3. Список ребер (Edge List)
Список ребер — это самое минималистичное, предельно простое, но весьма специфичное представление графа. В нем мы вообще не выделяем вершины как отдельные узлы-хранилища, а содержим глобальный плоский массив кортежей «откуда — куда — вес».
type Edge struct {
U int
V int
Weight int
}
type EdgeListGraph struct {
Edges []Edge
}
flowchart LR
subgraph EdgesArray["Плоский массив ребер []Edge"]
E0["{ U: 0, V: 1, Weight: 5 }"]
E1["{ U: 0, V: 2, Weight: 2 }"]
E2["{ U: 1, V: 2, Weight: 1 }"]
E3["{ U: 2, V: 3, Weight: 7 }"]
end
classDef default fill:#131d33,stroke:#38bdf8,stroke-width:1.5px,color:#f8fafc;
Плюсы:
- Структура данных представляет собой идеально непрерывный плоский массив в оперативной памяти.
- Превосходно подходит для сортировки всех ребер графа (например, по весу).
Минусы:
- Невозможно быстро узнать соседей заданной вершины: чтобы найти все ребра из вершины $u$, требуется полный просмотр всего массива ребер за $O(E)$.
- Проверка наличия связи между $u$ и $v$ также занимает время $O(E)$.
Где применяется:
Список ребер практически непригоден для локальной навигации и обходов (BFS, DFS). Его стихия — глобальные алгоритмы, обрабатывающие все ребра целиком:
- Алгоритм Крускала для построения минимального остовного дерева (Minimum Spanning Tree, MST).
- Алгоритм Беллмана-Форда (см. 6. Алгоритм Беллмана Форда) для релаксации путей в сетях с отрицательными весами.
Сравнительный анализ представлений графов
| Характеристика | Матрица смежности (Dense) | Список смежности (Sparse) | Список ребер (Edge List) |
|---|---|---|---|
| Память | $O(V^2)$ | $O(V + E)$ | $O(E)$ |
| Добавление ребра | $O(1)$ | $O(1)$ | $O(1)$ |
| Проверка ребра $(u, v)$ | $O(1)$ | $O(\text{deg}(u))$ | $O(E)$ |
| Перебор всех соседей $u$ | $O(V)$ | $O(\text{deg}(u))$ | $O(E)$ |
| Кэш-локальность | Отличная (при плоском []bool) |
Отличная внутри вершин | Идеальная |
| Оптимален для | Плотных сетей ($E \approx V^2$) | Разреженных сетей ($E \ll V^2$) | Сортировки ребер (MST) |
Итог: Что выбрать бэкенд-разработчику?
- Матрица смежности (одномерный плоский массив): Выбираем, если граф плотный ($E \approx V^2$), $V$ невелико (до нескольких тысяч) и нам критически важны частые проверки $O(1)$ на существование конкретного ребра.
- Список смежности (слайс слайсов): Стандарт по умолчанию (Default Choice). Идеален для разреженных графов. Дает быстрый перебор соседей, что необходимо для 99% графовых алгоритмов.
- Список смежности (
map): Выбираем только для быстрой прототипизации или если граф настолько динамичен и разрежен, что мы даже не знаем примерного количества вершин заранее. Остерегайтесь оверхедаhmapи работы Garbage Collector-а. - Список ребер: Только для специфических алгоритмов (Крускал, Беллман-Форд).
Теперь, когда наш граф надежно и оптимально размещен в оперативной памяти, мы можем начать по нему передвигаться. В следующей статье мы разберем самый важный алгоритм обхода, который лежит в основе поиска кратчайших путей в невзвешенных сетях (например, поиск друзей через одно рукопожатие): 2. Поиск в ширину BFS.