Перед вами задача из реального проекта. Вы строите сервис маршрутизации для авиакомпании. Есть шесть городов: Москва, Питер, Казань, Новосибирск, Екатеринбург и Сочи. Рейсы (с расстоянием в км):

  • Москва → Питер, 700 км
  • Москва → Казань, 820 км
  • Москва → Сочи, 1350 км
  • Питер → Екатеринбург, 2100 км
  • Казань → Екатеринбург, 960 км
  • Казань → Новосибирск, 2200 км
  • Екатеринбург → Новосибирск, 1400 км

Задача: запишите эти данные в коде на C# — так, чтобы можно было быстро ответить на вопросы: «Есть ли прямой рейс из А в Б?» и «Какие города доступны напрямую из Казани?». Каким типом данных вы воспользуетесь?

В предыдущем блоке мы разобрали хеш-таблицы — структуру, которая отвечает на вопрос «есть ли этот ключ?» за O(1). Хеш-таблица хороша для точечного поиска. Но есть целый класс задач, где данные — это не просто набор элементов, а связи между ними. Кто с кем дружит? Какой сервер смотрит на какой? Какой пакет зависит от какого? Для таких задач нужна структура, которая умеет хранить не значения, а отношения. Эта структура — граф.

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

Графы делятся по двум осям. По направленности: ориентированный (directed) — рёбра имеют направление, как односторонняя улица; неориентированный (undirected) — рёбра двусторонние, как дружба в соцсети. По весу: взвешенный (weighted) — у каждого ребра есть числовое значение (расстояние, стоимость, пропускная способность); невзвешенный (unweighted) — все рёбра равнозначны.

Реальных примеров — масса. Социальная сеть: пользователи — вершины, дружба — рёбра (неориентированные). Карта дорог: города — вершины, дороги — рёбра с весом (расстояние). Интернет: серверы — вершины, соединения — рёбра. Граф зависимостей пакетов: пакет A зависит от B — ориентированное ребро A → B. Git-история: каждый коммит ссылается на родителей — ориентированный граф.

Вот небольшой взвешенный граф с пятью вершинами, который мы будем использовать на протяжении всего урока:

Граф: 5 городов, 6 рёбер (расстояние в условных единицах) 4 6 2 5 3 7 0 Москва 1 Питер 2 Казань 3 Екатеринбург 4 Новосибирск

Этот граф неориентированный (все рёбра двусторонние) и взвешенный. Вершин — 5, рёбер — 6. Вопрос: как хранить его в памяти? Первый способ — матрица смежности (adjacency matrix). Заводим двумерный массив V × V, где V — число вершин. Элемент matrix[i, j] хранит вес ребра из вершины i в вершину j (или 0, если ребра нет).

Для нашего графа из 5 вершин это массив 5×5:

//  Строка = откуда, столбец = куда, значение = вес (0 = нет ребра)
//     0  1  2  3  4
//  0 [0, 4, 0, 2, 6]
//  1 [4, 0, 5, 0, 0]
//  2 [0, 5, 0, 3, 0]
//  3 [2, 0, 3, 0, 7]
//  4 [6, 0, 0, 7, 0]

int[,] matrix = new int[5, 5];

// Добавляем рёбра (неориентированный граф — симметрично)
void AddEdge(int[,] m, int from, int to, int weight)
{
    m[from, to] = weight;
    m[to, from] = weight; // убираем эту строку для ориентированного графа
}

AddEdge(matrix, 0, 1, 4);  // Москва — Питер
AddEdge(matrix, 0, 3, 2);  // Москва — Екатеринбург
AddEdge(matrix, 0, 4, 6);  // Москва — Новосибирск
AddEdge(matrix, 1, 2, 5);  // Питер — Казань
AddEdge(matrix, 2, 3, 3);  // Казань — Екатеринбург
AddEdge(matrix, 3, 4, 7);  // Екатеринбург — Новосибирск

// Проверить, есть ли ребро: O(1)
bool hasEdge = matrix[1, 2] != 0;  // true

// Получить всех соседей вершины 0: O(V)
for (int j = 0; j < 5; j++)
{
    if (matrix[0, j] != 0)
        Console.WriteLine($"0 → {j}, вес {matrix[0, j]}");
}

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

Ещё один минус: чтобы перечислить всех соседей вершины, нужно пройти всю строку — O(V), даже если у вершины только 2 соседа.

Второй способ — список смежности (adjacency list). Для каждой вершины хранится список её соседей. В C# это удобно реализовать через Dictionary<int, List<(int neighbor, int weight)>>.

var graph = new Dictionary<int, List<(int neighbor, int weight)>>();

// Инициализируем вершины
for (int i = 0; i < 5; i++)
    graph[i] = new List<(int, int)>();

// Добавляем рёбра
void AddEdge(int from, int to, int weight)
{
    graph[from].Add((to, weight));
    graph[to].Add((from, weight));  // для неориентированного графа
}

AddEdge(0, 1, 4);  // Москва — Питер
AddEdge(0, 3, 2);  // Москва — Екатеринбург
AddEdge(0, 4, 6);  // Москва — Новосибирск
AddEdge(1, 2, 5);  // Питер — Казань
AddEdge(2, 3, 3);  // Казань — Екатеринбург
AddEdge(3, 4, 7);  // Екатеринбург — Новосибирск

// Получить всех соседей вершины 0: O(degree) — только реальные соседи
foreach (var (neighbor, weight) in graph[0])
    Console.WriteLine($"0 → {neighbor}, вес {weight}");

// Проверить наличие ребра 0 → 2: O(degree)
bool hasEdge = graph[0].Any(e => e.neighbor == 2);

Список смежности хранит только существующие рёбра. Память — O(V + E), где E — число рёбер. Для нашего графа: 5 вершин + 12 записей (6 рёбер × 2 направления) = 17 единиц, а не 25 ячеек матрицы. Для реальных разреженных графов разница колоссальная.

Перечислить соседей — O(degree), то есть пропорционально числу реальных связей. Для соцсети с 300 друзьями — 300 операций вместо миллиарда. Зато проверить наличие конкретного ребра — тоже O(degree): нужно перебрать список. Если важна мгновенная проверка рёбер — можно заменить List на HashSet или Dictionary и получить O(1).

Третье представление — список рёбер (edge list). Просто набор всех рёбер в виде кортежей (from, to, weight):

var edges = new List<(int from, int to, int weight)>
{
    (0, 1, 4),  // Москва — Питер
    (0, 3, 2),  // Москва — Екатеринбург
    (0, 4, 6),  // Москва — Новосибирск
    (1, 2, 5),  // Питер — Казань
    (2, 3, 3),  // Казань — Екатеринбург
    (3, 4, 7),  // Екатеринбург — Новосибирск
};

// Можно отсортировать по весу — основа алгоритма Краскала
edges.Sort((a, b) => a.weight.CompareTo(b.weight));

Список рёбер занимает O(E) памяти — минимум из трёх вариантов. Но найти соседей вершины — O(E): надо просмотреть весь список. Это представление используется в алгоритмах, которые работают именно с рёбрами, а не с вершинами, — прежде всего в алгоритме Краскала для построения минимального остовного дерева: там рёбра сортируются по весу и обрабатываются одно за другим.

Сравнение всех трёх вариантов:

Параметр Матрица смежности Список смежности Список рёбер
Память O(V²) O(V + E) O(E)
Проверка ребра (u, v) O(1) O(degree) O(E)
Список соседей вершины O(V) O(degree) O(E)
Добавить ребро O(1) O(1) O(1)
Удалить ребро O(1) O(degree) O(E)
Лучше всего подходит Плотные графы, частая проверка рёбер Разреженные графы (большинство реальных) Алгоритмы на рёбрах (Краскал)

Граф — структура из вершин и рёбер, описывает произвольные связи между объектами. Бывает ориентированным / неориентированным и взвешенным / невзвешенным.

Матрица смежности — двумерный массив V×V; проверка ребра за O(1), но памяти O(V²). Подходит для плотных графов.

Список смежности — словарь или массив списков соседей; памяти O(V+E), перебор соседей O(degree). Выбор по умолчанию для разреженных графов.

Список рёбер — набор кортежей (from, to, weight); памяти O(E), удобен для алгоритмов, сортирующих рёбра (Краскал).

Разреженный граф — у большинства вершин мало рёбер; E ≪ V². Большинство реальных графов разреженные.

Обсуждение урока

0
Комментарии видны всем. Чтобы участвовать в обсуждении, войдите или зарегистрируйтесь.
Модерация сообщества

Пожаловаться на комментарий

Расскажите модераторам, что именно требует внимания.