给定一个迷宫,从入口到出口有多条路径。你应该选择哪种策略?一种方式是沿着每条路一直走到底,走不通就返回上一个分叉口换条路——这就是深度优先搜索。另一种方式是先探索所有离入口距离为1的格子,再探索距离为2的格子,层层推进——这就是广度优先搜索。

这两种策略代表了两种根本不同的思维方式:一种追求纵深,一种追求广度。在算法的世界里,它们演化成了DFS和BFS,成为图论和树遍历的两大基石。

栈与队列:两种数据结构的哲学分野

深度优先搜索(Depth-First Search,DFS)和广度优先搜索(Breadth-First Search,BFS)的核心区别在于数据结构的选择:DFS使用栈(Stack),BFS使用队列(Queue)。这个选择看似简单,却决定了两种算法完全不同的行为模式。

栈遵循后进先出(LIFO)原则。当DFS遍历到某个节点时,它会将该节点的所有邻居压入栈中,然后弹出栈顶元素继续探索。这意味着最后被发现的节点会被最先探索,形成一条不断向下延伸的路径,直到无路可走才回溯。

队列遵循先进先出(FIFO)原则。当BFS遍历到某个节点时,它会将该节点的所有邻居加入队列尾部,然后从队列头部取出节点继续探索。这意味着最先被发现的节点会被最先探索,形成层层推进的波纹状遍历模式。

sequenceDiagram
    participant S as 栈
    participant DFS as DFS遍历
    Note over S,DFS: DFS: 后进先出 (LIFO)
    S->>DFS: 弹出栈顶 (最后加入的)
    DFS->>S: 压入邻居 (新的成为栈顶)
    
    participant Q as 队列
    participant BFS as BFS遍历  
    Note over Q,BFS: BFS: 先进先出 (FIFO)
    Q->>BFS: 取出队首 (最早加入的)
    BFS->>Q: 加入邻居 (放在队尾)

DFS:一条路走到黑

DFS的核心思想非常直观:从起点出发,沿着一条路径尽可能深入,直到无法继续前进,然后回溯到上一个分叉点,选择另一条路径继续探索。这个过程可以自然地用递归实现,也可以显式地使用栈来模拟。

递归实现:代码最简洁的形式

递归是DFS最自然的实现方式。每次递归调用代表"沿着当前路径继续前进",递归返回代表"回溯到上一个节点"。

// DFS递归遍历图
public void dfsRecursive(int node, boolean[] visited, List<List<Integer>> graph) {
    visited[node] = true;
    // 处理当前节点
    System.out.print(node + " ");
    
    // 遍历所有邻居
    for (int neighbor : graph.get(node)) {
        if (!visited[neighbor]) {
            dfsRecursive(neighbor, visited, graph);
        }
    }
}

递归实现的优势在于代码简洁直观,但需要注意递归深度问题。对于深度很大的图(比如一条链),可能导致栈溢出。

迭代实现:显式栈的运用

迭代实现通过显式地使用栈来模拟递归过程,避免了递归深度限制的问题。

// DFS迭代遍历图
public void dfsIterative(int start, List<List<Integer>> graph) {
    int n = graph.size();
    boolean[] visited = new boolean[n];
    Deque<Integer> stack = new ArrayDeque<>();
    
    stack.push(start);
    while (!stack.isEmpty()) {
        int node = stack.pop();
        if (visited[node]) continue;
        
        visited[node] = true;
        System.out.print(node + " ");
        
        // 逆序添加邻居以保证遍历顺序与递归一致
        for (int i = graph.get(node).size() - 1; i >= 0; i--) {
            int neighbor = graph.get(node).get(i);
            if (!visited[neighbor]) {
                stack.push(neighbor);
            }
        }
    }
}

二叉树的三种DFS遍历

对于二叉树,DFS可以细分为三种遍历方式,区别在于访问根节点的时机:

遍历方式 访问顺序 特点
前序遍历 根→左→右 最先访问根,适合复制树结构
中序遍历 左→根→右 对于BST,输出有序序列
后序遍历 左→右→根 最后访问根,适合删除或计算子树信息
// 前序遍历(递归)
public void preorder(TreeNode root, List<Integer> result) {
    if (root == null) return;
    result.add(root.val);
    preorder(root.left, result);
    preorder(root.right, result);
}

// 前序遍历(迭代)
public List<Integer> preorderIterative(TreeNode root) {
    List<Integer> result = new ArrayList<>();
    if (root == null) return result;
    
    Deque<TreeNode> stack = new ArrayDeque<>();
    stack.push(root);
    
    while (!stack.isEmpty()) {
        TreeNode node = stack.pop();
        result.add(node.val);
        // 先压右孩子,再压左孩子(栈是后进先出)
        if (node.right != null) stack.push(node.right);
        if (node.left != null) stack.push(node.left);
    }
    return result;
}

// 中序遍历(迭代)
public List<Integer> inorderIterative(TreeNode root) {
    List<Integer> result = new ArrayList<>();
    Deque<TreeNode> stack = new ArrayDeque<>();
    TreeNode curr = root;
    
    while (curr != null || !stack.isEmpty()) {
        // 先走到最左边
        while (curr != null) {
            stack.push(curr);
            curr = curr.left;
        }
        // 弹出并访问
        curr = stack.pop();
        result.add(curr.val);
        // 转向右子树
        curr = curr.right;
    }
    return result;
}

// 后序遍历(迭代 - 反转法)
public List<Integer> postorderIterative(TreeNode root) {
    List<Integer> result = new ArrayList<>();
    if (root == null) return result;
    
    Deque<TreeNode> stack = new ArrayDeque<>();
    stack.push(root);
    
    while (!stack.isEmpty()) {
        TreeNode node = stack.pop();
        result.add(node.val);
        // 前序是根-左-右,这里变成根-右-左
        if (node.left != null) stack.push(node.left);
        if (node.right != null) stack.push(node.right);
    }
    // 反转得到左-右-根
    Collections.reverse(result);
    return result;
}

BFS:层层推进的波纹

BFS从起点开始,先访问所有距离为1的节点,然后访问所有距离为2的节点,以此类推。这种"波纹状"的扩展方式使BFS天然适合解决最短路径问题。

队列实现:先进先出的遍历

BFS的标准实现使用队列,代码结构非常清晰:

// BFS遍历图
public void bfs(int start, List<List<Integer>> graph) {
    int n = graph.size();
    boolean[] visited = new boolean[n];
    Queue<Integer> queue = new LinkedList<>();
    
    visited[start] = true;
    queue.offer(start);
    
    while (!queue.isEmpty()) {
        int node = queue.poll();
        System.out.print(node + " ");
        
        for (int neighbor : graph.get(node)) {
            if (!visited[neighbor]) {
                visited[neighbor] = true;
                queue.offer(neighbor);
            }
        }
    }
}

层序遍历:BFS在二叉树上的经典应用

二叉树的层序遍历是BFS最经典的应用之一。通过在每一层开始时记录队列大小,可以区分不同层的节点:

// LeetCode 102. 二叉树层序遍历
public List<List<Integer>> levelOrder(TreeNode root) {
    List<List<Integer>> result = new ArrayList<>();
    if (root == null) return result;
    
    Queue<TreeNode> queue = new LinkedList<>();
    queue.offer(root);
    
    while (!queue.isEmpty()) {
        int levelSize = queue.size();
        List<Integer> level = new ArrayList<>();
        
        for (int i = 0; i < levelSize; i++) {
            TreeNode node = queue.poll();
            level.add(node.val);
            
            if (node.left != null) queue.offer(node.left);
            if (node.right != null) queue.offer(node.right);
        }
        result.add(level);
    }
    return result;
}

复杂度分析:谁更快?谁更省?

DFS和BFS的时间复杂度都是 $O(V+E)$,其中 $V$ 是顶点数,$E$ 是边数。这是因为每个顶点和每条边都只会被访问一次。对于树结构,边数 $E = V - 1$,所以复杂度简化为 $O(V)$。

空间复杂度则有所不同:

算法 空间复杂度 说明
DFS $O(H)$ $H$ 为树的高度或图的深度,递归栈深度
BFS $O(W)$ $W$ 为树的宽度或图的广度,队列最大长度

在最坏情况下:

  • DFS的空间复杂度为 $O(V)$(链状结构)
  • BFS的空间复杂度也为 $O(V)$(完全二叉树底层节点数为 $V/2$)

实际选择建议:对于深度大、宽度小的结构,BFS可能更省空间;对于宽度大、深度小的结构,DFS更省空间。在不确定的情况下,DFS通常内存占用更稳定。

何时选择DFS?何时选择BFS?

场景 推荐算法 原因
最短路径(无权图) BFS BFS按层遍历,最先到达目标的就是最短路径
连通分量统计 DFS/BFS 两者都可以,DFS代码更简洁
拓扑排序 DFS/BFS DFS用后序遍历,BFS用入度表(Kahn算法)
环检测 DFS DFS可以检测回边,判断是否有环
路径搜索(所有解) DFS DFS天然支持回溯,可以找到所有路径
层级相关问题 BFS BFS天然按层遍历,方便处理层级信息

一个关键洞察:如果问题需要"最短"或"最少"的答案,BFS通常是更好的选择。如果需要"所有可能"或"存在性"的答案,DFS更合适。

LeetCode实战:DFS与BFS的经典题目

一、树结构遍历

LeetCode 104. 二叉树的最大深度

这是DFS最基础的应用。树的深度等于左子树深度和右子树深度的最大值加1。

// DFS递归解法
public int maxDepth(TreeNode root) {
    if (root == null) return 0;
    return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
}

// BFS解法
public int maxDepthBFS(TreeNode root) {
    if (root == null) return 0;
    
    Queue<TreeNode> queue = new LinkedList<>();
    queue.offer(root);
    int depth = 0;
    
    while (!queue.isEmpty()) {
        int size = queue.size();
        depth++;
        for (int i = 0; i < size; i++) {
            TreeNode node = queue.poll();
            if (node.left != null) queue.offer(node.left);
            if (node.right != null) queue.offer(node.right);
        }
    }
    return depth;
}

LeetCode 226. 翻转二叉树

这是一道经典的DFS题目。交换每个节点的左右子树即可。

// DFS递归解法
public TreeNode invertTree(TreeNode root) {
    if (root == null) return null;
    
    TreeNode temp = root.left;
    root.left = root.right;
    root.right = temp;
    
    invertTree(root.left);
    invertTree(root.right);
    
    return root;
}

LeetCode 236. 二叉树的最近公共祖先

这道题展示了DFS在后序遍历中的应用。当一个节点同时拥有p和q作为后代时,它就是最近公共祖先。

public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    // 基本情况:到达叶子节点或找到目标节点
    if (root == null || root == p || root == q) {
        return root;
    }
    
    // 在左右子树中搜索
    TreeNode left = lowestCommonAncestor(root.left, p, q);
    TreeNode right = lowestCommonAncestor(root.right, p, q);
    
    // 如果左右子树都找到了,当前节点就是LCA
    if (left != null && right != null) {
        return root;
    }
    
    // 返回非空的那一侧
    return left != null ? left : right;
}

二、图结构遍历

LeetCode 200. 岛屿数量

这是DFS/BFS在网格图上的经典应用。每找到一个'1’,就进行DFS/BFS将整个岛屿标记为已访问。

// DFS解法
public int numIslands(char[][] grid) {
    if (grid == null || grid.length == 0) return 0;
    
    int m = grid.length, n = grid[0].length;
    int count = 0;
    
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (grid[i][j] == '1') {
                count++;
                dfs(grid, i, j);
            }
        }
    }
    return count;
}

private void dfs(char[][] grid, int i, int j) {
    int m = grid.length, n = grid[0].length;
    
    // 边界检查
    if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] != '1') {
        return;
    }
    
    // 标记为已访问
    grid[i][j] = '2';
    
    // 四个方向DFS
    dfs(grid, i - 1, j);
    dfs(grid, i + 1, j);
    dfs(grid, i, j - 1);
    dfs(grid, i, j + 1);
}

// BFS解法
public int numIslandsBFS(char[][] grid) {
    if (grid == null || grid.length == 0) return 0;
    
    int m = grid.length, n = grid[0].length;
    int count = 0;
    int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
    
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (grid[i][j] == '1') {
                count++;
                Queue<int[]> queue = new LinkedList<>();
                queue.offer(new int[]{i, j});
                grid[i][j] = '2';
                
                while (!queue.isEmpty()) {
                    int[] cell = queue.poll();
                    for (int[] dir : dirs) {
                        int ni = cell[0] + dir[0];
                        int nj = cell[1] + dir[1];
                        if (ni >= 0 && ni < m && nj >= 0 && nj < n && grid[ni][nj] == '1') {
                            grid[ni][nj] = '2';
                            queue.offer(new int[]{ni, nj});
                        }
                    }
                }
            }
        }
    }
    return count;
}

LeetCode 133. 克隆图

这道题需要在DFS/BFS过程中维护新旧节点的映射关系。

// DFS解法
public Node cloneGraph(Node node) {
    if (node == null) return null;
    
    Map<Node, Node> visited = new HashMap<>();
    return dfs(node, visited);
}

private Node dfs(Node node, Map<Node, Node> visited) {
    // 如果已经克隆过,直接返回
    if (visited.containsKey(node)) {
        return visited.get(node);
    }
    
    // 克隆当前节点
    Node clone = new Node(node.val);
    visited.put(node, clone);
    
    // 克隆所有邻居
    for (Node neighbor : node.neighbors) {
        clone.neighbors.add(dfs(neighbor, visited));
    }
    
    return clone;
}

// BFS解法
public Node cloneGraphBFS(Node node) {
    if (node == null) return null;
    
    Map<Node, Node> visited = new HashMap<>();
    Queue<Node> queue = new LinkedList<>();
    
    Node clone = new Node(node.val);
    visited.put(node, clone);
    queue.offer(node);
    
    while (!queue.isEmpty()) {
        Node curr = queue.poll();
        
        for (Node neighbor : curr.neighbors) {
            if (!visited.containsKey(neighbor)) {
                visited.put(neighbor, new Node(neighbor.val));
                queue.offer(neighbor);
            }
            visited.get(curr).neighbors.add(visited.get(neighbor));
        }
    }
    
    return clone;
}

LeetCode 207. 课程表

这道题本质是检测有向图中是否存在环。可以使用DFS检测环,或使用BFS的拓扑排序(Kahn算法)。

// DFS检测环
public boolean canFinish(int numCourses, int[][] prerequisites) {
    List<List<Integer>> graph = new ArrayList<>();
    for (int i = 0; i < numCourses; i++) {
        graph.add(new ArrayList<>());
    }
    
    for (int[] pre : prerequisites) {
        graph.get(pre[1]).add(pre[0]);
    }
    
    // 0: 未访问, 1: 正在访问, 2: 已访问完成
    int[] state = new int[numCourses];
    
    for (int i = 0; i < numCourses; i++) {
        if (hasCycle(graph, i, state)) {
            return false;
        }
    }
    return true;
}

private boolean hasCycle(List<List<Integer>> graph, int node, int[] state) {
    if (state[node] == 1) return true;  // 发现环
    if (state[node] == 2) return false; // 已经访问过
    
    state[node] = 1;  // 标记为正在访问
    
    for (int neighbor : graph.get(node)) {
        if (hasCycle(graph, neighbor, state)) {
            return true;
        }
    }
    
    state[node] = 2;  // 标记为访问完成
    return false;
}

// BFS拓扑排序(Kahn算法)
public boolean canFinishBFS(int numCourses, int[][] prerequisites) {
    List<List<Integer>> graph = new ArrayList<>();
    int[] inDegree = new int[numCourses];
    
    for (int i = 0; i < numCourses; i++) {
        graph.add(new ArrayList<>());
    }
    
    for (int[] pre : prerequisites) {
        graph.get(pre[1]).add(pre[0]);
        inDegree[pre[0]]++;
    }
    
    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < numCourses; i++) {
        if (inDegree[i] == 0) {
            queue.offer(i);
        }
    }
    
    int count = 0;
    while (!queue.isEmpty()) {
        int course = queue.poll();
        count++;
        
        for (int neighbor : graph.get(course)) {
            if (--inDegree[neighbor] == 0) {
                queue.offer(neighbor);
            }
        }
    }
    
    return count == numCourses;
}

三、最短路径问题

LeetCode 127. 单词接龙

这道题要求最短转换序列的长度,是BFS求最短路径的典型应用。

public int ladderLength(String beginWord, String endWord, List<String> wordList) {
    Set<String> wordSet = new HashSet<>(wordList);
    if (!wordSet.contains(endWord)) return 0;
    
    Queue<String> queue = new LinkedList<>();
    queue.offer(beginWord);
    int level = 1;
    
    while (!queue.isEmpty()) {
        int size = queue.size();
        
        for (int i = 0; i < size; i++) {
            String word = queue.poll();
            
            if (word.equals(endWord)) {
                return level;
            }
            
            // 尝试改变每个位置的字符
            char[] chars = word.toCharArray();
            for (int j = 0; j < chars.length; j++) {
                char original = chars[j];
                
                for (char c = 'a'; c <= 'z'; c++) {
                    if (c == original) continue;
                    chars[j] = c;
                    String newWord = new String(chars);
                    
                    if (wordSet.contains(newWord)) {
                        queue.offer(newWord);
                        wordSet.remove(newWord);  // 避免重复访问
                    }
                }
                chars[j] = original;
            }
        }
        level++;
    }
    
    return 0;
}

LeetCode 130. 被围绕的区域

这道题的关键洞察是:任何与边界相连的’O’都不会被包围。我们可以从边界的’O’开始BFS/DFS标记所有不被包围的区域。

public void solve(char[][] board) {
    if (board == null || board.length == 0) return;
    
    int m = board.length, n = board[0].length;
    
    // 从边界的'O'开始标记
    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);
    }
    
    // 遍历整个矩阵,还原标记,填充被包围的区域
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (board[i][j] == 'O') board[i][j] = 'X';
            else if (board[i][j] == '#') board[i][j] = 'O';
        }
    }
}

private void dfs(char[][] board, int i, int j) {
    int m = board.length, n = board[0].length;
    
    if (i < 0 || i >= m || j < 0 || j >= n || board[i][j] != 'O') {
        return;
    }
    
    board[i][j] = '#';  // 标记为不被包围
    
    dfs(board, i - 1, j);
    dfs(board, i + 1, j);
    dfs(board, i, j - 1);
    dfs(board, i, j + 1);
}

题目模式总结

通过以上题目,可以总结出DFS和BFS在LeetCode上的几种常见模式:

DFS常见模式

  1. 树的遍历(前/中/后序)
  2. 图的连通分量统计
  3. 路径搜索(所有路径)
  4. 环检测(三色标记法)
  5. 拓扑排序(后序遍历逆序)

BFS常见模式

  1. 层序遍历
  2. 最短路径(无权图)
  3. 拓扑排序(Kahn算法)
  4. 从边界开始的填充问题

掌握这些模式,大多数DFS/BFS问题都能迎刃而解。记住:当问题涉及"最短"、“最少”、“层级"时,优先考虑BFS;当问题涉及"所有可能”、“存在性”、“路径搜索"时,优先考虑DFS


参考资料

  1. GeeksforGeeks. “Difference between BFS and DFS.” https://www.geeksforgeeks.org/dsa/difference-between-bfs-and-dfs/
  2. VisuAlgo. “Graph Traversal (Depth/Breadth First Search).” https://visualgo.net/en/dfsbfs
  3. LeetCode. “Depth-First Search Problem List.” https://leetcode.com/problem-list/depth-first-search/
  4. LeetCode. “Breadth-First Search Problem List.” https://leetcode.com/problem-list/breadth-first-search/
  5. Baeldung. “Depth First Search in Java.” https://www.baeldung.com/java-depth-first-search
  6. LeetCode Discuss. “Iterative | Recursive | DFS & BFS Tree Traversal.” https://leetcode.com/discuss/post/937307/
  7. Medium. “Leetcode Pattern 1 | BFS + DFS == 25% of the problems.” https://medium.com/leetcode-patterns/leetcode-pattern-1-bfs-dfs-25-of-the-problems-part-1-519450a84353