1. 题目解析与核心思路拆解
LeetCode 130题"被围绕的区域"是一道经典的图论连通性问题,考察对矩阵遍历和边界条件的处理能力。题目要求我们将一个由'X'和'O'组成的矩阵中,所有被'X'完全包围的'O'区域替换为'X',而与边缘相连的'O'区域则保持不变。
1.1 题目关键点分析
理解这道题需要抓住几个核心要点:
- 边界定义:矩阵的四条边(第一行、最后一行、第一列、最后一列)上的'O'及其连通区域都不算被包围
- 连通规则:两个'O'只有在水平或垂直方向相邻时才视为连通(对角线不算)
- 原地修改:题目要求直接修改输入矩阵,而不是返回新矩阵
- 包围判定:一个'O'区域要被视为被包围,必须满足该区域中没有任何一个'O'位于矩阵边缘
注意:很多初学者容易忽略"与边缘连通"这个条件,试图直接判断每个'O'是否被'X'包围,这种思路会导致逻辑复杂且效率低下。
1.2 解题思路对比
这道题有两种主流解法,各有特点:
正向判断法(解法一):
- 遍历矩阵中的每个'O'
- 使用BFS/DFS找出其连通区域
- 判断该区域是否接触边缘
- 根据判断结果决定是否替换为'X'
反向标记法(解法二):
- 先找出所有与边缘连通的'O'区域并标记
- 然后遍历整个矩阵
- 将未被标记的'O'改为'X'
- 将被标记的'O'恢复为原始值
从效率角度看,解法二通常更优,因为它只需要处理边缘相关的'O',避免了不必要的连通区域判断。下面我们将深入分析这两种实现方式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. BFS正向解法详解
2.1 算法流程拆解
BFS解法的核心流程可以分为以下几个步骤:
- 矩阵遍历:逐个检查矩阵中的每个单元格
- 发现'O':当遇到'O'时,初始化BFS遍历
- 连通区域探索:
- 使用队列实现BFS
- 记录当前连通区域的所有坐标
- 检查是否触及边缘
- 结果处理:
- 如果区域被包围,全部改为'X'
- 否则恢复为'O'
typescript复制function solve(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][] = [];
let isSurrounded = true;
tempQueue.push([i, j]);
board[i][j] = 'A'; // 临时标记
queue.push([i, j]);
// BFS遍历开始
while (tempQueue.length > 0) {
const [x, y] = tempQueue.shift()!;
// 边缘检查
if (x === 0 || x === rows - 1 || y === 0 || y === cols - 1) {
isSurrounded = false;
}
// 四个方向的探索
const directions = [[-1,0],[1,0],[0,-1],[0,1]];
for (const [dx, dy] of directions) {
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && nx < rows && ny >= 0 && ny < cols && board[nx][ny] === 'O') {
board[nx][ny] = 'A';
tempQueue.push([nx, ny]);
queue.push([nx, ny]);
}
}
}
// 根据是否被包围处理结果
if (isSurrounded) {
for (const [x, y] of queue) {
board[x][y] = 'X';
}
} else {
for (const [x, y] of queue) {
board[x][y] = 'O';
}
}
}
}
}
}
2.2 关键实现细节
-
双队列设计:
tempQueue:用于BFS遍历,控制搜索过程queue:记录当前连通区域的所有坐标,便于后续批量修改
-
临时标记'A':
- 防止重复处理已经访问过的'O'
- 在探索过程中先将'O'改为'A',最后根据情况决定恢复为'O'还是改为'X'
-
边缘检查时机:
- 在BFS过程中,每处理一个坐标就检查是否位于边缘
- 一旦发现边缘接触,立即标记该区域为不被包围
-
方向数组优化:
- 使用
directions数组统一处理四个方向的移动 - 使代码更简洁且易于维护
- 使用
2.3 复杂度分析
-
时间复杂度:O(m×n)
- 每个单元格最多被处理一次(通过临时标记避免重复)
- 最坏情况下需要遍历整个矩阵
-
空间复杂度:O(m×n)
- 队列可能存储所有单元格坐标(当矩阵全为'O'时)
- 实际应用中,空间消耗通常小于这个上限
3. DFS反向解法详解
3.1 算法思路优化
反向解法的核心洞察是:与其判断每个'O'区域是否被包围,不如直接找出所有不被包围的'O'(即与边缘连通的'O'),然后剩下的'O'就是需要被替换的。
这种思路的优势在于:
- 只需要处理边缘相关的'O',减少了不必要的遍历
- 逻辑更加清晰直观
- 代码实现更简洁
3.2 具体实现步骤
-
边缘遍历:
- 检查第一行和最后一行中的所有'O'
- 检查第一列和最后一列中的所有'O'(注意跳过角落已检查的点)
-
DFS标记:
- 对每个边缘'O'进行深度优先搜索
- 标记所有连通的'O'为特殊值(如'A')
-
矩阵重构:
- 遍历整个矩阵
- 将'A'恢复为'O'
- 将未被标记的'O'改为'X'
- 遍历整个矩阵
typescript复制function solve(board: string[][]): void {
if (board.length === 0 || board[0].length === 0) return;
const rows = board.length;
const cols = board[0].length;
// DFS标记函数
const mark = (x: number, y: number) => {
if (x < 0 || x >= rows || y < 0 || y >= cols || board[x][y] !== 'O') {
return;
}
board[x][y] = 'A';
mark(x - 1, y); // 上
mark(x + 1, y); // 下
mark(x, y - 1); // 左
mark(x, y + 1); // 右
};
// 标记边缘连通区域
for (let j = 0; j < cols; j++) {
if (board[0][j] === 'O') mark(0, j); // 第一行
if (board[rows-1][j] === 'O') mark(rows-1, j); // 最后一行
}
for (let i = 1; i < rows - 1; i++) {
if (board[i][0] === 'O') mark(i, 0); // 第一列
if (board[i][cols-1] === 'O') mark(i, cols-1); // 最后一列
}
// 最终处理
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
if (board[i][j] === 'O') {
board[i][j] = 'X';
} else if (board[i][j] === 'A') {
board[i][j] = 'O';
}
}
}
}
3.3 性能优化技巧
- 迭代式DFS:
- 对于大型矩阵,递归DFS可能导致栈溢出
- 可以改用栈实现的迭代DFS:
typescript复制const mark = (x: number, y: number) => {
const stack: [number, number][] = [[x, y]];
while (stack.length > 0) {
const [cx, cy] = stack.pop()!;
if (cx < 0 || cx >= rows || cy < 0 || cy >= cols || board[cx][cy] !== 'O') {
continue;
}
board[cx][cy] = 'A';
stack.push([cx - 1, cy]);
stack.push([cx + 1, cy]);
stack.push([cx, cy - 1]);
stack.push([cx, cy + 1]);
}
};
-
边缘遍历优化:
- 只检查真正的边缘点,避免重复检查角落
- 使用单独的循环处理行边缘和列边缘
-
标记值选择:
- 使用不会与原矩阵值冲突的标记(如'A')
- 确保在最后处理阶段能正确区分不同状态
3.4 复杂度分析
-
时间复杂度:O(m×n)
- 每个单元格最多被访问两次(标记阶段和最终处理阶段)
-
空间复杂度:
- 递归实现:O(m×n)(最坏情况下递归深度)
- 迭代实现:O(min(m,n))(栈的大小)
4. 常见问题与解决方案
4.1 边界条件处理
问题1:空矩阵输入
- 解决方案:在函数开始时检查矩阵是否为空
typescript复制if (board.length === 0 || board[0].length === 0) return;
问题2:单行或单列矩阵
- 解决方案:通用解法已经涵盖这种情况,无需特殊处理
4.2 性能优化问题
问题:大规模矩阵下的栈溢出
- 解决方案:
- 使用迭代DFS替代递归DFS
- 改用BFS实现标记过程(队列通常比递归栈更可控)
4.3 代码实现陷阱
陷阱1:忘记恢复边缘连通的'O'
- 解决方案:确保在最终处理阶段正确识别并恢复标记过的'O'
typescript复制else if (board[i][j] === 'A') {
board[i][j] = 'O';
}
陷阱2:方向遍历不完整
- 解决方案:使用方向数组确保不遗漏任何方向
typescript复制const directions = [[-1,0],[1,0],[0,-1],[0,1]];
for (const [dx, dy] of directions) {
// 处理每个方向
}
4.4 面试常见问题
Q1:如何证明你的算法是正确的?
- 回答要点:
- 所有与边缘连通的'O'都会被标记并保留
- 所有被包围的'O'都会被正确识别并替换
- 通过临时标记避免了重复处理
Q2:如果矩阵非常大,如何优化内存使用?
- 回答要点:
- 使用迭代代替递归
- 考虑按块处理矩阵(如果问题允许)
- 优化数据结构,减少存储开销
Q3:如何扩展算法处理8连通区域?
- 回答要点:
- 修改方向数组包含对角线方向
- 调整连通性判断条件
- 注意边界条件可能变得更复杂
5. 解法对比与选择建议
5.1 两种解法的对比
| 特性 | BFS正向解法 | DFS反向解法 |
|---|---|---|
| 思路方向 | 从每个'O'出发判断是否被包围 | 从边缘'O'出发标记不被包围区域 |
| 代码复杂度 | 较高 | 较低 |
| 空间复杂度 | O(m×n) | O(m×n)或O(min(m,n)) |
| 适用场景 | 理解连通区域判断逻辑 | 实际编码和面试 |
| 扩展性 | 较难扩展 | 易于扩展 |
5.2 选择建议
- 学习阶段:建议先实现BFS解法,深入理解连通区域的概念和判断逻辑
- 面试准备:重点掌握DFS反向解法,这是更高效的实现方式
- 实际应用:
- 小规模矩阵:两种方法均可
- 大规模矩阵:优先考虑DFS反向解法+迭代实现
5.3 变种问题思考
-
统计被包围区域的数量
- 修改算法,在替换'O'时增加计数器
-
找到最大的被包围区域
- 在BFS解法中记录连通区域大小
- 比较并保留最大值
-
处理动态变化的矩阵
- 考虑增量更新算法
- 可能需要引入更复杂的数据结构
在实际刷题过程中,理解这道题的解法不仅能帮助我们解决类似矩阵遍历问题,还能培养逆向思维的能力。很多图论问题都可以通过"正难则反"的思路找到更优雅的解决方案。
