算法面试必考:深度解析岛屿问题的DFS、BFS与并查集解法
1. 从一道经典面试题说起为什么“岛屿问题”是算法试金石如果你正准备技术面试或者在学习算法与数据结构那么“岛屿问题”这个名字你一定不陌生。它几乎是所有算法面试题库里的“钉子户”从初级到资深岗位都可能遇到它的变种。我第一次遇到这个问题是在一次模拟面试中题目很简单给定一个由1陆地和0水组成的二维网格计算网格中岛屿的数量。一个岛屿被水包围并且通过水平或垂直方向上相邻的陆地连接形成。当时我心想这不就是个简单的遍历吗结果在实现时边界条件、访问标记、递归深度……一堆细节让我手忙脚乱。后来我才明白“岛屿问题”远不止是数岛屿个数。它是一个算法思想的载体一个检验基本功的标尺。它之所以经典是因为它完美地将二维网格的遍历、图的搜索、连通性判断这些核心概念封装在一个直观的场景里。通过解决它你可以清晰地展示对深度优先搜索DFS、广度优先搜索BFS和并查集Union-Find, UF这三种截然不同但又至关重要的算法的理解与应用能力。面试官通过这道题能快速考察你的问题建模、代码实现、边界处理乃至算法优化的思维层次。今天我们就抛开那些浮于表面的题解深入“岛屿问题”的腹地。我将结合自己多次“踩坑”和面试别人的经验为你拆解DFS、BFS、UF这三种解法的核心思想、实现细节、性能差异以及各自的适用场景。我们不止于“写出能跑的代码”更要弄明白“为什么选这个算法”以及“实际编码时哪里最容易出错”。无论你是正在刷题的学生还是需要巩固基础的开发者这篇文章都将带你重新认识这个老问题背后的新世界。2. 问题定义与建模把地图抽象成图在深入算法之前我们必须把问题本身吃透。所谓“岛屿问题”其实是一个系列最常见的有以下三种变体岛屿数量Number of Islands计算网格中岛屿的总数。这是最基础的形式。岛屿的最大面积Max Area of Island找到网格中面积最大的岛屿并返回其面积即相连的1的个数。岛屿的周长Island Perimeter计算所有岛屿的周长总和。尽管问题不同但它们的核心模型是一致的将一个二维网格Grid建模为一个无向图Graph。节点Node网格中的每一个格子Cell就是一个节点。边Edge如果两个相邻的格子上下左右四方向都是陆地1那么它们之间就存在一条边代表它们属于同一个岛屿。连通分量Connected Component一个岛屿就是图中的一个连通分量即通过边相互连接的一组陆地节点。基于这个模型我们的核心任务就变成了在一个图中找出所有互不连通的连通分量。对于“数量”问题我们统计分量个数对于“面积”问题我们找出包含节点最多的分量对于“周长”问题我们需要计算每个分量边界与“水”相邻的边数。有了这个清晰的图模型我们再来审视三种算法就会发现它们其实是解决“寻找连通分量”这一问题的三种不同策略。3. 深度优先搜索DFS递归的优雅与栈的隐忧DFS是解决岛屿问题最直观、代码最简洁的方法。它的核心思想是“一条路走到黑碰壁再回头”。从一个未被访问的陆地出发尽可能深地探索其所有相邻的陆地直到所有相连的陆地都被访问并标记。这个过程就像用油漆涂抹一个岛屿从一点开始涂满整个区域。3.1 递归实现清晰但需警惕深度递归实现是DFS最自然的表达。以下是解决“岛屿数量”问题的递归DFS核心代码以Python为例def numIslands(grid): if not grid: return 0 rows, cols len(grid), len(grid[0]) count 0 def dfs(r, c): # 递归终止条件越界、遇到水、或已访问 if r 0 or r rows or c 0 or c cols or grid[r][c] ! 1: return # 标记当前单元格为已访问原地修改将1改为0或其他标记 grid[r][c] 0 # 递归探索四个方向 dfs(r 1, c) # 下 dfs(r - 1, c) # 上 dfs(r, c 1) # 右 dfs(r, c - 1) # 左 for r in range(rows): for c in range(cols): if grid[r][c] 1: # 发现一个新岛屿的起点 count 1 dfs(r, c) # “淹没”整个岛屿 return count为什么这样设计原地标记我们直接将访问过的陆地1修改为0。这节省了额外的visited矩阵空间是这类问题的常用技巧。但请注意这破坏了原始输入数据。如果题目要求不能修改原数组则需要创建一个等大的visited布尔矩阵。递归方向顺序上下左右的顺序无关紧要只要覆盖四个邻接方向即可。递归终止条件这是递归正确性的保证。必须包含网格边界检查、非陆地判断以及已访问判断通过将1改为0隐式实现。实操心得与避坑指南栈溢出风险这是递归DFS最大的隐患。想象一个极端情况整个网格全是1形成一个超大的岛屿。递归深度将达到M * N网格单元格总数。对于较大的网格例如1000x1000递归深度可能超过Python默认的递归深度限制约1000层导致RecursionError。解决方案对于可能的大规模输入优先考虑使用迭代DFS显式栈或BFS。或者在明确知道数据规模可控时使用递归。方向数组的运用代码中四个方向的递归调用显得有些重复。更优雅的做法是使用一个方向数组dirs [(1,0), (-1,0), (0,1), (0,-1)]然后在循环中调用dfs(rdr, cdc)。这使代码更简洁也更容易扩展到八方向斜对角连通的情况。“淹没”与“标记”的选择对于“岛屿数量”将1改为0淹没是高效的。但对于“岛屿最大面积”你需要在DFS过程中计数此时可以改为一个不会与1和0冲突的标记如2或者在visited矩阵中标记。3.2 迭代实现显式栈规避递归深度的利器当担心递归深度时我们可以用栈Stack来模拟递归过程实现迭代DFS。def numIslands_dfs_iterative(grid): if not grid: return 0 rows, cols len(grid), len(grid[0]) count 0 dirs [(1,0), (-1,0), (0,1), (0,-1)] for r in range(rows): for c in range(cols): if grid[r][c] 1: count 1 stack [(r, c)] grid[r][c] 0 # 入栈即标记 while stack: cr, cc stack.pop() # 栈顶弹出实现深度优先 for dr, dc in dirs: nr, nc cr dr, cc dc if 0 nr rows and 0 nc cols and grid[nr][nc] 1: stack.append((nr, nc)) grid[nr][nc] 0 # 入栈即标记避免重复入栈 return count为什么用栈栈的“后进先出”LIFO特性保证了我们总是优先探索最新发现的节点模拟了递归“深入”的行为。注意stack.pop()弹出的是最后一个元素。与递归的对比优势完全避免了递归深度限制理论上可以处理任意大的连通分量。劣势代码比递归稍显复杂需要手动管理栈和循环。在探索路径非常深时显式栈的内存开销与递归的调用栈开销是同一量级的。经验之谈在面试中如果你先写出递归DFS面试官很可能会追问“如果网格非常大递归可能导致栈溢出怎么办” 这时你能流畅地给出迭代DFS方案并解释栈与递归的等价关系绝对是加分项。4. 广度优先搜索BFS层序遍历与队列的掌控BFS采用了与DFS相反的策略“广撒网层层推进”。从一个起点开始先访问所有直接相邻的节点再访问这些相邻节点的相邻节点以此类推。在岛屿问题中BFS像水波纹一样从一个陆地中心扩散开来直到覆盖整个岛屿。4.1 BFS实现与队列的核心作用BFS的实现离不开队列Queue这个数据结构。以下是BFS解决“岛屿数量”的代码from collections import deque def numIslands_bfs(grid): if not grid: return 0 rows, cols len(grid), len(grid[0]) count 0 dirs [(1,0), (-1,0), (0,1), (0,-1)] for r in range(rows): for c in range(cols): if grid[r][c] 1: count 1 grid[r][c] 0 queue deque() queue.append((r, c)) while queue: cr, cc queue.popleft() # 队列头弹出实现广度优先 for dr, dc in dirs: nr, nc cr dr, cc dc if 0 nr rows and 0 nc cols and grid[nr][nc] 1: queue.append((nr, nc)) grid[nr][nc] 0 return count为什么用队列队列的“先进先出”FIFO特性保证了节点按照被发现的顺序依次被访问。这确保了我们先处理完当前“层”距离起点为d的所有节点的所有节点再处理下一“层”距离为d1的节点。BFS与DFS的关键区别访问顺序DFS追求深度BFS追求广度。数据结构DFS用栈递归调用栈或显式栈BFS用队列。空间复杂度在最坏情况下例如只有一个巨大的岛屿BFS队列中可能同时存储多达 O(min(M, N)) 个节点想象一个细长的岛屿边界。而DFS递归的空间复杂度是 O(MN)整个岛屿的递归深度。对于极端形状的网格BFS在空间上可能有优势但通常两者在最坏情况下的空间复杂度都被认为是 O(MN)。4.2 BFS的适用场景与实战技巧虽然对于基础的“岛屿数量”问题BFS和DFS可以互换但在某些变体问题中BFS的特性使其成为更自然或更优的选择。“最短路径”类岛屿问题如果问题变成“从某个陆地到另一个陆地的最短路径长度”假设只能走陆地那么BFS是天然的解。因为BFS第一次访问到目标节点时经过的层数就是最短路径长度。DFS则无法保证这一点。层次信息需求如果需要知道岛屿的“轮廓”或者按距离中心点的远近进行处理BFS的层序遍历特性提供了便利。避免递归开销和迭代DFS一样BFS也完全避免了递归调用带来的开销和深度限制。编码细节提醒入队即标记在将相邻节点加入队列的同时就立即将其标记为已访问如grid[nr][nc] 0。这是至关重要的一步。如果等从队列中弹出时才标记会导致同一个节点被多次加入队列造成时间和空间的浪费甚至在极端情况下导致死循环。使用dequePython中使用collections.deque作为队列比使用listpop(0)操作是O(N)在性能上要好得多因为popleft()是O(1)操作。5. 并查集Union-Find动态连通性的高效管理并查集是一种专门用于处理动态连通性问题的数据结构。它不关心搜索或遍历的顺序而是专注于高效地“合并”连通区域和“查询”两个元素是否属于同一区域。对于岛屿问题我们可以将每个陆地单元格初始化为一个独立的集合然后遍历网格将相邻的陆地单元格进行“合并”Union。最终集合的数量就是岛屿的数量。5.1 并查集的核心操作与实现一个经典的并查集实现包含以下部分class UnionFind: def __init__(self, n): self.parent list(range(n)) # 每个节点的父节点初始指向自己 self.rank [0] * n # 基于秩的优化树的高度 self.count n # 连通分量的个数 def find(self, x): # 路径压缩在查找根节点时将路径上的节点直接指向根 if self.parent[x] ! x: self.parent[x] self.find(self.parent[x]) return self.parent[x] def union(self, x, y): rootX self.find(x) rootY self.find(y) if rootX rootY: return # 已经在同一集合 # 按秩合并将矮树合并到高树下保持树平衡 if self.rank[rootX] self.rank[rootY]: self.parent[rootX] rootY elif self.rank[rootX] self.rank[rootY]: self.parent[rootY] rootX else: self.parent[rootY] rootX self.rank[rootX] 1 self.count - 1 # 合并后分量数减一为什么需要路径压缩和按秩合并这是并查集高效的关键。简单的实现parent数组直接存储父节点在多次操作后可能退化成一条链使得find操作变慢。路径压缩在查询时“拍平”树结构按秩合并则在合并时避免树变得过高。这两种优化使得单次操作的均摊时间复杂度接近常数级 O(α(n))其中 α 是增长极慢的反阿克曼函数。5.2 应用并查集解决岛屿问题要将并查集应用到二维网格我们需要将二维坐标映射到一维索引。通常使用index r * cols c。def numIslands_uf(grid): if not grid: return 0 rows, cols len(grid), len(grid[0]) # 初始化并查集只给陆地单元格分配位置 # 首先统计初始陆地数量 uf UnionFind(rows * cols) # 但我们需要知道哪些位置是水哪些是陆地。更高效的做法是只连接陆地。 # 我们可以先假设所有单元格都是水不属于任何岛屿遇到陆地时再处理连接。 # 实际上我们初始化一个大小为 rows*cols 的UF但初始分量数是 rows*cols每个单元格独立。 # 在遍历中我们只对陆地单元格进行合并并且需要忽略水单元格。 # 一个技巧先遍历一遍只将陆地单元格的parent设为自身水单元格的parent设为一个特殊值如-1但这样较复杂。 # 更清晰的做法在遍历中只处理值为1的单元格及其右侧、下侧的邻居避免重复连接。 # 简化实现遍历所有单元格如果是陆地先将其视为一个独立分量。 # 但实际上UF初始化时已经将所有位置视为独立。我们需要在合并后从总数中减去水的部分。 # 标准做法 water_count 0 for r in range(rows): for c in range(cols): if grid[r][c] 0: water_count 1 else: # 如果是陆地检查其右侧和下侧的邻居避免重复检查左上 if r 1 rows and grid[r1][c] 1: uf.union(r * cols c, (r1) * cols c) if c 1 cols and grid[r][c1] 1: uf.union(r * cols c, r * cols (c1)) # 岛屿数量 总单元格数 - 水单元格数 - (总合并次数)不对。 # 并查集的 count 属性记录的是当前连通分量的总数包括水和陆地。 # 我们需要的是陆地连通分量的数量。 # 因此更好的初始化是只为陆地单元格创建并查集元素。 # 让我们换一种更清晰的实现 def numIslands_uf_clear(grid): if not grid: return 0 rows, cols len(grid), len(grid[0]) dummy_node rows * cols # 虚拟节点用于代表“水” uf UnionFind(rows * cols 1) # 多一个位置给虚拟节点 for r in range(rows): for c in range(cols): if grid[r][c] 1: # 当前是陆地 index r * cols c # 检查下方和右方的陆地进行合并 if r 1 rows and grid[r1][c] 1: uf.union(index, (r1) * cols c) if c 1 cols and grid[r][c1] 1: uf.union(index, r * cols (c1)) else: # 当前是水将其与虚拟节点合并 index r * cols c uf.union(index, dummy_node) # 此时所有水单元格都与 dummy_node 连通。 # 岛屿数量 总连通分量数 - 1 (减去水所在的这个连通分量) return uf.count - 1并查集解法的核心逻辑初始化为网格中每个单元格创建一个并查集元素同时额外创建一个代表“所有水”的虚拟节点。遍历与合并遍历每个单元格。如果是陆地1检查其右侧和下方的邻居只需检查两个方向避免重复连接。如果邻居也是陆地则执行union操作将它们合并到同一个集合中。如果是水0则将其与虚拟水节点合并。统计结果遍历结束后并查集中连通分量的总数减去1减去代表所有水的那个大集合剩下的就是陆地连通分量即岛屿的数量。为什么只检查右和下因为遍历是从左上到右下进行的。检查当前单元格的右邻居和下邻居可以确保每对相邻的陆地都会被连接一次且仅一次。如果检查四个方向会导致重复的union操作虽然结果正确但效率稍低。5.3 并查集的优劣与思考优势动态处理能力强如果题目变为“动态添加陆地”如numIslands II并查集是唯一高效的选择。DFS/BFS在每次添加后都需要重新全局遍历而并查集只需处理新陆地与周围的合并。理论时间复杂度优经过优化的并查集每次union或find操作接近常数时间。整体算法时间复杂度接近 O(MN * α(MN))在处理超大网格时具有理论优势。劣势与注意事项代码复杂度高需要单独实现并查集数据结构代码量比DFS/BFS大。不直观对于“淹没岛屿”或“计算面积”这类需要遍历所有节点的问题并查集并不直接支持。你需要额外维护每个集合的大小等信息增加了复杂度。初始化开销需要为每个网格单元格创建并查集元素空间复杂度为 O(M*N)。经验之谈在面试中如果面试官问“还有别的方法吗”你提出并查集并清晰地说出它的适用场景动态连通性和优劣对比能极大展现你的算法知识广度。但对于静态的、只需一次计算的问题DFS/BFS通常是更简单直接的选择。6. 三种算法的对比与选型指南为了更直观地对比我将三种算法的核心特点总结如下表特性深度优先搜索 (DFS)广度优先搜索 (BFS)并查集 (Union-Find)核心思想递归/栈深入探索队列层层扩散集合合并与查询时间复杂度O(M*N)O(M*N)O(MN * α(MN))接近O(M*N)空间复杂度O(M*N) (递归栈) / O(min(M, N)) (显式栈最坏)O(min(M, N)) (队列)O(M*N) (parent数组)代码简洁性递归非常简洁迭代稍复杂中等需管理队列相对复杂需实现数据结构适用问题静态岛屿数量、面积、周长静态岛屿数量、最短路径问题静态岛屿数量、动态添加陆地问题优势代码直观易于实现变体如面积可求最短路径空间可能更优动态操作高效理论复杂度优劣势递归有栈溢出风险对于简单问题优势不明显代码复杂不适用于需遍历节点的问题如何选择—— 我的实战决策流程看问题是否动态如果问题涉及陆地单元格的动态增加Online首选并查集。这是它的主场。看是否需要路径信息如果需要找最短路径或层次信息首选BFS。看问题是否简单静态对于经典的、一次性的“岛屿数量”、“最大面积”、“周长”问题优先考虑递归DFS。因为它代码最短最不容易出错面试时能快速写出来。考虑数据规模如果网格特别大如数万乘数万担心递归栈溢出可以使用迭代DFS或BFS。作为备选方案并查集是展示你知识广度的好工具。当面试官要求给出不同解法时它可以作为继DFS/BFS之后的第三个方案。记住没有绝对最好的算法只有最适合当前场景的算法。理解它们的本质你就能在遇到变体问题时游刃有余。7. 举一反三解决岛屿问题的变体掌握了核心算法我们来看看如何将它们应用到开头提到的变体问题上。7.1 岛屿的最大面积Max Area of Island这个问题要求我们遍历每个岛屿时统计其大小。DFS和BFS稍作修改即可。DFS解法递归def maxAreaOfIsland(grid): if not grid: return 0 rows, cols len(grid), len(grid[0]) max_area 0 def dfs(r, c): if r 0 or r rows or c 0 or c cols or grid[r][c] ! 1: # 注意输入可能是整数1 return 0 grid[r][c] 0 # 标记为已访问 area 1 # 当前单元格面积 area dfs(r1, c) area dfs(r-1, c) area dfs(r, c1) area dfs(r, c-1) return area for r in range(rows): for c in range(cols): if grid[r][c] 1: max_area max(max_area, dfs(r, c)) return max_area关键修改dfs函数返回以(r, c)为起点的岛屿面积。通过累加四个方向的返回值得到总面积。7.2 岛屿的周长Island Perimeter计算周长可以换一个角度遍历每个陆地单元格检查其四个边界。如果相邻格子是水或者出界则这条边就是岛屿边界的一部分周长加1。def islandPerimeter(grid): if not grid: return 0 rows, cols len(grid), len(grid[0]) perimeter 0 dirs [(1,0), (-1,0), (0,1), (0,-1)] for r in range(rows): for c in range(cols): if grid[r][c] 1: for dr, dc in dirs: nr, nc r dr, c dc # 如果邻居是水或出界则这条边是周长的一部分 if nr 0 or nr rows or nc 0 or nc cols or grid[nr][nc] 0: perimeter 1 return perimeter思考这种方法的时间复杂度是 O(4MN) O(M*N)。我们并没有使用DFS/BFS去“淹没”岛屿而是直接基于每个陆地单元格进行局部判断。这说明了并非所有岛屿问题都必须用搜索或并查集有时简单的计数更高效。8. 总结与进阶思考通过以上的拆解我们可以看到“岛屿问题”这个看似简单的题目是串联起图论基础算法DFS/BFS和高级数据结构并查集的绝佳桥梁。它考察的远不止是代码实现更是对问题本质的理解和算法工具的灵活运用。在我自己的学习和面试经历中关于岛屿问题还有以下几点进阶思考方向扩展如果岛屿的连通性定义为八个方向包括对角线代码只需要修改方向数组dirs即可。这对应着图像处理中的“8连通”概念。并行化可能对于超大规模的网格例如卫星地图处理并查集由于其局部合并的特性比需要全局顺序遍历的DFS/BFS更容易进行并行化改造。内存优化对于极其稀疏的网格陆地很少我们可以不创建M*N大小的visited数组或并查集而是只记录陆地坐标但这通常会增加算法的逻辑复杂度。从“岛屿”到“图”彻底理解岛屿问题后你可以尝试解决更一般的连通分量问题例如在社交网络中寻找社区在电路板中寻找连通区域等。算法思想是相通的。最后我的建议是不要满足于AC通过一道题。尝试用三种方法都实现一遍分析它们的时间和空间消耗思考各自的优缺点。下次遇到面试官追问“还有别的方法吗”或者“如果数据动态变化怎么办”时你就能从容不迫对答如流。算法学习的精髓正在于这种从多角度审视和解决同一问题的思维训练。