LeetCode 130. 被围绕的区域:两种解法详解(BFS/DFS)

0 阅读8分钟

LeetCode 中等难度题目「130. 被围绕的区域」,这道题是典型的图的连通性问题,核心考察 BFS 和 DFS 的实际应用,还能帮我们理清“边界判断”的关键逻辑,新手也能轻松上手。

先先明确题目核心需求,避免踩坑:给一个 m x n 的矩阵,由 'X' 和 'O' 组成,我们要“捕获”所有被围绕的 'O',并原地替换成 'X';而不被围绕的 'O'(只要和矩阵边缘的 'O' 连通,就不算被围绕),要保留下来。

先划重点(题目隐藏陷阱):

  • 连接:仅水平、垂直相邻(斜向不算);

  • 被围绕:整个 'O' 区域完全不接触矩阵边缘,且被 'X' 包围;

  • 要求:原地修改矩阵,无需返回值。

拿到这道题,第一反应可能是“遍历每个 'O',判断它是否被包围”,但这样容易绕弯路(比如重复判断连通区域)。其实换个思路更高效:先找到所有不被包围的 'O'(边缘连通的),标记出来,剩下的 'O' 就是被包围的,直接替换成 'X' 即可

下面分别讲解两种解法,一种是“正向判断连通区域”(BFS),一种是“反向标记边缘连通区域”(DFS),附完整代码和详细解析。

解法一:BFS 正向遍历 + 连通区域判断(solve_1)

思路核心

遍历矩阵中每个 'O',用 BFS 遍历它所在的整个连通区域,同时判断这个区域是否“触达边缘”:

  1. 如果连通区域中有任意一个 'O' 在矩阵边缘 → 不被包围,保留为 'O';

  2. 如果连通区域所有 'O' 都不在边缘 → 被包围,全部替换为 'X';

  3. 用临时标记 'A' 避免重复遍历(遍历过程中先把 'O' 改成 'A',后续根据是否被包围,再改回 'O' 或改成 'X')。

完整代码(TypeScript)

/**
 Do not return anything, modify board in-place instead.
 */
function solve_1(board: string[][]): void {
  if (board.length === 0 || board[0].length === 0) {
    return;
  }
  const rows = board.length;
  const cols = board[0].length;
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      if (board[i][j] === 'O') {
        const queue: [number, number][] = []; // 存储当前连通区域的所有坐标(用于后续修改)
        const tempQueue: [number, number][] = []; // BFS遍历队列(用于扩散连通区域)
        let isSurround = true; // 标记当前连通区域是否被包围

        tempQueue.push([i, j]);
        board[i][j] = 'A'; // 临时标记,避免重复遍历
        queue.push([i, j]);

        // BFS遍历连通区域(上下左右四个方向)
        while (tempQueue.length > 0) {
          const [x, y] = tempQueue.shift()!; // BFS用shift()(队列:先进先出),DFS用pop()(栈:后进先出)

          // 关键判断:只要有一个坐标在边缘,当前区域就不被包围
          if (x === 0 || x === rows - 1 || y === 0 || y === cols - 1) {
            isSurround = false;
          }

          // 上:判断边界 + 是'O',才继续遍历
          if (x > 0 && board[x - 1][y] === 'O') {
            board[x - 1][y] = 'A';
            tempQueue.push([x - 1, y]);
            queue.push([x - 1, y]);
          }
          // 下
          if (x < rows - 1 && board[x + 1][y] === 'O') {
            board[x + 1][y] = 'A';
            tempQueue.push([x + 1, y]);
            queue.push([x + 1, y]);
          }
          // 左
          if (y > 0 && board[x][y - 1] === 'O') {
            board[x][y - 1] = 'A';
            tempQueue.push([x, y - 1]);
            queue.push([x, y - 1]);
          }
          // 右
          if (y < cols - 1 && board[x][y + 1] === 'O') {
            board[x][y + 1] = 'A';
            tempQueue.push([x, y + 1]);
            queue.push([x, y + 1]);
          }
        }

        // 根据是否被包围,修改当前连通区域的所有坐标
        if (isSurround) {
          // 被包围:替换为X
          for (const [x, y] of queue) {
            board[x][y] = 'X';
          }
        } else {
          // 不被包围:恢复为O
          for (const [x, y] of queue) {
            board[x][y] = 'O';
          }
        }
      }
    }
  }
};

关键细节 & 易错点

  • 两个队列的作用:tempQueue 用于 BFS 扩散遍历,queue 用于记录当前连通区域的所有坐标(方便后续批量修改),缺一不可;

  • 临时标记 'A':避免同一 'O' 被多次遍历(比如相邻的 'O' 重复触发 BFS),提升效率;

  • 边缘判断时机:遍历连通区域的每个坐标时,只要有一个坐标触达边缘,就立即将 isSurround 设为 false(无需继续判断该区域的其他坐标);

  • BFS vs DFS:这里用 shift() 实现 BFS(队列),如果换成 pop(),就是 DFS(栈),逻辑完全一致,只是遍历顺序不同。

复杂度分析

时间复杂度:O(m×n),每个单元格最多被遍历一次(临时标记 'A' 避免重复);

空间复杂度:O(m×n),最坏情况下(全是 'O'),两个队列会存储所有单元格坐标。

解法二:DFS 反向标记 + 批量修改(solve_2)

这是更高效、更简洁的解法,核心思路是“反向操作”:先标记所有不被包围的 'O'(边缘连通的),再批量处理剩余的 'O' 和标记

逻辑比解法一更清晰:边缘的 'O' 一定不被包围,它们连通的 'O' 也不被包围,先把这些 'O' 标记为 'A';最后遍历整个矩阵,把 'O'(被包围的)改成 'X',把 'A'(不被包围的)改回 'O'。

完整代码(TypeScript)

function solve_2(board: string[][]): void {
  const rows = board.length;
  if (rows === 0) return; // 边界处理:空矩阵直接返回
  const cols = board[0].length;
  const visited = new Set<string>(); // 可选:用于标记已遍历的边缘连通O,避免重复(本题可省略,因标记为'A'已实现去重)

  // DFS辅助函数:标记边缘连通的O为'A'
  const helper = (board: string[][], x: number, y: number, rows: number, cols: number): void => {
    // 边界判断 + 当前位置不是O(无需标记)
    if (
      x < 0 || x >= rows ||
      y < 0 || y >= cols ||
      board[x][y] !== 'O'
    ) {
      return;
    }

    // 标记为A(表示是边缘连通的O,不替换)
    board[x][y] = 'A';

    // 递归遍历上下左右四个方向(DFS核心:深度优先扩散)
    helper(board, x - 1, y, rows, cols); // 上
    helper(board, x + 1, y, rows, cols); // 下
    helper(board, x, y - 1, rows, cols); // 左
    helper(board, x, y + 1, rows, cols); // 右
  }

  // 第一步:遍历矩阵边缘,标记所有边缘连通的O为'A'
  // 遍历第一行和最后一行(所有列)
  for (let j = 0; j < cols; j++) {
    helper(board, 0, j, rows, cols);          // 第一行
    helper(board, rows - 1, j, rows, cols);   // 最后一行
  }
  // 遍历第一列和最后一列(排除已遍历的行边缘,避免重复)
  for (let i = 1; i < rows - 1; i++) {
    helper(board, i, 0, rows, cols);          // 第一列
    helper(board, i, cols - 1, rows, cols);   // 最后一列
  }

  // 第二步:批量修改矩阵
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      if (board[i][j] === 'O') {
        // 未被标记的O → 被包围,替换为X
        board[i][j] = 'X';
      } else if (board[i][j] === 'A') {
        // 标记过的O → 边缘连通,恢复为O
        board[i][j] = 'O';
      }
      // X保持不变,无需处理
    }
  }
};

关键细节 & 优化点

  • 反向思路的优势:无需判断每个连通区域是否被包围,只需要处理边缘及其连通的 'O',逻辑更简洁,代码量更少;

  • DFS 辅助函数:递归终止条件要完整(边界 + 非 'O'),避免数组越界;

  • 边缘遍历优化:先遍历第一行、最后一行(所有列),再遍历第一列、最后一列(排除首尾行),避免重复遍历边缘单元格;

  • visited 集合:本题可省略,因为我们用 'A' 标记了已遍历的 'O',再次遇到 'A' 时会被递归终止条件过滤;但如果不想修改原矩阵(本题要求原地修改,所以无需),可以用 visited 记录已遍历坐标。

复杂度分析

时间复杂度:O(m×n),每个单元格最多被遍历两次(一次标记,一次批量修改);

空间复杂度:O(m×n),最坏情况下(全是 'O'),递归栈深度会达到 m×n(可优化为迭代 DFS,降低空间复杂度到 O(min(m,n)))。

两种解法对比 & 选择建议

解法核心思路优点缺点适用场景
solve_1(BFS正向)遍历每个O,判断连通区域是否被包围逻辑直观,容易理解,适合新手需要两个队列,空间开销稍大新手入门,理解连通区域判断逻辑
solve_2(DFS反向)标记边缘连通O,再批量修改代码简洁,效率更高,空间更优递归可能栈溢出(可优化为迭代)实际刷题、面试(推荐写法)

面试高频考点 & 避坑指南

  • 核心考点:图的连通性(BFS/DFS)、原地修改技巧、边界判断;

  • 常见坑1:忘记处理空矩阵(board.length === 0),导致数组越界;

  • 常见坑2:边缘判断不完整(漏判某一行/一列),导致部分边缘O被误判为被包围;

  • 常见坑3:重复遍历(未用临时标记),导致超时;

  • 优化技巧:DFS 递归栈溢出时,可改为迭代 DFS(用栈模拟递归),或直接用 BFS 实现反向标记。

总结

「被围绕的区域」本质是“连通区域的边界判断”,核心思路有两种:正向判断每个连通区域是否触达边缘,或反向标记边缘连通的区域再批量处理。

实际刷题中,解法二(DFS反向标记) 更推荐,代码简洁、效率更高,也是面试中常考的最优写法;解法一适合新手理解连通区域的遍历逻辑,打好基础。

建议大家动手敲一遍代码,对比两种解法的执行过程,重点体会“临时标记”和“反向思路”的妙用——很多图论问题,换个角度就能简化逻辑。