1. 问题背景与题目解析
今天我们要啃的是一道经典的广度优先搜索(BFS)题目——LeetCode 994题"腐烂的橘子"。这道题在2023年Google的面试题库中出现频率排名前20%,也是国内大厂笔试的常客。题目描述看似简单,但想要写出最优解需要掌握几个关键技巧。
题目场景是这样的:在一个网格表示的橘园中,每个格子可能包含以下三种情况:
- 0 代表空单元格
- 1 代表新鲜橘子
- 2 代表腐烂橘子
每分钟,任何与腐烂橘子相邻(上下左右)的新鲜橘子都会腐烂。我们需要计算直到没有新鲜橘子被腐烂为止需要经过的最少分钟数。如果不可能使所有橘子腐烂,则返回-1。
举个例子:
输入:[[2,1,1],[1,1,0],[0,1,1]]
输出:4
这个网格的腐烂过程如下:
分钟0:
[2,1,1]
[1,1,0]
[0,1,1]
分钟1:
[2,2,1]
[2,1,0]
[0,1,1]
分钟2:
[2,2,2]
[2,1,0]
[0,1,1]
分钟3:
[2,2,2]
[2,2,0]
[0,1,1]
分钟4:
[2,2,2]
[2,2,0]
[0,2,1]
此时已经没有新鲜橘子能被腐烂了,但还有一个橘子没被腐烂,所以返回-1吗?不,实际上最后一个橘子无法被腐烂(因为被0隔开),所以正确输出是4分钟(虽然最后还剩一个新鲜橘子)。
2. 解题思路与算法选择
这道题本质上是一个多源点广度优先搜索(Multi-source BFS)问题。为什么选择BFS而不是DFS?因为BFS天然适合计算最短路径或最小时间的问题——它总是先处理距离源点最近的节点。
我们需要考虑以下几个关键点:
- 多源点处理:初始时所有腐烂橘子(值为2的格子)都应该作为BFS的起点
- 时间记录:需要记录每个橘子被腐烂的时间
- 新鲜橘子计数:需要知道初始有多少新鲜橘子,最后还剩多少
具体算法步骤:
- 遍历整个网格,收集所有腐烂橘子的位置(队列初始化),同时统计新鲜橘子数量
- 如果没有新鲜橘子,直接返回0
- 进行多源BFS,每次处理当前队列中的所有节点(同一分钟被腐烂的橘子)
- 对于每个腐烂橘子,检查其四个方向的邻居,如果是新鲜橘子就将其腐烂并加入队列
- 每次处理完一批节点(代表一分钟过去),时间+1
- 最后检查是否还有新鲜橘子剩余
3. JavaScript实现详解
现在让我们用JavaScript来实现这个算法。我会分步骤解释每个关键部分的代码。
3.1 基础框架与变量初始化
javascript复制function orangesRotting(grid) {
const rows = grid.length;
if (rows === 0) return -1;
const cols = grid[0].length;
// 队列存储腐烂橘子的坐标和时间
const queue = [];
let fresh = 0;
let minutes = 0;
// 第一次遍历:初始化队列和新鲜橘子计数
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
if (grid[i][j] === 2) {
queue.push([i, j, 0]); // [row, col, time]
} else if (grid[i][j] === 1) {
fresh++;
}
}
}
// 如果没有新鲜橘子,直接返回0
if (fresh === 0) return 0;
// 方向数组:上右下左
const directions = [[-1,0],[0,1],[1,0],[0,-1]];
// ... 继续实现BFS部分
}
3.2 BFS核心逻辑实现
javascript复制 // BFS处理
while (queue.length > 0) {
const [row, col, time] = queue.shift();
minutes = time; // 更新总时间
// 检查四个方向
for (const [dr, dc] of directions) {
const r = row + dr;
const c = col + dc;
// 检查边界和新鲜橘子
if (r >= 0 && r < rows && c >= 0 && c < cols && grid[r][c] === 1) {
grid[r][c] = 2; // 标记为腐烂
fresh--; // 新鲜橘子减少
queue.push([r, c, time + 1]); // 加入队列,时间+1
}
}
}
// 检查是否还有新鲜橘子剩余
return fresh === 0 ? minutes : -1;
3.3 完整代码整合
javascript复制function orangesRotting(grid) {
const rows = grid.length;
if (rows === 0) return -1;
const cols = grid[0].length;
const queue = [];
let fresh = 0;
let minutes = 0;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
if (grid[i][j] === 2) {
queue.push([i, j, 0]);
} else if (grid[i][j] === 1) {
fresh++;
}
}
}
if (fresh === 0) return 0;
const directions = [[-1,0],[0,1],[1,0],[0,-1]];
while (queue.length > 0) {
const [row, col, time] = queue.shift();
minutes = time;
for (const [dr, dc] of directions) {
const r = row + dr;
const c = col + dc;
if (r >= 0 && r < rows && c >= 0 && c < cols && grid[r][c] === 1) {
grid[r][c] = 2;
fresh--;
queue.push([r, c, time + 1]);
}
}
}
return fresh === 0 ? minutes : -1;
}
4. 复杂度分析与优化思路
4.1 时间复杂度分析
这个算法的时间复杂度是O(m×n),其中m和n分别是网格的行数和列数。这是因为:
- 初始遍历整个网格来统计新鲜橘子和初始化队列:O(m×n)
- BFS过程:最坏情况下需要访问所有格子:O(m×n)
- 所以总时间复杂度是O(m×n)
空间复杂度主要是队列的开销,最坏情况下所有格子都是腐烂橘子,所以也是O(m×n)
4.2 可能的优化方向
虽然这个解法已经是最优的,但还有几个可以优化的细节:
- 使用双指针代替shift():JavaScript数组的shift()操作是O(n)的,我们可以用两个指针来模拟队列,避免频繁移位
- 层级遍历优化:可以在每一分钟处理完所有当前层级的节点后再增加时间,而不是每个节点都存储时间
- 提前终止:当新鲜橘子数量为0时可以直接终止BFS
优化后的层级遍历版本:
javascript复制function orangesRotting(grid) {
const rows = grid.length;
if (rows === 0) return -1;
const cols = grid[0].length;
const queue = [];
let fresh = 0;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
if (grid[i][j] === 2) {
queue.push([i, j]);
} else if (grid[i][j] === 1) {
fresh++;
}
}
}
if (fresh === 0) return 0;
const directions = [[-1,0],[0,1],[1,0],[0,-1]];
let minutes = 0;
while (queue.length > 0 && fresh > 0) {
const size = queue.length;
for (let i = 0; i < size; i++) {
const [row, col] = queue.shift();
for (const [dr, dc] of directions) {
const r = row + dr;
const c = col + dc;
if (r >= 0 && r < rows && c >= 0 && c < cols && grid[r][c] === 1) {
grid[r][c] = 2;
fresh--;
queue.push([r, c]);
}
}
}
if (queue.length > 0) minutes++;
}
return fresh === 0 ? minutes : -1;
}
5. 常见错误与边界情况
在解决这个问题时,有几个常见的陷阱需要注意:
5.1 边界情况处理
- 全空网格:应该返回0(因为没有橘子需要腐烂)
- 没有新鲜橘子:直接返回0
- 无法全部腐烂:返回-1
- 只有腐烂橘子:返回0
- 只有新鲜橘子:返回-1
5.2 常见编码错误
- 忘记统计新鲜橘子数量:导致无法判断最终是否全部腐烂
- 时间计算错误:应该在处理完每一"层"腐烂橘子后再增加时间
- 方向数组遗漏:少写了某个方向导致某些橘子无法被感染
- 网格边界检查遗漏:访问数组时忘记检查边界导致越界错误
5.3 测试用例建议
建议测试以下几个关键场景:
javascript复制// 测试用例1:正常情况
console.log(orangesRotting([[2,1,1],[1,1,0],[0,1,1]])); // 4
// 测试用例2:无法全部腐烂
console.log(orangesRotting([[2,1,1],[0,1,1],[1,0,1]])); // -1
// 测试用例3:没有新鲜橘子
console.log(orangesRotting([[0,2]])); // 0
// 测试用例4:多源点
console.log(orangesRotting([[2,1,1],[1,2,1],[1,1,2]])); // 1
// 测试用例5:大网格
console.log(orangesRotting([[1,1,1,1,1],[1,1,1,1,1],[1,1,2,1,1],[1,1,1,1,1],[1,1,1,1,1]])); // 4
6. 实际应用与变种题目
这道题虽然场景是橘子腐烂,但它实际上代表了一类"多源点扩散"问题,在实际中有很多应用:
- 疫情传播模型:计算病毒传播需要的时间
- 火灾蔓延模拟:预测火势扩散范围
- 网络广播协议:信息在网络节点间的传播
- 图像处理:区域填充算法
类似的LeetCode题目还有:
-
- 01 Matrix(多源点最短距离)
-
- Walls and Gates(几乎相同的题目)
-
- Minesweeper(扫雷游戏中的扩散逻辑)
如果你理解了这道题,可以尝试解决这些变种题目来巩固BFS的应用能力。
