这道力扣130题"被围绕的区域"乍看简单,实则暗藏玄机。题目要求我们将二维矩阵中所有被'X'完全包围的'O'区域全部替换为'X',而边缘相连的'O'区域则保持不变。这种"包围"与"保留"的二元判断,正是考察我们对图遍历算法的深入理解。
我第一次做这道题时,直觉反应是直接扫描整个矩阵,遇到'O'就进行DFS/BFS判断是否被包围。但很快发现这种思路存在致命缺陷——对于大型矩阵,这种逐个判断的方式会导致大量重复计算,时间复杂度可能达到O(n^4)级别。经过反复推敲,最终采用了更聪明的"逆向思维"解法:
与其费力判断哪些'O'被包围,不如先标记出肯定不会被包围的'O'(即与边缘相连的区域),剩下的'O'自然就是被包围的。
这种思路转换将问题复杂度直接降为O(n^2),是典型的空间换时间策略。具体实现分为三个关键步骤:
边缘处理是这道题的第一个关键点。我们需要特别注意矩阵的四个边界:
cpp复制// 处理首行和末行
for(int i = 0; i < board.size(); i++) {
if(i == 0 || i == board.size() - 1) {
for(int k = 0; k < board[0].size(); k++) {
if(board[i][k] == 'O') {
board[i][k] = '1';
become(board,i,k); // 标记连通区域
}
}
}
}
// 处理中间行的首列和末列
for(int i = 1; i < board.size() - 1; i++) {
for(int k = 0; k < board[0].size(); k++) {
if(k == 0 || k == board[0].size() - 1) {
if(board[i][k] == 'O') {
board[i][k] = '1';
become(board,i,k); // 标记连通区域
}
}
}
}
这里有几个值得注意的细节:
become函数使用BFS(广度优先搜索)来标记所有与边缘'O'相连的区域:
cpp复制void become(vector<vector<char>>& board, int i, int k) {
queue<PII> que;
que.push({i,k});
while(que.size()) {
auto [x,y] = que.front();
que.pop();
for(int a = 0; a < 4; a++) {
int new_x = x + dx[a];
int new_y = y + dy[a];
if(new_x >= 0 && new_x < board.size() &&
new_y >= 0 && new_y < board[0].size() &&
board[new_x][new_y] == 'O') {
board[new_x][new_y] = '1';
que.push({new_x,new_y});
}
}
}
}
BFS的实现有几个关键点:
dx/dy简化四方向移动代码完成边缘标记后,主处理逻辑就变得非常清晰:
cpp复制for(int i = 0; i < board.size(); i++) {
for(int k = 0; k < board[0].size(); k++) {
if(board[i][k] == 'O') {
board[i][k] = 'X'; // 被包围的'O'改为'X'
}
if(board[i][k] == '1') {
board[i][k] = 'O'; // 恢复边缘'O'
}
}
}
这个双重循环完成了两个任务:
该算法的时间复杂度主要由三部分组成:
因此总体时间复杂度为O(n^2),这是处理二维矩阵问题的常见复杂度。
空间消耗主要来自:
在实际面试中,可以讨论使用迭代而非递归来避免栈溢出风险,这也是为什么示例代码选择BFS实现。
在实现这道题时,容易犯的几个错误:
边界条件遗漏:忘记处理中间行的首尾列
cpp复制// 错误示例:漏掉了中间行的首尾列
for(int i = 0; i < board.size(); i++) {
if(i == 0 || i == board.size() - 1) {
// 只处理了首行和末行
}
}
标记冲突:使用不恰当的标记字符导致混淆
cpp复制// 错误示例:使用'X'作为临时标记
board[i][k] = 'X'; // 这会与原有'X'混淆
无限循环:BFS/DFS中缺少访问标记
cpp复制// 错误示例:缺少已访问判断
if(board[new_x][new_y] == 'O') {
que.push({new_x,new_y}); // 可能导致重复处理
}
当算法出现问题时,可以采用以下调试方法:
cpp复制void printBoard(const vector<vector<char>>& board) {
for(const auto& row : board) {
for(char c : row) cout << c;
cout << endl;
}
}
这道题在面试中主要考察:
基于相同思想的其他题目:
这类矩阵遍历问题通常有以下解题模式:
在实际工程中,类似的算法可用于:
让我们再完整审视整个解决方案,理解每个部分的设计考量:
cpp复制class Solution {
int dx[4] = {0,0,1,-1}; // 水平方向移动
int dy[4] = {1,-1,0,0}; // 垂直方向移动
typedef pair<int,int> PII; // 坐标类型定义
public:
void solve(vector<vector<char>>& board) {
if(board.empty()) return; // 空矩阵处理
// 第一步:标记边缘'O'及其连通区域
markEdgeRegions(board);
// 第二步:转换内部'O'为'X'
convertInternalRegions(board);
// 第三步:恢复边缘'O'
restoreEdgeRegions(board);
}
private:
void markEdgeRegions(vector<vector<char>>& board) {
int rows = board.size();
int cols = board[0].size();
// 处理首行和末行
for(int col = 0; col < cols; ++col) {
if(board[0][col] == 'O') {
board[0][col] = '1';
bfsMark(board, 0, col);
}
if(board[rows-1][col] == 'O') {
board[rows-1][col] = '1';
bfsMark(board, rows-1, col);
}
}
// 处理首列和末列(跳过已处理的首末行)
for(int row = 1; row < rows-1; ++row) {
if(board[row][0] == 'O') {
board[row][0] = '1';
bfsMark(board, row, 0);
}
if(board[row][cols-1] == 'O') {
board[row][cols-1] = '1';
bfsMark(board, row, cols-1);
}
}
}
void bfsMark(vector<vector<char>>& board, int i, int k) {
queue<PII> que;
que.push({i,k});
while(!que.empty()) {
auto [x,y] = que.front();
que.pop();
for(int dir = 0; dir < 4; ++dir) {
int nx = x + dx[dir];
int ny = y + dy[dir];
if(nx >= 0 && nx < board.size() &&
ny >= 0 && ny < board[0].size() &&
board[nx][ny] == 'O') {
board[nx][ny] = '1';
que.push({nx,ny});
}
}
}
}
void convertInternalRegions(vector<vector<char>>& board) {
for(auto& row : board) {
for(auto& cell : row) {
if(cell == 'O') {
cell = 'X';
}
}
}
}
void restoreEdgeRegions(vector<vector<char>>& board) {
for(auto& row : board) {
for(auto& cell : row) {
if(cell == '1') {
cell = 'O';
}
}
}
}
};
这个重构后的版本将逻辑拆分为更清晰的四个部分,每个函数只负责一个明确的任务,提高了代码的可读性和可维护性。在面试中,这种模块化的代码结构通常会获得加分。
虽然我们以C++为例,但同样的算法思想可以应用于其他语言。以下是不同语言实现时需要注意的特点:
| 语言 | BFS实现特点 | 边界处理 | 代码风格差异 |
|---|---|---|---|
| C++ | 使用STL queue,显式管理内存 | 手动检查数组边界 | 面向过程/面向对象混合 |
| Java | 使用LinkedList作为队列 | 自动数组越界检查 | 更面向对象,需要类封装 |
| Python | 使用deque实现队列 | 负索引处理不同 | 更简洁的语法糖 |
| JavaScript | 使用数组模拟队列 | 灵活的数组操作 | 函数式编程风格更常见 |
以Python为例,等效的实现可能如下:
python复制from collections import deque
class Solution:
def solve(self, board: List[List[str]]) -> None:
if not board:
return
rows, cols = len(board), len(board[0])
# 标记边缘'O'
for i in [0, rows-1]:
for j in range(cols):
if board[i][j] == 'O':
self.bfs_mark(board, i, j)
for j in [0, cols-1]:
for i in range(1, rows-1):
if board[i][j] == 'O':
self.bfs_mark(board, i, j)
# 转换内部'O'为'X',恢复边缘'1'为'O'
for i in range(rows):
for j in range(cols):
if board[i][j] == 'O':
board[i][j] = 'X'
elif board[i][j] == '1':
board[i][j] = 'O'
def bfs_mark(self, board, i, j):
queue = deque([(i,j)])
board[i][j] = '1'
while queue:
x, y = queue.popleft()
for dx, dy in [(0,1),(0,-1),(1,0),(-1,0)]:
nx, ny = x+dx, y+dy
if 0 <= nx < len(board) and 0 <= ny < len(board[0]) and board[nx][ny] == 'O':
board[nx][ny] = '1'
queue.append((nx,ny))
Python版本利用了更简洁的语法和内置数据结构,但核心算法思想完全一致。
虽然这是一道算法题,但类似的思路在实际工程中有广泛应用:
理解这类算法不仅能帮助通过技术面试,更能为解决实际问题提供思路。例如,在处理图像时,我们可能需要先标记出所有与边缘相连的特定颜色区域,这与本题的处理逻辑高度相似。
为什么BFS比DFS更适合这道题?这涉及到两种遍历方式的本质区别:
| 特性 | BFS | DFS |
|---|---|---|
| 实现方式 | 队列 | 栈/递归 |
| 内存使用 | 较均匀 | 最坏情况下较高 |
| 适用场景 | 最短路径、层级关系 | 拓扑排序、回溯 |
| 本题目优势 | 避免栈溢出,更直观 | 代码更简洁 |
对于矩阵遍历问题,BFS通常更受青睐,因为:
不过在某些特定情况下,DFS的简洁性可能更有优势。例如当只需要判断是否存在路径而不需要具体路径时,DFS的递归实现可能更简洁。
在实际编码面试中,除了正确性,面试官通常也会关注代码的性能优化。针对这道题,我们可以考虑以下优化手段:
以并行处理为例,伪代码如下:
cpp复制// 伪代码:并行处理四个边缘
parallel_for(edge in [top, bottom, left, right]) {
for(cell in edge) {
if(cell == 'O') {
cell = '1';
bfs_mark(cell);
}
}
}
当然,在面试中通常不需要展示这种高级优化,但了解这些技巧可以展现你对性能问题的深入思考。
为了验证算法的正确性,应当设计全面的测试用例:
最小矩阵测试:
全'O'矩阵:
全'X'矩阵:
混合情况:
特殊形状:
好的测试用例应该覆盖:
在面试中,除了写出正确的代码,如何表达和解释你的思路同样重要:
例如,开始编码前可以先说明:
"我计划分三步解决这个问题:
我选择使用BFS进行区域标记,因为..."
这种结构化的表达方式会给面试官留下良好印象。
这道"被围绕的区域"题目虽然表面上是关于矩阵操作,实则蕴含了几个重要的编程思维:
这些思维模式可以迁移到其他编程问题中。例如,逆向思维在动态规划、贪心算法等问题中也很常见;标记法则广泛应用于各种原地算法问题。
为了巩固这类问题的解法,推荐练习以下相似题目:
岛屿数量(LeetCode 200):
最大岛屿面积(LeetCode 695):
矩阵中的最长递增路径(LeetCode 329):
墙与门(LeetCode 286):
通过对比这些题目,可以更深入理解矩阵遍历类问题的共性和差异,形成系统的解题思路。
基于这道题的面试经验,我总结出以下实战建议:
例如,面试官可能会问:
"如果现在要保留所有与指定位置相连的'O'区域,该如何修改算法?"
这时可以回答:
"我们可以把指定位置当作边缘来处理,从这里开始BFS标记所有相连区域,然后再进行常规处理。这样就能保留指定区域及其连通区域。"
这种灵活应对的能力往往比单纯写出正确答案更重要。