Защо ни трябва обхождане
Имайки граф, често искаме да отговорим на въпроси като:
- До кои върхове мога да стигна, ако тръгна от връх X?
- Кой е най-късият път (брой стъпки) от A до B?
- Колко „групи приятели" има (свързани компоненти)?
- Има ли цикъл в графа?
Всички тези въпроси се решават с обхождане на графа — систематично посещение на върховете. Има два класически алгоритъма: BFS и DFS.
Шаблонът visited
И при двата алгоритъма ни трябва множество visited, в което пазим вече посетените върхове. Без него ще обикаляме безкрайно в кръг (при графи с цикли).
var visited = new HashSet<int>();
// преди да посетим връх:
if (visited.Contains(v)) continue;
visited.Add(v);
// ... обработваме vBFS — Breadth-First Search
BFS (обхождане в ширина) посещава върховете „пласт по пласт" — първо стартовия връх, после всичките му съседи, после техните съседи и т.н. Идеално за намиране на най-къс път в нетегловен граф (по брой стъпки).
Реализира се с опашка (Queue) — точно затова в предишния урок казахме „опашката е важна за обхождане в ширина".
Алгоритъм стъпка по стъпка
- Сложи стартовия връх в опашка. Маркирай го като посетен.
- Докато опашката не е празна:
- а. Изтегли първия връх от опашката (Dequeue).
- б. Обработи го (отпечатай, преброй и т.н.).
- в. За всеки негов непосетен съсед — маркирай и сложи в опашката.
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} |
| 1 | 1 | 2, 5 | [2, 5] | {1, 2, 5} |
| 2 | 2 | 3 | [5, 3] | {1, 2, 5, 3} |
| 3 | 5 | 4 | [3, 4] | {1, 2, 5, 3, 4} |
| 4 | 3 | — | [4] | (без промяна) |
| 5 | 4 | — | [ ] | (без промяна) |
Ред на обхождане: 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 за същия граф. „⮑" показва навлизане по-дълбоко, „⮐" — връщане назад:
| Действие | Връх | Ред дотук |
|---|---|---|
| ⮑ влез | 1 | 1 |
| ⮑ влез | 2 | 1 2 |
| ⮑ влез | 3 | 1 2 3 |
| ⮑ влез | 4 | 1 2 3 4 |
| ⮑ влез | 5 | 1 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: намери най-краткия път през лабиринт. Решетката е граф — всяка свободна клетка е връх, а съседните свободни клетки (горе/долу/ляво/дясно) са свързани с ребро.
Не строим граф изрично — ползваме координатите (ред, колона) направо. Съседите се намират чрез четирите посоки:
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 (стек / рекурсия) | |
|---|---|---|
| Структура | Queue | Stack или рекурсия |
| Ред на обхождане | По нива — близките първо | В дълбочина — следва един път до края |
| Най-къс път (нетегловен) | ✓ Да — естествено | ✗ Не — може да даде по-дълъг |
| Откриване на цикъл | Може, но по-неестествено | ✓ По-естествено |
| Памет | Може да заеме много (цяло ниво в опашката) | Малко — само текущият път |
| Реализация | Само итеративно (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 от всеки непосетен връх.