3. Поиск в глубину DFS
В статье 2. Поиск в ширину BFS мы подробно исследовали волновой алгоритм, который расходится концентрическими кругами вширь. Он идеален для нахождения кратчайших путей в невзвешенных графах. Но что делать, если перед инженером стоит иная задача: не найти самый быстрый маршрут, а исследовать всю структуру графа до самых глубин, выявить скрытые циклы, разбить систему на изолированные компоненты связности или распутать сложнейший клубок зависимостей между пакетами?
Для этих фундаментальных задач применяется Поиск в глубину (Depth-First Search, DFS).
Механика алгоритма: Исследователь лабиринта
Если BFS опирается на Очередь (FIFO), то в фундаменте DFS всегда лежит Стек (LIFO — Last In, First Out).
Механика DFS повторяет античный миф о Тесее в лабиринте Минотавра или поведение спелеолога с клубком нити:
- Вы выбираете первый попавшийся коридор и стремительно идете вперед так глубоко, как только возможно, разматывая за собой путеводную нить.
- Когда вы упираетесь в тупик (или натыкаетесь на вершину, где уже побывали), вы делаете шаг назад (Backtracking), сматывая часть нити, и пробуете свернуть в следующее неисследованное ответвление.
flowchart TD
0["Узел 0<br>★ Старт"] -->|"1. Спуск вглубь"| 1["Узел 1"]
1 -->|"2. Спуск вглубь"| 3["Узел 3"]
3 -.->|"3. Тупик! Возврат"| 1
1 -->|"4. Ветвление"| 4["Узел 4"]
4 -.->|"5. Тупик! Возврат"| 1
1 -.->|"6. Возврат"| 0
0 -->|"7. Новая ветка"| 2["Узел 2"]
classDef startNode fill:#b91c1c,stroke:#f87171,stroke-width:2px,color:#ffffff;
classDef pathNode fill:#1e3a8a,stroke:#60a5fa,stroke-width:1.5px,color:#ffffff;
classDef deadEnd fill:#334155,stroke:#94a3b8,stroke-width:1.5px,color:#ffffff;
class 0 startNode;
class 1,2,4 pathNode;
class 3 deadEnd;
Mechanical Sympathy: Стек вызовов против Кучи
Реализовать DFS можно двумя принципиально разными путями: рекурсивно и итеративно. Для разработчика на Go выбор между ними — это не вопрос личного вкуса, а строгий архитектурный компромисс между простотой кода и поведением рантайма на больших объемах данных.
1. Рекурсивный подход (Call Stack)
Самый лаконичный и выразительный способ. Роль стека выполняет собственный стек вызовов программы (Call Stack), а возврат назад (Backtracking) осуществляется естественным возвратом из функции.
2. Итеративный подход (Heap Stack)
При итеративном подходе мы создаем собственный стек на базе обычного слайса в куче (Heap), как мы делали в статье 4. Стек. Слайс размещается в непрерывном блоке оперативной памяти, обладает превосходной пространственной локальностью (Spatial Locality) и избавляет runtime Go от необходимости непрерывно растягивать системные стеки горутин.
Реализация на Go (Idiomatic & Production-Ready)
Используем представление SparseGraph (Список смежности на слайсах) из статьи 1. Представление графов.
Классический Рекурсивный DFS
package main
// SparseGraph - список смежности на слайсах.
type SparseGraph struct {
adj [][]int
}
// DFSRecursive запускает рекурсивный обход графа в глубину.
// Используем плоский массив visited для O(1) доступа и Cache Locality.
func DFSRecursive(graph *SparseGraph, start int, action func(int)) {
V := len(graph.adj)
if V == 0 || start < 0 || start >= V {
return
}
visited := make([]bool, V)
// Объявляем анонимную функцию для рекурсии (Closure)
var dfs func(node int)
dfs = func(node int) {
visited[node] = true
action(node) // Выполняем бизнес-логику
// Идем вглубь по каждому соседу
for _, neighbor := range graph.adj[node] {
if !visited[neighbor] {
dfs(neighbor)
}
}
}
dfs(start)
}
Итеративный DFS (Highload)
Этот вариант гарантированно стабилен при любой экстремальной глубине графа и не создает паразитной нагрузки на механизм перемещения стека горутин:
// DFSIterative выполняет обход в глубину без использования рекурсии.
func DFSIterative(graph *SparseGraph, start int, action func(int)) {
V := len(graph.adj)
if V == 0 || start < 0 || start >= V {
return
}
visited := make([]bool, V)
// Преаллоцируем стек (например, на 1024 элемента,
// слайс сам вырастет при необходимости через runtime.growslice)
stack := make([]int, 0, 1024)
stack = append(stack, start)
for len(stack) > 0 {
// Pop из стека (берем последний элемент)
lastIdx := len(stack) - 1
current := stack[lastIdx]
stack = stack[:lastIdx]
// В итеративном DFS узел может попасть в стек несколько раз,
// поэтому мы ПРОВЕРЯЕМ visited после извлечения.
if visited[current] {
continue
}
visited[current] = true
action(current)
// Добавляем соседей в стек.
// ВНИМАНИЕ: Чтобы обход полностью совпадал с рекурсивным (слева направо),
// соседей нужно добавлять в стек в ОБРАТНОМ порядке.
neighbors := graph.adj[current]
for i := len(neighbors) - 1; i >= 0; i-- {
neighbor := neighbors[i]
if !visited[neighbor] {
stack = append(stack, neighbor)
}
}
}
}
Паттерны с собеседований (Алгоритмические задачи)
DFS — безусловный фаворит алгоритмических секций на собеседованиях. Большинство сложных графовых задач элегантно сводятся к модификации обхода в глубину.
Паттерн 1: Подсчет компонент связности (Connected Components)
Классическая задача (LeetCode 200 - Number of Islands):
Дан граф (или двумерная матрица с землей и водой), который разбит на изолированные, не связанные друг с другом кластеры. Требуется определить их количество.
Решение:
Итерируемся по всем вершинам цикла for i := 0; i < V; i++. Если вершина i еще не помечена в массиве visited:
- Увеличиваем счетчик компонент на $1$.
- Запускаем от нее DFS.
- DFS обойдет и закрасит в
visitedвесь остров целиком. - Внешний цикл продолжится, пропуская уже посещенные вершины, и споткнется только о следующий изолированный кластер.
flowchart LR
subgraph Comp1["Компонента 1 (Остров A)"]
A1["0"] --- A2["1"]
A2 --- A3["2"]
end
subgraph Comp2["Компонента 2 (Остров B)"]
B1["3"] --- B2["4"]
end
subgraph Comp3["Компонента 3 (Изолированный сервер)"]
C1["5"]
end
classDef default fill:#131d33,stroke:#38bdf8,stroke-width:1.5px,color:#f8fafc;
Паттерн 2: Поиск циклов в ориентированном графе (Three-Color Marking)
Инженерная задача:
Как убедиться, что граф микросервисных вызовов или граф пакетов в проекте на Go не содержит циклических зависимостей (Import Cycle)?
Для ориентированных графов простого двоичного флага visited (были / не были) недостаточно. Нам необходима раскраска в три цвета (состояния):
- 0 (Белый / White): Узел еще не обнаружен.
- 1 (Серый / Gray): Мы вошли в узел, он прямо сейчас находится в активной цепочке вызовов (исследуются его потомки).
- 2 (Черный / Black): Мы полностью закончили обход узла и всех его поддеревьев, узел снят со стека.
Если в процессе спуска DFS мы пытаемся шагнуть в вершину, которая уже помечена Серым цветом (Gray) — обнаружено Обратное ребро (Back edge), ведущее к нашему собственному предку. Это математически строго доказывает наличие цикла!
flowchart TD
W0["0 (Черный)"] --> G1["1 (Серый)"]
G1 --> G2["2 (Серый)"]
G2 --> G3["3 (Серый)"]
G3 -.->|"★ Back Edge (Цикл!)"| G1
classDef grayNode fill:#d97706,stroke:#fbbf24,stroke-width:2px,color:#ffffff;
classDef blackNode fill:#1e293b,stroke:#64748b,stroke-width:1px,color:#94a3b8;
class W0 blackNode;
class G1,G2,G3 grayNode;
// HasCycle проверяет наличие циклов в ориентированном графе через трехцветную раскраску.
func HasCycle(graph *SparseGraph) bool {
V := len(graph.adj)
colors := make([]uint8, V) // 0 - White, 1 - Gray, 2 - Black
var dfs func(node int) bool
dfs = func(node int) bool {
colors[node] = 1 // Помечаем как "в процессе" (Gray)
for _, neighbor := range graph.adj[node] {
if colors[neighbor] == 1 {
// Встретили Gray узел - нашли цикл!
return true
}
if colors[neighbor] == 0 {
if dfs(neighbor) {
return true
}
}
}
colors[node] = 2 // Полностью обработали (Black)
return false
}
// Проходим по всем узлам (на случай, если граф не связный)
for i := 0; i < V; i++ {
if colors[i] == 0 {
if dfs(i) {
return true
}
}
}
return false
}
Временная и пространственная сложность
- Временная сложность: $O(V + E)$ — алгоритм посетит каждую вершину ровно один раз и рассмотрит каждое исходящее ребро ровно один раз.
- Пространственная сложность: $O(V)$ — массив посещенных узлов и Стек вызовов (или явный слайс-буфер). В худшем случае (вырожденный граф-цепочка) глубина стека достигнет $V$.
Итог
- DFS (Поиск в глубину) — идеален для исследования топологии графа: поиска всех путей, циклов и компонент связности.
- В основе DFS лежит Стек (LIFO).
- В Go рекурсивный DFS прост в написании, но опасен для CPU при огромной глубине из-за механизма динамического расширения стека горутин (
runtime.morestack). - Метод "Трех цветов" (White/Gray/Black) — индустриальный стандарт для обнаружения циклических зависимостей (Import Cycles) в направленных графах.
Если DFS позволяет нам находить циклы, то что делать с ориентированным графом, в котором циклов гарантированно нет (DAG — Directed Acyclic Graph)? Такие графы используются для описания пайплайнов (CI/CD), систем сборки (Make/Task) и зависимостей модулей. И чтобы выполнить все задачи в правильном порядке, нам понадобится алгоритм, который базируется на самом DFS. В следующей статье мы разберем: 4. Топологическая сортировка.