2. DFS и BFS на матрицах
«Широта охватывает горизонт слой за слоем, глубина же устремляется к цели, не оглядываясь назад».
— Брайан Керниган
В 1. Теория. Grid задачи мы установили, что любая координатная сетка $R \times C$ — это неявный граф. Но когда дело доходит до решения конкретной задачи, перед инженером встает фундаментальная дилемма: DFS (поиск в глубину) или BFS (поиск в ширину)?
Этот выбор определяет не просто структуру кода, а физическое поведение программы в оперативной памяти:
- Сколько килобайт выделится на стеке горутины?
- Сколько раз сработает сборщик мусора (GC)?
- Выдержит ли алгоритм сетку $10\,000 \times 10\,000$ или рухнет с
panic: runtime error: stack overflow?
1. Сравнительная анатомия двух стратегий
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef bfs fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef dfs fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc
Start["Сетка R x C"]:::entry --> Choice{"Цель поиска"}
Choice -- "Кратчайший путь / Минимальное время" --> BFS["BFS (Очередь FIFO)<br>Волна расширяется концентрическими кругами<br>Память: O(min(R, C))"]:::bfs
Choice -- "Связные компоненты / Заливка (Flood Fill)" --> DFS["DFS (Стек LIFO / Рекурсия)<br>Пробивает путь до упора<br>Память: O(R * C) в худшем случае"]:::dfs
BFS --> Done["Гарантия кратчайшего пути"]:::success
DFS --> Done
| Параметр | Поиск в ширину (BFS) | Поиск в глубину (DFS) |
|---|---|---|
| Структура данных | Очередь (FIFO: First-In-First-Out) | Стек (LIFO: Last-In-First-Out) или рекурсия |
| Оптимальность пути | Гарантирует кратчайший путь в невзвешенной сетке | Не гарантирует кратчайший путь (находит любой) |
| Потребление памяти | Пропорционально периметру фронта волны: $\mathcal{O}(\min(R, C))$ | Пропорционально максимальной глубине пути: до $\mathcal{O}(R \cdot C)$ |
| Поведение кэша CPU | Высокая пространственная локальность (соседи рядом) | Прыжки по памяти при глубоком погружении |
| Угроза для Go | Утечка базового массива при наивном queue[1:] |
Переполнение или копирование стека горутины |
2. Поиск в глубину (DFS): Рекурсивный vs Итеративный
1. Рекурсивный DFS (Идиоматичный и лаконичный)
Подходит для большинства задач среднего масштаба (до $1000 \times 1000$ ячеек):
package main
var dirs = [4][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}}
// DFSRecursive обходит связную компоненту и заливает ее нулями (In-Place)
func DFSRecursive(grid [][]byte, r, c int) {
rows, cols := len(grid), len(grid[0])
// Базовый случай выхода: границы сетки или вода
if r < 0 || r >= rows || c < 0 || c >= cols || grid[r][c] == '0' {
return
}
// Маркируем клетку как посещенную (затопляем)
grid[r][c] = '0'
for _, d := range dirs {
DFSRecursive(grid, r+d[0], c+d[1])
}
}
2. Итеративный DFS на явном срезе-стеке (Zero Stack Overflow)
Для экстремально глубоких матриц рекурсию заменяют явным срезом-стеком в куче:
func DFSIterative(grid [][]byte, startR, startC int) {
rows, cols := len(grid), len(grid[0])
// Предвыделяем емкость стека
stack := make([][2]int, 0, 1024)
stack = append(stack, [2]int{startR, startC})
grid[startR][startC] = '0'
for len(stack) > 0 {
// Pop с вершины стека
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
r, c := top[0], top[1]
for _, d := range dirs {
nr, nc := r+d[0], c+d[1]
if nr >= 0 && nr < rows && nc >= 0 && nc < cols && grid[nr][nc] == '1' {
grid[nr][nc] = '0' // Маркируем сразу при обнаружении!
stack = append(stack, [2]int{nr, nc})
}
}
}
}
3. Поиск в ширину (BFS): Послойный волновой фронт
BFS — единственный корректный выбор, когда требуется найти наименьшее число шагов от точки $A$ до точки $B$.
package main
// Point представляет координаты и накопленное расстояние
type Point struct {
r, c, dist int
}
// ShortestPathBFS находит минимальное расстояние от старта до цели
func ShortestPathBFS(grid [][]byte, start, target [2]int) int {
rows, cols := len(grid), len(grid[0])
if grid[start[0]][start[1]] == '0' || grid[target[0]][target[1]] == '0' {
return -1
}
// Предвыделяем срез под очередь для исключения реаллокаций
queue := make([]Point, 0, rows*cols)
queue = append(queue, Point{r: start[0], c: start[1], dist: 0})
grid[start[0]][start[1]] = '0' // КРИТИЧНО: маркировка при PUSH!
head := 0 // Указатель на начало очереди (O(1) pop без утечек)
for head < len(queue) {
curr := queue[head]
head++
// Если достигли цели — кратчайший путь найден!
if curr.r == target[0] && curr.c == target[1] {
return curr.dist
}
for _, d := range dirs {
nr, nc := curr.r+d[0], curr.c+d[1]
if nr >= 0 && nr < rows && nc >= 0 && nc < cols && grid[nr][nc] == '1' {
grid[nr][nc] = '0' // Немедленно маркируем, предотвращая повторное добавление
queue = append(queue, Point{r: nr, c: nc, dist: curr.dist + 1})
}
}
}
return -1 // Путь заблокирован препятствиями
}
Почему указатель head побеждает queue = queue[1:]?
Операция queue = queue[1:] сдвигает указатель заголовка среза, но не освобождает память базового массива до тех пор, пока сборщик мусора не очистит весь срез целиком. Использование простого целочисленного индекса head сохраняет базовый массив непрерывным и избавляет процессор от лишних модификаций slice header.
4. Mechanical Sympathy: Локальность памяти и GC Overhead
- Запрет на
container/list:
Стандартный связный списокcontainer/listв Go аллоцирует структуру узла в куче на каждый добавленный элемент. При матрице $1000 \times 1000$ это создаст $10^6$ аллокаций. Слайс-очередь с плоской структуройPointработает в едином непрерывном буфере, ускоряя обход в 7–12 раз! - Векторы направлений в L1:
Массив[4][2]intзанимает ровно 64 байта и постоянно удерживается в L1-кэше ядра процессора. - Branch Predictor:
Условия проверки границnr >= 0 && nr < rowsдля внутренних клеток матрицы выполняются со 100% предсказуемостью.
5. Вопросы на собеседовании
6. Резюме
- Кратчайший путь $\to$ только BFS: Никогда не используйте DFS для поиска кратчайшего пути в невзвешенных сетках.
- Маркировка при PUSH: Помечайте ячейки как посещенные до помещения в очередь, чтобы исключить дублирование и взрыв памяти.
- Очередь на указателе
head: Избегайте аллокаций иcontainer/list, используя плоский срез с целочисленным смещениемhead. - Рекурсия vs Итерация: Для матриц размером $> 1000 \times 1000$ применяйте итеративный стек, спасая горутину от
stack copying.
В следующей лекции мы разберем классическую матричную задачу на пространственную оптимизацию, где строки и столбцы обнуляются с нулевым расходом дополнительной памяти: 3. Set Matrix Zeroes.