7. Number of Islands
«Вся сложность анализа больших структур часто сводится к тому, чтобы аккуратно и вовремя пометить уже пройденное».
— Брайан Керниган
Введение: Выделение компонент связности в распределенных сетках
Задача Number of Islands (LeetCode 200) — абсолютная классика алгоритмических интервью, проверяющая знание алгоритмов обхода графов и умение находить компоненты связности в неявных сетках (Implicit Grids).
Условие задачи: дана двумерная бинарная матрица grid размера $M \times N$, где '1' обозначает участок суши, а '0' — воду. Остров образуется смежными по вертикали или горизонтали единицами. Требуется подсчитать суммарное количество островов.
Исходная сетка (4x5):
[ '1', '1', '0', '0', '0' ] ──► Остров 1
[ '1', '1', '0', '0', '0' ]
[ '0', '0', '1', '0', '0' ] ──► Остров 2
[ '0', '0', '0', '1', '1' ] ──► Остров 3
Количество островов: 3
В реальной бэкенд-инженерии поиск компонент связности на матрицах и графах лежит в основе ключевых подсистем:
- Мониторинг распределенных кластеров: Обнаружение групп взаимно изолированных нод при сплит-брейне (Network Partition / Split-Brain Detection).
- Сегментация геопространственных данных: Объединение полигонов и тайлов в картографических движках (GIS).
- Анализ разреженных графов: Выявление изолированных сообществ в социальных графах и антифрод-системах.
- Сжатие битовых масок и изображений: Поиск связных областей для алгоритмов RLE (Run-Length Encoding) и фильтрации шума.
На собеседованиях уровня Middle+/Senior эта задача служит отличной отправной точкой для глубокой дискуссии: почему DFS может вызвать Stack Overflow, как устроен кольцевой буфер для BFS без утечек памяти и когда структура непересекающихся множеств (DSU) превосходит классические обходы.
Архитектурный выбор: Сравнение трех фундаментальных стратегий
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef warn fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
Grid["Входная сетка M x N"]:::entry --> Scan["Линейный обход ячеек:<br/>for r .. rows; for c .. cols"]:::process
Scan --> Check{"grid[r][c] == '1'?"}
Check -- "Нет ('0')" --> Next["Переход к следующей ячейке"]:::process
Next --> Scan
Check -- "Да ('1')" --> Inc["islands++<br/>Запуск 'затопления'"]:::accent
Inc --> Strategy{"Выбор стратегии"}:::warn
Strategy --> DFS["1. Рекурсивный DFS<br/>Затопление in-place '1' -> '0'<br/>0 аллокаций, стек горутины"]:::process
Strategy --> BFS["2. Итеративный BFS<br/>Очередь ячеек (FIFO)<br/>Стабильная память волны"]:::process
Strategy --> DSU["3. Disjoint Set Union<br/>Иерархия parent/rank<br/>Идеально для потоковых данных"]:::process
DFS --> Sunk["Остров полностью затоплен"]:::success
BFS --> Sunk
DSU --> Sunk
Sunk --> Next
| Стратегия | Время | Дополнительная память | Плюсы | Минусы / Риски |
|---|---|---|---|---|
| 1. DFS (In-place sinking) | $O(M \cdot N)$ | $O(M \cdot N)$ стек | Минимальный объем кода, нулевые аллокации в куче (0 B/op). |
Риск stack overflow или дорогого stack copying на длинных узких змейках. |
| 2. BFS (Queue) | $O(M \cdot N)$ | $O(\min(M, N))$ очередь | Память ограничена длиной фронта волны. Нет глубокой рекурсии. | Требует ручной реализации эффективной очереди (на базе среза или кольцевого буфера). |
| 3. DSU (Union-Find) | $O(M \cdot N \cdot \alpha(V))$ | $O(M \cdot N)$ | Идеален для динамических сценариев: острова добавляются в реальном времени. | Оверхед на предвыделение массивов parent и rank. |
1. Рекурсивный DFS с In-Place затоплением (Канонический подход)
Самый быстрый и лаконичный способ — техника «затопления» (Flood Fill / Sinking). При обнаружении '1' мы увеличиваем счетчик островов и рекурсивно превращаем все смежные '1' в '0', исключая необходимость вспомогательной матрицы visited.
package islands
// NumIslands подсчитывает количество островов методом DFS in-place.
// Временная сложность: O(M * N)
// Пространственная сложность: O(M * N) в худшем случае для стека рекурсии
func NumIslands(grid [][]byte) int {
if len(grid) == 0 || len(grid[0]) == 0 {
return 0
}
rows, cols := len(grid), len(grid[0])
islandsCount := 0
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
if grid[r][c] == '1' {
islandsCount++
// Затапливаем текущий остров целиком
sinkDFS(grid, r, c, rows, cols)
}
}
}
return islandsCount
}
// sinkDFS рекурсивно обходит смежные клетки и обращает '1' в '0'.
func sinkDFS(grid [][]byte, r, c, rows, cols int) {
// Базовые условия останова: выход за границы или клетка является водой/уже затоплена
if r < 0 || r >= rows || c < 0 || c >= cols || grid[r][c] == '0' {
return
}
// Затапливаем ячейку
grid[r][c] = '0'
// Рекурсивно обходим 4 соседних направления
sinkDFS(grid, r-1, c, rows, cols) // Вверх
sinkDFS(grid, r+1, c, rows, cols) // Вниз
sinkDFS(grid, r, c-1, rows, cols) // Влево
sinkDFS(grid, r, c+1, rows, cols) // Вправо
}
2. Итеративный BFS: Защита от переполнения стека и ловушка очереди
Если в продакшене матрица имеет размер $5000 \times 5000$, змеевидный остров из 1 000 000 ячеек приведет к тысячам раундов runtime.morestack в Go. В таких условиях рекурсия недопустима, и применяется итеративный BFS.
Оптимизация памяти очереди в Go: Упаковка координат
Вместо создания структуры type Point struct { r, c int }, которая требует 16 байт, мы можем упаковать координаты (r, c) в одно 32-битное беззнаковое целое: uint32(r)<<16 | uint32(c).
Для матрицы $1000 \times 1000$ координаты гарантированно укладываются в 16 бит. Это снижает потребление памяти очередью в 2 раза и улучшает кэш-локальность.
// NumIslandsBFS решает задачу итеративно через BFS с компактной очередью.
func NumIslandsBFS(grid [][]byte) int {
if len(grid) == 0 || len(grid[0]) == 0 {
return 0
}
rows, cols := len(grid), len(grid[0])
islands := 0
// Очередь с плоской упаковкой координат: (r << 16) | c
queue := make([]uint32, 0, 1024)
// Дельта-массивы смещений для 4 направлений
dr := [4]int{-1, 1, 0, 0}
dc := [4]int{0, 0, -1, 1}
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
if grid[r][c] == '1' {
islands++
// Mark-on-Push: немедленно затапливаем стартовую ячейку
grid[r][c] = '0'
queue = append(queue[:0], uint32(r)<<16|uint32(c))
head := 0
for head < len(queue) {
curr := queue[head]
head++
cr := int(curr >> 16)
cc := int(curr & 0xFFFF)
for i := 0; i < 4; i++ {
nr, nc := cr+dr[i], cc+dc[i]
if nr >= 0 && nr < rows && nc >= 0 && nc < cols && grid[nr][nc] == '1' {
// СТРОГО: Mark-on-Push! Зануляем ДО добавления в очередь
grid[nr][nc] = '0'
queue = append(queue, uint32(nr)<<16|uint32(nc))
}
}
}
}
}
}
return islands
}
3. Disjoint Set Union (DSU) для динамических нагрузок
В распределенных геоинформационных сервисах или онлайн-играх карта не статична: пользователи ставят блоки, дамбы прорываются, острова объединяются. Статический DFS/BFS требует полного пересчета за $O(M \cdot N)$ при каждом изменении.
Структура Disjoint Set Union с эвристиками сжатия путей (Path Compression) и объединения по рангу (Union by Rank) выполняет добавление суши и слияние островов за почти константное время: $O(\alpha(N))$ (где $\alpha$ — обратная функция Аккермана, $\alpha \le 4$ для любых мыслимых масштабов).
type DSU struct {
parent []int
rank []int
count int // Количество независимых компонент суши
}
func NewDSU(grid [][]byte) *DSU {
rows, cols := len(grid), len(grid[0])
total := rows * cols
parent := make([]int, total)
rank := make([]int, total)
count := 0
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
if grid[r][c] == '1' {
id := r*cols + c
parent[id] = id
count++
}
}
}
return &DSU{parent: parent, rank: rank, count: count}
}
func (d *DSU) Find(i int) int {
// Сжатие пути (Path Compression)
if d.parent[i] != i {
d.parent[i] = d.Find(d.parent[i])
}
return d.parent[i]
}
func (d *DSU) Union(i, j int) {
rootI := d.Find(i)
rootJ := d.Find(j)
if rootI == rootJ {
return
}
// Объединение по рангу (Union by Rank)
if d.rank[rootI] < d.rank[rootJ] {
d.parent[rootI] = rootJ
} else if d.rank[rootI] > d.rank[rootJ] {
d.parent[rootJ] = rootI
} else {
d.parent[rootJ] = rootI
d.rank[rootI]++
}
d.count-- // Два острова слились в один
}
Механика рантайма: Кэш, память и многопоточность
1. Pointer Chasing при обходе [][]byte
Тип [][]byte — это массив указателей на строки.
При обходе DFS шаги r-1 и r+1 переходят по разным строкам, вызывая скачки по несвязанным адресам памяти в куче.
Для сеток масштаба $10000 \times 10000$ это приводит к потере до 40% тактов процессора на ожидание данных из DRAM. В критичных к производительности системах сетку хранят в плоском срезе []byte размера $M \cdot N$, преобразуя координаты формулой: idx = r * cols + c.
2. Мутабельность контракта и Data Race
Функция NumIslands мутирует входной слайс in-place (grid[r][c] = '0').
Если сетка передается в несколько параллельных воркеров без предварительного клонирования, возникает гонка данных (Data Race), детектируемая go test -race.
Если вызывающей стороне необходимо сохранить исходные данные, перед вызовом алгоритма создают глубокую копию строк:
func cloneGrid(grid [][]byte) [][]byte {
cp := make([][]byte, len(grid))
for i := range grid {
cp[i] = make([]byte, len(grid[i]))
copy(cp[i], grid[i])
}
return cp
}
Вопросы для собеседования
Резюме
- Задача о числе островов — эталонная задача на поиск компонент связности графа в неявной сетке.
- Техника In-Place затопления (
grid[r][c] = '0') исключает необходимость вспомогательной структурыvisited, снижая потребление памяти до минимума. - Для BFS критически важен инвариант Mark-on-Push: ячейка обязана помечаться посещенной ДО попадания в очередь, иначе очередь экспоненциально раздувается.
- Для динамических систем с добавлением новых ячеек в реальном времени оптимален Disjoint Set Union (DSU).
- В распределенных системах задача масштабируется через Domain Decomposition и склеивание граничных швов.
На этом мы завершаем кластер матричных задач. В следующей главе мы перейдем к фундаментальной теории строковых алгоритмов: 1. Теория. Строковые задачи, где разберем внутреннее устройство stringStruct в рантайме Go, кодировку UTF-8, разницу между байтами и рунами, а также механику Zero-Allocation манипуляций со строками.