华为OD机试真题解析:基于BFS的疫情扩散时间计算与多语言实现
1. 项目概述与核心价值
最近在技术社区和求职圈里,华为OD(Outsourcing Development)的机试真题讨论热度一直很高。很多朋友在准备这类上机考试时,常常会卡在算法题上,尤其是那些结合了现实场景的模拟题。今天要拆解的这道“计算疫情扩散时间”真题,就是一个非常典型的例子。它表面上是一个网格遍历问题,但内核考察的是对广度优先搜索(BFS)算法的深刻理解和灵活应用能力。这道题不仅出现在华为OD的机试中,其变体也常见于各大厂的笔试环节,是检验候选人基础算法功底和问题建模能力的试金石。
这道题的核心是模拟疫情在固定区域内的扩散过程。给你一个初始的感染状态网格,你需要计算出整个区域全部被感染所需的时间。这听起来像是一个简单的模拟,但其中关于时间单位的定义、感染规则的边界条件、以及如何高效地判断“全部感染”状态,都是容易踩坑的地方。对于正在备战C++、Java、Python等语言方向机试的朋友来说,吃透这道题,不仅能掌握BFS在矩阵问题上的标准写法,更能理解如何将现实世界的连续过程,离散化为计算机可以一步步处理的模型。接下来,我会从问题本质、多种思路对比、到不同语言的代码实现细节,为你完整拆解这道题,并分享一些在机试实战中的编码技巧和避坑指南。
2. 问题本质与数学模型抽象
2.1 场景还原与问题定义
首先,我们需要把题目描述从自然语言转化为精确的计算机模型。通常,题目会给出一个n x m的二维网格,每个格子(cell)的初始状态是已知的:
- 0: 代表该区域健康,未被感染。
- 1: 代表该区域在初始时刻(第0天)就已经被感染。
疫情的扩散规则一般是:在每一个单位时间(比如一天)内,一个已被感染的格子,会将其上下左右四个相邻方向(通常不考虑斜对角)的健康格子(状态为0)感染,使其状态变为1。这个过程会持续进行,直到满足终止条件。
这里需要明确几个关键点,这些往往是题目没说清但必须由我们假设,或者在审题时需要特别注意的:
- 时间单位:扩散是离散时间步进,还是连续过程?机试题中几乎都是离散的。我们假设在
t时刻,所有在t-1时刻已被感染的格子会同时尝试感染其邻居。感染是瞬间完成的,然后时间推进到t+1。 - 感染源:初始感染源(状态为1的格子)可能不止一个,它们在第0天同时开始扩散。
- 终止条件:有两种常见理解:
- 所有格子都被感染。这是最直接的目标,计算从第0天到全图变为1所需的天数。
- 疫情无法继续扩散。即某一时刻后,图中不再有状态为0的格子与状态为1的格子相邻。此时即使还有0,疫情也无法传过去(可能被隔离)。本题目标通常是前者。
- 边界处理:网格边界外的区域如何处理?通常视为“墙”,疫情无法扩散出去,外部也不会影响内部。
经过这样的分析,问题的本质就清晰了:给定一个带初始状态的矩阵,按照四邻域感染规则,模拟扩散过程,并返回达到“全感染”状态所需的最短时间(单位步数)。如果初始状态全部为1,则时间为0;如果存在永远无法被感染的0(例如被1完全包围的独立0区域,但根据四邻域规则和初始1的同时扩散,在连通图内通常不会出现),则可能需要返回-1或特定标识,但本题一般保证初始感染源最终能感染全图。
2.2 算法选择:为什么是BFS?
面对这种“从多个源点同时开始,逐层向外扩展,直到覆盖所有可达区域,并计算扩展层数”的问题,广度优先搜索(BFS)是近乎完美的解决方案。
我们可以这样类比:将每个网格格子看作图中的一个节点,相邻格子之间有一条无向边。初始感染源(状态1的节点)就是我们的“起点集合”。BFS的特性是先访问距离起点为k的所有节点,再访问距离为k+1的节点。这里的“距离”正好对应了“感染所需的时间”。BFS保证当我们第一次访问(感染)一个健康节点时,所用的步数就是最短的感染时间。
与深度优先搜索(DFS)相比,BFS能更自然、更高效地模拟这种同步、层进式的扩散过程。用DFS则需要额外记录每个节点被感染的时间,并处理时间更新的问题,逻辑会更复杂,且不易直观理解。
核心思路步骤:
- 初始化:遍历整个网格,将所有初始感染点(值为1)的坐标加入队列(BFS的起点),并将这些点对应的“感染时间”记为0。同时,统计健康点(值为0)的数量。
- BFS循环:当队列不为空且还有健康点存在时,进行循环。
- 从队列中取出一个已感染点
(x, y)及其感染时间t。 - 查看其上下左右四个邻居
(nx, ny)。 - 如果邻居坐标合法、且是健康点(值为0),则将其状态标记为已感染(设为1),将其感染时间记为
t+1,并将其加入队列。同时,将剩余健康点计数减1。
- 从队列中取出一个已感染点
- 结果判断:BFS结束后,检查健康点计数。如果计数为0,说明全部感染,最后一次感染发生的时间(即BFS过程中记录的最大时间)就是答案。如果计数不为0,说明存在无法被感染的区域(根据题目假设,可能返回-1)。
3. 核心细节解析与多语言实现要点
理解了BFS框架后,我们来看看不同语言实现时需要注意的细节和技巧。这些细节往往决定了代码的简洁性、效率和正确性。
3.1 数据结构与状态记录
高效实现BFS的关键在于选择合适的数据结构来存储“待处理的节点”和“节点的附加信息(如感染时间)”。
队列(Queue):这是BFS的核心。我们需要一个支持先进先出(FIFO)操作的数据结构。
- C++:首选
std::queue。通常将坐标(x, y)和当前时间t打包成一个结构体或std::tuple入队。也可以使用两个队列,或者一个队列配合层次遍历的技巧(记录每一层的size)。 - Java:使用
LinkedList作为Queue的实现。可以定义一个小类Cell包含x, y, time,或者使用int[]数组。 - Python:使用
collections.deque,它的popleft()和append()操作都是O(1)的,性能远优于用list模拟队列。 - JavaScript:数组配合指针模拟队列,或者直接使用数组的
push和shift方法(但shift是O(n)操作,对于大数据量可能性能不佳)。更好的方法是自己维护头尾指针。
- C++:首选
时间/状态记录:有两种主流方法。
- 修改原数组:直接将感染的点从0改为1。这是最节省空间的方法。感染时间可以通过BFS的层数间接得到,或者额外维护一个
dist或time矩阵来记录每个点被感染的时间。在机试中,如果允许修改输入,直接修改原数组是最快的。 - 独立的访问标记数组:创建一个与原网格同尺寸的
visited或time二维数组。初始感染点时间为0并入队标记。这样可以不破坏原始输入数据。
- 修改原数组:直接将感染的点从0改为1。这是最节省空间的方法。感染时间可以通过BFS的层数间接得到,或者额外维护一个
注意:在机试环境中,务必先明确题目是否允许修改输入参数。有些判题系统传入的是只读引用,修改可能导致错误。最稳妥的方式是,如果不确定,就使用独立的标记数组。
3.2 方向数组与越界检查
处理四方向或八方向移动时,使用方向数组是避免写重复代码的最佳实践。
// C++ 示例 int dirs[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; // 上下左右在BFS循环中,只需遍历这个数组,即可得到下一个坐标(nx, ny) = (x + dir[0], y + dir[1])。
越界检查是必须的,且应放在尝试访问网格值之前,以防止数组访问越界导致运行时错误。
# Python 示例 if 0 <= nx < rows and 0 <= ny < cols and grid[nx][ny] == 0: # 执行感染操作3.3 层次遍历与时间计算
如何计算“最大时间”?有两种常见方法:
- 携带时间入队:每个队列元素存储
(x, y, time)。当从队列中取出一个元素时,其邻居的感染时间就是time + 1。BFS结束后,最后一个被感染的点所携带的时间就是答案。这种方法直观,但队列元素体积稍大。 - 层次遍历(推荐):在每一轮BFS开始前,记录当前队列的长度
size,这个size代表了当前“同一时间点”的所有感染源。然后执行size次出队操作,处理这一层的所有节点,它们的邻居都属于下一层(time+1)。处理完一层后,时间time加1。这种方法无需在队列中存储时间,逻辑清晰,且便于理解“同步扩散”。// Java 层次遍历片段示例 int time = 0; while (!queue.isEmpty() && healthyCount > 0) { int size = queue.size(); for (int i = 0; i < size; i++) { int[] cell = queue.poll(); int x = cell[0], y = cell[1]; // ... 处理四个方向 ... } time++; // 一层处理完毕,时间+1 } // 最终时间 time 即为答案,但需要注意初始第0天的处理。
4. 多语言代码实现与逐行分析
下面,我将分别用 C++, Java, Python 和 JavaScript 实现基于层次遍历BFS的解法,并附上关键行的注释。
4.1 C++ 实现
#include <iostream> #include <vector> #include <queue> using namespace std; int calculateInfectionTime(vector<vector<int>>& grid) { if (grid.empty() || grid[0].empty()) return 0; int rows = grid.size(); int cols = grid[0].size(); queue<pair<int, int>> q; // 队列只存坐标 int healthyCount = 0; // 统计健康区域数 int time = 0; // 经过的时间 // 1. 初始化:找到所有初始感染源,并统计健康区域 for (int i = 0; i < rows; ++i) { for (int j = 0; j < cols; ++j) { if (grid[i][j] == 1) { q.push({i, j}); // 感染源入队 } else if (grid[i][j] == 0) { healthyCount++; } // 其他状态(如-1代表隔离)可根据题目处理 } } // 如果初始就没有健康区域,直接返回0 if (healthyCount == 0) return 0; // 方向数组:上、下、左、右 vector<pair<int, int>> directions = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; // 2. BFS层次遍历 while (!q.empty() && healthyCount > 0) { int currentLevelSize = q.size(); // 当前这一轮要处理的感染源数量 for (int i = 0; i < currentLevelSize; ++i) { auto [x, y] = q.front(); q.pop(); // 遍历四个邻居 for (auto& dir : directions) { int nx = x + dir.first; int ny = y + dir.second; // 检查邻居是否合法且为健康区域 if (nx >= 0 && nx < rows && ny >= 0 && ny < cols && grid[nx][ny] == 0) { grid[nx][ny] = 1; // 标记为已感染 q.push({nx, ny}); // 新感染源加入队列,下一轮处理 healthyCount--; // 健康区域减少 } } } time++; // 当前层所有感染源扩散完毕,时间+1 // 注意:如果本轮有新的感染发生,time才应该增加。 // 但根据逻辑,只要healthyCount>0且队列不空,本轮一定有新感染。 // 有一种边界情况:初始队列不为空,但所有感染源都被墙包围,无法感染任何新格子。 // 此时循环会一直进行,但healthyCount不会减少。需要额外判断,但本题通常不会出现。 } // 3. 判断结果 return healthyCount == 0 ? time : -1; // 如果还有健康区域,说明无法全部感染 } // 示例用法 int main() { // 示例网格:3x3,中心初始感染 vector<vector<int>> grid = { {0, 0, 0}, {0, 1, 0}, {0, 0, 0} }; int result = calculateInfectionTime(grid); cout << "Time to fully infect: " << result << endl; // 输出应为 2 return 0; }C++实现要点:
- 使用
std::queue<pair<int,int>>存储坐标,简洁高效。 - 使用结构化绑定
auto [x, y](C++17) 使代码更清晰。 - 直接在原
grid上修改,将0改为1,作为感染标记,节省空间。 healthyCount是关键变量,用于提前终止循环和判断最终结果。
4.2 Java 实现
import java.util.LinkedList; import java.util.Queue; public class PandemicSpreadTime { public int calculateInfectionTime(int[][] grid) { if (grid == null || grid.length == 0 || grid[0].length == 0) { return 0; } int rows = grid.length; int cols = grid[0].length; Queue<int[]> queue = new LinkedList<>(); int healthyCount = 0; int time = 0; // 初始化队列和健康计数 for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { if (grid[i][j] == 1) { queue.offer(new int[]{i, j}); } else if (grid[i][j] == 0) { healthyCount++; } } } // 如果没有健康区域,无需扩散 if (healthyCount == 0) { return 0; } // 方向数组 int[][] directions = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; // BFS while (!queue.isEmpty() && healthyCount > 0) { int levelSize = queue.size(); // 遍历当前层的所有感染源 for (int i = 0; i < levelSize; i++) { int[] cell = queue.poll(); int x = cell[0]; int y = cell[1]; for (int[] dir : directions) { int nx = x + dir[0]; int ny = y + dir[1]; if (nx >= 0 && nx < rows && ny >= 0 && ny < cols && grid[nx][ny] == 0) { grid[nx][ny] = 1; // 感染 queue.offer(new int[]{nx, ny}); healthyCount--; } } } time++; // 当前层处理完毕,时间递增 } // 判断是否全部感染 return healthyCount == 0 ? time : -1; } // 测试 public static void main(String[] args) { PandemicSpreadTime solver = new PandemicSpreadTime(); int[][] grid = { {0, 0, 0}, {0, 1, 0}, {0, 0, 0} }; int result = solver.calculateInfectionTime(grid); System.out.println("Time to fully infect: " + result); // 输出 2 } }Java实现要点:
- 使用
LinkedList作为Queue的实现。 - 队列元素使用
int[]{x, y}小数组,比创建对象开销小。 - 逻辑与C++版本几乎一一对应,体现了算法与语言的相对独立性。
4.3 Python 实现
from collections import deque from typing import List def calculate_infection_time(grid: List[List[int]]) -> int: if not grid or not grid[0]: return 0 rows, cols = len(grid), len(grid[0]) queue = deque() healthy_count = 0 time = 0 # 初始化 for i in range(rows): for j in range(cols): if grid[i][j] == 1: queue.append((i, j)) elif grid[i][j] == 0: healthy_count += 1 if healthy_count == 0: return 0 # 方向数组 directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] # BFS while queue and healthy_count > 0: level_size = len(queue) for _ in range(level_size): x, y = queue.popleft() for dx, dy in directions: nx, ny = x + dx, y + dy # 检查边界和状态 if 0 <= nx < rows and 0 <= ny < cols and grid[nx][ny] == 0: grid[nx][ny] = 1 queue.append((nx, ny)) healthy_count -= 1 time += 1 return time if healthy_count == 0 else -1 # 测试 if __name__ == "__main__": grid = [ [0, 0, 0], [0, 1, 0], [0, 0, 0] ] result = calculate_infection_time(grid) print(f"Time to fully infect: {result}") # 输出 2Python实现要点:
- 务必使用
collections.deque作为队列,其popleft()是O(1)操作。 - 使用元组
(x, y)存储坐标,非常方便。 - Python的语法让边界检查和条件判断写起来很简洁。
4.4 JavaScript 实现
function calculateInfectionTime(grid) { if (!grid || grid.length === 0 || grid[0].length === 0) { return 0; } const rows = grid.length; const cols = grid[0].length; const queue = []; // 用数组模拟队列 let head = 0; // 队列头指针 let healthyCount = 0; let time = 0; // 初始化 for (let i = 0; i < rows; i++) { for (let j = 0; j < cols; j++) { if (grid[i][j] === 1) { queue.push([i, j]); } else if (grid[i][j] === 0) { healthyCount++; } } } if (healthyCount === 0) return 0; // 方向数组 const directions = [[-1, 0], [1, 0], [0, -1], [0, 1]]; // BFS while (head < queue.length && healthyCount > 0) { const levelSize = queue.length - head; // 当前层的节点数 for (let i = 0; i < levelSize; i++) { const [x, y] = queue[head++]; // 从头部取出元素,头指针后移 for (const [dx, dy] of directions) { const nx = x + dx; const ny = y + dy; if (nx >= 0 && nx < rows && ny >= 0 && ny < cols && grid[nx][ny] === 0) { grid[nx][ny] = 1; queue.push([nx, ny]); healthyCount--; } } } time++; } return healthyCount === 0 ? time : -1; } // 测试 const grid = [ [0, 0, 0], [0, 1, 0], [0, 0, 0] ]; const result = calculateInfectionTime(grid); console.log(`Time to fully infect: ${result}`); // 输出 2JavaScript实现要点:
- 使用数组
queue配合头指针head来模拟队列,避免shift()操作导致的低性能。 - 使用解构赋值
const [x, y] = queue[head++]来获取坐标。 - 层次遍历时,通过
queue.length - head计算当前层大小,这是一个常用技巧。
5. 常见问题、边界情况与实战技巧
在实际机试或刷题中,除了写出核心算法,处理好边界情况和优化代码细节同样重要。
5.1 典型边界情况与处理
- 空网格或全0网格:如果网格为空,或所有格子初始都是0(无感染源),疫情无法开始。根据题目要求,可能返回-1、0或特定值。通常,全0网格应返回-1(表示永远无法感染),因为队列初始为空,BFS不会执行。我们的代码中,
healthyCount等于总格子数,最终返回-1。 - 全1网格:初始已全部感染,时间应为0。我们的代码中,初始化后
healthyCount为0,直接返回0。 - 无法完全感染:存在被隔离的健康区域(例如,被-1表示的隔离墙完全包围)。我们的算法通过最终的
healthyCount是否大于0来判断,并返回-1。 - 大网格与性能:网格可能非常大(如1000x1000)。BFS的时间复杂度是O(N),其中N是网格单元格总数,空间复杂度最坏也是O(N)(队列存储)。这在机试限制内通常是可接受的。但要避免在循环中创建不必要的临时对象(如在Java中频繁
new int[]在大型循环里)。
5.2 机试实战技巧与避坑指南
- 明确输入输出格式:机试题通常会详细说明输入如何给出(例如,第一行是两个整数n,m,后面n行每行m个数字),以及输出要求(一个整数)。务必严格按照要求读取输入和输出结果,不要添加任何额外的提示信息。
- 使用静态数组或预分配内存:在C/C++中,对于大的二维数组,避免使用
vector<vector<int>>的频繁push_back,如果尺寸固定,可以直接使用原生数组或预先resize。在Java中,对于固定大小的队列,可以预分配LinkedList,但通常影响不大。 - 注意时间计数起点:这是最容易出错的地方之一。在我们的层次遍历代码中,
time的初始值是0。循环中,每处理完一层,time++。这意味着:- 第0天:初始状态,
time=0。 - 第1天:初始感染源完成第一轮扩散后,
time=1。 - 所以,如果初始只有一个感染源在中心,感染全图需要2步,我们的函数返回2。务必理解题目问的是“经过多少时间后全部感染”,还是“在第几天全部感染”。如果是“经过多少时间”,我们返回的
time是对的。如果是“在第几天”,可能需要返回time-1。仔细审题!
- 第0天:初始状态,
- 使用调试打印:在本地IDE编写时,可以在关键步骤打印网格状态和队列信息,帮助理解BFS过程。但在提交代码前务必删除。
- 考虑多源BFS的优化:本题本身就是多源BFS。我们初始化时将所有源点一次性加入队列,它们的时间都是0。BFS会自然保证从所有源点同步扩散。这是标准做法,无需优化。
- 语言特性选择:在Python中,
deque比list快得多。在JavaScript中,避免在循环里用shift()。在Java中,LinkedList的poll()和offer()是标准队列操作。
5.3 复杂度分析
- 时间复杂度:O(N),其中 N = rows * cols。每个格子最多入队和出队一次,每次处理时检查四个方向是常数操作。
- 空间复杂度:O(N),最坏情况下队列需要存储几乎所有的格子(例如从角落开始扩散)。
6. 思路扩展与变体探讨
掌握了基础模型后,我们可以看看一些可能的变体,这有助于应对更灵活的考题。
6.1 变体一:扩散速度不同
如果题目改为:不同类型的感染源扩散速度不同,比如有的源点一天可以扩散到相邻格,有的需要两天。这可以通过在队列元素中存储“该节点下一次可进行扩散的剩余时间”来建模。或者更简单,在BFS时,不是每轮所有节点都扩散,而是每个节点有自己的“冷却时间”。这更接近于Dijkstra 算法求最短路径的思想,其中边的权重就是扩散所需时间。此时需要使用优先队列(最小堆)而不是普通队列。
6.2 变体二:存在隔离区或障碍物
网格中可能有一些格子是障碍物(用-1或2表示),疫情无法通过。这在我们的代码中很容易处理:在检查邻居时,增加一个条件grid[nx][ny] != -1即可。BFS会自动绕过这些障碍。
6.3 变体三:计算最后一个被感染的点/时间
有时题目不仅要求总时间,还要求最后一个被感染的点的坐标。我们可以在感染一个健康点时,记录下它的坐标和时间。BFS结束后,最后记录的那个点就是答案。由于BFS是层次遍历,同一层可能有多个点同时被感染,需要根据题目要求决定(例如,按特定顺序选择)。
6.4 从矩阵到图
这道题的本质是在一个无权无向图上求多源点到所有其他点的最短距离的最大值。网格只是图的一种特殊表现形式(每个节点与上下左右四个邻居相连)。所以,解决这类问题的核心图论算法就是多源BFS。理解这一点后,即使题目背景换成“社交网络信息传播”、“火灾蔓延”、“网络爬虫抓取”等,你都能识别出这是同一类问题。
我个人在刷题和面试中总结的经验是,对于矩阵上的BFS问题,“方向数组+队列+已访问标记”是一个万能模板。难点往往在于对问题本身的建模(如何定义状态、如何定义转移规则)和对边界情况的处理。在机试的紧张环境下,先把这套模板写对、写熟,就能解决一大类问题。然后,再根据具体题目要求,调整时间计算逻辑、增加特殊状态判断等。最后,一定要自己用几个简单的测试用例(比如1x1网格,2x2网格,全1,全0,有障碍物等)快速验证一下,确保逻辑正确,尤其是时间计数这种细节,往往就是差之毫厘,谬以千里。