【LeetCode算法题精讲】图算法精讲——从图遍历到拓扑排序

一、引言------为什么图算法被低估了?

打开LeetCode题库,图相关的题目约200道,远少于数组和字符串。但面试中的图算法,往往是区分"会刷题"和"真正理解算法"的分水岭

为什么?

因为图算法不考"死记硬背",它考的是抽象能力------你能不能把一个问题「翻译」成图的语言。

比如:

  • 一个网格中的岛屿 → 无向图的连通分量

  • 一个学期的选课安排 → 有向图的环检测

本文精选两道题,覆盖图算法两大核心范式:

  1. 200. 岛屿数量(Number of Islands)------图遍历(DFS/BFS/并查集)

  2. 207. 课程表(Course Schedule)------拓扑排序(环检测)

两道题都是Medium难度、大厂高频,而且一道题吃透,就能举一反三解决一大类问题


二、图的基础认知------面试需要知道什么

在上手做题之前,先建立图的核心认知框架。

图的基本概念

面试中你只需要知道这四个概念:

  • 顶点(Vertex):图中的节点

  • 边(Edge):顶点之间的连接

  • 有向图 vs 无向图:边是否有方向

  • DAG(有向无环图):拓扑排序的前提

图的存储方式

面试中99%的场景用邻接表

复制代码
// 邻接表:List<List<Integer>>
List<List<Integer>> graph = new ArrayList<>(n);
for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
// 添加有向边 a -> b
graph.get(a).add(b);

为什么不用邻接矩阵? 空间O(V²)太大,V=10⁴时矩阵有10⁸个元素,邻接表只存实际边。

两种遍历框架

  • DFS:递归/栈,适合"是否存在路径"、"连通分量"

  • BFS:队列,适合"最短路径"、"拓扑排序"


三、200. 岛屿数量------DFS解法(面试首选)

问题分析

给一个二维网格,'1' 是陆地,'0' 是水,相邻(上下左右)的陆地算同一个岛屿。问有多少个岛屿。

复制代码
输入: grid = [
  ['1','1','0','0','0'],
  ['1','1','0','0','0'],
  ['0','0','1','0','0'],
  ['0','0','0','1','1']
]
输出: 3

思路推导

核心洞察: 每个岛屿就是一个连通分量,找到所有连通分量的数量。

DFS淹没法: 遍历网格,遇到 '1' 就计数+1,然后从这个格子出发,DFS把所有相邻的 '1' 都标记成 '0'(淹没)。这样每个岛屿只会被计数一次。

三语言实现

Java:

复制代码
class Solution {
    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, m, n);
                }
            }
        }
        return count;
    }

    private void dfs(char[][] grid, int i, int j, int m, int n) {
        if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] == '0') return;
        grid[i][j] = '0';  // 淹没
        dfs(grid, i - 1, j, m, n);
        dfs(grid, i + 1, j, m, n);
        dfs(grid, i, j - 1, m, n);
        dfs(grid, i, j + 1, m, n);
    }
}

Python:

复制代码
class Solution:
    def numIslands(self, grid: List[List[str]]) -> int:
        if not grid:
            return 0
        m, n = len(grid), len(grid[0])
        count = 0

        def dfs(i: int, j: int) -> None:
            if i < 0 or i >= m or j < 0 or j >= n or grid[i][j] == '0':
                return
            grid[i][j] = '0'
            dfs(i - 1, j)
            dfs(i + 1, j)
            dfs(i, j - 1)
            dfs(i, j + 1)

        for i in range(m):
            for j in range(n):
                if grid[i][j] == '1':
                    count += 1
                    dfs(i, j)
        return count

C++:

复制代码
class Solution {
public:
    int numIslands(vector<vector<char>>& grid) {
        if (grid.empty()) return 0;
        int m = grid.size(), n = grid[0].size();
        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, m, n);
                }
            }
        }
        return count;
    }

private:
    void dfs(vector<vector<char>>& grid, int i, int j, int m, int n) {
        if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] == '0') return;
        grid[i][j] = '0';
        dfs(grid, i - 1, j, m, n);
        dfs(grid, i + 1, j, m, n);
        dfs(grid, i, j - 1, m, n);
        dfs(grid, i, j + 1, m, n);
    }
};

复杂度分析

  • 时间复杂度: O(M×N),每个格子最多访问一次

  • 空间复杂度: O(M×N) 最坏,递归栈深度

面试追问:递归栈溢出怎么办?

当网格很大(如10000×10000)时,递归DFS可能栈溢出。

解决方案: 改用BFS(迭代队列)或显式栈(手动模拟递归)。


四、200. 岛屿数量------BFS解法(面试备选)

思路推导

用队列代替递归。每次遇到 '1',把它入队,然后逐层扩散。

复制代码
class Solution {
    public int numIslands(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[]> q = new LinkedList<>();
                    q.offer(new int[]{i, j});
                    grid[i][j] = '0';
                    while (!q.isEmpty()) {
                        int[] cur = q.poll();
                        for (int[] d : dirs) {
                            int ni = cur[0] + d[0], nj = cur[1] + d[1];
                            if (ni >= 0 && ni < m && nj >= 0 && nj < n && grid[ni][nj] == '1') {
                                grid[ni][nj] = '0';
                                q.offer(new int[]{ni, nj});
                            }
                        }
                    }
                }
            }
        }
        return count;
    }
}

复杂度分析

  • 时间复杂度: O(M×N)

  • 空间复杂度: O(min(M,N)),队列中最多同时存对角线上的元素

DFS vs BFS 对比

维度 DFS BFS
代码简洁度 ⭐⭐⭐⭐⭐ ⭐⭐⭐
空间效率 差(递归栈) 好(队列)
大网格稳定性 可能栈溢出 稳定
面试推荐度 面试首选 备选方案

五、200. 岛屿数量------并查集解法(进阶)

思路推导

并查集(Union-Find)的核心思想:每个 '1' 初始时是自己的集合,相邻的 '1' 合并。最终集合的数量就是岛屿数。

伪代码实现

复制代码
class UnionFind {
    int[] parent;
    int count;  // 集合数量

    public UnionFind(char[][] grid) {
        int m = grid.length, n = grid[0].length;
        parent = new int[m * n];
        count = 0;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (grid[i][j] == '1') {
                    parent[i * n + j] = i * n + j;
                    count++;
                }
            }
        }
    }

    public int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]);  // 路径压缩
        return parent[x];
    }

    public void union(int x, int y) {
        int rootX = find(x), rootY = find(y);
        if (rootX != rootY) {
            parent[rootX] = rootY;
            count--;
        }
    }
}

复杂度分析

  • 时间复杂度: O(M×N×α(MN)),α 是阿克曼函数的反函数,几乎为常数

  • 空间复杂度: O(M×N)

三种解法对比

维度 DFS BFS 并查集
代码量
空间 可能栈溢出 稳定 稳定
可扩展性 高(动态合并)
面试推荐 ⭐⭐⭐ ⭐⭐⭐ ⭐⭐

六、200. 岛屿数量------变体与面试追问

695. 岛屿的最大面积

在DFS/BFS的同时,记录每个岛屿的面积,取最大值。

复制代码
// 只需要在DFS中返回面积
private int dfs(char[][] grid, int i, int j, int m, int n) {
    if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] == '0') return 0;
    grid[i][j] = '0';
    return 1 + dfs(grid, i - 1, j, m, n)
             + dfs(grid, i + 1, j, m, n)
             + dfs(grid, i, j - 1, m, n)
             + dfs(grid, i, j + 1, m, n);
}

463. 岛屿的周长

每块陆地有4条边,如果相邻也是陆地则减1条边。

复制代码
public int islandPerimeter(int[][] grid) {
    int m = grid.length, n = grid[0].length;
    int perimeter = 0;
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (grid[i][j] == 1) {
                perimeter += 4;
                if (i > 0 && grid[i - 1][j] == 1) perimeter -= 2;
                if (j > 0 && grid[i][j - 1] == 1) perimeter -= 2;
            }
        }
    }
    return perimeter;
}

1905. 统计子岛屿

如果网格2中岛屿的所有格子都在网格1的同一个岛屿中,则是子岛屿。

面试追问路线图


七、207. 课程表------BFS拓扑排序(面试首选)

问题分析

给定 numCourses 门课和先修关系 prerequisites[i] = [ai, bi](先学bi才能学ai),判断是否能完成所有课程。

复制代码
输入:numCourses = 2, prerequisites = [[1,0]]
输出:true(先学0,再学1)

输入:numCourses = 2, prerequisites = [[1,0],[0,1]]
输出:false(形成环,0依赖1,1依赖0)

思路推导

核心洞察: 将课程看作顶点,先修关系看作有向边,问题转化为:检测有向图中是否有环。有环则无法完成,无环则可以。

Kahn算法(BFS拓扑排序):

  1. 统计每个顶点的入度(有多少条边指向它)

  2. 将入度为0的顶点入队(这些课程没有前置依赖)

  3. 依次出队,将其邻接点的入度减1,如果邻接点入度变为0则入队

  4. 如果最终入队过的顶点数等于总顶点数,说明无环

三语言实现

Java:

复制代码
class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        // 1. 构建邻接表和入度表
        List<List<Integer>> graph = new ArrayList<>(numCourses);
        int[] inDegree = new int[numCourses];
        for (int i = 0; i < numCourses; i++) graph.add(new ArrayList<>());
        for (int[] p : prerequisites) {
            graph.get(p[1]).add(p[0]);  // b → a
            inDegree[p[0]]++;
        }

        // 2. 入度为0的节点入队
        Queue<Integer> q = new LinkedList<>();
        for (int i = 0; i < numCourses; i++) {
            if (inDegree[i] == 0) q.offer(i);
        }

        // 3. BFS拓扑排序
        int count = 0;
        while (!q.isEmpty()) {
            int cur = q.poll();
            count++;
            for (int next : graph.get(cur)) {
                inDegree[next]--;
                if (inDegree[next] == 0) q.offer(next);
            }
        }

        return count == numCourses;
    }
}

Python:

复制代码
class Solution:
    def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool:
        # 1. 构建邻接表和入度表
        graph = [[] for _ in range(numCourses)]
        in_degree = [0] * numCourses
        for a, b in prerequisites:
            graph[b].append(a)
            in_degree[a] += 1

        # 2. 入度为0的节点入队
        q = deque([i for i in range(numCourses) if in_degree[i] == 0])

        # 3. BFS拓扑排序
        count = 0
        while q:
            cur = q.popleft()
            count += 1
            for nxt in graph[cur]:
                in_degree[nxt] -= 1
                if in_degree[nxt] == 0:
                    q.append(nxt)

        return count == numCourses

C++:

复制代码
class Solution {
public:
    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        // 1. 构建邻接表和入度表
        vector<vector<int>> graph(numCourses);
        vector<int> inDegree(numCourses, 0);
        for (auto& p : prerequisites) {
            graph[p[1]].push_back(p[0]);
            inDegree[p[0]]++;
        }

        // 2. 入度为0的节点入队
        queue<int> q;
        for (int i = 0; i < numCourses; i++) {
            if (inDegree[i] == 0) q.push(i);
        }

        // 3. BFS拓扑排序
        int count = 0;
        while (!q.empty()) {
            int cur = q.front(); q.pop();
            count++;
            for (int next : graph[cur]) {
                if (--inDegree[next] == 0) q.push(next);
            }
        }

        return count == numCourses;
    }
};

复杂度分析

  • 时间复杂度: O(V+E),V是课程数,E是先修关系数

  • 空间复杂度: O(V+E),存储邻接表和入度表


八、207. 课程表------DFS环检测(面试备选)

思路推导

三色标记法:

  • 0(未访问):还没遍历到

  • 1(访问中):在当前DFS路径上

  • 2(已访问):已经遍历完毕

如果DFS过程中遇到状态为 1 的节点,说明有环。

复制代码
class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        List<List<Integer>> graph = new ArrayList<>(numCourses);
        for (int i = 0; i < numCourses; i++) graph.add(new ArrayList<>());
        for (int[] p : prerequisites) graph.get(p[1]).add(p[0]);

        int[] visited = new int[numCourses]; // 0:未访问 1:访问中 2:已访问

        for (int i = 0; i < numCourses; i++) {
            if (visited[i] == 0 && hasCycle(graph, visited, i)) return false;
        }
        return true;
    }

    private boolean hasCycle(List<List<Integer>> graph, int[] visited, int cur) {
        if (visited[cur] == 1) return true;  // 发现环
        if (visited[cur] == 2) return false; // 已确认无环

        visited[cur] = 1;  // 标记为"访问中"
        for (int next : graph.get(cur)) {
            if (hasCycle(graph, visited, next)) return true;
        }
        visited[cur] = 2;  // 标记为"已访问"
        return false;
    }
}

BFS vs DFS 对比

维度 BFS(Kahn算法) DFS(三色标记)
代码直观度 高(入度表直观) 中(三色状态)
能否返回拓扑序 能(出队顺序) 需额外处理
面试推荐度 ⭐⭐⭐⭐⭐ ⭐⭐⭐

九、207. 课程表------变体与面试追问

210. 课程表 II

直接返回拓扑排序的结果(出队顺序),而不是只返回true/false。只需要在BFS中记录出队顺序即可。

复制代码
// 在BFS中记录
List<Integer> order = new ArrayList<>();
while (!q.isEmpty()) {
    int cur = q.poll();
    order.add(cur);  // 记录拓扑序
    // ...
}
return order.size() == numCourses ? order : new int[0];

630. 课程表 III

加入时间维度:每门课有持续时间 duration 和最晚截止时间 lastDay。贪心 + 优先队列,按截止时间排序,用最大堆维护持续时间。

269. 火星词典

从已排序的单词列表中提取字符顺序关系,构建有向图,用拓扑排序还原字符顺序。这是拓扑排序在面试中的最高阶应用

面试追问方向


十、总结------图算法面试的"三板斧"

回顾本文,图算法面试其实就三个核心范式:

第一板斧:图遍历(DFS/BFS)

  • 解决:连通性问题、路径问题、可达性问题

  • 代表题:200. 岛屿数量 → 695. 最大面积 → 827. 最大人工岛

第二板斧:拓扑排序

  • 解决:依赖关系问题、顺序约束问题

  • 代表题:207. 课程表 → 210. 课程表 II → 269. 火星词典

第三板斧:并查集

  • 解决:动态连通性问题、集合合并问题

  • 代表题:200. 岛屿数量(并查集版)→ 684. 冗余连接 → 990. 等式方程


DSA系列进度:15/∞


延伸阅读:

  • LeetCode 200 题解区

  • LeetCode 207 题解区

  • 《算法导论》第22章:图的基本算法

  • LeetCode Explore:图算法专题

相关推荐
余额瞒着我当琳1 小时前
C++ list第二讲数据结构修炼:迭代器源码 + 栈队列适配器 + LeetCode 三道高频题
数据结构·c++·list
_Narcissus_1 小时前
常见数论算法笔记
数据结构·c++·算法·高精度·数论·快速幂·质数筛
吴声子夜歌1 小时前
Java面试——数据结构(二)
java·数据结构·面试
CVHub1 小时前
智能标注工具 X-AnyLabeling v4 全面升级:迈向 AI 时代的数据生产基础设施
人工智能·算法·github
octopus_c4 小时前
数据结构:堆(Heap)详解
c语言·数据结构·算法
YH552698410 小时前
GPT‑5.6 Sol 原本支持 1M 上下文,Codex 现已放开此前限制,如何看待这次调整?
java·jvm·人工智能·gpt·算法·chatgpt
遥感知识服务11 小时前
从局部阈值、双极化到暗地表剔除:NASA OPERA DSWx-S1全球动态水体算法拆解
大数据·人工智能·深度学习·神经网络·算法·机器学习
晚风醉蝶12 小时前
1-13-TimSort
算法
ZJU_统一阿萨姆12 小时前
【算子开发】算子融合与Softmax_LayerNorm实现
人工智能·算法·语言模型·硬件架构