深度优先搜索

MrHe··4 min read

一提到DFS,很多人脑子里可能冒出来的是“递归”、“栈”、“回溯”这些词。没错,但这些都是实现它的工具。那DFS的本质是什么?

在我看来,DFS的本质就是 “一条路走到黑,不撞南墙不回头” 的莽夫精神。

想象你在走一个迷宫,怎么走?最符合直觉的策略就是,随便选一个路口,一直走下去。如果遇到死胡同了,怎么办?退回到上一个路口,换个方向再试试。如果这个路口的所有方向都试过了,还是死胡同,那就再退回到上上个路口……直到找到出口,或者所有路都试完了,发现根本没出口。

这就是DFS的核心思想:深入探索,走不通再退回,换路再探

光说不练假把式。我们来看一个最经典的题目:岛屿数量。

题目: 给你一个由 '1'(陆地)和 '0'(水)组成的二维网格,请你计算网格中岛屿的数量。岛屿总是被水包围,并且每座岛屿只能由水平方向和竖直方向上相邻的陆地连接形成。

// 例子
[
  ['1','1','0','0','0'],
  ['1','1','0','0','0'],
  ['0','0','1','0','0'],
  ['0','0','0','1','1']
]
// 这个网格里有 3 个岛屿。

解法一:最直观的解法 —— 递归

拿到这个题,我第一反应就是,遍历整个网格。当我遇到一个 '1'(陆地)时,我就找到了一个新岛屿的“一部分”。

那怎么确定整个岛屿的范围呢?

很简单,从这个点出发,向它的上下左右四个方向去探索。如果邻居也是 '1',那它就和我是同一块岛屿。然后,再从这个邻居出发,继续向它的邻居的邻居探索......

这个过程听起来是不是很熟悉?这就是一个典型的递归过程。

process(i, j) 函数的含义就是:请把 (i, j) 位置以及所有与它相连的陆地都给我“淹掉”(比如变成'2'),这样下次就不会重复计算了

思路:

  1. 写一个主函数,用两层 for 循环遍历整个 grid

  2. 循环中,如果 grid[i][j] == '1',说明我们发现了一个新岛屿。此时,岛屿数量 islands++

  3. 为了不重复计算,我们需要把这个新发现的岛屿的所有陆地都标记一下,表示“已访问”。怎么标记?就从 (i, j) 这个点开始,进行深度优先搜索,把所有相连的 '1' 都改成一个特殊字符,比如 '2' 或者 '0'。

  4. 这个DFS过程,就交给一个递归函数 infect(grid, i, j) 来做。

infect(grid, i, j) 的逻辑:

  • Base Case (递归出口):

    • 如果 (i, j) 越界了 (i < 0, i >= rows, j < 0, j >= cols),直接返回。

    • 如果 grid[i][j] 不是 '1'(说明是水,或者是已经访问过的陆地),也直接返回。

  • 递归过程:

    • 先把当前的 grid[i][j] 标记为已访问,比如 grid[i][j] = '2'

    • 然后,莽夫精神来了:一条路走到黑。

      • 往上走:infect(grid, i - 1, j)

      • 往下走:infect(grid, i + 1, j)

      • 往左走:infect(grid, i, j - 1)

      • 往右走:infect(grid, i, j + 1)

这个递归过程会像病毒一样,从一个点开始,感染所有与它相连的 '1',把它们都变成 '2'。当 infect 过程结束后,整个岛屿就被“淹没”了。主循环继续扫描,直到找到下一个 '1'。

代码实现:

public class Solution1_RecursiveDFS {

    public int numIslands(char[][] grid) {
        if (grid == null || grid.length == 0) {
            return 0;
        }

        int rows = grid.length;
        int cols = grid[0].length;
        int islands = 0;

        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                if (grid[i][j] == '1') {
                    islands++;
                    infect(grid, i, j);
                }
            }
        }
        return islands;
    }

    // 递归函数:把(i,j)以及相邻的陆地都变成'2'
    private void infect(char[][] grid, int i, int j) {
        int rows = grid.length;
        int cols = grid[0].length;

        // Base Case: 越界或不是陆地,就返回
        if (i < 0 || i >= rows || j < 0 || j >= cols || grid[i][j] != '1') {
            return;
        }

        // 将当前陆地标记为已访问
        grid[i][j] = '2'; 

        // 向四个方向递归探索
        infect(grid, i - 1, j); // 上
        infect(grid, i + 1, j); // 下
        infect(grid, i, j - 1); // 左
        infect(grid, i, j + 1); // 右
    }
}

递归解法非常直观,代码也简洁。但它有个问题,如果岛屿的形状特别“深”,比如说一条长长的蛇形,递归深度可能会非常大,有导致栈溢出(StackOverflowError)的风险。

那有没有办法不使用系统栈,自己来控制这个过程呢?


解法二:自己动手,丰衣足食 —— 迭代与栈

系统递归的本质是利用函数调用栈来保存现场。我们完全可以自己模拟这个过程。用什么来模拟?当然是 栈(Stack)

思路:

  1. 主函数的逻辑和解法一完全一样:遍历网格,遇到 '1' 就 islands++,然后开始“淹没”这个岛屿。

  2. “淹没”的过程,我们不用递归,而是用一个栈来辅助。

    • 创建一个栈,把当前发现的陆地坐标 (i, j) 压入栈中。

    • 同时,为了防止重复入栈,先把 grid[i][j] 标记为已访问 ('2')。

    • 进入一个 while 循环,只要栈不为空,就说明这个岛屿还有部分没探索完。

    • 在循环中:

      • 弹出一个坐标 (cur_i, cur_j)

      • 查看它的四个邻居 (next_i, next_j)

      • 如果邻居是合法的(不越界)并且是 '1'(未访问的陆地),就把这个邻居的坐标压入栈中,并立刻标记为 '2'。

    • while 循环结束时,栈空了,说明与 (i, j) 相连的所有陆地都被访问并标记过了。整个岛屿“淹没”完毕。

这个过程和递归的本质是一样的。递归是隐式地用系统栈,而我们现在是显式地用一个我们自己创建的 Stack 数据结构来做同样的事情。

代码实现:

import java.util.Stack;

public class Solution2_IterativeDFS {

    // 辅助类,用来存坐标
    private static class Position {
        int x, y;
        public Position(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }

    public int numIslands(char[][] grid) {
        if (grid == null || grid.length == 0) {
            return 0;
        }

        int rows = grid.length;
        int cols = grid[0].length;
        int islands = 0;

        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                if (grid[i][j] == '1') {
                    islands++;
                    // 使用栈来“淹没”岛屿
                    Stack<Position> stack = new Stack<>();
                    stack.push(new Position(i, j));
                    grid[i][j] = '2'; // 标记为已访问

                    while (!stack.isEmpty()) {
                        Position current = stack.pop();
                        int curI = current.x;
                        int curJ = current.y;

                        // 探索四个方向
                        // 上
                        if (curI - 1 >= 0 && grid[curI - 1][curJ] == '1') {
                            stack.push(new Position(curI - 1, curJ));
                            grid[curI - 1][curJ] = '2';
                        }
                        // 下
                        if (curI + 1 < rows && grid[curI + 1][curJ] == '1') {
                            stack.push(new Position(curI + 1, curJ));
                            grid[curI + 1][curJ] = '2';
                        }
                        // 左
                        if (curJ - 1 >= 0 && grid[curI][curJ - 1] == '1') {
                            stack.push(new Position(curI, curJ - 1));
                            grid[curI][curJ - 1] = '2';
                        }
                        // 右
                        if (curJ + 1 < cols && grid[curI][curJ + 1] == '1') {
                            stack.push(new Position(curI, curJ + 1));
                            grid[curI][curJ + 1] = '2';
                        }
                    }
                }
            }
        }
        return islands;
    }
}

这种迭代的写法避免了栈溢出的风险,在处理极端测试用例时更稳健。虽然代码看起来比递归版本长一点,但思路同样清晰。

那么,还有没有别的搞法?DFS的玩法当然不止这些。


解法三:不改原数组的“干净”解法 —— 引入 visited 数组

前面两种解法都直接修改了输入的 grid 数组。在很多面试或实际工程中,修改输入参数可能是不被允许的,被认为是一种“不纯”的函数行为。

那我们可以在不修改 grid 的前提下完成任务吗?

当然可以。我们只需要一个和 grid 等大的辅助空间,专门用来记录哪些位置被访问过。通常我们会用一个 boolean[][] visited 数组。

这个思路可以应用在递归和迭代两种实现上,我们以递归为例进行改造。

思路:

  1. 在主函数中,额外创建一个 boolean[][] visited 数组,所有元素初始为 false

  2. 主循环遍历 grid,判断条件变为:grid[i][j] == '1' && !visited[i][j]。当这个条件满足时,我们才发现了一个新的、未被访问过的岛屿。

  3. islands++ 后,调用 infect(grid, i, j, visited)

  4. infect 函数的逻辑几乎不变,只是把 “修改 grid[i][j] 为 '2'” 这个操作,换成 “修改 visited[i][j]true”。

    • Base Case: 增加了 visited[i][j] == true 的判断。

    • 递归过程:visited 数组作为参数传递下去。

代码实现:

public class Solution3_VisitedArrayDFS {

    public int numIslands(char[][] grid) {
        if (grid == null || grid.length == 0) {
            return 0;
        }

        int rows = grid.length;
        int cols = grid[0].length;
        int islands = 0;
        boolean[][] visited = new boolean[rows][cols];

        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                // 发现一个新岛屿的未访问部分
                if (grid[i][j] == '1' && !visited[i][j]) {
                    islands++;
                    infect(grid, i, j, visited);
                }
            }
        }
        return islands;
    }

    private void infect(char[][] grid, int i, int j, boolean[][] visited) {
        int rows = grid.length;
        int cols = grid[0].length;

        // Base Case: 越界、是水域、或已访问过
        if (i < 0 || i >= rows || j < 0 || j >= cols || grid[i][j] == '0' || visited[i][j]) {
            return;
        }

        // 标记为已访问
        visited[i][j] = true;

        // 向四个方向递归探索
        infect(grid, i - 1, j, visited);
        infect(grid, i + 1, j, visited);
        infect(grid, i, j - 1, visited);
        infect(grid, i, j + 1, visited);
    }
}

这种方法用空间换来了对原数组的保护,代码逻辑同样清晰。在需要保持输入数据不变的场景下,这是首选方案。

总结一下

今天我们用三种方式搞定了“岛屿数量”这个问题,但这三种方式的核心都是深度优先搜索(DFS):

  1. 递归DFS:最直观,代码最简洁,但有栈溢出风险。

  2. 迭代DFS(手动栈):模拟递归过程,避免了栈溢出,性能更稳定。

  3. 使用visited数组的DFS:不修改原数组,是更规范、更通用的写法。