图论专题(上)------ DFS / BFS 与拓扑排序
Hot100 图论专题共 13 题,本篇覆盖前 5 题:岛屿数量、腐烂的橘子、课程表、实现 Trie 前缀树、岛屿的最大面积。这几题分别考察 DFS flood fill、BFS 多源扩散、拓扑排序、前缀树四个核心图论技巧,是竞赛和面试的高频考点。
一、前置知识:图的 DFS 与 BFS 框架
图和树的区别在于图可能有环,所以 DFS/BFS 时需要额外的 visited 数组标记已访问节点,防止重复访问。
矩阵(网格图)是图的一种特殊形式,每个格子是一个节点,上下左右四个方向是边。
DFS 框架(网格图):
cpp
int dirs[4][2] = {{0,1},{0,-1},{1,0},{-1,0}}; // 右左下上
void dfs(vector<vector<char>>& grid, int i, int j) {
int m = grid.size(), n = grid[0].size();
if (i < 0 || i >= m || j < 0 || j >= n) return; // 越界
if (grid[i][j] == '0') return; // 已访问或不可达
grid[i][j] = '0'; // 标记为已访问(直接修改原数组)
for (auto& d : dirs) dfs(grid, i + d[0], j + d[1]);
}
BFS 框架(网格图):
cpp
int dirs[4][2] = {{0,1},{0,-1},{1,0},{-1,0}};
void bfs(vector<vector<int>>& grid, int si, int sj) {
queue<pair<int,int>> q;
q.push({si, sj});
grid[si][sj] = 0; // 入队时标记,防止重复入队
while (!q.empty()) {
auto [i, j] = q.front(); q.pop();
for (auto& d : dirs) {
int ni = i + d[0], nj = j + d[1];
if (ni < 0 || ni >= m || nj < 0 || nj >= n) continue;
if (grid[ni][nj] == 0) continue;
grid[ni][nj] = 0;
q.push({ni, nj});
}
}
}
注意:标记已访问要在入队时做,而不是出队时。如果出队时才标记,同一个节点可能被多次入队,导致重复处理甚至死循环。
二、岛屿数量(#200)
题意
给一个由 '1'(陆地)和 '0'(水)组成的二维网格,计算岛屿的数量。岛屿由上下左右相邻的陆地连接而成,四周都是水。
输入:
11110
11010
11000
00000
输出:1(整个连通块是一个岛屿)
输入:
11000
11000
00100
00011
输出:3
思路
经典 flood fill 问题。
遍历整个网格,每遇到一个 '1',就说明发现了一个新岛屿,计数加一,然后用 DFS 把这个岛屿所有相连的 '1' 全部标记为 '0'(沉岛),避免重复计数。
这样遍历结束后,DFS 被触发的次数就等于岛屿数量。
网格:
1 1 0 0 0
1 1 0 0 0
0 0 1 0 0
0 0 0 1 1
(0,0)=1,发现新岛屿,count=1
DFS从(0,0)出发,把(0,0)(0,1)(1,0)(1,1)全部标记为0
网格变为:
0 0 0 0 0
0 0 0 0 0
0 0 1 0 0
0 0 0 1 1
继续扫描,(2,2)=1,发现新岛屿,count=2
DFS把(2,2)标记为0
继续扫描,(3,3)=1,发现新岛屿,count=3
DFS把(3,3)(3,4)标记为0
最终count=3
DFS 递归时,先判断越界和当前格子是否为 '0',满足任一条件就返回,否则标记并向四个方向递归。
代码
cpp
class Solution {
public:
void dfs(vector<vector<char>>& grid, int i, int j) {
int m = grid.size(), n = grid[0].size();
// 越界或已经是水,直接返回
if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] == '0') 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);
}
int numIslands(vector<vector<char>>& grid) {
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); // 把整个岛屿沉掉
}
}
}
return count;
}
};
复杂度
- 时间 :O(mn)O(mn)O(mn),每个格子最多被访问一次(DFS 时标记,之后不会再进入)
- 空间 :O(mn)O(mn)O(mn),递归栈最深为网格大小(全是陆地时)
三、腐烂的橘子(#994)
题意
网格中 0 表示空格,1 表示新鲜橘子,2 表示腐烂橘子。每过一分钟,腐烂橘子上下左右相邻的新鲜橘子会被感染变腐烂。返回所有橘子腐烂所需的最少分钟数,如果不可能全部腐烂则返回 -1。
输入:
2 1 1
1 1 0
0 1 1
第0分钟:2 1 1 / 1 1 0 / 0 1 1
第1分钟:2 2 1 / 2 1 0 / 0 1 1
第2分钟:2 2 2 / 2 2 0 / 0 1 1
第3分钟:2 2 2 / 2 2 0 / 0 2 1
第4分钟:2 2 2 / 2 2 0 / 0 2 2
输出:4
思路
这是多源 BFS 问题。腐烂橘子会同时向四周扩散,不是从单一起点出发,而是从所有腐烂橘子同时开始。
把所有初始腐烂橘子一次性全部入队,然后做 BFS,每一轮(处理完当前队列中的所有节点)代表过了一分钟。
流程:
- 遍历网格,把所有初始腐烂橘子入队,同时统计新鲜橘子数量
fresh - BFS 逐层扩散,每次处理完一整层(一分钟),把感染到的新鲜橘子入队,
fresh-- - BFS 结束后,如果
fresh > 0,说明有孤立的新鲜橘子无法被感染,返回 -1 - 否则返回经过的分钟数
注意:如果初始就没有新鲜橘子,直接返回 0,不需要进入 BFS。
初始:腐烂橘子(0,0)入队,fresh=7
第1分钟:处理(0,0),感染(0,1)和(1,0)
fresh=5,队列:[(0,1),(1,0)]
第2分钟:处理(0,1)(1,0),感染(0,2)(1,1)
fresh=3,队列:[(0,2),(1,1)]
第3分钟:处理(0,2)(1,1),感染(2,1)
fresh=2,队列:[(2,1)](注意(1,2)是空格,无法感染)
第4分钟:处理(2,1),感染(2,2)
fresh=1?
重新数:3×3网格,空格(1,2),橘子8个,初始腐烂1个,新鲜7个
最终fresh=0,minutes=4,返回4 ✓
代码
cpp
class Solution {
public:
int orangesRotting(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
queue<pair<int,int>> q;
int fresh = 0;
// 初始化:所有腐烂橘子入队,统计新鲜橘子
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++) {
if (grid[i][j] == 2) q.push({i, j});
else if (grid[i][j] == 1) fresh++;
}
if (fresh == 0) return 0; // 没有新鲜橘子,直接返回
int dirs[4][2] = {{0,1},{0,-1},{1,0},{-1,0}};
int minutes = 0;
while (!q.empty() && fresh > 0) {
minutes++;
int size = q.size(); // 这一分钟内要处理的腐烂橘子数
for (int k = 0; k < size; k++) {
auto [i, j] = q.front(); q.pop();
for (auto& d : dirs) {
int ni = i + d[0], nj = j + d[1];
if (ni < 0 || ni >= m || nj < 0 || nj >= n) continue;
if (grid[ni][nj] != 1) continue; // 不是新鲜橘子
grid[ni][nj] = 2; // 感染,同时标记已访问
fresh--;
q.push({ni, nj});
}
}
}
return fresh == 0 ? minutes : -1;
}
};
复杂度
- 时间 :O(mn)O(mn)O(mn),每个格子最多入队一次
- 空间 :O(mn)O(mn)O(mn),队列最多存所有格子
四、课程表(#207)
题意
共有 numCourses 门课,编号 0 到 n-1。给一个先决条件数组 prerequisites,prerequisites[i] = [a, b] 表示学课 a 之前必须先学课 b。判断是否能完成所有课程的学习(即判断有向图中是否有环)。
输入: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,说明它的所有先修课都学完了,可以加入队列继续学。
如果最终处理的课程数等于总课程数,说明图中无环,可以完成所有课程;如果图中有环,环上的节点入度永远无法降为 0,最终处理数量小于总课程数。
numCourses=4, prerequisites=[[1,0],[2,0],[3,1],[3,2]]
建图(邻接表,b→a 表示b的后继是a):
0 → [1, 2]
1 → [3]
2 → [3]
3 → []
入度统计:
课程0: 0(没有先修课)
课程1: 1(先修课0)
课程2: 1(先修课0)
课程3: 2(先修课1和2)
初始队列(入度为0):[0]
处理课程0:
后继1入度 1→0,入队
后继2入度 1→0,入队
count=1,队列[1,2]
处理课程1:
后继3入度 2→1
count=2,队列[2]
处理课程2:
后继3入度 1→0,入队
count=3,队列[3]
处理课程3:
count=4,队列空
count=4 == numCourses=4 → 无环,返回true ✓
代码
cpp
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
// 建邻接表和入度数组
vector<vector<int>> graph(numCourses);
vector<int> inDegree(numCourses, 0);
for (auto& pre : prerequisites) {
graph[pre[1]].push_back(pre[0]); // pre[1] → pre[0]
inDegree[pre[0]]++;
}
// 入度为0的节点入队
queue<int> q;
for (int i = 0; i < numCourses; i++)
if (inDegree[i] == 0) q.push(i);
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)O(V + E)O(V+E),VVV 为课程数,EEE 为先修关系数
- 空间 :O(V+E)O(V + E)O(V+E),邻接表 + 入度数组 + 队列
五、实现 Trie(前缀树)(#208)
题意
实现一个前缀树,支持以下操作:
insert(word):插入字符串search(word):返回字符串是否在前缀树中startsWith(prefix):返回是否存在以prefix为前缀的字符串
前置知识:Trie 的结构
Trie(前缀树 / 字典树)是一种专门处理字符串的树形数据结构。每个节点代表一个字符,从根节点到某个节点的路径代表一个字符串前缀。
插入 "app"、"apple"、"apply" 后的 Trie:
root
└─ a
└─ p
└─ p [isEnd=true] ← "app" 在这里结束
└─ l
├─ e [isEnd=true] ← "apple" 在这里结束
└─ y [isEnd=true] ← "apply" 在这里结束
每个节点包含:
children[26]:26 个字母对应的子节点指针,初始全为nullptrisEnd:标记当前节点是否是某个单词的结尾
插入 :从根开始,按字符逐层向下,节点不存在就创建,最后把终止节点的 isEnd 置为 true。
查询 :从根开始,按字符逐层向下,某字符对应的子节点不存在就返回 false,走完所有字符后检查 isEnd。
前缀查询 :和查询完全一样,只是最后不需要检查 isEnd,路径存在就说明有以该前缀开头的单词。
查询 "app":
root→a→p→p,节点存在,isEnd=true → 返回true ✓
查询 "ap":
root→a→p,节点存在,但 isEnd=false → 返回false
("ap"没有被插入过,只是路径经过这里)
前缀查询 "ap":
root→a→p,路径存在 → 返回true ✓
(存在以"ap"为前缀的单词,如"app"、"apple"等)
代码
cpp
class Trie {
struct TrieNode {
TrieNode* children[26];
bool isEnd;
TrieNode() : isEnd(false) {
for (int i = 0; i < 26; i++) children[i] = nullptr;
}
};
TrieNode* root;
// 从根出发,沿 word 路径走到底,返回最后一个节点(路径不存在返回 nullptr)
TrieNode* searchPrefix(const string& word) {
TrieNode* cur = root;
for (char c : word) {
int idx = c - 'a';
if (!cur->children[idx]) return nullptr; // 路径断了
cur = cur->children[idx];
}
return cur;
}
public:
Trie() { root = new TrieNode(); }
void insert(string word) {
TrieNode* cur = root;
for (char c : word) {
int idx = c - 'a';
if (!cur->children[idx])
cur->children[idx] = new TrieNode(); // 节点不存在则创建
cur = cur->children[idx];
}
cur->isEnd = true; // 标记单词结尾
}
bool search(string word) {
TrieNode* node = searchPrefix(word);
return node != nullptr && node->isEnd; // 路径存在且是完整单词
}
bool startsWith(string prefix) {
return searchPrefix(prefix) != nullptr; // 路径存在即可
}
};
复杂度
- 插入 / 查询 :O(L)O(L)O(L),LLL 为字符串长度,每个字符走一步
- 空间 :O(Σ⋅L⋅N)O(\Sigma \cdot L \cdot N)O(Σ⋅L⋅N),Σ=26\Sigma=26Σ=26,NNN 为插入的字符串数量,最坏情况每个字符都要新建节点
六、岛屿的最大面积(#695)
题意
给一个二进制网格,1 表示陆地,0 表示海洋,求最大岛屿的面积(面积 = 格子数)。
输入:
0 0 1 0 0 0 0 1 0 0
0 0 0 0 0 0 0 1 1 1
0 1 1 0 1 0 0 0 0 0
0 1 0 0 1 1 0 0 1 0
0 1 0 0 1 1 0 0 1 1
0 0 0 0 0 0 0 0 0 1
输出:6(右侧连通块面积为6)
思路
和岛屿数量几乎完全一样,DFS 时额外返回当前连通块的格子数,遍历过程中取最大值。
DFS 函数:越界或为 0 返回 0;否则标记为 0,返回 1+1 +1+ 四个方向 DFS 结果之和,这个和就是以当前格子为起点的连通块大小。
以某个面积为4的连通块为例:
1 1
1 0
1
DFS(0,0):标记,返回 1+DFS(0,1)+DFS(1,0)+...
DFS(0,1):标记,返回 1(无更多相邻1)
DFS(1,0):标记,返回 1+DFS(2,0)
DFS(2,0):标记,返回 1
= 1+1+(1+1) = 4
代码
cpp
class Solution {
public:
int dfs(vector<vector<int>>& grid, int i, int j) {
int m = grid.size(), n = grid[0].size();
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) + dfs(grid, i-1, j)
+ dfs(grid, i, j+1) + dfs(grid, i, j-1);
}
int maxAreaOfIsland(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
int res = 0;
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
if (grid[i][j] == 1)
res = max(res, dfs(grid, i, j));
return res;
}
};
复杂度
- 时间 :O(mn)O(mn)O(mn),每个格子最多访问一次
- 空间 :O(mn)O(mn)O(mn),递归栈
七、本篇小结
| 题目 | 核心算法 | 关键点 | 时间 | 空间 |
|---|---|---|---|---|
| 岛屿数量 | DFS flood fill | 每发现新连通块计数,DFS 沉岛避免重复 | O(mn)O(mn)O(mn) | O(mn)O(mn)O(mn) |
| 腐烂的橘子 | 多源 BFS | 所有腐烂橘子同时入队,按层扩散计时 | O(mn)O(mn)O(mn) | O(mn)O(mn)O(mn) |
| 课程表 | 拓扑排序 Kahn | 入度为0入队,处理总数==课程总数则无环 | O(V+E)O(V+E)O(V+E) | O(V+E)O(V+E)O(V+E) |
| 实现 Trie | 前缀树 | 26叉树,isEnd 标记单词结尾,路径即前缀 | O(L)O(L)O(L) | O(ΣLN)O(\Sigma LN)O(ΣLN) |
| 岛屿最大面积 | DFS flood fill | DFS 返回连通块大小,取所有连通块的最大值 | O(mn)O(mn)O(mn) | O(mn)O(mn)O(mn) |
DFS 和 BFS 怎么选:
- 求最短路径、最少步数、最快时间 → BFS(按层扩散,天然保证最短)
- 判断连通性、统计连通块大小、枚举所有路径 → DFS 和 BFS 均可,DFS 代码更简洁