Лого на 91. НЕГ „Проф. Константин Гълъбов“

Модул 2 · Урок 6

Графи — обхождане (BFS и DFS)

Двата основни алгоритъма: BFS с опашка за най-къс път, DFS със стек/рекурсия за дълбочина. Свързани компоненти и шаблон с visited.

Защо ни трябва обхождане

Имайки граф, често искаме да отговорим на въпроси като:

  • До кои върхове мога да стигна, ако тръгна от връх X?
  • Кой е най-късият път (брой стъпки) от A до B?
  • Колко „групи приятели" има (свързани компоненти)?
  • Има ли цикъл в графа?

Всички тези въпроси се решават с обхождане на графа — систематично посещение на върховете. Има два класически алгоритъма: BFS и DFS.

Шаблонът visited

И при двата алгоритъма ни трябва множество visited, в което пазим вече посетените върхове. Без него ще обикаляме безкрайно в кръг (при графи с цикли).

var visited = new HashSet<int>();
// преди да посетим връх:
if (visited.Contains(v)) continue;
visited.Add(v);
// ... обработваме v

BFS — Breadth-First Search

BFS (обхождане в ширина) посещава върховете „пласт по пласт" — първо стартовия връх, после всичките му съседи, после техните съседи и т.н. Идеално за намиране на най-къс път в нетегловен граф (по брой стъпки).

12345ниво 0ниво 1ниво 2BFS от връх 1 — обхожда връх по връх в нарастващо разстояние
Фиг. 1 — BFS от връх 1: ниво 0 = {1}, ниво 1 = {2, 5}, ниво 2 = {3, 4}

Реализира се с опашка (Queue) — точно затова в предишния урок казахме „опашката е важна за обхождане в ширина".

Алгоритъм стъпка по стъпка

  1. Сложи стартовия връх в опашка. Маркирай го като посетен.
  2. Докато опашката не е празна:
  3. а. Изтегли първия връх от опашката (Dequeue).
  4. б. Обработи го (отпечатай, преброй и т.н.).
  5. в. За всеки негов непосетен съсед — маркирай и сложи в опашката.

BFS в C#

using System.Collections.Generic;

static void BFS(
    Dictionary<int, HashSet<int>> graph,
    int start)
{
    var visited = new HashSet<int> { start };
    var queue = new Queue<int>();
    queue.Enqueue(start);

    while (queue.Count > 0)
    {
        int v = queue.Dequeue();
        Console.Write(v + " ");

        foreach (var neighbor in graph[v])
        {
            if (!visited.Contains(neighbor))
            {
                visited.Add(neighbor);
                queue.Enqueue(neighbor);
            }
        }
    }
}

Трасиране стъпка по стъпка (за дъската)

Граф: 1—2, 1—5, 2—3, 2—5, 3—4, 4—5. BFS от връх 1 (приемаме, че съседите се обхождат в нарастващ ред):

СтъпкаИзваждаме (Dequeue)Добавяме в опашкатаОпашка следvisited
старт1[1]{1}
112, 5[2, 5]{1, 2, 5}
223[5, 3]{1, 2, 5, 3}
354[3, 4]{1, 2, 5, 3, 4}
43[4](без промяна)
54[ ](без промяна)

Ред на обхождане: 1 2 5 3 4. Забележи как опашката винаги изважда отпред и добавя отзад — затова близките върхове излизат първи.

BFS за най-къс път

Малка модификация: вместо да пазим само посетените, пазимразстоянието до всеки връх. Първият път, когато BFS стигне до връх, е по най-късия път (защото обхожда нарастващо по нива).

static int ShortestPath(
    Dictionary<int, HashSet<int>> graph,
    int from, int to)
{
    if (from == to) return 0;
    var distance = new Dictionary<int, int> { [from] = 0 };
    var queue = new Queue<int>();
    queue.Enqueue(from);

    while (queue.Count > 0)
    {
        int v = queue.Dequeue();
        foreach (var n in graph[v])
        {
            if (distance.ContainsKey(n)) continue;
            distance[n] = distance[v] + 1;
            if (n == to) return distance[n];
            queue.Enqueue(n);
        }
    }
    return -1;   // няма път
}

DFS — Depth-First Search

DFS (обхождане в дълбочина) тръгва от стартовия връх и върви „навътре", докато може. Когато стигне до връх без непосетени съседи — връща се назад и опитва друг път.

Реализира се с стек (Stack) — или още по-естествено, с рекурсия (която ползва стека на извикванията).

DFS рекурсивно (по-кратко)

static void DFS(
    Dictionary<int, HashSet<int>> graph,
    int v,
    HashSet<int> visited)
{
    if (visited.Contains(v)) return;
    visited.Add(v);
    Console.Write(v + " ");

    foreach (var neighbor in graph[v])
        DFS(graph, neighbor, visited);
}

// Извикване:
DFS(graph, 1, new HashSet<int>());

DFS итеративно (със Stack)

Същата логика, но без рекурсия — полезно ако графът е много голям и рискуваш да препълниш стека на извикванията.

static void DFSIter(
    Dictionary<int, HashSet<int>> graph,
    int start)
{
    var visited = new HashSet<int>();
    var stack = new Stack<int>();
    stack.Push(start);

    while (stack.Count > 0)
    {
        int v = stack.Pop();
        if (visited.Contains(v)) continue;
        visited.Add(v);
        Console.Write(v + " ");

        foreach (var n in graph[v])
            if (!visited.Contains(n))
                stack.Push(n);
    }
}

Трасиране стъпка по стъпка (за дъската)

Нека проследим DFS (рекурсивно) от връх 1 за същия граф. „⮑" показва навлизане по-дълбоко, „⮐" — връщане назад:

ДействиеВръхРед дотук
⮑ влез11
⮑ влез21 2
⮑ влез31 2 3
⮑ влез41 2 3 4
⮑ влез51 2 3 4 5
⮐ връщане (всички съседи посетени)5→4→3→2→1край

DFS върви до дъно по един път (1→2→3→4→5), после се връща. Сравни с BFS реда отгоре (1, 2, 5, 3, 4) — DFS отива надълбоко, BFS — наширoко.

Лабиринт — BFS върху решетка

Най-нагледното приложение на BFS: намери най-краткия път през лабиринт. Решетката е граф — всяка свободна клетка е връх, а съседните свободни клетки (горе/долу/ляво/дясно) са свързани с ребро.

SE
Фиг. 2 — Лабиринт: S = старт, E = край, тъмните клетки са стени. BFS намира най-малкия брой стъпки.

Не строим граф изрично — ползваме координатите (ред, колона) направо. Съседите се намират чрез четирите посоки:

static int MazeShortestPath(string[] maze)
{
    int rows = maze.Length, cols = maze[0].Length;
    // намери S и E
    (int r, int c) start = (0, 0), end = (0, 0);
    for (int r = 0; r < rows; r++)
        for (int c = 0; c < cols; c++)
        {
            if (maze[r][c] == 'S') start = (r, c);
            if (maze[r][c] == 'E') end = (r, c);
        }

    var queue = new Queue<(int r, int c, int dist)>();
    var visited = new HashSet<(int, int)> { start };
    queue.Enqueue((start.r, start.c, 0));

    int[] dr = { -1, 1, 0, 0 };   // горе, долу
    int[] dc = { 0, 0, -1, 1 };   // ляво, дясно

    while (queue.Count > 0)
    {
        var (r, c, dist) = queue.Dequeue();
        if ((r, c) == end) return dist;

        for (int k = 0; k < 4; k++)
        {
            int nr = r + dr[k], nc = c + dc[k];
            // в границите? не е стена? непосетен?
            if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
            if (maze[nr][nc] == '#') continue;
            if (visited.Contains((nr, nc))) continue;

            visited.Add((nr, nc));
            queue.Enqueue((nr, nc, dist + 1));
        }
    }
    return -1;   // няма път
}

Ключово

Това е същият BFS — само че „съседите" се изчисляват от четирите посоки вместо от списък. Шаблонът Queue + visited е винаги един и същ.

Откриване на цикъл с DFS

Граф има цикъл, ако по време на DFS стигнем до връх, който вече е посетен — и той не е родителят, от който току-що дойдохме.

static bool HasCycle(
    Dictionary<int, HashSet<int>> graph,
    int v, int parent,
    HashSet<int> visited)
{
    visited.Add(v);
    foreach (var n in graph[v])
    {
        if (!visited.Contains(n))
        {
            if (HasCycle(graph, n, v, visited)) return true;
        }
        else if (n != parent)
        {
            return true;   // стигнахме до посетен връх, който не е родителят → цикъл
        }
    }
    return false;
}

// За несвързан граф — проверяваме от всеки непосетен връх.

Бележка

parent ни трябва, защото в ненасочен граф реброто A—B води от A до B и обратно — без проверката за родител всяко ребро би изглеждало като цикъл.

BFS срещу DFS

BFS (опашка)DFS (стек / рекурсия)
СтруктураQueueStack или рекурсия
Ред на обхожданеПо нива — близките първоВ дълбочина — следва един път до края
Най-къс път (нетегловен)✓ Да — естествено✗ Не — може да даде по-дълъг
Откриване на цикълМоже, но по-неестествено✓ По-естествено
ПаметМоже да заеме много (цяло ниво в опашката)Малко — само текущият път
РеализацияСамо итеративно (Queue)По-кратко рекурсивно

Класическо приложение — свързани компоненти

Свързан компонент е максимална група върхове, между които има пътища. Например в граф „приятелства" — различни групи ученици, които не се познават помежду си.

Алгоритъм: за всеки непосетен връх → стартирай BFS/DFS от него → всичко, до което стигне = един компонент → повтори.

static List<List<int>> ConnectedComponents(
    Dictionary<int, HashSet<int>> graph)
{
    var components = new List<List<int>>();
    var visited = new HashSet<int>();

    foreach (var start in graph.Keys)
    {
        if (visited.Contains(start)) continue;

        // BFS от start, събираме всичко в текущия компонент
        var component = new List<int>();
        var queue = new Queue<int>();
        queue.Enqueue(start);
        visited.Add(start);
        while (queue.Count > 0)
        {
            int v = queue.Dequeue();
            component.Add(v);
            foreach (var n in graph[v])
                if (!visited.Contains(n))
                {
                    visited.Add(n);
                    queue.Enqueue(n);
                }
        }
        components.Add(component);
    }
    return components;
}

Често срещани грешки

  • Без visited — алгоритъмът цикли безкрайно при цикличен граф. Винаги имай HashSet<int> visited.
  • Маркиране в грешния момент — при BFS маркирай върха когато го добавиш в опашката, не когато го извадиш. Иначе може да го сложиш няколко пъти и да го броиш многократно.
  • BFS за най-къс път, но в тегловен граф — не работи! BFS брои стъпки, не теглá. За тегловен граф трябва Dijkstra (по-сложен алгоритъм).
  • Дълбока рекурсивна DFS на голям граф — рискуваш StackOverflowException. За > 10 000 върха ползвай итеративен вариант със собствен Stack.

Какво трябва да запомниш

  • BFS = ширина = Queue = намира най-къс път (по брой стъпки).
  • DFS = дълбочина = Stack или рекурсия = удобно за откриване на цикли и компоненти.
  • И двата алгоритъма са почти едни и същи — единствената разлика е дали ползваш Queue или Stack.
  • Винаги пази HashSet<int> visited.
  • Свързани компоненти = повтаряй BFS/DFS от всеки непосетен връх.