Вы пишете внутренний инструмент для HR-отдела крупной IT-компании. Нужно выяснить, через кого конкретный сотрудник связан с другим — и сколько «рукопожатий» разделяет двух людей. Граф знакомств уже есть: каждый узел — сотрудник (номер), каждое ребро — они знают друг друга лично.

Граф (неориентированный, без весов):

  • 0 (Алина) знакома с 1 (Борисом) и 2 (Верой)
  • 1 (Борис) знаком с 0, 3 (Геннадием)
  • 2 (Вера) знакома с 0, 4 (Дмитрием)
  • 3 (Геннадий) знаком с 1, 5 (Еленой)
  • 4 (Дмитрий) знаком с 2
  • 5 (Елена) знакома с 3

Задача: не используя специальных алгоритмов, попробуйте на бумаге (или в коде) ответить: сколько шагов от Алины (0) до Елены (5)? Через кого нужно пройти? Каким способом вы будете искать путь?

В прошлом уроке мы разобрали три способа хранить граф — матрицу смежности, список смежности и список рёбер. Теперь у нас есть граф в памяти. Что с ним делать дальше? Первое, что нужно уметь, — это обойти его: посетить каждую вершину хотя бы один раз. Как именно обходить — зависит от задачи. Один из двух фундаментальных алгоритмов обхода — BFS, обход в ширину.

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

Возьмём граф из задачи выше: шесть сотрудников, стартуем из вершины 0 (Алина).

Волны BFS от вершины 0 (Алина) 0 Алина уровень 0 1 Борис 2 Вера уровень 1 3 Гена 4 Дима уровень 2 5 (Елена) — уровень 3

Алина на уровне 0. Борис и Вера — на уровне 1 (один шаг от старта). Геннадий и Дмитрий — на уровне 2. Елена — на уровне 3. BFS гарантирует: когда мы впервые добираемся до вершины, мы сделали это кратчайшим путём.

Алгоритм шаг за шагом:

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

Ключевое слово — очередь. Именно FIFO (first in, first out) обеспечивает обход слоями: мы сначала обрабатываем всех соседей текущего уровня, прежде чем перейти к следующему. Если бы использовали стек — получили бы DFS (обход в глубину). Реализуем BFS на C#. Граф задан списком смежности — это выбор по умолчанию для алгоритмов обхода, потому что перебор соседей там за O(degree), а не за O(V).

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

// Список смежности графа (6 вершин: 0–5)
var graph = new Dictionary<int, List<int>>
{
    [0] = new List<int> { 1, 2 },
    [1] = new List<int> { 0, 3 },
    [2] = new List<int> { 0, 4 },
    [3] = new List<int> { 1, 5 },
    [4] = new List<int> { 2 },
    [5] = new List<int> { 3 }
};

void BfsLevels(Dictionary<int, List<int>> g, int start)
{
    var visited = new HashSet<int>();
    var queue = new Queue<int>();

    visited.Add(start);
    queue.Enqueue(start);

    int level = 0;
    while (queue.Count > 0)
    {
        // Сколько вершин в текущем уровне?
        int levelSize = queue.Count;
        var levelNodes = new List<int>();

        for (int i = 0; i < levelSize; i++)
        {
            int node = queue.Dequeue();
            levelNodes.Add(node);

            foreach (int neighbor in g[node])
            {
                if (!visited.Contains(neighbor))
                {
                    visited.Add(neighbor);    // помечаем ДО добавления в очередь
                    queue.Enqueue(neighbor);
                }
            }
        }

        Console.WriteLine($"Уровень {level}: {string.Join(", ", levelNodes)}");
        level++;
    }
}

BfsLevels(graph, 0);

Вывод:

Уровень 0: 0
Уровень 1: 1, 2
Уровень 2: 3, 4
Уровень 3: 5

Разберём ключевые детали. HashSet<int> visited хранит уже посещённые вершины — проверка за O(1). Queue<int> queue — рабочая очередь алгоритма. Важный момент: мы помечаем вершину как посещённую в момент добавления в очередь, а не в момент извлечения. Если делать наоборот — одна и та же вершина попадёт в очередь несколько раз через разных соседей, что даст дубли в выводе и лишнюю работу.

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

Для этого заводим массив parent[]: parent[v] хранит, из какой вершины мы пришли в v. Зная родителя каждой вершины, можно восстановить путь от цели до старта — а потом перевернуть.

List<int> BfsShortestPath(Dictionary<int, List<int>> g, int start, int end)
{
    var visited = new HashSet<int>();
    var queue = new Queue<int>();
    var parent = new Dictionary<int, int>();   // parent[v] = откуда пришли в v

    visited.Add(start);
    queue.Enqueue(start);
    parent[start] = -1;  // у стартовой вершины нет родителя

    while (queue.Count > 0)
    {
        int node = queue.Dequeue();

        if (node == end)
            break;  // нашли — дальше искать не нужно

        foreach (int neighbor in g[node])
        {
            if (!visited.Contains(neighbor))
            {
                visited.Add(neighbor);
                queue.Enqueue(neighbor);
                parent[neighbor] = node;  // запоминаем, откуда пришли
            }
        }
    }

    // Если цель не была достигнута
    if (!parent.ContainsKey(end))
        return new List<int>();

    // Восстанавливаем путь от конца к началу
    var path = new List<int>();
    int current = end;
    while (current != -1)
    {
        path.Add(current);
        current = parent[current];
    }

    path.Reverse();
    return path;
}

var route = BfsShortestPath(graph, 0, 5);
Console.WriteLine(string.Join(" → ", route));  // 0 → 1 → 3 → 5

Алгоритм возвращает List<int> — последовательность вершин от старта до финиша. Если пути нет (граф несвязный, цель недостижима), возвращается пустой список. Длина пути — path.Count - 1 (число рёбер), то есть количество «рукопожатий».

Теперь о том, зачем BFS существует за пределами учебных примеров.

Кратчайший путь в невзвешенном графе — первое и самое прямолинейное применение. «Через сколько промежуточных серверов пакет доходит от A до B?» — BFS. «Минимальное число ходов конём на шахматной доске из клетки A в клетку B?» — граф клеток, рёбра — допустимые ходы конём, BFS.

Обход веб-страниц (web crawling). Поисковый робот начинает с одного URL и обходит граф ссылок в ширину: сначала все ссылки с главной страницы, потом все ссылки с этих страниц. BFS гарантирует, что ближайшие по глубине страницы индексируются первыми.

«Шесть рукопожатий» в соцсетях. LinkedIn и Facebook используют BFS, чтобы показать, через кого вы связаны с нужным человеком. Уровень 1 — ваши прямые контакты, уровень 2 — контакты ваших контактов. «People you may know» — это часто вершины на уровне 2 от вас.

Обход дерева по уровням (level-order traversal). Двоичное дерево — частный случай графа. BFS по нему даёт именно обход уровень за уровнем: сначала корень, потом его дети, потом внуки. Это нужно, например, чтобы сериализовать дерево или найти ближайшего предка.

Проверка связности графа. Запустите BFS из любой вершины: если все V вершин оказались посещёнными — граф связный. Если нет — граф несвязный, и количество отдельных запусков BFS даст число компонент связности.

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

Для связного графа с V вершинами, где E ≈ V (разреженный), это линейное время — алгоритм масштабируется хорошо даже на миллионах вершин. Граф дорог России содержит ~100 000 населённых пунктов и ~300 000 дорог — BFS обойдёт его за несколько сотен миллисекунд.

BFS (обход в ширину) — алгоритм обхода графа, который посещает вершины слой за слоем, используя очередь. Сначала все соседи старта, потом их соседи, и так далее.

Ключевое правило реализации: помечать вершину как посещённую при добавлении в очередь, а не при извлечении. Иначе одна вершина попадёт в очередь несколько раз.

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

BFS гарантирует кратчайший путь только в невзвешенном графе. Для взвешенных графов (разные стоимости рёбер) нужен алгоритм Дейкстры.

Сложность: O(V + E) по времени, O(V) по памяти.

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

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

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

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

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