代表性面试主题

算法面试:如何用迭代 DFS 计算岛屿数量?

编程题中等
OfferCC 编辑团队发布 更新

题干

给定一个由字符 1(陆地)和 0(水)组成的二维网格,只考虑水平和垂直相邻,返回岛屿数量。你会如何设计、证明并实现一个能处理大块连续陆地的解法?

题干与适用场景

给定一个 m × n 二维网格,元素只可能是字符 "1"(陆地)或 "0"(水)。两个陆地格只有在水平或垂直相邻时才连通;一座岛屿是一个最大的连通陆地集合。返回岛屿数量。

约束为 1 <= m, n <= 300。实现仍会防御空数组,避免把调用方约束写成运行时崩溃。默认允许修改输入网格;若调用方要求保留输入,就改用同尺寸的 visited 矩阵。

这是一道通用算法题,适合软件工程岗位的编码轮。它考察把矩阵转成隐式图、遍历连通分量,以及让代码与复杂度分析保持一致。

面试官考察点

强回答会先把每个陆地格视为顶点,把四方向相邻视为边。随后得到关键结论:扫描时每遇到一个尚未访问的陆地格,就发现了一个新的连通分量;从该格出发遍历并标记整座岛屿,之后不会重复计数。

实现细节同样重要。邻居应在入栈时标记,而非出栈时才标记,否则同一格可能被多个邻居重复压栈。迭代 DFS 避免一整块陆地导致递归调用栈过深。强回答还会准确说明:原地标记省掉了 visited 矩阵,但显式栈最坏仍需 O(mn) 空间。

普通回答往往只说“用 DFS”,却没有定义连通规则、修改输入的副作用、正确性不变量或对抗测试。

回答前需要澄清的问题

  • 连通是否包含对角线? 本题只算四方向;若包含八方向,只需扩充方向数组,答案可能改变。
  • 能否修改输入? 可以时把访问过的 "1" 改成 "0";不可以时使用 visited,时间不变但增加 O(mn) 存储。
  • 网格是否规则且非空? 题目保证矩形且非空;生产函数仍可对空输入返回 0。若允许锯齿数组,边界判断必须按当前行长度处理。
  • 只求静态总数,还是持续加入陆地后查询? 静态网格适合 DFS 或 BFS;动态加点更适合并查集。
  • 数据规模与语言栈限制是什么? 300×300 全为陆地时可能形成 90,000 层递归路径,因此这里选择显式栈。

30 秒回答框架

“我把陆地格看成隐式图的顶点,四方向相邻就是边。按行扫描网格;每次遇到 1,它一定属于尚未处理的新连通分量,所以岛屿数加一。然后用显式栈做 DFS,把可达陆地在入栈时改成 0。这样每个格最多入栈一次,不会重复计数,也规避了深递归。时间是 O(mn),显式栈最坏 O(mn)。若输入不可修改,我会用 visited 矩阵保存访问状态。”

分步骤深入解答

第一步:从暴力重复搜索找到瓶颈

如果从每个陆地格都独立搜索其连通区域,同一座岛会被反复遍历。瓶颈不在找邻居,而在没有跨搜索保存“已经属于某个已计数分量”的状态。

全局扫描配合永久访问标记可消除重复:只有扫描遇到尚未标记的陆地时才启动一次搜索。

第二步:建立计数不变量

扫描到位置 (r, c) 时,之前启动过的每次 DFS 恰好标记了一整个岛屿。若当前格仍为 "1",它不可能属于这些岛,因此必须是一个新岛的起点,计数加一。

从该点出发的 DFS 只沿四方向陆地移动,所以不会越过水连接两座岛;同时会访问所有可达陆地,因此这座岛以后不会再次触发计数。这同时证明了“不漏计”和“不重计”。

第三步:在入栈时标记

假设一个未标记格同时邻接两个已在栈中的格。若等到出栈才标记,这两个格都可能把它压栈。结果通常仍正确,但栈里出现重复工作,复杂度推导也变得松散。

发现邻居时立刻把它改为 "0",再入栈。此后任何方向再次看到它都不会重复加入,保证每个陆地格最多入栈一次。

第四步:实现迭代 DFS

javascript
function numIslands(grid) {
  if (grid.length === 0 || grid[0].length === 0) return 0;

  const rows = grid.length;
  const cols = grid[0].length;
  const directions = [[1, 0], [-1, 0], [0, 1], [0, -1]];
  let islands = 0;

  for (let row = 0; row < rows; row += 1) {
    for (let col = 0; col < cols; col += 1) {
      if (grid[row][col] !== "1") continue;

      islands += 1;
      grid[row][col] = "0";
      const stack = [[row, col]];

      while (stack.length > 0) {
        const [currentRow, currentCol] = stack.pop();

        for (const [rowOffset, colOffset] of directions) {
          const nextRow = currentRow + rowOffset;
          const nextCol = currentCol + colOffset;

          if (
            nextRow >= 0 && nextRow < rows &&
            nextCol >= 0 && nextCol < cols &&
            grid[nextRow][nextCol] === "1"
          ) {
            grid[nextRow][nextCol] = "0";
            stack.push([nextRow, nextCol]);
          }
        }
      }
    }
  }

  return islands;
}

扫描检查 mn 个格;每个陆地格最多入栈一次,并检查四个邻居,所以时间为 O(mn)。显式栈在全陆地网格上最坏保存 O(mn) 个坐标。输入被原地修改;若复制网格,复制本身也需要 O(mn) 时间和空间。

第五步:用边界和对抗用例验证

至少检查:空数组返回 0;单个水格返回 0;单个陆地格返回 1;全水返回 0;全陆地返回 1;只有对角线相邻的两个陆地返回 2;题目示例中的三个分离区域返回 3;300×300 全陆地不会触发递归溢出。

再检查输入副作用。若测试后还要复用原网格,应在调用前复制,或改用 visited。这项选择必须出现在接口说明中,不能藏在实现里。

第六步:比较替代方案

BFS 与迭代 DFS 的时间和最坏空间相同。需要按距离分层处理时选 BFS;这里只需要穷尽连通分量,两者都合适。递归 DFS 更短,只有在网格足够小或语言明确支持所需深度时才更简单。并查集适合陆地持续加入、每步都要返回岛屿数的版本;对一次静态计数会增加索引和集合维护成本。

高质量示范回答

“这道题等价于统计隐式无向图的连通分量。每个 1 是顶点,上下左右的 1 之间有边。我会扫描整个网格:如果当前位置还是 1,此前的搜索就没有到达它,因此发现了一座新岛,计数加一;随后从它开始迭代 DFS,把整座岛改成 0。

我会在邻居入栈时就标记,防止它被多个相邻格重复压栈。选择显式栈是因为 300×300 全陆地可能形成很深的递归路径。每格最多被处理一次,每次只看四个方向,所以时间 O(mn),栈最坏 O(mn)。这个版本会修改输入;如果接口要求保留网格,我会把标记放进 O(mn) 的 visited 矩阵。最后我会用对角线不连通、全水、全陆地和空输入测试边界。”

这段回答把建模、计数依据、实现风险、副作用和验证连成一条推导链,不依赖背诵模板。

常见错误

  • 把对角线也算作连通 → 改变题意并可能少算岛屿 → 方向数组只保留上下左右。
  • 出栈时才标记 → 同一格可能被多个邻居重复压栈 → 发现有效邻居时立即标记再入栈。
  • 宣称原地方案空间 O(1) → 忽略显式栈的最坏规模 → 报告辅助空间最坏 O(mn)。
  • 递归 DFS 不讨论深度 → 大块连续陆地可能耗尽语言调用栈 → 使用迭代遍历或明确规模保证。
  • 偷偷修改调用方数据 → 后续逻辑读到被清空的网格 → 在接口中说明副作用,必要时使用 visited。
  • 每个陆地都重新搜索 → 同一分量被反复遍历 → 只从未访问陆地启动搜索。
  • 只测常规矩形 → 漏掉空、全水、全陆地和对角线反例 → 用最小、极端和对抗用例覆盖假设。

追问及应对

追问一:如果不允许修改输入呢?

创建 m × n 布尔矩阵,在入栈时把对应位置设为已访问。计数不变量和时间 O(mn) 不变;额外存储明确增加到 O(mn)。若允许复制输入,复制与 visited 的渐进空间相同,只是语义不同。

追问二:如果对角线也算连通呢?

把方向从四个扩展为八个,遍历框架不变。应先用 [[1, 0], [0, 1]] 这类只对角相邻的例子确认预期,因为四方向答案为 2,八方向答案为 1。

追问三:如果陆地会逐个加入,并要求每次返回数量呢?

静态 DFS 会重复扫描。改用并查集:新陆地先让计数加一,再与每个已存在的相邻陆地合并;每次成功合并两个不同集合,计数减一。还要处理重复添加,避免重复加一。

追问四:如果网格非常稀疏且坐标范围巨大呢?

不应分配完整矩阵。用哈希集合保存实际陆地坐标,只遍历这些坐标并查四邻居;复杂度以陆地数 k 表示,期望时间 O(k),访问集合和栈为 O(k)。这改变了输入表示,只有稀疏坐标列表可用时才成立。

公开来源

同类题目

相关面试工具

用 Screenshot 处理算法题

截图题目后,按顺序看约束、解法、代码、边界条件和复杂度。

查看工具