Skip to content

深度优先遍历 (Depth-First Search, DFS)

1. DFS 核心概览

深度优先遍历(DFS)是一种用于遍历或搜索树或图的算法。它的核心思想是:“不到黄河心不死”。从起始节点开始,沿着一条路径不断向下钻取,直到遇到叶子节点(或死胡同),然后退回(回溯)到上一个分叉口,继续探索下一条路径。

通俗理解: 走迷宫时,你一直贴着右边的墙壁走,遇到死胡同就退回上一个路口,换一条路继续贴着墙壁走,直到走遍整个迷宫。

1.1 DFS 的核心利器:栈 (Stack)

与 BFS 依赖“队列”不同,DFS 极其依赖栈(后进先出 LIFO)。 在实际编码中,DFS 有两种实现方式:

  1. 隐式栈(递归): 利用 JavaScript 引擎自身的“函数调用栈(Call Stack)”。代码极其简洁优雅,是日常开发和面试的首选。
  2. 显式栈(迭代): 手动创建一个数组 [] 模拟栈。主要用于树极深、递归容易爆栈的极端场景。

1.2 DFS 的王牌应用场景

应用场景详细说明
连通性问题寻找图中有多少个独立连通的区域(如著名的“岛屿数量”问题)。
寻找所有路径走迷宫找寻所有可行的路径(即之前探讨过的回溯算法)。
树的各类遍历前序遍历(根左右)、中序遍历(左根右)、后序遍历(左右根)。
拓扑排序 / 环检测判断一个图中是否存在死循环(如检测 NPM 包的循环依赖)。

2. 经典 DFS 场景与 JavaScript 实现

2.1 二叉树的基础遍历 (Tree DFS)

在二叉树中,DFS 最经典的体现就是前、中、后序遍历。由于树没有环,我们不需要担心走回头路。

JavaScript 代码实现 (递归版前序遍历)

js
/**
 * 二叉树前序遍历 (DFS - 根、左、右)
 * @param {TreeNode} root
 * @return {number[]}
 */
function preorderTraversal(root) {
    const result = [];
    
    // 定义 DFS 递归函数
    function dfs(node) {
        if (!node) return; // 终止条件:遇到空节点,停止下钻
        
        result.push(node.val); // 1. 先处理当前节点 (根)
        dfs(node.left);        // 2. 疯狂向左子树下钻 (左)
        dfs(node.right);       // 3. 左边走不通了,再向右子树下钻 (右)
    }
    
    dfs(root);
    return result;
}

2.2 网格/图的 DFS:岛屿数量 (Number of Islands)

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

核心逻辑: 遍历整个网格,一旦遇到 '1',说明发现了一个新岛屿(数量+1)。然后立刻启动 DFS,像病毒感染一样,把与它相连的所有 '1' 都变成 '0'(即标记为已访问),防止后续遍历时重复计算。

JavaScript 代码实现

js
/**
 * 岛屿数量
 * @param {character[][]} grid
 * @return {number}
 */
function numIslands(grid) {
    if (!grid || grid.length === 0) return 0;
    
    let count = 0;
    const rows = grid.length;
    const cols = grid[0].length;
    
    // 定义 DFS 感染函数
    function dfs(r, c) {
        // 终止条件:越界,或者是水 ('0'),停止探索
        if (r < 0 || c < 0 || r >= rows || c >= cols || grid[r][c] === '0') {
            return;
        }
        
        // 【关键】将当前陆地变成水 (标记为已访问),防止死循环!
        grid[r][c] = '0';
        
        // 向四个方向继续深度感染
        dfs(r - 1, c); // 上
        dfs(r + 1, c); // 下
        dfs(r, c - 1); // 左
        dfs(r, c + 1); // 右
    }
    
    // 遍历整个网格找陆地
    for (let i = 0; i < rows; i++) {
        for (let j = 0; j < cols; j++) {
            if (grid[i][j] === '1') {
                count++;      // 发现新岛屿
                dfs(i, j);    // 启动 DFS,把这整座岛屿全部“击沉”
            }
        }
    }
    
    return count;
}

3. 常见问题深度剖析 (FAQ)

常见疑惑深度解析与应对策略
1. DFS 和 回溯算法(Backtracking)到底是什么关系?回溯是 DFS 的一种特殊形式。 DFS 强调的是“结构上的遍历”(把每个角落都看一遍);而回溯强调的是“状态的记录与撤销”(走到这一步记录一下,退回来时要把状态恢复原样,以便探索另一条路)。在上面的“岛屿数量”中,我们把 1 变成 0没有变回 1,这是纯粹的 DFS;如果探索完还需要变回 1,那就是回溯。
2. DFS 找出来的路径是最短路径吗?绝对不是! DFS 找到的第一条路径,仅仅是它“一头扎到底”碰巧撞上的那条路,极其有可能是绕了十万八千里的大远路。找最短路径必须用 BFS。 DFS 只能回答“能不能走到”或“一共有几条路”。
3. 二叉树的 前序、中序、后序 为什么都属于 DFS?因为它们本质上都是靠递归一路下钻到底。它们的唯一区别仅仅是**“处理当前节点数据的时机”**不同:
🔹 第一次经过该节点就处理:前序。
🔹 左子树全处理完了,退回到该节点时再处理:中序。
🔹 左右子树都处理完了,最后离开该节点前处理:后序。