6. Rotting Oranges
«Распределенная система — это система, в которой сбой узла, о самом существовании которого вы даже не догадывались, способен вывести из строя вашу собственную программу».
— Лесли Лэмпорт
LeetCode 994. Rotting Oranges. Дана двумерная сетка grid размером $M \times N$, в которой:
0обозначает пустую ячейку,1— свежий апельсин,2— гнилой апельсин.
Каждую минуту любой гнилой апельсин заражает всех своих 4-связных соседей (вверх, вниз, влево, вправо), превращая их в гнилые. Требуется определить минимальное количество минут, необходимое для того, чтобы в сетке не осталось ни одного свежего апельсина. Если хотя бы один свежий апельсин невозможно заразить (он изолирован пустыми ячейками), вернуть -1.
Эта задача — канонический представитель класса задач на мультиисточниковый поиск в ширину (Multi-Source BFS). В отличие от стандартного BFS, стартующего из одной фиксированной вершины, здесь процесс диффузии одновременно начинается из множества очагов заражения. Для бэкенд-архитектора это точнейшая математическая модель работы протоколов распространения сплетен (Gossip Protocols в HashiCorp Consul, Apache Cassandra и Amazon DynamoDB) и каскадной инвалидации кэшей в распределённых сетях CDN.
1. Архитектура Multi-Source BFS: Единое время для всех очагов
Главный ментальный барьер — как корректно синхронизировать время, если источников инфекции несколько?
Решение поражает своей элегантностью: все начальные источники заражения помещаются в единую очередь на нулевом уровне ($t = 0$).
Минута 0: Очередь: [ Очаг1, Очаг2 ] ──► levelSize = 2, заражают соседей
Минута 1: Очередь: [ Нов1, Нов2, Нов3 ] ──► levelSize = 3, заражают следующих...
Минута 2: Очередь: [ Нов4, Нов5 ] ──► levelSize = 2...
Волновой фронт расширяется параллельно из всех гнилых апельсинов с одинаковой скоростью — ровно 1 шаг за 1 минуту. Таким образом, минимальное время заражения каждого свежего апельсина равно его кратчайшему расстоянию до ближайшего очага инфекции.
2. Вопросы интервьюеру перед реализацией
- Что делать, если в сетке изначально нет ни одного свежего апельсина?
Ответ: Вернуть0, так как времени на заражение не требуется. - Может ли сетка не содержать ни одного гнилого апельсина, но содержать свежие?
Ответ: Да. В этом случае свежие апельсины никогда не сгниют — алгоритм должен вернуть-1. - Разрешена ли модификация массива
grid(in-place)?
Ответ: Да. Замена1на2прямо в сетке исключает необходимость аллокации дополнительной матрицы или хеш-таблицыvisited.
3. Идиоматическая реализация на Go: Zero-Alloc упаковка координат
Для максимальной производительности в Go мы упаковываем двумерные координаты (r, c) в одно плоское целое число: pos = r * n + c. Это позволяет использовать в качестве очереди непрерывный срез []int, обеспечивая абсолютную кэш-локальность и нулевую нагрузку на Garbage Collector.
package main
// orangesRotting вычисляет время полного заражения сетки с помощью Multi-Source BFS.
// Временная сложность: O(M * N) — каждая ячейка сетки просматривается не более константного числа раз.
// Пространственная сложность: O(M * N) — максимальный размер плоской очереди в худшем случае.
func orangesRotting(grid [][]int) int {
m, n := len(grid), len(grid[0])
queue := make([]int, 0, m*n)
head := 0
freshCount := 0
// 1. Предварительное сканирование сетки
for r := 0; r < m; r++ {
for c := 0; c < n; c++ {
switch grid[r][c] {
case 2:
// Упаковываем двумерную координату в одно число (r * n + c)
queue = append(queue, r*n+c)
case 1:
freshCount++
}
}
}
// Если свежих апельсинов изначально нет, время равно 0
if freshCount == 0 {
return 0
}
minutes := 0
// Смещение по 4 направлениям (вверх, вниз, влево, вправо)
dirs := [4][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}}
// 2. Multi-Source BFS по уровням
for head < len(queue) {
levelSize := len(queue) - head
infectedThisMinute := false
for i := 0; i < levelSize; i++ {
pos := queue[head]
head++
// Распаковка плоского индекса обратно в координаты (r, c)
r := pos / n
c := pos % n
for _, d := range dirs {
nr, nc := r+d[0], c+d[1]
// Проверка границ и наличия свежего апельсина
if nr >= 0 && nr < m && nc >= 0 && nc < n && grid[nr][nc] == 1 {
grid[nr][nc] = 2 // Заражаем на месте!
freshCount--
infectedThisMinute = true
queue = append(queue, nr*n+nc)
}
}
}
// Увеличиваем счетчик минут ТОЛЬКО если на этом шаге был заражен хотя бы один апельсин!
if infectedThisMinute {
minutes++
}
}
// Если остались недостижимые свежие апельсины — возвращаем -1
if freshCount > 0 {
return -1
}
return minutes
}
4. Визуализация волновой диффузии
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Start["Сканирование сетки: сбор всех '2' в queue"]:::entry --> CheckFresh{"freshCount == 0?"}:::warning
CheckFresh -- "Да: свежих нет" --> ReturnZero["return 0"]:::success
CheckFresh -- "Нет" --> OuterLoop{"head < len(queue)?"}:::warning
OuterLoop -- "Да: шаг времени" --> Snapshot["levelSize = len(queue) - head<br>infectedThisMinute = false"]:::accent
Snapshot --> ProcessLevel["Обработка ровно levelSize гнилых апельсинов"]:::process
ProcessLevel --> InfectNeighbours["Заразить свежих соседей:<br>grid[nr][nc] = 2<br>freshCount--<br>queue.append()"]:::process
InfectNeighbours --> CheckInfected{"infectedThisMinute == true?"}:::warning
CheckInfected -- "Да" --> IncMinutes["minutes++"]:::accent
CheckInfected -- "Нет" --> OuterLoop
IncMinutes --> OuterLoop
OuterLoop -- "Нет: очередь пуста" --> FinalCheck{"freshCount > 0?"}:::warning
FinalCheck -- "Да: остались изолированные" --> ReturnFail["return -1"]:::error
FinalCheck -- "Нет: все сгнили" --> ReturnMinutes["return minutes"]:::success
5. Механическая симпатия: Память, Кэш-линии и Off-by-One
- Плоская координата против структур:
Если создавать очередь структурtype Point struct { r, c int }, каждый элемент займет 16 байт. Упаковка в плоскийintзанимает 8 байт (на amd64). В одну 64-байтную кэш-линию L1 процессора помещается ровно 8 координат, что в 2 раза увеличивает плотность кэша и снижает количество Cache Miss. - Борьба с Off-by-One ошибкой таймера:
Классическая ошибка — прибавлятьminutes++на каждом обороте внешнего цикла. На последнем шаге гнилые апельсины извлекаются из очереди, проверяют своих соседей (которые уже либо гнилые, либо пустые) и никого не заражают. Без флагаinfectedThisMinuteтаймер ошибочно накрутит лишнюю минуту. - In-place модификация против
visited:
Перезаписьgrid[nr][nc] = 2выполняется в уже существующем срезе. Она не требует ни единой аллокации в куче и выполняется процессором за 1 такт через обычную инструкцию записиMOVQ.
6. System Design: Gossip Protocols и Epidemic Replication
Мультиисточниковый BFS — это фундаментальный алгоритм распределённых систем:
- Cassandra & DynamoDB: Узлы кластера не имеют центрального мастера. При изменении топологии узел объявляет статус своим $K$ соседям. Каждую секунду каждый зараженный узел передает сплетню дальше. Через $O(\log N)$ раундов весь кластер приходит к консенсусу (Eventual Consistency).
- Consul (Serf): Мониторинг жизнеспособности серверов через gossip-пинг. Отказ ноды распространяется по кластеру волновым фронтом точно так же, как гнилой апельсин заражает ящик.
Резюме для интервью
| Параметр | Значение |
|---|---|
| Алгоритм | Multi-Source Level-Order BFS |
| Сложность по времени | $O(M \cdot N)$ |
| Сложность по памяти | $O(M \cdot N)$ на очередь |
| Ключевой инвариант | Все очаги помещаются в очередь в момент времени $0$ |
| Главная ловушка | Не накручивать лишнюю минуту на последнем пустом шаге |
В следующей задаче — 7. Open the Lock — мы применим волновой поиск к четырехзначному кодовому замку с запрещенными комбинациями.