矩阵搜索算法:从边界DFS/BFS解决“被围绕的区域”问题
2026/9/6 17:43:45 网站建设 项目流程

1. 问题引入:从棋盘游戏到矩阵搜索

最近在整理算法笔记时,翻到了一个挺有意思的题目,题目名字叫“被围绕的区域”。乍一看,这名字有点抽象,但如果你把它想象成一个棋盘游戏,或者一个简单的图像处理问题,就很好理解了。

想象一下,你有一个二维的棋盘,上面有两种棋子,一种是“O”,一种是“X”。现在游戏规则是:所有被“X”棋子完全包围(上下左右四个方向)的“O”棋子,都要被翻转成“X”。但是,如果“O”棋子位于棋盘的边缘,或者通过上下左右移动,能够连接到棋盘边缘的“O”,那么这些“O”就是“幸存者”,不能被翻转。

这其实就是LeetCode上第130题“被围绕的区域”所描述的场景。题目会给你一个m x n的字符矩阵board,其中‘X’‘O’。你需要实现一个函数,将所有被‘X’围绕的‘O’都替换为‘X’。而那些与边界相连的‘O’则保持不变。

为什么这个问题值得拿出来单独讲?因为在很多涉及矩阵搜索、图像连通域分析、甚至是游戏地图处理的场景里,这种“从边界向内渗透”的思想非常关键。它不仅仅是简单的深度优先搜索(DFS)或广度优先搜索(BFS)遍历,更包含了一种“逆向思维”:与其费力地去寻找所有被包围的内部区域,不如先标记出所有不可能被包围的“安全区”,剩下的自然就是需要处理的目标了。这种思路能极大地简化问题,也是这道题的核心考点。

2. 核心思路拆解:为什么是“从外向内”的DFS?

当我们拿到一个矩阵,最直观的想法可能是:遍历整个矩阵,遇到一个‘O’,就看看它是否被‘X’完全包围。但这个“判断是否被包围”的操作非常复杂。你需要以这个‘O’为起点进行搜索,检查它的连通区域是否接触到了矩阵边界。如果接触到了,那整个连通区域都是安全的;如果没接触到,那整个连通区域都需要被翻转。这个过程中,你可能会对同一个‘O’区域进行重复的搜索和判断,效率很低。

一个更高效、更聪明的策略是“从边界出发,标记所有安全区域”。具体步骤如下:

  1. 边界扫描:我们首先遍历矩阵的四条边(第一行、最后一行、第一列、最后一列)。只要在边界上发现‘O’,这个‘O’以及所有与它相连的‘O’,就一定是“安全”的,因为它们直接或间接地接触到了边界,不可能被完全包围。
  2. 标记安全区:对于每一个边界上的‘O’,我们以其为起点,进行一次深度优先搜索(DFS)或广度优先搜索(BFS),将所有与之连通的‘O’都标记为一个特殊的临时值(例如‘#’)。这个标记的意思是:“此位置是‘O’,但它是安全的,最终需要保留。”
  3. 全局清理与替换:完成所有边界出发的搜索和标记后,我们再次遍历整个矩阵。
    • 此时,所有未被标记的‘O’(即那些没有被‘#’覆盖的‘O’),就是被‘X’完全包围的内部区域,我们将它们替换成‘X’
    • 同时,我们把之前标记的所有‘#’,恢复成‘O’。这样,安全的‘O’区域就保留了下来。

这个思路的精妙之处在于,它把原本需要针对每个内部区域进行复杂判断的问题,转化为了两次简单的矩阵遍历和一次从边界出发的连通区域标记。时间复杂度从可能的高阶降到了稳定的O(m*n),因为每个单元格最多被访问常数次。

2.1 深度优先搜索(DFS)的实现要点

既然题目提示了用DFS,我们就重点聊聊DFS的实现。DFS的本质是“一条路走到黑,走不通再回头”,非常适合用来探索一个连通区域。

在这个问题里,DFS函数dfs(int i, int j)的职责很明确:如果当前位置(i, j)是有效的矩阵坐标,并且该位置的值是‘O’,那么我们就将其标记为‘#’,然后递归地向其四个方向(上、下、左、右)继续探索。

这里有几个关键的实现细节和容易踩坑的地方:

递归的终止条件:这是DFS不写崩的基础。条件必须包括:

  • 坐标越界(i < 0 || j < 0 || i >= m || j >= n)。
  • 当前位置不是‘O’(可能是‘X’或已经被标记过的‘#’)。

方向数组的使用:定义一个二维数组int[][] dirs = {{1,0}, {-1,0}, {0,1}, {0,-1}};来代表四个方向的偏移量。这样在递归时,代码会更清晰,避免写四行相似的递归调用。

避免递归栈溢出:虽然题目常见的矩阵尺寸(比如200x200)下,递归深度一般不会导致栈溢出,但这是一个好习惯。对于特别大的矩阵,递归DFS可能会导致StackOverflowError。在这种情况下,可以改用显式栈(Stack)进行迭代式的深度优先搜索,或者直接使用BFS(队列实现)。BFS在空间消耗上通常更稳定,但DFS的代码通常更简洁。作为一道算法题,递归DFS通常是可接受的。

注意:在标记边界‘O’时,我们只对边界上的点发起DFS调用。千万不要在标记阶段去遍历整个矩阵然后调用DFS,那会做大量无用功,又退化成了最初的笨办法。

3. 代码实现与逐行解析

理解了思路,我们来看具体的Java代码实现。我会把代码分成几个部分,并加上详细的注释。

class Solution { // 方向数组,分别代表下、上、右、左 int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; int m, n; // 矩阵的行数和列数 public void solve(char[][] board) { if (board == null || board.length == 0) return; m = board.length; n = board[0].length; // 步骤1:遍历四条边,对边界上的‘O’进行DFS标记 // 第一列和最后一列 for (int i = 0; i < m; i++) { if (board[i][0] == 'O') dfs(board, i, 0); if (board[i][n - 1] == 'O') dfs(board, i, n - 1); } // 第一行和最后一行 (注意角点已经被上面的循环处理过,但重复判断无害) for (int j = 0; j < n; j++) { if (board[0][j] == 'O') dfs(board, 0, j); if (board[m - 1][j] == 'O') dfs(board, m - 1, j); } // 步骤2:遍历整个矩阵,进行最终替换 for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (board[i][j] == 'O') { // 未被标记的‘O’,是被包围的,替换为‘X’ board[i][j] = 'X'; } else if (board[i][j] == '#') { // 被标记为安全区域的‘#’,恢复为‘O’ board[i][j] = 'O'; } // 已经是‘X’的位置不变 } } } // DFS递归函数,用于标记所有与(i,j)连通的‘O’ private void dfs(char[][] board, int i, int j) { // 递归终止条件:越界或当前位置不是‘O’ if (i < 0 || i >= m || j < 0 || j >= n || board[i][j] != 'O') { return; } // 将当前‘O’标记为特殊字符‘#’,表示已访问且是安全区域 board[i][j] = '#'; // 向四个方向递归探索 for (int[] dir : dirs) { int newI = i + dir[0]; int newJ = j + dir[1]; dfs(board, newI, newJ); } } }

代码关键点解析

  1. 边界遍历的顺序:代码先遍历左右两列(第一列和最后一列),再遍历上下两行(第一行和最后一行)。顺序其实无所谓,只要确保四条边上的每个点都被检查到即可。角上的点(如(0,0))会被检查两次,但这只是多了一次条件判断,dfs函数内部的board[i][j] != ‘O’会阻止重复递归,所以没有副作用。
  2. 标记字符的选择:我们选择‘#’作为临时标记。这里必须选择一个原矩阵中不存在的字符。绝对不能标记为‘X’,否则在后续DFS中,这个“假X”会阻挡搜索,导致连通区域标记不全。也不能标记为‘O’以外的其他字母,以免混淆。
  3. 最终替换的逻辑:最后的双重循环是精髓。它同时完成了两件事:
    • if (board[i][j] == ‘O’):经过前面的标记,如果还有字符是‘O’,说明它既不在边界,也没有被任何从边界开始的DFS访问到,那它一定是被包围的,翻转为‘X’
    • else if (board[i][j] == ‘#’):将我们之前标记的安全区域恢复为‘O’。 这个逻辑清晰地将矩阵分成了三类区域进行处理。

4. 从DFS到BFS:另一种实现视角

虽然题目要求用DFS,但了解BFS的实现同样重要,尤其是在面对深度很大的图时,BFS使用队列,可以避免递归栈溢出的风险。思路完全一样,只是把递归调用换成了队列操作。

下面是使用BFS(队列)实现的bfs标记函数:

private void bfs(char[][] board, int i, int j) { if (board[i][j] != 'O') return; Queue<int[]> queue = new LinkedList<>(); queue.offer(new int[]{i, j}); board[i][j] = '#'; // 入队即标记,防止重复入队 while (!queue.isEmpty()) { int[] cell = queue.poll(); int x = cell[0], y = cell[1]; // 遍历四个方向 for (int[] dir : dirs) { int newX = x + dir[0]; int newY = y + dir[1]; // 检查新坐标是否有效且为‘O’ if (newX >= 0 && newX < m && newY >= 0 && newY < n && board[newX][newY] == 'O') { board[newX][newY] = '#'; // 标记 queue.offer(new int[]{newX, newY}); // 入队 } } } }

在主函数solve中,只需要把调用dfs(board, i, j)的地方换成bfs(board, i, j)即可。

DFS与BFS的选择

  • DFS(递归):代码简洁,直观,易于理解。但在极端情况下(如矩阵非常大且全是‘O’),递归深度可能达到m*n,有栈溢出风险。
  • BFS(队列):使用显式的队列数据结构,没有递归深度限制,空间复杂度在最坏情况下也是O(m*n)。代码稍长,但更稳健。

对于算法面试,通常说明思路并给出DFS版本即可。如果被问到优化或大数据处理,可以再提出BFS版本作为改进。

5. 实战中的陷阱与边界条件处理

理论很完美,但一写代码就出bug,是算法练习中的常态。下面我总结几个在实现“被围绕的区域”时,最容易栽跟头的地方。

5.1 空输入与极小输入

这是许多问题的“第零个”测试用例。我们的代码必须能处理。

  • boardnull
  • board为空数组[]
  • board[[]](即只有一行但该行为空)。 在代码开头,我们必须加上防御性判断:if (board == null || board.length == 0) return;。获取列数n时,也要确保m>0,否则board[0].length会出错。上面的代码通过先判断board.length,再赋值m,自然规避了这个问题。

5.2 标记字符的冲突

前面提到过,一定要用一个矩阵中绝对不可能出现的字符来做临时标记。除了‘#’,也可以用其他字符如‘A’‘*’等。但千万不要用‘X’‘O’。我曾见过有人图省事,想把安全的‘O’直接改成‘X’,最后再改回来,这会导致DFS搜索时,刚被改成‘X’的点阻挡了对其相邻‘O’的继续探索,造成标记不完全。

5.3 递归函数中的重复访问与栈溢出

在DFS函数中,必须先标记,再递归。顺序不能错。

// 正确顺序 board[i][j] = ‘#’; // 先标记 for (dir : dirs) { dfs(newI, newJ); // 再递归 } // 错误示范:如果后标记,在递归调用中,可能会因为再次访问到自身(通过邻居的邻居)而导致无限递归或重复处理。 for (dir : dirs) { dfs(newI, newJ); // 先递归 } board[i][j] = ‘#’;

同时,递归的终止条件board[i][j] != ‘O’也包含了board[i][j] == ‘#’的情况,这防止了对已标记点的重复访问,也构成了递归结束的重要条件。

对于可能的大数据,要在心里有根弦:DFS递归可能溢出。虽然力扣的测试用例通常不会卡这个,但知道BFS是更安全的备选方案,是一个加分项。

5.4 矩阵只有一行或一列的情况

这是一个非常狡猾的边界条件。考虑一个1 x n的矩阵(只有一行),或者一个m x 1的矩阵(只有一列)。

  • 对于1 x n的矩阵,它的“第一行”就是“最后一行”,也是“唯一一行”。按照我们的算法,我们会遍历这一行的所有列(即所有元素),因为它们都在边界上。所以,这一行所有的‘O’都会被标记为安全,最后都不会被翻转为‘X’这是符合题意的,因为在一行矩阵里,所有元素都在边界,不存在“被围绕”的概念。
  • 同理,对于m x 1的矩阵,所有元素都在第一列/最后一列,也都是安全的。 我们的代码逻辑天然正确处理了这种情况,不需要额外判断。

6. 算法变种与扩展思考

掌握了基础解法,我们可以看看这个模式能解决哪些类似问题,以及有哪些可以深入思考的方向。

6.1 连通分量计数问题

“被围绕的区域”本质是寻找与边界相连的连通分量。一个很自然的扩展是:如何统计矩阵中‘O’形成的连通区域总数?或者,如何统计完全被‘X’包围的连通区域的数量?

  • 总数:遍历矩阵,对每个未访问过的‘O’发起DFS/BFS,标记整个连通区域,计数器加一。
  • 被包围的数量:可以先使用本题的“边界标记法”,标记出所有安全区域。然后再次遍历矩阵,对剩余的、未被标记的‘O’区域进行DFS/BFS并计数。这个数量就是被包围的区域数量。

6.2 “岛屿”类问题的通用解法

本题是“岛屿”系列问题的一个变种。经典的“岛屿数量”(LeetCode 200)问题是统计由‘1’(陆地)构成的连通区域数量,其DFS/BFS的框架和本题一模一样,只是没有了“从边界出发”这个前置条件,而是需要全局搜索。

这类问题的代码框架具有很强的复用性:

  1. 定义方向数组。
  2. 编写一个dfs/bfs函数,用于标记或处理一个连通分量。
  3. 在主函数中,以特定的顺序(全局遍历或只遍历边界)调用这个搜索函数。

6.3 性能分析与优化

我们的算法时间复杂度是O(m*n),因为每个单元格最多被访问两次(一次标记,一次最终替换)。空间复杂度方面:

  • DFS递归:取决于递归深度,最坏O(m*n)
  • BFS队列:同样最坏O(m*n)
  • 标记本身是原地修改,没有使用额外空间存储矩阵。

这已经是理论上的最优复杂度了,因为我们必须访问每个单元格至少一次。在实际面试中,能清晰分析出这个复杂度即可。

一个可能的“优化”讨论点在于:我们是否真的需要遍历四条边?对于某些形状的矩阵,也许可以稍微减少几次循环。但在我看来,这种优化带来的代码复杂性提升远大于其微乎其微的性能收益,保持代码清晰易懂更重要。

7. 测试用例设计与调试技巧

自己写代码,自己设计测试用例验证,是提升算法能力的关键一步。对于本题,我们可以设计以下几类测试用例:

  1. 常规用例

    // 输入 [['X','X','X','X'], ['X','O','O','X'], ['X','X','O','X'], ['X','O','X','X']] // 期望输出:中间的那个‘O’被包围,应翻转。右下角的‘O’在边界,保留。 [['X','X','X','X'], ['X','X','X','X'], ['X','X','X','X'], ['X','O','X','X']]
  2. 全为‘X’或全为‘O’

    • ‘X’:矩阵应无任何变化。
    • ‘O’:所有‘O’都在边界或与边界相连,因此全部保留,矩阵不变。
  3. 边界情况

    • 单行单列:[['O','X','O']]-> 所有‘O’保留。
    • 空输入:[]null,程序应正常返回,不抛出异常。
  4. 复杂连通

    // 一个巨大的‘O’区域,中间包含‘X’,但整体连接到边界。 // 这种用例用来测试DFS/BFS的标记是否能覆盖整个复杂区域。

调试技巧: 当程序输出不对时,不要急于看代码。可以:

  1. 打印中间状态:在完成边界标记后,打印一下矩阵,看看‘#’是否正确地标记了所有应该安全的‘O’区域。
  2. 单步跟踪:用一个简单的2x2或3x3矩阵,在纸上手动模拟DFS的递归过程,检查标记顺序和终止条件。
  3. 检查循环边界:确认遍历四条边的循环,下标是否正确,特别是m-1n-1的使用。
  4. 检查方向数组:确保dirs数组包含了所有四个方向,没有遗漏或重复。

这道“被围绕的区域”是一个练习矩阵搜索和逆向思维的绝佳题目。它教会我们的不仅仅是DFS/BFS的写法,更是一种“正难则反”的解题策略——当直接求解目标困难时,尝试去标记它的补集(非目标),往往能豁然开朗。下次当你遇到类似“寻找内部封闭区域”的问题时,不妨先想想:能不能从边界开始?

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询