算法面试突击手册 —— 图(BFS/DFS/拓扑排序/并查集)


一、概念与特点

大白话 + 类比

图 = 城市之间的交通网

  • 节点 = 城市, = 道路
  • DFS:你开车从 A 出发,一条路走到尽头(不撞南墙不回头),再倒车回来换一条路
  • BFS:你从 A 放出一群侦察兵,同时向所有相邻城市前进,一圈一圈扩散
  • 拓扑排序:大学选课——先修课必须在前面,把依赖关系排成一条合法的学习路径
  • 并查集:家族认亲——两个人分别往上查族谱,看是否属于同一个祖先

遍历选择策略——选错会走弯路

场景选 DFS选 BFS原因
找所有连通分量两者都能遍历整个连通区域
最短路径(无权图)BFS 按层扩散,首次到达即最短
拓扑排序DFS 后序 / BFS Kahn 都可
判断连通性遍历完看是否全覆盖
多源扩散BFS 多起点同时入队,自然按层扩散

识别信号词

  • “岛屿”、“区域”、“矩阵中的连通块” → DFS/BFS 沉岛
  • “课程表”、“先修课”、“依赖关系”、“能否完成” → 拓扑排序
  • “朋友”、“连通”、“集合合并” → 并查集
  • “腐烂”、“扩散”、“最短时间”、“感染” → 多源 BFS
  • “Trie”、“前缀树”、“自动补全” → 特殊多叉树
  • “除法求值”、“变量关系” → 带权图 BFS

四种核心代码模板

// 1. DFS 沉岛(二维网格)
void dfs(char[][] grid, int i, int j) {
    if (i < 0 || i >= grid.length || j < 0 || j >= grid[0].length
        || grid[i][j] != '1') return;
    grid[i][j] = '0'; // "沉掉"当前位置,防止重复访问
    dfs(grid, i+1, j); dfs(grid, i-1, j);
    dfs(grid, i, j+1); dfs(grid, i, j-1);
}

// 2. BFS 队列 —— 适合求最短距离
Queue<int[]> q = new LinkedList<>();
q.offer(start);
while (!q.isEmpty()) {
    int size = q.size(); // 当前层节点数
    for (int i = 0; i < size; i++) { /* 处理一层 */ }
}

// 3. 拓扑排序(BFS Kahn 算法)
int[] indegree = new int[n];
for (int[] edge : edges) indegree[edge[1]]++;
Queue<Integer> q = new LinkedList<>();
for (int i = 0; i < n; i++) if (indegree[i] == 0) q.offer(i);
while (!q.isEmpty()) {
    int u = q.poll();
    for (int v : adj[u]) if (--indegree[v] == 0) q.offer(v);
}

// 4. 并查集
class UnionFind {
    int[] parent, rank;
    // 查找(路径压缩)
    int find(int x) {
        return parent[x] == x ? x : (parent[x] = find(parent[x]));
    }
    // 合并(按秩合并)
    void union(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return;
        if (rank[rx] < rank[ry]) parent[rx] = ry;
        else if (rank[rx] > rank[ry]) parent[ry] = rx;
        else { parent[ry] = rx; rank[rx]++; }
    }
}

二、Hot 100 精讲题目


200. Number of Islands 岛屿数量

📌 二维网格中,‘1’ 是陆地,‘0’ 是水。水平和垂直方向相连的陆地组成岛屿。计算岛屿数量。如 [["1","1","0"],["1","0","1"],["0","0","1"]] → 3

🔍 解题分析

为什么用 DFS

这是经典的”连通分量计数”问题。DFS 可以一次性遍历完整个岛屿(所有相连的 ‘1’),然后我们把遍历过的陆地”沉掉”(改成 ‘0’),避免重复计数。

核心思路(步骤化)

  1. 遍历每个格子
  2. 遇到 ‘1’ → count++(发现新岛屿)→ 调用 dfs 把整个岛沉掉
  3. DFS 内部:四方向递归扩散,每到一个 ‘1’ 就改成 ‘0’
  4. 遍历结束,返回 count

“沉岛”的含义:遇到一个 ‘1’ 说明发现了陆地。用 DFS 遍历它的上下左右相邻陆地,全部改成 ‘0’(沉入水中)。这样后续遍历就不会再把同一岛的陆地重复计数。

关键边界条件/易错点

易错点说明
越界检查必须在最前面先判断 i/j 是否合法,再判断 grid[i][j]
沉岛时机必须在递归前沉,不能递归后沉——否则会无限递归
四方向 vs 八方向题目说”相邻”默认只有上下左右(4 方向),不是对角线

✅ 完整 Java 实现

public int numIslands(char[][] grid) {
    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++;           // 岛屿数 +1
                dfs(grid, i, j);   // 把整座岛沉入水中
            }
        }
    }

    return count;
}

private void dfs(char[][] grid, int i, int j) {
    // 越界或遇到水 → 停止扩散
    if (i < 0 || i >= grid.length || j < 0 || j >= grid[0].length
        || grid[i][j] != '1') {
        return;
    }

    // 沉岛:将当前陆地标记为水,表示已访问
    grid[i][j] = '0';

    // 向四个方向扩散,继续吞噬相连的陆地
    dfs(grid, i + 1, j); // 下
    dfs(grid, i - 1, j); // 上
    dfs(grid, i, j + 1); // 右
    dfs(grid, i, j - 1); // 左
}

⏱ 复杂度分析

  • 时间复杂度:O(mn) —— 每个格子最多被访问两次(一次外层遍历,一次 DFS)
  • 空间复杂度:O(mn) —— 递归栈最坏情况(全为 ‘1’ 的矩阵),等于格子总数

💡 记忆口诀

“遇 1 加一,DFS 四向沉岛;沉过的永远不会再被数到”


695. Max Area of Island 岛屿的最大面积

📌 找最大岛屿的面积(岛屿中 ‘1’ 的数量)。网格只含 0 和 1。如 [[0,0,1,0,0,0,0,1,0,0,0,0,0],[0,0,0,0,0,0,0,1,1,1,0,0,0],...] → 6

🔍 解题分析

与 200 题完全相同的模板,区别在于:

  1. 输入是 int[][](不是 char[][]),判断 grid[i][j] != 1
  2. DFS 需要返回面积1 + dfs(上) + dfs(下) + dfs(左) + dfs(右)

DFS 返回值设计:每个格子返回它自己(1)加上四个方向能探索到的陆地面积之和。递归自动汇总了整块岛屿的面积。

关键边界条件/易错点

易错点说明
返回值在沉岛之后先沉掉当前格子(标记为已访问),再累加面积。return 1 + dfs() + dfs()...
空网格如果网格全 0,maxArea 保持 0,逻辑正确
int vs char这题是 int[][],判断用 != 1;200 题是 char[][],判断用 != '1'

✅ 完整 Java 实现

public int maxAreaOfIsland(int[][] grid) {
    int m = grid.length, n = grid[0].length;
    int maxArea = 0;

    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (grid[i][j] == 1) {
                // 计算当前岛屿的面积,并更新最大值
                int area = dfs(grid, i, j);
                maxArea = Math.max(maxArea, area);
            }
        }
    }

    return maxArea;
}

// 返回从 (i,j) 开始能探索到的岛屿面积
private int dfs(int[][] grid, int i, int j) {
    // 越界 或 不是陆地 → 贡献面积为 0
    if (i < 0 || i >= grid.length || j < 0 || j >= grid[0].length
        || grid[i][j] != 1) {
        return 0;
    }

    // 沉岛:标记为已访问
    grid[i][j] = 0;

    // 当前格子面积 1 + 四个方向各自探索的面积
    return 1 + dfs(grid, i + 1, j)  // 下
             + dfs(grid, i - 1, j)  // 上
             + dfs(grid, i, j + 1)  // 右
             + dfs(grid, i, j - 1); // 左
}

⏱ 复杂度分析

  • 时间复杂度:O(mn) —— 每个格子最多访问两次
  • 空间复杂度:O(mn) —— 递归栈最坏情况

💡 记忆口诀

“沉岛时数格子,1 + 四方向和——递归自动汇总整块面积”


207. Course Schedule 课程表

📌 numCourses 门课,prerequisites[i] = [a, b] 表示必须先修 b 才能修 a。判断能否完成所有课程(= 有向图无环)。如 numCourses=2, prerequisites=[[1,0]] → true

🔍 解题分析

为什么是拓扑排序

课程依赖关系天然形成有向图:b → a(b 是 a 的先修课)。如果这个图里有环(A 依赖 B,B 依赖 A),就不可能完成所有课程。拓扑排序正好用来判断有向图是否有环。

BFS Kahn 算法(步骤化)

  1. 构建邻接表 adj(b → a 的方向)和入度数组 indegree
  2. 所有入度为 0 的节点入队(没有先修依赖的课可以直接学)
  3. 每次从队列取出一个节点(学一门课),将它的所有后继节点的入度减 1
  4. 如果某个后继节点入度变为 0,入队(它的所有先修课都学完了)
  5. 最终看能不能学完所有节点(count == n)

类比:想象修课就像拆积木,入度为 0 就是”没有压在它上面的积木”。每次取一块能直接拿起来的(入度 0),拿了之后压在它下面的积木的依赖就少了一层。

关键边界条件/易错点

易错点说明
边方向[a, b] 是 b→a(先修 b 才能修 a),入度加在 a 上
有环的判断最终 count < n 说明有环(部分节点入度永远不会变为 0)
单门课没有任何先修要求,入度全是 0,直接返回 true

✅ 完整 Java 实现

public boolean canFinish(int numCourses, int[][] prerequisites) {
    // 1. 构建邻接表 + 入度数组
    // adj[i] = 学完课程 i 之后能学的课的列表
    List<Integer>[] adj = new List[numCourses];
    for (int i = 0; i < numCourses; i++) {
        adj[i] = new ArrayList<>();
    }
    int[] indegree = new int[numCourses]; // indegree[i] = 课程 i 的先修课数量

    for (int[] pre : prerequisites) {
        int course = pre[0]; // 要学的课
        int prereq = pre[1]; // 先修课
        adj[prereq].add(course); // 学完 prereq 才能学 course
        indegree[course]++;      // course 的入度 +1(多了一门先修课)
    }

    // 2. 入度为 0 的课入队(没有先修依赖,可以直接学)
    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < numCourses; i++) {
        if (indegree[i] == 0) {
            queue.offer(i);
        }
    }

    // 3. BFS:逐门学习
    int learned = 0; // 已学课程数
    while (!queue.isEmpty()) {
        int course = queue.poll();
        learned++; // 学完这门课

        // 把这门课的所有后继课的入度减 1
        for (int next : adj[course]) {
            indegree[next]--;
            // 后继课的先修条件全部满足了 → 可以学了
            if (indegree[next] == 0) {
                queue.offer(next);
            }
        }
    }

    // 4. 学完了所有课 → 无环;否则有环
    return learned == numCourses;
}

手算验证numCourses=4, prerequisites=[[1,0],[2,1],[3,2],[1,3]](存在环 1→2→3→1)

| 初始入度 | 0:0, 1:2, 2:1, 3:1 | | 入队 | [0] | | 学 0 | learned=1,0 的后继 1 入度减为 1 | | 队列空 | learned=1 ≠ 4 → false(有环) ✓ |

⏱ 复杂度分析

  • 时间复杂度:O(V + E) —— V 是课程数,E 是依赖关系数
  • 空间复杂度:O(V + E) —— 邻接表 + 入度数组 + 队列

💡 记忆口诀

“入度为零的课先学,学完降低依赖课入度,全学完无环”


208. Implement Trie (Prefix Tree) 实现 Trie(前缀树)

📌 实现前缀树,支持 insert(word), search(word), startsWith(prefix) 操作。

🔍 解题分析

Trie 是什么

Trie 是一棵 26 叉树,每个节点有 26 个可能的子节点(对应 a-z)。从根到某个节点的路径代表一个前缀。如果该节点被标记为 isEnd = true,说明这个前缀是一个完整的单词。

操作分解

  • insert(“cat”):从根开始,沿 c→a→t 建路径,在 t 节点标记 isEnd=true
  • search(“cat”):沿 c→a→t 走,如果路径存在且 t 节点的 isEnd 是 true→找到
  • search(“ca”):沿 c→a 走,路径存在但 a 的 isEnd 是 false→只是前缀,不是单词
  • startsWith(“ca”):沿 c→a 走,路径存在→是前缀

类比:Trie 就像一个电子词典的索引。输入 “app”,它不需要遍历整个词典,而是直接沿着 a→p→p 的路径走,路径上的节点告诉你”这是前缀还是完整单词”。

关键边界条件/易错点

易错点说明
search 需要 isEndsearch 必须检查 isEnd,否则 “app” 没插入时 “app” 也能搜到(如果有 “apple”)
startsWith 不需要 isEnd只要路径通就行
字符转下标c - 'a' 映射到 0~25

✅ 完整 Java 实现

class Trie {
    // 内部节点类
    private static class TrieNode {
        TrieNode[] children = new TrieNode[26]; // 26 个子节点
        boolean isEnd = false; // 标记:当前节点是否是一个完整单词的结尾
    }

    private TrieNode root; // 根节点(空节点)

    public Trie() {
        root = new TrieNode();
    }

    // 插入单词
    public void insert(String word) {
        TrieNode node = root;
        for (char c : word.toCharArray()) {
            int idx = c - 'a'; // a→0, b→1, ..., z→25
            // 如果这个字母对应的子节点不存在,创建一个
            if (node.children[idx] == null) {
                node.children[idx] = new TrieNode();
            }
            node = node.children[idx]; // 沿路径往下走
        }
        // 在最后一个字符的节点上打标记
        node.isEnd = true;
    }

    // 搜索完整单词
    public boolean search(String word) {
        TrieNode node = findNode(word);
        // 路径存在 且 该节点是单词结尾
        return node != null && node.isEnd;
    }

    // 搜索前缀
    public boolean startsWith(String prefix) {
        // 只要能走到最后一个字符就行,不要求 isEnd
        return findNode(prefix) != null;
    }

    // 辅助方法:沿着 prefix 走,返回最后一个节点
    // 如果路径中途断了,返回 null
    private TrieNode findNode(String prefix) {
        TrieNode node = root;
        for (char c : prefix.toCharArray()) {
            int idx = c - 'a';
            if (node.children[idx] == null) {
                return null; // 路径断了
            }
            node = node.children[idx];
        }
        return node; // 走到了 prefix 的最后一个字符
    }
}

⏱ 复杂度分析

  • 时间复杂度:O(L) —— L 为单词/前缀长度,每次操作只遍历 L 步
  • 空间复杂度:O(N × 26) —— N 为插入的所有单词的总字符数

💡 记忆口诀

“26 个孩子一个妈(根节点),字母是路径,isEnd 标记完整单词”


399. Evaluate Division 除法求值

📌 给定除法等式 a / b = value,回答任意两个变量的除法结果。若无法确定返回 -1.0。如 equations=[["a","b"],["b","c"]], values=[2.0,3.0], queries=[["a","c"]][6.0]

🔍 解题分析

为什么是图

除法关系构成带权有向图。a / b = 2.0 意味着:

  • a → b 权重 2.0
  • b → a 权重 1/2.0(倒数)

要从 a 求 c,只需找到 a 到 c 的一条路径,路径上权重的乘积就是答案。a → b (×2.0) → c (×3.0) = 6.0。

核心思路(步骤化)

  1. 建图Map<String, Map<String, Double>> —— 节点 → {邻居节点, 边权重}
  2. 对每个 equation,插入两条边(正向 + 反向)
  3. 对每个 query,BFS/DFS 从 start 走到 end,路径权重连乘
  4. 如果 start 或 end 不在图中,或路径不连通,返回 -1.0

为什么 BFS:找两点间的路径,BFS 和 DFS 都可以。BFS 找到的第一条路径就是最短路径,但这里任意路径都行。

关键边界条件/易错点

易错点说明
双向边必须同时存 a→b (权重 v) 和 b→a (权重 1/v)
start == end如果 start 和 end 相同且在图中,返回 1.0
节点不在图中返回 -1.0
用 Pair 或自定义类Java 标准库有 AbstractMap.SimpleEntry 可以临时用
除法链路径上权重是连乘,不是累加

✅ 完整 Java 实现

public double[] calcEquation(List<List<String>> equations, double[] values,
                              List<List<String>> queries) {
    // 1. 建图:节点 → {邻居节点 → 边权重}
    Map<String, Map<String, Double>> graph = new HashMap<>();

    for (int i = 0; i < equations.size(); i++) {
        String a = equations.get(i).get(0); // 被除数
        String b = equations.get(i).get(1); // 除数
        double v = values[i];               // a / b = v

        // 正向边:a → b,权重 v
        graph.computeIfAbsent(a, k -> new HashMap<>()).put(b, v);
        // 反向边:b → a,权重 1/v
        graph.computeIfAbsent(b, k -> new HashMap<>()).put(a, 1.0 / v);
    }

    // 2. 逐个回答查询
    double[] result = new double[queries.size()];
    for (int i = 0; i < queries.size(); i++) {
        String start = queries.get(i).get(0);
        String end = queries.get(i).get(1);
        result[i] = bfs(graph, start, end);
    }

    return result;
}

// BFS 寻找 start → end 的路径权重乘积
private double bfs(Map<String, Map<String, Double>> graph,
                   String start, String end) {
    // 节点不在图中 → 无法计算
    if (!graph.containsKey(start) || !graph.containsKey(end)) {
        return -1.0;
    }
    // 同一个节点 → 结果是 1.0(a / a = 1.0)
    if (start.equals(end)) {
        return 1.0;
    }

    // BFS:队列存 (当前节点, 从 start 到当前节点的累积乘积)
    Queue<AbstractMap.SimpleEntry<String, Double>> queue = new LinkedList<>();
    Set<String> visited = new HashSet<>();

    queue.offer(new AbstractMap.SimpleEntry<>(start, 1.0));
    visited.add(start);

    while (!queue.isEmpty()) {
        AbstractMap.SimpleEntry<String, Double> pair = queue.poll();
        String node = pair.getKey();
        double curVal = pair.getValue(); // start → node 的累积乘积

        // 遍历当前节点的所有邻居
        for (Map.Entry<String, Double> edge : graph.get(node).entrySet()) {
            String neighbor = edge.getKey();
            double weight = edge.getValue();
            double newVal = curVal * weight; // 累积乘积

            // 到达终点!
            if (neighbor.equals(end)) {
                return newVal;
            }

            if (!visited.contains(neighbor)) {
                visited.add(neighbor);
                queue.offer(new AbstractMap.SimpleEntry<>(neighbor, newVal));
            }
        }
    }

    // 不连通 → 无法计算
    return -1.0;
}

⏱ 复杂度分析

  • 时间复杂度:O(Q × (V + E)) —— 每个查询做一次 BFS
  • 空间复杂度:O(V + E) —— 图存储

💡 记忆口诀

“除法即带权边,正反两条路(互为倒数),BFS 沿路连乘”


994. Rotting Oranges 腐烂的橘子

📌 网格:0 空,1 新鲜橘子,2 腐烂橘子。每分钟烂橘子会感染上下左右相邻的新鲜橘子。求全部腐烂的最少分钟数。若不可能全烂返回 -1。

🔍 解题分析

为什么是多源 BFS

多个初始烂橘子同时开始扩散。BFS 天然支持多起点同时入队,一层一层扩散。每扩散一层 = 经过一分钟。

核心思路(步骤化)

  1. 第一遍扫描网格:
    • 收集所有初始烂橘子(值为 2)的位置入队
    • 统计新鲜橘子数量 freshCount
  2. 如果 freshCount == 0,返回 0(没有需要腐烂的)
  3. BFS 扩散:每轮处理队列中当前层的所有烂橘子
    • 向四个方向扩散
    • 感染到的新鲜橘子变成烂橘子并入队
    • freshCount—
    • 每完成一层扩散,minutes++
  4. BFS 结束后:freshCount > 0 → 有橘子永远烂不了,返回 -1;否则返回 minutes-1

为什么返回 minutes-1:最后一层扩散完后 minutes 又加了一次,但实际上不需要这额外的一分钟。

关键边界条件/易错点

易错点说明
初始无新鲜橘子直接返回 0
分钟数的计算BFS 结束后返回 minutes - 1(最后一轮扩散后不再需要时间)
队列为空且 freshCount > 0说明被隔离的新鲜橘子永远烂不了

✅ 完整 Java 实现

public int orangesRotting(int[][] grid) {
    int m = grid.length, n = grid[0].length;
    Queue<int[]> queue = new LinkedList<>();
    int freshCount = 0; // 新鲜橘子数量

    // 第一遍扫描:收集烂橘子 + 统计新鲜橘子
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (grid[i][j] == 2) {
                queue.offer(new int[]{i, j}); // 腐烂源入队
            } else if (grid[i][j] == 1) {
                freshCount++; // 统计新鲜橘子
            }
        }
    }

    // 没有需要感染的目标
    if (freshCount == 0) return 0;

    int minutes = 0;
    int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; // 四个方向

    // 多源 BFS
    while (!queue.isEmpty()) {
        int size = queue.size(); // 当前分钟需要处理的腐烂源数量
        minutes++; // 进入新的一分钟

        for (int i = 0; i < size; i++) {
            int[] cur = queue.poll(); // 取出一个腐烂源
            int r = cur[0], c = cur[1];

            // 向四个方向扩散感染
            for (int[] d : dirs) {
                int nr = r + d[0], nc = c + d[1];

                // 在网格内 且 是新鲜橘子 → 感染它
                if (nr >= 0 && nr < m && nc >= 0 && nc < n
                    && grid[nr][nc] == 1) {
                    grid[nr][nc] = 2;      // 感染!
                    freshCount--;           // 新鲜橘子-1
                    queue.offer(new int[]{nr, nc}); // 下一轮的腐烂源
                }
            }
        }
    }

    // freshCount > 0 说明有橘子永远烂不了(被隔离了)
    return freshCount == 0 ? minutes - 1 : -1;
}

手算验证grid = [[2,1,1],[1,1,0],[0,1,1]]

分钟队列中的烂橘子本轮感染freshCount
0(0,0)5
1(0,0)(1,0), (0,1)3
2(1,0), (0,1)(1,1), (0,2)1
3(1,1), (0,2)(2,1)0

分钟数 = 4-1 = 3?实际需要 3 分钟,minutes-1=3 ✓(最后一轮后 freshCount=0)

⏱ 复杂度分析

  • 时间复杂度:O(mn) —— 每个格子最多入队一次
  • 空间复杂度:O(mn) —— 队列最多存 mn 个位置

💡 记忆口诀

“烂橘子一起入队,每轮扩散一圈加一分钟;最后检查有没有烂不到的”


🔜 上一章:12-贪心 | 下一章:14-堆