一、引言------为什么图算法被低估了?
打开LeetCode题库,图相关的题目约200道,远少于数组和字符串。但面试中的图算法,往往是区分"会刷题"和"真正理解算法"的分水岭。
为什么?
因为图算法不考"死记硬背",它考的是抽象能力------你能不能把一个问题「翻译」成图的语言。
比如:
-
一个网格中的岛屿 → 无向图的连通分量
-
一个学期的选课安排 → 有向图的环检测
本文精选两道题,覆盖图算法两大核心范式:
-
200. 岛屿数量(Number of Islands)------图遍历(DFS/BFS/并查集)
-
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拓扑排序):
-
统计每个顶点的入度(有多少条边指向它)
-
将入度为0的顶点入队(这些课程没有前置依赖)
-
依次出队,将其邻接点的入度减1,如果邻接点入度变为0则入队
-
如果最终入队过的顶点数等于总顶点数,说明无环
三语言实现
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:图算法专题


