华为OD机试真题解析:BFS算法解决“欢乐的周末”最短路径问题
1. 项目概述:从一道机试真题看算法实战
最近在技术社区和求职圈里,“华为OD机试”的热度一直居高不下。很多朋友在准备面试时,都会把历年的机试真题当作重要的练兵场。今天我想和大家深入聊聊其中一道挺有意思的题目——“欢乐的周末”。这道题不仅频繁出现在各类机试真题的讨论中,也因为它融合了基础的图论思想和实际的生活场景,成为了检验候选人基础编码和问题建模能力的经典案例。
简单来说,“欢乐的周末”描述了一个典型的搜索问题:在一个由网格表示的地图上,有若干个人(比如小华和小为)和若干家餐厅。每个人只能向上下左右四个方向移动,目标是找到那些能让所有人同时到达的餐厅位置。题目会给出地图大小、障碍物位置、人物和餐厅坐标,我们需要计算出符合条件的餐厅数量。这听起来像是一个简单的BFS(广度优先搜索)应用,但其中关于“同时到达”的定义、多源点搜索的处理以及效率优化,都藏着不少值得琢磨的细节。无论是用C++追求极致性能,还是用Python讲究快速实现,亦或是用Java构建清晰结构,这道题都能很好地体现你的编程功底和思维习惯。
2. 核心需求与问题建模拆解
在动手写代码之前,我们必须把题目要求彻底吃透,并转化为清晰的计算机模型。很多同学栽跟头,不是因为算法不会,而是因为题意理解有偏差。
2.1 题目场景还原与关键约束
我们首先在脑子里构建出这个“周末觅食”的场景。想象一个M x N的网格,就像一张城市地图:
- 网格单元状态:每个格子可能是空地(0)、障碍物(-1)、人物位置(如2代表小华,3代表小为)或餐厅位置(1)。
- 移动规则:每个人物每步只能向上、下、左、右四个方向移动到相邻的空地或餐厅格子,不能穿越障碍物,也不能走出地图边界。
- 核心目标:找出所有这样的餐厅——从小华和小为的位置出发,都能通过一条路径抵达该餐厅,并且他们到达该餐厅所需的步数(即最短路径长度)是相同的。
这里有几个极易混淆的要点,我必须重点强调:
注意:“同时到达”指的是路径长度相等,而不是要求他们像赛跑一样同时出发、同时刻到达。只要从各自起点到某个餐厅的最短距离数值相等,这个餐厅就符合条件。他们完全可以在不同的时间点出发,只要步数一样就行。另一个坑:题目并未要求路径必须唯一,也未要求路径不能交叉或共享。只要存在至少一条从每个人到餐厅的路径,且最短路径长度相等即可。
2.2 数学模型抽象与算法选型
基于以上分析,我们可以将问题抽象为:
- 输入:一个
M x N的矩阵grid,以及标识了人物和餐厅的特定值。 - 处理:对每个人物位置,计算其到地图上所有可达格子的最短距离。这本质上是一个多源点最短路径问题,但源点只有两个(小华和小为)。
- 输出:遍历所有餐厅格子,检查对于每个餐厅,两个人物到它的最短距离是否都被计算出来(即都可达)且这两个距离值相等。统计满足条件的餐厅数量。
算法选择思路:
- 为什么是BFS,而不是DFS?在无权图(每一步代价相同)中寻找最短路径,BFS具有天然优势。BFS从起点一层层向外扩张,第一次访问到某个节点时所经历的步数就是最短步数。DFS则需要遍历所有可能路径才能确定最短的那条,效率低得多。
- 单源BFS vs 多源BFS:我们有两个明确的起点(小华和小为)。最直接的思路是对每个人物分别进行一次BFS,生成两个独立的距离矩阵。这样逻辑清晰,实现简单。虽然有多余的遍历,但鉴于M和N通常不会巨大(机试题常见范围),两次BFS是完全可接受的。
- 存储结构:我们需要存储每个人物到每个格子的最短距离。可以用两个与
grid同尺寸的二维数组dist1和dist2,初始值设为-1(表示不可达或未访问)。在BFS过程中,将步数记录进去。
3. 核心算法实现与代码解析
理论清晰后,我们来看具体实现。我会以Python版本作为主线进行详细讲解,因为它语法简洁,易于理解思路,然后再对比其他语言的关键点。Python版本追求的是思路的清晰和实现的快捷。
3.1 数据结构与BFS模板设计
首先,我们设计BFS函数。它需要接收起点坐标、地图信息,并返回一个距离矩阵。
from collections import deque from typing import List def bfs(start_x: int, start_y: int, grid: List[List[int]]) -> List[List[int]]: """ 从起点(start_x, start_y)进行BFS,返回到达每个点的最短步数矩阵。 不可达点距离为-1。 """ m, n = len(grid), len(grid[0]) # 初始化距离矩阵,-1表示未访问/不可达 dist = [[-1] * n for _ in range(m)] # 如果起点就是障碍,直接返回(根据题意,人物不会在障碍上) if grid[start_x][start_y] == -1: return dist queue = deque() queue.append((start_x, start_y)) dist[start_x][start_y] = 0 # 起点距离为0 # 四个方向向量:上、下、左、右 directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] while queue: x, y = queue.popleft() current_dist = dist[x][y] for dx, dy in directions: nx, ny = x + dx, y + dy # 检查新坐标是否合法、不是障碍、且未被访问过 if 0 <= nx < m and 0 <= ny < n: if grid[nx][ny] != -1 and dist[nx][ny] == -1: dist[nx][ny] = current_dist + 1 queue.append((nx, ny)) return dist关键点解析:
- 使用
deque作为队列:在Python中,collections.deque的popleft()操作是O(1),用列表模拟队列的pop(0)是O(n),在BFS中性能差异巨大。 - 距离矩阵初始化:用
-1同时表示“未访问”和“不可达”,简化了逻辑。访问过后,值更新为非负整数(步数)。 - 方向数组:用一个列表定义四个方向,比写四个if语句更简洁,不易出错。
- 访问判断顺序:先判断坐标合法性,再判断是否为障碍和是否已访问。这个顺序很重要,可以避免数组越界访问。
3.2 主逻辑整合与餐厅判定
有了BFS函数,主函数就负责组织数据、调用BFS并统计结果。
def happy_weekend(grid: List[List[int]]) -> int: m, n = len(grid), len(grid[0]) # 步骤1:找到所有人物和餐厅的位置 people = [] restaurants = [] for i in range(m): for j in range(n): val = grid[i][j] if val == 2 or val == 3: # 假设2和3代表不同的人 people.append((i, j)) elif val == 1: # 餐厅 restaurants.append((i, j)) if len(people) != 2: # 根据题意,这里可能默认就是两个人,否则需要处理异常或更多逻辑 return 0 # 步骤2:分别计算每个人到所有点的距离 dist_maps = [] for px, py in people: dist_map = bfs(px, py, grid) dist_maps.append(dist_map) # 步骤3:遍历所有餐厅,检查条件 count = 0 for rx, ry in restaurants: valid = True target_dist = None for i, dist_map in enumerate(dist_maps): d = dist_map[rx][ry] if d == -1: # 有一个人不可达该餐厅 valid = False break if target_dist is None: target_dist = d # 记录第一个人到餐厅的距离 elif d != target_dist: # 后续人的距离与第一个人不相等 valid = False break if valid: count += 1 return count主逻辑要点:
- 预处理扫描:先遍历一遍地图,记录下所有人物和餐厅的坐标。这样在后续判断时,就无需再次遍历整个地图来寻找餐厅。
- 条件判断逻辑:对于每个餐厅,我们依次检查每个人物的距离矩阵。一旦发现某个人不可达(距离为-1),或者当前人的距离与之前记录的距离(
target_dist)不相等,就立刻标记为无效并跳出循环。这是一种短路评估,可以提高效率。 - 边界情况:代码中假设了恰好有两个人。如果题目可能变化,这里需要增加更健壮的判断。
3.3 多语言实现关键差异与技巧
虽然算法核心一致,但不同语言在实现时有其惯用写法和优化点。
C++实现要点:
- 队列:使用
std::queue<std::pair<int, int>>。 - 距离存储:通常使用
vector<vector<int>>,初始化时可以用INT_MAX或-1表示未访问。 - 性能:C++版本通常最快。要注意传递大型vector时尽量使用引用,避免拷贝。
- 代码风格:结构清晰,常将BFS封装为函数,主函数内使用
auto和范围for循环 (for (auto& row : grid)) 让代码更现代。
Java实现要点:
- 队列:使用
LinkedList<int[]>或ArrayDeque<int[]>作为Queue。 - 距离存储:使用
int[][]数组。 - 方向数组:可以定义为
int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}};。 - 特性:代码结构严谨,通常定义为一个
Solution类,包含方法。要注意Java中数组是对象,dist数组需要显式初始化。
JavaScript (Node.js) 实现要点:
- 队列:用数组模拟,但要注意
shift()操作在V8引擎中可能不是O(1)。对于性能要求高的场景,可以自己实现一个简单的队列类,或者使用[head, tail]指针。 - 距离存储:使用二维数组。
- 输入处理:机试环境下的JS输入处理通常是难点,需要熟悉
readline模块按行读取并解析数据。 - 语法:使用
const/let,箭头函数,解构赋值等ES6+特性可以让代码更简洁。
实操心得:在机试的紧张环境下,选择你最熟悉的语言。Python胜在代码量少,思路表达快;C++胜在绝对性能和控制力;Java胜在稳健和强大的IDE提示;JS则要特别注意输入输出格式。先把核心逻辑写对,再考虑优化。
4. 测试用例设计与边界情况分析
写完代码只是第一步,设计全面的测试用例才能确保代码的健壮性。很多同学的程序在简单用例上通过,却败在了边界情况上。
4.1 常规测试用例
- 基础用例:小地图,人物和餐厅路径清晰。
输入: 3 3 0 0 0 2 0 1 3 0 0 输出:1 (餐厅(1,2)距离两人都是2步) - 无解用例:有餐厅,但有人无法到达,或距离不等。
输入: 3 3 2 -1 1 0 -1 0 3 0 0 输出:0 (小华被障碍挡住,无法到达任何餐厅) - 多餐厅筛选:多个餐厅,只有部分满足条件。
输入: 4 4 0 0 0 1 2 0 -1 0 0 -1 0 0 3 0 0 1 输出:1 (只有右下角的餐厅满足条件)
4.2 边界与极端情况
这些是真正考验代码鲁棒性的地方:
- 最小地图:
1x1网格,且该格子就是餐厅和两个人?(通常人物和餐厅是不同值,这种输入可能非法,但代码要能处理而不崩溃)。 - 满障碍地图:除了人物格子,其余全是
-1,输出应为0。 - 人物起点即餐厅:如果某个人物初始位置就在一个标为1的格子上(虽然题目可能不允许),那么他到该餐厅的距离是0。需要和另一个人的距离比较。
- 大地图性能:例如
100x100的全空地地图,两个人分处对角,餐厅在中间。两次BFS要能快速完成。这时BFS的O(M*N)复杂度是可靠的。 - 输入格式验证:机试中,需要严格遵循题目规定的输入格式(例如先输入M N,再输入M行数据)。读取代码要能正确处理。
避坑技巧:在本地调试时,不要只用手算的简单用例。构造上述边界用例,并用打印中间结果(如两个人的距离矩阵)的方式,一步步跟踪程序状态,能帮你快速定位逻辑错误。例如,在BFS结束后,打印出
dist矩阵,看看是否和你预期的最短距离一致。
5. 算法优化与思路拓展
在确保正确性的基础上,我们可以思考一下是否有优化空间,以及这道题相关的变体。
5.1 潜在优化点分析
- 双向BFS(Bi-directional BFS):对于单源点找固定目标的最短路径,双向BFS可以从起点和终点同时开始搜索,相遇时即找到路径,能显著减少搜索空间。但在这道题中,我们的目标是计算起点到所有点的距离,而不是到某一个特定点,所以双向BFS不适用。
- 多源BFS(Multi-source BFS)一次性计算:我们可以修改BFS,初始时将两个人的起点都加入队列,并记录每个格子是被谁、在什么时候访问的。但这需要更复杂的状态记录(格子被两个人访问的步数),实现起来比两次独立的BFS更复杂,且容易出错。在只有两个源点的情况下,收益不大,代码清晰度下降,不推荐。
- 早期剪枝:在统计结果时,如果我们发现某个餐厅对于第一个人就不可达,那么根本不需要查询第二个人到该餐厅的距离。我们的代码已经通过“短路评估”实现了这一点。
- 空间优化:如果地图非常大,两个
MxN的距离矩阵可能占用较多内存。但考虑到机试约束,这通常不是瓶颈。极端情况下,可以尝试只存储必要的距离信息,例如只记录餐厅格子的距离。
结论:对于这道题,两次清晰的单源BFS是最佳实践。它时间复杂度是O(2 * M * N),即O(M*N),空间复杂度是O(M*N),完全在合理范围内。优化应优先保证代码正确、可读,而非追求极致的常数时间优化。
5.2 相关变体题目思维延伸
理解这道题的解法后,可以轻松应对一系列变体:
- 变体1:找到任意一个欢乐餐厅。不需要统计数量,找到一个即可。可以在遍历餐厅时,找到第一个符合条件的就返回。
- 变体2:计算所有人到某个餐厅的最短路径和。这就是更经典的多源BFS问题,初始化队列时放入所有人的起点,最终距离矩阵记录的就是到达每个点的最近的那个人(或某种聚合信息,如最小步数)。但本题要求的是“距离相等”,而非“和最小”或“最大最小”,所以不同。
- 变体3:路径上存在不同代价。如果网格不再是简单的空地/障碍,而是每个格子有通过代价(如时间、花费),那么就需要使用Dijkstra算法(或优先队列BFS)来求单源最短路径。
- 变体4:更多人参与。如果有K个人,思路完全一样,进行K次BFS,然后检查每个餐厅是否满足所有K个人的距离都相等。复杂度变为
O(K * M * N)。
掌握BFS解决网格最短路径问题的核心模板,就能以不变应万变。模板包括:队列定义、距离矩阵初始化、方向数组、入队出队循环、以及合法的邻接点判断。
6. 机试实战策略与调试技巧
最后,结合这道题,聊聊在华为OD或其他公司机试中的实战策略。
6.1 时间分配与解题步骤
- 审题(5分钟):绝对不要跳过!用笔或注释标记出所有输入输出格式、关键约束(如“同时到达”的定义、移动方向、障碍物)。像“欢乐的周末”这种题目,必须在纸上画出几个小例子,验证自己的理解。
- 思路设计(10分钟):确定算法核心(本题即BFS),设计数据结构(距离矩阵),想好主函数流程(找点->BFS->统计)。在脑子里或草稿上过一遍简单用例和边界用例。
- 编码实现(20-25分钟):按照设计,将代码模块化地写出来。先写BFS函数并确保其正确性(可以先用一个简单用例测试)。再写主逻辑。使用清晰的变量名。
- 测试调试(10-15分钟):这是最关键的一步。不要只依赖题目给的样例。
- 自测小用例:用你审题时画的例子。
- 测试边界用例:地图大小为1,全障碍,人物紧挨餐厅等。
- 打印调试:在机试环境中,
printf/cout/print是你的好朋友。打印出距离矩阵,一眼就能看出BFS计算是否正确。
- 检查提交(5分钟):检查是否有拼写错误,数组大小是否开够,输入读取循环是否正确。最后提交。
6.2 常见错误排查清单
如果在调试时发现结果不对,可以按这个清单排查:
- 输入读取错误:是最常见的错误之一。确认
M和N读取正确,确认读取网格的循环次数是M次。 - 方向数组越界:在BFS中访问
(nx, ny)前,必须检查0 <= nx < m and 0 <= ny < n。 - 障碍物判断逻辑:
grid[nx][ny] != -1这个条件是否包含了起点?起点可能是2或3,不是0,但肯定不是-1,所以我们的判断grid[nx][ny] != -1是合理的。 - 距离初始化:
dist矩阵是否用-1正确初始化了?起点距离是否设置为0了? - 队列状态:是否在将新节点
(nx, ny)加入队列之前,就更新了它的dist值?这是BFS防止重复入队的关键。 - 餐厅判断逻辑:在统计时,是否错误地要求了“路径必须唯一”或误解了“同时”?
- 全局变量污染:如果使用了全局变量,在多次调用BFS函数时,是否做了正确的重置?更好的做法是让BFS函数返回新的距离矩阵。
6.3 编码风格与可读性建议
在机试中,代码不仅是给机器跑的,也是给阅卷人看的。清晰的代码结构能减少你自己的错误,也可能在边界情况下赢得一些印象分。
- 函数化:将BFS封装成独立的函数。
- 命名清晰:
dist_to_person1,restaurant_list比d1,list1好得多。 - 注释关键步骤:在复杂的逻辑判断或循环处写上简短注释。
- 避免魔法数字:用常量或变量代替
2,3,1,-1等。例如PERSON_A = 2,RESTAURANT = 1。
这道“欢乐的周末”就像一把尺子,能量出你对基础搜索算法的掌握是否扎实,对问题细节的把握是否精准。它不追求高深的算法,但非常考验基本功和严谨性。希望这次的拆解,不仅能帮你搞定这一道题,更能让你建立起解决这一类网格搜索问题的信心和方法论。在机试和日常开发中,这种化繁为简、严谨建模的能力,永远是最宝贵的。