网格 dfs 与 FloodFill:从岛屿、区域到搜索路径

目录

[引入:网格 DFS 的共同结构](#引入:网格 DFS 的共同结构)

一、图像渲染:从起点扩散同一种颜色

二、岛屿数量:外层寻找起点,内部淹没整座岛

[三、岛屿的最大面积:让 DFS 返回连通块大小](#三、岛屿的最大面积:让 DFS 返回连通块大小)

四、被围绕的区域:从边界反向寻找安全区域

五、太平洋大西洋水流问题:从终点反向搜索

六、扫雷游戏:把四方向换成八方向

七、单词搜索:需要恢复当前路径的访问状态

[八、黄金矿工和不同路径 III:访问标记就是路径状态](#八、黄金矿工和不同路径 III:访问标记就是路径状态)

九、从多道网格题中归纳规律

八、易错点

九、本篇总结


引入:网格 DFS 的共同结构

网格题就是在由行和列组成的二维数组中搜索。DFS 是 Depth First Search 的缩写,中文是深度优先搜索,表示从当前位置沿一个方向继续深入,走不通后再返回尝试其他方向。

FloodFill 可以理解成"泛洪填充":从一个起点向相邻位置扩散,把和起点连通、并且满足条件的格子全部访问或修改rowcol 表示当前格子的行号和列号,DIRECTIONS 表示移动方向,visited 表示某个格子是否已经在当前搜索路径中访问过。

网格题中最容易固定下来的检查顺序是:先判断是否越界,再判断当前格子是否满足条件,然后标记或修改当前格子,最后递归搜索相邻位置。如果题目要求寻找一条具体路径,递归返回后还要恢复标记;如果题目只是处理连通块,修改网格本身通常就可以同时完成访问标记。

一、图像渲染:从起点扩散同一种颜色

题目描述

题目:图像渲染。LeetCode 733。

给定一个二维图像、起点坐标和新颜色,把与起点上下左右连通、并且颜色和起点相同的所有像素改成新颜色。

题目链接:图像渲染

算法原理

先记住起点原来的颜色 oldColor。从起点出发,只进入颜色仍然等于 oldColor 的格子。访问一个格子后立即改成 newColor,这样它既完成了染色,也不会被后续递归再次处理。

如果新旧颜色相同,改色后无法区分已经访问和还没有访问的位置,因此可以直接返回原图像。

Java 代码

java 复制代码
class Solution {
    private static final int[][] DIRECTIONS = {
            {-1, 0},
            {1, 0},
            {0, -1},
            {0, 1}
    };

    public int[][] floodFill(int[][] image,
                             int sr, int sc, int color) {
        int oldColor = image[sr][sc];
        if (oldColor == color) return image;

        dfs(image, sr, sc, oldColor, color);
        return image;
    }

    private void dfs(int[][] image, int row, int col,
                     int oldColor, int newColor) {
        if (row < 0 || row >= image.length
                || col < 0 || col >= image[0].length
                || image[row][col] != oldColor) {
            return;
        }

        image[row][col] = newColor;
        for (int[] direction : DIRECTIONS) {
            dfs(image,
                    row + direction[0],
                    col + direction[1],
                    oldColor, newColor);
        }
    }
}

代码说明

DIRECTIONS 中的四组数字分别表示向上、向下、向左和向右移动。direction[0] 是行变化量,direction[1] 是列变化量。

递归入口先取得起点颜色。dfs 先进行越界和颜色判断,通过后把当前格子改成新颜色,再遍历四个方向。改色动作放在递归之前,所以同一个格子不会重复扩散。

二、岛屿数量:外层寻找起点,内部淹没整座岛

题目描述

题目:岛屿数量。LeetCode 200。

给定一个由 '1''0' 组成的网格,其中 '1' 表示陆地,'0' 表示水。上下左右连接的陆地属于同一座岛屿,返回岛屿数量。

题目链接:岛屿数量

算法原理

外层双重循环负责逐个检查网格。遇到一个还没有处理的 '1',就说明发现了一座新岛屿,答案加一,然后从这个位置开始 DFS,把整座相连的岛屿都标记为已经处理。

代码把访问过的陆地改成 '0'。这样同一座岛屿中的其他格子即使之后被外层循环遇到,也不会再次增加答案。

Java 代码

java 复制代码
class Solution {
    private static final int[][] DIRECTIONS = {
            {-1, 0}, {1, 0}, {0, -1}, {0, 1}
    };

    public int numIslands(char[][] grid) {
        int ret = 0;
        for (int row = 0; row < grid.length; row++) {
            for (int col = 0; col < grid[0].length; col++) {
                if (grid[row][col] == '1') {
                    ret++;
                    dfs(grid, row, col);
                }
            }
        }
        return ret;
    }

    private void dfs(char[][] grid, int row, int col) {
        if (row < 0 || row >= grid.length
                || col < 0 || col >= grid[0].length
                || grid[row][col] != '1') {
            return;
        }

        grid[row][col] = '0';
        for (int[] direction : DIRECTIONS) {
            dfs(grid,
                    row + direction[0],
                    col + direction[1]);
        }
    }
}

代码说明

ret 表示已经发现的岛屿数量。外层循环发现一个 '1' 后,先让 ret++,再调用 dfs 把这座岛屿的所有陆地改成 '0'

这里的 dfs 不需要返回面积或路径,只负责把当前连通块处理完。外层"发现新起点"和内层"扩散整块区域"是两个不同职责,分开后不容易重复计数。

三、岛屿的最大面积:让 DFS 返回连通块大小

题目描述

题目:岛屿的最大面积。LeetCode 695。

给定一个由 0 和 1 组成的网格,1 表示陆地,0 表示水。上下左右连接的陆地属于同一座岛屿,返回面积最大的岛屿面积。

题目链接:岛屿的最大面积

算法原理

这道题和岛屿数量使用相同的连通块搜索,只是 DFS 的返回值不同。当前陆地格子本身贡献 1,再加上上下左右四个方向能够访问到的陆地数量,就是从当前格子出发的岛屿面积。

访问当前格子后把它改成 0,避免同一块陆地被重复计算。外层循环对每个未处理的陆地调用面积 DFS,再用 Math.max 保留最大值。

Java 代码

java 复制代码
class Solution {
    public int maxAreaOfIsland(int[][] grid) {
        int ret = 0;
        for (int row = 0; row < grid.length; row++) {
            for (int col = 0; col < grid[0].length; col++) {
                if (grid[row][col] == 1) {
                    ret = Math.max(ret,
                            dfs(grid, row, col));
                }
            }
        }
        return ret;
    }

    private int dfs(int[][] grid, int row, int col) {
        if (row < 0 || row >= grid.length
                || col < 0 || col >= grid[0].length
                || grid[row][col] == 0) {
            return 0;
        }

        grid[row][col] = 0;
        return 1
                + dfs(grid, row - 1, col)
                + dfs(grid, row + 1, col)
                + dfs(grid, row, col - 1)
                + dfs(grid, row, col + 1);
    }
}

代码说明

递归出口返回 0,表示越界或当前格子不是陆地,不会给面积增加贡献。合法陆地格子先改成 0,然后返回 1 加四个方向的面积。

Math.max(ret, dfs(...)) 会比较当前最大面积和新发现岛屿的面积,保留较大值。Math.max 是 Java Math 工具类中的方法,用来返回两个数中较大的一个。

四、被围绕的区域:从边界反向寻找安全区域

题目描述

题目:被围绕的区域。LeetCode 130。

给定一个由 XO 组成的二维棋盘,把所有被 X 包围的 O 改成 X。与边界相连的 O 不会被包围,应保持不变。

题目链接:被围绕的区域

算法原理

直接寻找每个被包围的区域不太容易判断,因此可以反过来寻找"绝对安全"的区域。边界上的 O 一定不会被包围,从所有边界 O 出发做 DFS,把能够连接到边界的 O 暂时标记为 A

扫描整个棋盘时,剩下的 O 都没有连接到边界,可以改成 X;暂时标记为 A 的位置再恢复为 O。

Java 代码

java 复制代码
class Solution {
    private static final int[][] DIRECTIONS = {
            {-1, 0}, {1, 0}, {0, -1}, {0, 1}
    };

    public void solve(char[][] board) {
        if (board.length == 0) return;

        int rows = board.length;
        int cols = board[0].length;
        for (int row = 0; row < rows; row++) {
            dfs(board, row, 0);
            dfs(board, row, cols - 1);
        }
        for (int col = 0; col < cols; col++) {
            dfs(board, 0, col);
            dfs(board, rows - 1, col);
        }

        for (int row = 0; row < rows; row++) {
            for (int col = 0; col < cols; col++) {
                if (board[row][col] == 'O') {
                    board[row][col] = 'X';
                } else if (board[row][col] == 'A') {
                    board[row][col] = 'O';
                }
            }
        }
    }

    private void dfs(char[][] board, int row, int col) {
        if (row < 0 || row >= board.length
                || col < 0 || col >= board[0].length
                || board[row][col] != 'O') {
            return;
        }

        board[row][col] = 'A';
        for (int[] direction : DIRECTIONS) {
            dfs(board,
                    row + direction[0],
                    col + direction[1]);
        }
    }
}

代码说明

边界循环可能会重复访问角落,但 dfs 只有遇到 'O' 才会继续,所以不会产生错误。标记 A 的意思是"和边界相连、需要保留"。

最后一次遍历把普通 O 改成 X,把 A 恢复成 O。这类题的关键不是改变 DFS,而是先把题目条件转换成"从边界寻找安全连通块"。

五、太平洋大西洋水流问题:从终点反向搜索

题目描述

题目:太平洋大西洋水流问题。LeetCode 417。

给定一个高度矩阵,水可以从高度较高或相等的格子流向高度较低或相等的相邻格子。矩阵上边和左边连接太平洋,下边和右边连接大西洋,返回能够让水流到两个海洋的所有格子。

题目链接:太平洋大西洋水流问题

算法原理

如果从每个格子出发模拟水流,重复搜索很多次。可以反过来从两个海洋的边界出发,沿着"高度不下降"的方向向内搜索。

某个格子如果能够从太平洋边界被搜索到,说明水可以从这个格子流向太平洋;同理,能够从大西洋边界搜索到,说明水可以流向大西洋。最后同时被两个访问数组标记的位置就是答案。

Java 代码

java 复制代码
class Solution {
    private static final int[][] DIRECTIONS = {
            {-1, 0}, {1, 0}, {0, -1}, {0, 1}
    };

    public List<List<Integer>> pacificAtlantic(int[][] heights) {
        int rows = heights.length;
        int cols = heights[0].length;
        boolean[][] pacific = new boolean[rows][cols];
        boolean[][] atlantic = new boolean[rows][cols];

        for (int row = 0; row < rows; row++) {
            dfs(heights, row, 0, pacific);
            dfs(heights, row, cols - 1, atlantic);
        }
        for (int col = 0; col < cols; col++) {
            dfs(heights, 0, col, pacific);
            dfs(heights, rows - 1, col, atlantic);
        }

        List<List<Integer>> ret = new ArrayList<>();
        for (int row = 0; row < rows; row++) {
            for (int col = 0; col < cols; col++) {
                if (pacific[row][col] && atlantic[row][col]) {
                    ret.add(Arrays.asList(row, col));
                }
            }
        }
        return ret;
    }

    private void dfs(int[][] heights, int row, int col,
                     boolean[][] visited) {
        if (visited[row][col]) return;
        visited[row][col] = true;

        for (int[] direction : DIRECTIONS) {
            int nextRow = row + direction[0];
            int nextCol = col + direction[1];
            if (nextRow < 0 || nextRow >= heights.length
                    || nextCol < 0
                    || nextCol >= heights[0].length) {
                continue;
            }
            if (heights[nextRow][nextCol] < heights[row][col]) {
                continue;
            }
            dfs(heights, nextRow, nextCol, visited);
        }
    }
}

代码说明

pacificatlantic 分别记录能够从两个海洋反向到达的位置。visited 是当前这次反向搜索的访问标记,不是回溯路径标记,因为同一个海洋的搜索中,某个位置访问一次后结果就已经确定。

反向移动时,下一格高度必须大于等于当前格高度。原本水是从高处流向低处,反过来搜索就要从低处走向高处。

六、扫雷游戏:把四方向换成八方向

题目描述

题目:扫雷游戏。LeetCode 529。

给定一个扫雷棋盘和点击位置。如果点击到雷,将其标记为 X;如果点击到空白位置,根据周围八个方向的雷数显示数字。如果周围没有雷,则继续展开相邻空白区域。

题目链接:扫雷游戏

算法原理

扫雷使用八个方向,而不是常见的上下左右四个方向。点击空白格时先统计周围八个位置的雷数;如果雷数大于 0,显示对应数字;如果雷数为 0,把当前格标记为 B,再继续搜索八个方向。

Java 代码

java 复制代码
class Solution {
    private static final int[][] DIRECTIONS = {
            {-1, -1}, {-1, 0}, {-1, 1},
            {0, -1},           {0, 1},
            {1, -1},  {1, 0},  {1, 1}
    };

    public char[][] updateBoard(char[][] board, int[] click) {
        dfs(board, click[0], click[1]);
        return board;
    }

    private void dfs(char[][] board, int row, int col) {
        if (row < 0 || row >= board.length
                || col < 0 || col >= board[0].length
                || board[row][col] != 'E') {
            return;
        }

        int mines = 0;
        for (int[] direction : DIRECTIONS) {
            int nextRow = row + direction[0];
            int nextCol = col + direction[1];
            if (nextRow >= 0 && nextRow < board.length
                    && nextCol >= 0
                    && nextCol < board[0].length
                    && board[nextRow][nextCol] == 'M') {
                mines++;
            }
        }

        if (mines > 0) {
            board[row][col] = (char) ('0' + mines);
            return;
        }

        board[row][col] = 'B';
        for (int[] direction : DIRECTIONS) {
            dfs(board,
                    row + direction[0],
                    col + direction[1]);
        }
    }
}

代码说明

DIRECTIONS 中的八组变化量覆盖了当前格周围的所有位置。只有还没有处理的空白格 'E' 才会继续递归,雷和已经显示的格子不会重复处理。

(char) ('0' + mines) 把 1 到 8 的数字转换成字符。周围有雷时只显示数量,不再向外扩散;周围没有雷时标记为 B,再搜索八个方向。

七、单词搜索:需要恢复当前路径的访问状态

题目描述

题目:单词搜索。LeetCode 79。

给定一个字符网格和一个单词,判断能否通过上下左右相邻的格子依次组成这个单词。同一个格子在一条路径中不能重复使用。

题目链接:单词搜索

算法原理

外层双重循环把每个格子都作为起点尝试。递归状态包括当前匹配到的单词下标 index、当前行列坐标以及 visited 标记。

先判断越界、当前格是否已经在路径中、当前字符是否匹配。通过判断后标记当前格,再递归四个方向。若四个方向都失败,取消当前格的标记,返回上一层尝试其他选择。

Java 代码

java 复制代码
class Solution {
    private static final int[][] DIRECTIONS = {
            {-1, 0}, {1, 0}, {0, -1}, {0, 1}
    };

    public boolean exist(char[][] board, String word) {
        boolean[][] visited = new boolean[
                board.length][board[0].length];

        for (int row = 0; row < board.length; row++) {
            for (int col = 0; col < board[0].length; col++) {
                if (dfs(board, word, 0,
                        row, col, visited)) {
                    return true;
                }
            }
        }
        return false;
    }

    private boolean dfs(char[][] board, String word,
                        int index, int row, int col,
                        boolean[][] visited) {
        if (row < 0 || row >= board.length
                || col < 0 || col >= board[0].length
                || visited[row][col]
                || board[row][col] != word.charAt(index)) {
            return false;
        }
        if (index == word.length() - 1) return true;

        visited[row][col] = true;
        for (int[] direction : DIRECTIONS) {
            if (dfs(board, word, index + 1,
                    row + direction[0],
                    col + direction[1], visited)) {
                visited[row][col] = false;
                return true;
            }
        }
        visited[row][col] = false;
        return false;
    }
}

代码说明

index 表示当前要匹配单词中的哪个字符。找到最后一个字符时可以直接返回 true。其他情况下,当前格先标记为已访问,再去寻找下一个字符。

这里必须恢复 visited[row][col] = false。即使某条路径失败,也要恢复;如果找到答案后提前返回,也要恢复当前标记,避免共享的访问数组留下不必要的状态。

八、黄金矿工和不同路径 III:访问标记就是路径状态

题目描述

题目:黄金矿工。LeetCode 1219。

在一个网格中从任意有黄金的格子出发,每次只能上下左右移动,不能进入没有黄金的格子,也不能重复访问格子,返回能够收集到的最大黄金数量。

题目链接:黄金矿工

题目:不同路径 III。LeetCode 980。

给定一个网格,从起点走到终点,要求经过所有可走的空格恰好一次,返回满足条件的路径数量。

题目链接:不同路径 III

算法原理

这两道题都不是简单的连通块统计,因为"当前走过哪些格子"会影响后面的选择。同一个坐标,如果已经走过的格子不同,未来能走的路线也不同,所以需要在递归过程中维护访问状态,并在返回后撤销。

黄金矿工的递归返回从当前格出发还能获得的最大黄金,进入格子后把它暂时改成 0,四个方向尝试结束后恢复原来的黄金值。不同路径 III 还需要记录剩余必须经过的格子数量,走到终点时只有剩余数量满足要求才算一条完整路径。

Java 代码

java 复制代码
class Solution {
    private static final int[][] DIRECTIONS = {
            {-1, 0}, {1, 0}, {0, -1}, {0, 1}
    };

    public int getMaximumGold(int[][] grid) {
        int ret = 0;
        for (int row = 0; row < grid.length; row++) {
            for (int col = 0; col < grid[0].length; col++) {
                if (grid[row][col] != 0) {
                    ret = Math.max(ret,
                            dfs(grid, row, col));
                }
            }
        }
        return ret;
    }

    private int dfs(int[][] grid, int row, int col) {
        if (row < 0 || row >= grid.length
                || col < 0 || col >= grid[0].length
                || grid[row][col] == 0) {
            return 0;
        }

        int gold = grid[row][col];
        grid[row][col] = 0;
        int best = 0;
        for (int[] direction : DIRECTIONS) {
            best = Math.max(best,
                    dfs(grid,
                            row + direction[0],
                            col + direction[1]));
        }
        grid[row][col] = gold;
        return gold + best;
    }
}

代码说明

gold 暂存当前格子的黄金数量,随后把当前格改成 0,表示本条路径不能再次进入。四个方向都尝试完成后,把 gold 写回去,这就是路径级状态恢复。

和岛屿面积题不同,黄金矿工不能永久把格子改成 0,因为其他起点或其他路径还需要使用这个格子。是否恢复状态,要看题目要求的是"整个搜索过程只处理一次",还是"每条候选路径都可以重新尝试"。

九、从多道网格题中归纳规律

图像渲染、岛屿数量和最大面积都可以看成连通块问题。外层寻找新的起点,内部 DFS 扩散整块区域;访问标记可以通过修改网格完成。它们的差别主要是返回值:图像渲染修改颜色,岛屿数量返回连通块个数,最大面积返回连通块大小。

被围绕的区域和太平洋大西洋水流问题都使用了"反向思考"。被围绕的区域从边界找安全区域,水流问题从海洋边界反向寻找能够到达的位置。很多网格题的难点不在 DFS 代码,而在于找到合适的搜索起点。

单词搜索、黄金矿工和不同路径 III 属于路径型回溯。此时 visited 或网格修改只在当前路径有效,递归返回后必须恢复。扫雷的主要变化是方向从四方向扩展到八方向。

因此遇到网格题时,可以先判断三个问题:**移动方向是四方向还是八方向?访问过的格子是整个搜索过程都不能再处理,还是只在当前路径中不能重复?DFS 需要返回数量、最大值、真假,还是只修改网格?**这三个问题通常能确定代码骨架。

八、易错点

  1. 访问数组元素前没有先判断行列是否越界。
  2. 忘记标记访问,导致相邻格子之间反复递归。
  3. 四方向和八方向混淆。
  4. FloodFill 新旧颜色相同时仍然继续扩散。
  5. 岛屿数量没有在发现新起点后淹没整座岛,造成重复计数。
  6. 被围绕的区域没有从边界寻找安全的 O。
  7. 单词搜索、黄金矿工等路径题修改状态后没有恢复。
  8. visitedmemo 混淆:visited 限制当前路径,memo 保存状态答案。

九、本篇总结

网格 DFS 的固定骨架可以概括为"确定起点、判断边界、判断格子、标记访问、沿方向递归"。连通块题通常可以永久标记,路径题通常必须在递归返回后恢复。题目看起来可能是染色、岛屿、棋盘或路径,但只要先确认移动方向、访问范围和返回结果,往往都能还原出相同的搜索结构。

相关推荐
NoteDeep 深度笔记16 分钟前
Observer: 思维 ≠ 自己
笔记
VALENIAN瓦伦尼安教学设备1 小时前
设备状态检测振动分析实训台案例分析
大数据·数据库·人工智能·嵌入式硬件·算法
米饭不加菜1 小时前
一些常用模块简介
经验分享·笔记·嵌入式硬件
小小晓.1 小时前
C++ 值类别与完美转发深度剖析:从左值右值到 std::forward 源码解密
开发语言·c++·算法
怕浪猫1 小时前
用了两年 AI 编程工具后,我重新理解了什么是「资深工程师」
算法·面试·架构
hetao17338372 小时前
2026-08-27~29 hetao1733837 的刷题记录
c++·算法
sunshine22 girl2 小时前
Angular7,9,学习笔记三 dom操作
笔记·学习·angular
云淡风轻~窗明几净2 小时前
宇宙管理学猜想
算法·图论
春风解人意2 小时前
从零开始学习嵌入式P28----线程
c语言·学习·算法