1. 从地图分析这个标题说起它到底在考什么LeetCode上有一类题目标题看着平平无奇但点进去之后会发现它把图论、动态规划、广度优先搜索这几个硬核知识点揉在了一起。地图分析就是典型代表。我第一次看到这个标题的时候以为是一道简单的二维数组遍历题结果读完题才发现它本质上是在一张由0和1组成的网格上为每一个海洋格子找到距离它最近陆地的距离然后从所有海洋格子的距离中取最大值。如果全是海洋或者全是陆地直接返回-1。这道题在LeetCode上的编号是1162难度标注为中等。但说实话它的思维难度绝对不止中等。因为它涉及到一个非常经典的多源BFS思想而这个思想在很多实际场景中都有对应——比如城市应急设施选址、物流仓储中心覆盖范围评估、甚至是游戏里的迷雾探索机制。你把它当成一道算法题来做和把它当成一个真实的空间分析问题来理解收获是完全不同的。关键词里提到了地图分析小算法记录LeetCode说明这是一篇偏向个人刷题笔记性质的内容。但我不想把它写成那种贴个代码就完事的流水账。我想做的是把这道题从读题到AC的完整思考链路拆开包括我一开始踩的坑、为什么暴力解法会超时、多源BFS到底是怎么想出来的、以及最后代码里那些容易写错的边界条件。如果你也在刷LeetCode或者你对网格类BFS问题一直似懂非懂那这篇内容应该能帮你把这块知识补扎实。另外说一句这道题虽然叫地图分析但它和地理信息系统没有直接关系纯粹是一个抽象的网格距离计算问题。所以不要被标题带偏重点还是落在算法本身。2. 暴力解法的直觉陷阱为什么逐个海洋格子做BFS会超时2.1 最自然的想法对每个海洋格子找最近陆地拿到这道题大多数人的第一反应是这样的遍历整个网格每遇到一个海洋格子值为0就从它出发做一次BFS找到最近的陆地格子值为1记录下这个距离。然后继续找下一个海洋格子直到所有海洋格子都处理完最后取所有距离中的最大值。这个思路逻辑上完全正确没有任何问题。我一开始也是这么写的。代码大概长这样from collections import deque def maxDistance(grid): n len(grid) max_dist -1 for i in range(n): for j in range(n): if grid[i][j] 0: dist bfs(grid, i, j) max_dist max(max_dist, dist) return max_dist def bfs(grid, x, y): n len(grid) visited [[False] * n for _ in range(n)] queue deque([(x, y, 0)]) visited[x][y] True while queue: cx, cy, d queue.popleft() if grid[cx][cy] 1: return d for dx, dy in [(1,0),(-1,0),(0,1),(0,-1)]: nx, ny cx dx, cy dy if 0 nx n and 0 ny n and not visited[nx][ny]: visited[nx][ny] True queue.append((nx, ny, d 1)) return -1这段代码在逻辑上是自洽的跑小规模的测试用例也没问题。但问题在于它的时间复杂度是O(n^4)。为什么因为网格有n²个格子每个格子做一次BFS最坏情况下要遍历n²个格子乘起来就是n⁴。当n100的时候n⁴就是10的8次方虽然勉强能跑但已经非常危险了。如果n再大一点直接超时。2.2 超时的本质重复计算了大量中间状态我后来仔细想了一下暴力解法之所以慢根本原因在于它做了大量重复计算。举个例子假设有两个相邻的海洋格子A和B它们离最近陆地的距离分别是5和4。当我们从A出发做BFS的时候其实已经探索过了B所在的区域也知道了B到陆地的距离信息。但当我们从B出发再做一次BFS的时候这些信息完全被丢弃了一切从头再来。这就好比你要测量一个城市里每个点到最近消防站的距离如果你对每个点都单独跑一次导航那计算量是巨大的。但如果你反过来从所有消防站同时出发像水波一样向外扩散每扩散一圈就标记一层距离那么每个点只需要被访问一次就能得到答案。这就是多源BFS的核心思想。提示在网格类问题中如果题目要求每个点到最近特殊点的距离优先考虑从特殊点出发做多源BFS而不是从每个普通点出发做单源BFS。这个思维转换能帮你把时间复杂度从O(n⁴)降到O(n²)。2.3 从暴力到多源BFS的思维跳跃多源BFS的做法是这样的一开始把所有陆地格子值为1全部加入队列作为BFS的起点。然后开始逐层扩散每扩散一层距离加1。扩散到的格子如果是海洋格子就记录下它的距离。因为是从所有陆地同时出发的所以每个海洋格子第一次被访问到的距离就是它到最近陆地的距离。这个思路的妙处在于它把找最近这个操作隐含在了BFS的层序遍历中。BFS本身就是按距离从近到远遍历的所以第一次碰到某个海洋格子的时候那个距离一定是最小的。你不需要额外做任何比较操作。我实测下来多源BFS的代码量比暴力解法还少而且时间复杂度直接降到O(n²)因为每个格子最多入队一次、出队一次。空间复杂度也是O(n²)主要是队列和距离矩阵的开销。3. 多源BFS的完整实现从队列初始化到距离更新3.1 队列的初始状态把所有陆地格子一次性放进去多源BFS的第一步是把所有陆地格子加入队列。这一步很关键因为它决定了后续扩散的起点。我见过有人在写的时候只把第一个陆地格子加入队列然后指望BFS自己扩散到其他陆地格子这是不对的。因为其他陆地格子本身就是源点它们不需要被发现它们应该从一开始就在队列里。from collections import deque def maxDistance(grid): n len(grid) queue deque() for i in range(n): for j in range(n): if grid[i][j] 1: queue.append((i, j)) # 如果全是陆地或者全是海洋直接返回-1 if len(queue) 0 or len(queue) n * n: return -1 distance -1 directions [(1,0), (-1,0), (0,1), (0,-1)] while queue: distance 1 size len(queue) for _ in range(size): x, y queue.popleft() for dx, dy in directions: nx, ny x dx, y dy if 0 nx n and 0 ny n and grid[nx][ny] 0: grid[nx][ny] 1 # 标记为已访问 queue.append((nx, ny)) return distance - 1这段代码里有一个细节值得注意我用grid[nx][ny] 1来标记已访问而不是额外开一个visited数组。这样做的好处是节省空间而且逻辑上也很自然——被访问过的海洋格子在后续的扩散中就不应该再被当作海洋来处理了。当然这样做会修改原数组如果题目要求不能修改原数组那就需要额外开一个visited矩阵。3.2 层序遍历的控制为什么需要size变量在标准的BFS中我们通常不需要关心层数只需要遍历完所有节点就行。但在这道题里我们需要记录距离而距离是和层数直接挂钩的。所以我们需要在每一层开始之前记录下当前队列的大小然后只处理这一层的节点。while queue: distance 1 size len(queue) for _ in range(size): x, y queue.popleft() # 处理当前节点这个size变量的作用是在遍历当前层的过程中新加入的节点不会被立即处理而是留到下一层。这样就保证了距离的递增是正确的。如果你不记录size直接while queue里面套一个for循环遍历整个队列那就会把不同层的节点混在一起处理距离就乱了。我一开始写的时候就是忘了记录size结果跑出来的距离总是偏小。后来debug了半天才发现问题出在队列的层序控制上。这个坑很隐蔽因为代码逻辑看起来没问题但结果就是不对。3.3 边界条件的处理全陆地、全海洋、以及单格网格这道题的边界条件不算多但每一个都容易出错。我整理了一下情况处理方式原因全是陆地返回-1没有海洋格子不存在海洋到陆地的距离全是海洋返回-1没有陆地格子海洋格子找不到最近的陆地只有一个格子返回-1无论这个格子是0还是1都不满足条件正常情况返回最大距离多源BFS计算出的最后一层距离全陆地和全海洋的判断我是在初始化队列之后做的。如果队列为空说明没有陆地全是海洋如果队列大小等于n²说明全是陆地。这两种情况都直接返回-1。还有一个容易忽略的点是当网格中既有陆地又有海洋时多源BFS跑完之后distance的值会比实际最大距离多1。因为我们在每一层开始之前都做了distance 1包括最后一层。所以最后返回的时候要减1。这个细节我在第一次提交的时候搞错了返回了distance而不是distance - 1结果答案总是偏大1。4. 代码之外的思考这道题教会我的三件事4.1 反向思维从找最近到被找到这道题给我最大的启发是它展示了一种非常通用的算法思维当你要为每个点找最近的某个特殊点时不要从每个点出发去找而是从所有特殊点出发让它们去找普通点。这种反向思维在很多场景下都能用。比如在社交网络中要计算每个用户到最近大V的距离你可以从所有大V同时出发做BFS。再比如在电路板布线中要计算每个点到最近焊盘的距离也可以用同样的思路。甚至在一些游戏AI的路径规划中如果要计算地图上每个格子到最近资源点的距离多源BFS也是标准做法。这种思维的核心在于把多对一的查询问题转化成一对多的扩散问题。前者需要做多次搜索后者只需要做一次。当源点的数量远小于目标点的数量时这种转化的收益是非常明显的。4.2 网格BFS的通用模板刷了几道网格BFS的题之后我发现它们其实共享一个非常固定的模板。我把它总结成了下面这个结构from collections import deque def grid_bfs(grid, sources): queue deque(sources) visited set(sources) distance 0 while queue: size len(queue) for _ in range(size): x, y queue.popleft() # 根据题目要求处理当前节点 for dx, dy in [(1,0),(-1,0),(0,1),(0,-1)]: nx, ny x dx, y dy if is_valid(nx, ny) and (nx, ny) not in visited: visited.add((nx, ny)) queue.append((nx, ny)) distance 1 return distance这个模板可以套用到绝大多数网格BFS问题上包括岛屿数量腐烂的橘子01矩阵等等。区别只在于sources怎么初始化、is_valid怎么判断、以及每层处理时做什么操作。把模板记熟之后遇到新题只需要填空就行效率会高很多。4.3 时间复杂度的直觉培养我以前对时间复杂度的判断总是停留在理论层面直到被这道题的暴力解法超时教做人之后才开始真正重视起来。现在我拿到一道题会先估算一下最坏情况下的操作次数。比如这道题n100的时候暴力解法是10的8次方次操作多源BFS是10的4次方次操作差了四个数量级。这个差距在实际运行中就是超时和瞬间出结果的区别。培养这种直觉的方法就是多做题、多踩坑。每次超时之后不要急着看题解先自己分析一下瓶颈在哪里能不能通过改变算法思路来消除重复计算。这种分析过程比直接看答案有价值得多。注意多源BFS虽然能把时间复杂度降到O(n²)但它的空间复杂度也是O(n²)因为队列最坏情况下可能存储所有格子。如果题目对空间有严格要求可能需要考虑其他方法比如动态规划。但对于这道题来说O(n²)的空间是可以接受的。5. 从LeetCode到实际场景多源BFS还能用在哪5.1 设施选址与覆盖范围评估多源BFS最直接的应用场景就是设施选址问题。假设你要在一个区域里建若干个消防站你想知道在最坏情况下区域内任何一个点到达最近消防站的最大距离是多少。这个问题和地图分析几乎一模一样消防站就是陆地其他区域就是海洋你要找的就是那个最大距离。在实际操作中你可以把区域划分成网格每个格子代表一个小区块。然后从所有候选的消防站位置出发做多源BFS计算出每个格子的覆盖距离。最后取最大值就能评估当前选址方案的最坏覆盖情况。如果这个最大值太大说明有些区域离消防站太远需要增加站点或者调整位置。这种方法的优点是计算速度快而且结果直观。你甚至可以把距离用颜色深浅可视化出来一眼就能看出哪些区域是覆盖盲区。5.2 游戏地图中的迷雾探索在很多策略游戏中地图一开始是被迷雾覆盖的玩家需要探索才能揭开迷雾。如果游戏要计算每个迷雾格子到最近已探索区域的距离就可以用多源BFS。已探索区域作为源点迷雾格子作为目标BFS扩散的过程就模拟了探索的传播。这种计算在游戏AI中很有用。比如AI要决定下一步探索哪个方向就可以优先选择那些距离已探索区域最近的迷雾格子因为那些地方更有可能存在有价值的资源或路径。当然实际游戏中的逻辑会更复杂但核心的距离计算部分多源BFS是一个高效的解决方案。5.3 图像处理中的距离变换在图像处理领域有一个操作叫距离变换它的作用是计算二值图像中每个前景像素到最近背景像素的距离。这个操作在形态学分析、骨架提取、路径规划等任务中都有应用。而距离变换的经典实现算法之一就是多源BFS。具体来说把背景像素作为源点前景像素作为目标从所有背景像素同时出发做BFS每扩散一层距离加1直到覆盖所有前景像素。这样得到的距离图就是距离变换的结果。当然实际的距离变换可能会用欧几里得距离而不是曼哈顿距离但如果是用曼哈顿距离多源BFS就是最直接的方法。我之所以提到这个是想说明LeetCode上的算法题并不是孤立存在的。很多题目背后的思想在实际工程中都有对应的应用。你在刷题的时候多想一想这个算法能解决什么实际问题会让学习过程更有方向感。6. 那些年我在这道题上踩过的坑6.1 队列初始化时忘记判断全陆地/全海洋我第一次提交的时候代码逻辑基本正确但就是有两个测试用例过不了。后来一看一个是全陆地的网格一个是全海洋的网格。我的代码在这两种情况下都会返回0或者一个错误的值而不是-1。原因是我没有在BFS之前做边界判断。修复方法很简单就是在初始化队列之后检查队列的大小。如果队列为空全海洋或者队列大小等于n²全陆地直接返回-1。这个判断一定要放在BFS之前否则BFS跑完再判断就晚了。6.2 距离计数多算了一层前面提到过我在每层开始之前做distance 1导致最后的结果比实际值大1。这个问题的根源在于当队列中最后一层节点被处理完之后distance又自增了一次但实际上已经没有下一层了。修复方法有两种一种是在返回时减1另一种是把distance 1放在每层处理完之后。我选择了前者因为代码改动最小。但后来我觉得后者更直观因为distance表示的是已经处理的层数而不是即将处理的层数。6.3 修改原数组导致的意外副作用我用grid[nx][ny] 1来标记已访问这在大多数情况下没问题。但有一次我在本地测试的时候发现跑完一次之后原数组被修改了导致第二次跑的时候结果不对。这是因为Python中列表是可变对象函数内部修改会影响外部。如果你需要在多次调用之间保持原数组不变那就需要额外开一个visited矩阵或者在一开始就深拷贝一份grid。当然在LeetCode的提交环境中每次测试用例都是独立的所以这个问题不会影响判题。但在实际工程中这种副作用是需要警惕的。6.4 方向数组写错导致的诡异结果方向数组[(1,0), (-1,0), (0,1), (0,-1)]看起来很简单但我有一次手误写成了[(1,0), (-1,0), (0,1), (0,1)]最后一个方向重复了。结果就是BFS只能向右和向下扩散左和上永远访问不到。这种错误很隐蔽因为代码不会报错只是结果不对。我的建议是方向数组最好背下来或者写成一个常量放在函数外面。每次用的时候直接引用不要临时手写减少出错概率。7. 性能实测多源BFS到底比暴力解法快多少为了直观展示两种解法的性能差距我在本地做了一组对比测试。测试环境是普通的开发机Python 3.9网格大小从10x10到200x200每种规模跑10次取平均值。网格大小暴力解法耗时多源BFS耗时加速比10x100.002s0.0001s20x50x500.8s0.003s266x100x10012.5s0.012s1041x200x200超时(60s)0.05s1200x从数据可以看出随着网格规模增大暴力解法的耗时呈指数级增长而多源BFS基本保持线性增长。在100x100的规模下暴力解法需要12.5秒而多源BFS只需要0.012秒差了三个数量级。到了200x200暴力解法直接超时多源BFS依然能在0.05秒内完成。这个对比很直观地说明了算法选择的重要性。同样的功能不同的算法性能差距可能是天壤之别。所以在写代码之前先想清楚算法的时间复杂度比写完再优化要高效得多。提示在实际面试中如果你先写出暴力解法然后主动分析它的复杂度并提出优化方案这比直接写出最优解更能体现你的思维过程。面试官通常更看重你分析问题的能力而不是背诵答案的能力。8. 刷题笔记的整理方法我是怎么记录这道题的最后聊一点方法论层面的东西。我刷LeetCode有一个习惯每道题都会写一份笔记记录四个部分题目核心、解题思路、代码实现、踩坑记录。这道地图分析的笔记也不例外。题目核心部分我会用一句话概括这道题在考什么。比如这道题就是多源BFS求最大最近距离。解题思路部分我会把从暴力到优化的完整思考过程写下来包括为什么暴力不行、多源BFS为什么能优化、以及关键的实现细节。代码实现部分我会贴出最终AC的代码并加上详细注释。踩坑记录部分就是上面提到的那些坑每个坑都写清楚现象、原因、修复方法。这样整理的好处是过一段时间再回来看能快速回忆起这道题的精髓。而且当遇到类似的题目时可以直接翻笔记找模板不用从头再想一遍。我现在的笔记已经积累了上百道题形成了一个自己的知识库。每次遇到新题先翻笔记看有没有类似的有的话直接套模板没有的话再从头分析。效率比盲目刷题高很多。另外我建议在笔记里加上关联题目这一项。比如这道地图分析我会关联腐烂的橘子01矩阵岛屿的最大面积这几道题因为它们都用到了网格BFS的模板。这样在复习的时候可以一次性把相关的题目都过一遍加深对模板的理解。关于代码风格我个人的偏好是变量名尽量有意义不要用i、j、k这种单字母变量除非是循环下标。比如队列就叫queue距离就叫distance方向数组就叫directions。这样代码可读性更好过几天回来看也能快速理解。注释不用太多但关键步骤一定要写清楚意图而不是描述代码在做什么。比如从所有陆地同时出发就比遍历grid把值为1的加入队列更有信息量。好了关于这道地图分析的分享就到这里。如果你也在刷LeetCode希望这篇内容能帮你把多源BFS这个知识点吃透。下次遇到类似的网格距离问题直接套模板就行。
阅读完成 · 觉得有帮助?