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 遍历它所在的整个连通区域,同时判断这个区域是否“触达边缘”:
-
如果连通区域中有任意一个 'O' 在矩阵边缘 → 不被包围,保留为 'O';
-
如果连通区域所有 'O' 都不在边缘 → 被包围,全部替换为 'X';
-
用临时标记 '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反向标记) 更推荐,代码简洁、效率更高,也是面试中常考的最优写法;解法一适合新手理解连通区域的遍历逻辑,打好基础。
建议大家动手敲一遍代码,对比两种解法的执行过程,重点体会“临时标记”和“反向思路”的妙用——很多图论问题,换个角度就能简化逻辑。