前言
图论是算法中的高级内容,很多人觉得图论很难。其实图就是节点+边的组合,掌握BFS、Dijkstra、拓扑排序这三个核心算法,90%的图论题都能解决。
我并没有能力让你看完就精通所有图论,我只是想让你理解图的表示方法、BFS求最短路的思路、Dijkstra的优化过程、以及拓扑排序的应用场景。
摘要
从"网络延迟时间"问题出发,剖析图论的核心算法与应用场景。通过BFS的层序遍历、Dijkstra从O(n²)到O(nlogn)的优化、以及拓扑排序的入度法实现,揭秘图论的解题套路。配合LeetCode高频题目与详细图解,给出图论算法的完整攻略。
一、什么是图
周二下午,哈吉米遇到图论题,懵了。
南北绿豆:"先搞清楚图是什么。"
1.1 图的概念
图(Graph) = 节点(Vertex)+ 边(Edge)
生活化场景:
社交网络:
节点 = 人
边 = 朋友关系
地图导航:
节点 = 城市
边 = 道路
课程依赖:
节点 = 课程
边 = 先修关系
哈吉米:"懂了,图就是用来描述关系的。"
1.2 图的分类
南北绿豆:"图分两大类。"
无向图 vs 有向图:
无向图:A ←→ B(双向)
示例:朋友关系(互相是朋友)
有向图:A → B(单向)
示例:关注关系(A关注B,B不一定关注A)
无权图 vs 带权图:
无权图:A —— B(边没有权重)
示例:是否是朋友(只有是和否)
带权图:A —5km— B(边有权重)
示例:城市间的距离
1.3 图的存储
阿西噶阿西:"图有两种存储方式:邻接矩阵和邻接表。"
邻接矩阵(适合稠密图):
int[][] graph = new int[n][n];
graph[i][j] = 1; // 表示i到j有边
邻接表(适合稀疏图):
List<List<Integer>> graph = new ArrayList<>();
graph.get(i).add(j); // 表示i到j有边
图示:
flowchart LR
A["图的存储"]
B["邻接矩阵<br/>二维数组<br/>O(n²)空间"]
C["邻接表<br/>List数组<br/>O(V+E)空间"]
A --> B
A --> C
style B fill:#ffe6e6
style C fill:#e1ffe1
二、BFS求最短路(无权图)
南北绿豆:"BFS是图论最基础的算法。"
2.1 题目
LeetCode 1091 - 二进制矩阵中的最短路径
给你一个 n × n 的二进制矩阵 grid 中,返回矩阵中最短畅通路径的长度。
如果不存在这样的路径,返回 -1 。
畅通路径是指从左上角 (0, 0) 到右下角 (n - 1, n - 1) 的路径,
该路径中所有访问的单元格都是 0 ,且所有相邻单元格应当在 8 个方向之一 上连通。
示例:
输入:grid = [[0,1],[1,0]]
输出:2
输入:grid = [[0,0,0],[1,1,0],[1,1,0]]
输出:4
输入:grid = [[1,0,0],[1,1,0],[1,1,0]]
输出:-1
2.2 思路分析
南北绿豆:"BFS的核心思想:一层一层扩散,第一次到达终点就是最短路。"
为什么BFS能求最短路?
阿西噶阿西:"因为BFS是按距离从近到远搜索,第一次到达的一定是最短的。"
图示:
起点(0,0)
↓
第1层:距离1的所有点
↓
第2层:距离2的所有点
↓
...
第k层:到达终点
2.3 BFS模板
Java版本:
public int bfs(int[][] grid) {
Queue<int[]> queue = new LinkedList<>();
boolean[][] visited = new boolean[n][m];
// 起点入队
queue.offer(new int[]{0, 0});
visited[0][0] = true;
int steps = 0;
while (!queue.isEmpty()) {
int size = queue.size();
steps++; // 层数+1
// 遍历当前层
for (int i = 0; i < size; i++) {
int[] curr = queue.poll();
int x = curr[0], y = curr[1];
// 到达终点
if (x == n - 1 && y == m - 1) {
return steps;
}
// 扩展8个方向
for (int[] dir : directions) {
int nx = x + dir[0];
int ny = y + dir[1];
if (isValid(nx, ny) && !visited[nx][ny]) {
queue.offer(new int[]{nx, ny});
visited[nx][ny] = true;
}
}
}
}
return -1; // 无法到达
}
C++版本:
int bfs(vector<vector<int>>& grid) {
queue<pair<int, int>> q;
vector<vector<bool>> visited(n, vector<bool>(m, false));
q.push({0, 0});
visited[0][0] = true;
int steps = 0;
while (!q.empty()) {
int size = q.size();
steps++;
for (int i = 0; i < size; i++) {
auto [x, y] = q.front();
q.pop();
if (x == n - 1 && y == m - 1) {
return steps;
}
for (auto& dir : directions) {
int nx = x + dir[0];
int ny = y + dir[1];
if (isValid(nx, ny) && !visited[nx][ny]) {
q.push({nx, ny});
visited[nx][ny] = true;
}
}
}
}
return -1;
}
Python版本:
def bfs(grid):
from collections import deque
queue = deque([(0, 0)])
visited = set()
visited.add((0, 0))
steps = 0
while queue:
size = len(queue)
steps += 1
for _ in range(size):
x, y = queue.popleft()
if x == n - 1 and y == m - 1:
return steps
for dx, dy in directions:
nx, ny = x + dx, y + dy
if isValid(nx, ny) and (nx, ny) not in visited:
queue.append((nx, ny))
visited.add((nx, ny))
return -1
三、Dijkstra最短路(带权图)
南北绿豆:"BFS只能处理无权图(或者所有边权重相同),带权图要用Dijkstra。"
3.1 题目
LeetCode 743 - 网络延迟时间
有 n 个网络节点,标记为 1 到 n。
给你一个列表 times,times[i] = [ui, vi, wi],表示从节点 ui 到节点 vi 的信号传递时间为 wi。
现在从某个节点 k 发出一个信号。需要多久才能使所有节点都收到信号?
如果不能使所有节点收到信号,返回 -1 。
示例:
输入:times = [[2,1,1],[2,3,1],[3,4,1]], n = 4, k = 2
输出:2
3.2 思路分析
南北绿豆:"Dijkstra的核心思想:每次选择距离起点最近的未访问节点,松弛它的邻居。"
生活化理解:
阿西噶阿西:"想象你从起点出发,手里有一张各城市的最短距离表。"
初始:
起点距离=0
其他城市距离=∞
每一轮:
1. 选距离最小的未访问城市
2. 从这个城市出发,更新邻居的距离
3. 标记这个城市已访问
哈吉米:"贪心选最近的,然后更新。"
3.3 Dijkstra执行过程
示例:times = [[2,1,1],[2,3,1],[3,4,1]], n=4, k=2
图结构:
1
↑1
2 →1→ 3 →1→ 4
执行过程:
| 轮次 | 选择节点 | dist数组 | 更新操作 |
|---|---|---|---|
| 初始 | - | [∞,∞,0,∞,∞] | 起点2距离=0 |
| 1 | 2(dist=0) | [∞,1,0,1,∞] | 更新1(0+1=1),更新3(0+1=1) |
| 2 | 1(dist=1) | [∞,1,0,1,∞] | 1没有出边 |
| 3 | 3(dist=1) | [∞,1,0,1,2] | 更新4(1+1=2) |
| 4 | 4(dist=2) | [∞,1,0,1,2] | 4没有出边 |
最终答案:max(dist) = 2
3.4 Dijkstra代码(堆优化)
Java版本:
public int networkDelayTime(int[][] times, int n, int k) {
// 建图:邻接表
List<int[]>[] graph = new ArrayList[n + 1];
for (int i = 1; i <= n; i++) {
graph[i] = new ArrayList<>();
}
for (int[] time : times) {
graph[time[0]].add(new int[]{time[1], time[2]}); // [邻居, 权重]
}
// dist[i]:起点到i的最短距离
int[] dist = new int[n + 1];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[k] = 0;
// 优先队列:按距离排序
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[1] - b[1]);
pq.offer(new int[]{k, 0}); // [节点, 距离]
while (!pq.isEmpty()) {
int[] curr = pq.poll();
int node = curr[0];
int distance = curr[1];
// 已经找到更短的路径了,跳过
if (distance > dist[node]) {
continue;
}
// 松弛操作:更新邻居的距离
for (int[] edge : graph[node]) {
int neighbor = edge[0];
int weight = edge[1];
int newDist = dist[node] + weight;
if (newDist < dist[neighbor]) {
dist[neighbor] = newDist;
pq.offer(new int[]{neighbor, newDist});
}
}
}
// 找最大距离
int maxDist = 0;
for (int i = 1; i <= n; i++) {
if (dist[i] == Integer.MAX_VALUE) {
return -1; // 有节点到不了
}
maxDist = Math.max(maxDist, dist[i]);
}
return maxDist;
}
C++版本:
int networkDelayTime(vector<vector<int>>& times, int n, int k) {
vector<vector<pair<int, int>>> graph(n + 1);
for (auto& time : times) {
graph[time[0]].push_back({time[1], time[2]});
}
vector<int> dist(n + 1, INT_MAX);
dist[k] = 0;
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq;
pq.push({0, k}); // {距离, 节点}
while (!pq.empty()) {
auto [distance, node] = pq.top();
pq.pop();
if (distance > dist[node]) continue;
for (auto& [neighbor, weight] : graph[node]) {
int newDist = dist[node] + weight;
if (newDist < dist[neighbor]) {
dist[neighbor] = newDist;
pq.push({newDist, neighbor});
}
}
}
int maxDist = 0;
for (int i = 1; i <= n; i++) {
if (dist[i] == INT_MAX) return -1;
maxDist = max(maxDist, dist[i]);
}
return maxDist;
}
Python版本:
def networkDelayTime(times, n, k):
import heapq
from collections import defaultdict
graph = defaultdict(list)
for u, v, w in times:
graph[u].append((v, w))
dist = [float('inf')] * (n + 1)
dist[k] = 0
pq = [(0, k)] # (距离, 节点)
while pq:
distance, node = heapq.heappop(pq)
if distance > dist[node]:
continue
for neighbor, weight in graph[node]:
newDist = dist[node] + weight
if newDist < dist[neighbor]:
dist[neighbor] = newDist
heapq.heappush(pq, (newDist, neighbor))
maxDist = max(dist[1:])
return -1 if maxDist == float('inf') else maxDist
时间复杂度:O((V+E)logV),V是节点数,E是边数
四、拓扑排序
南北绿豆:"拓扑排序处理有向无环图(DAG)的排序问题。"
4.1 题目
LeetCode 207 - 课程表
你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses - 1 。
在选修某些课程之前需要一些先修课程。
先修课程按数组 prerequisites 给出,prerequisites[i] = [ai, bi] 表示如果要学习课程 ai 则必须先学习课程 bi 。
请你判断是否可能完成所有课程的学习?
示例:
输入:numCourses = 2, prerequisites = [[1,0]]
输出:true
解释:总共有 2 门课程。学习课程 1 之前,你需要完成课程 0 。这是可能的。
输入:numCourses = 2, prerequisites = [[1,0],[0,1]]
输出:false
解释:总共有 2 门课程。学习课程 1 之前,需要先完成课程 0 ;并且学习课程 0 之前,你需要先完成课程 1 。这是不可能的。
4.2 思路分析
南北绿豆:"这题本质:判断有向图是否有环。"
拓扑排序思路:
- 统计每个节点的入度(有多少条边指向它)
- 把入度为0的节点入队(没有前置依赖)
- BFS遍历,每次取出入度为0的节点,将其邻居的入度-1
- 如果能遍历完所有节点,说明无环;否则有环
图示:
课程关系:
0 → 1 → 3
↓
2
入度统计:
0: 入度0(没有前置课程)
1: 入度1(需要先学0)
2: 入度1(需要先学1)
3: 入度1(需要先学1)
拓扑排序:
第1轮:0入队(入度0)
第2轮:1入队(0学完后,1的入度变0)
第3轮:2和3入队
顺序:0 → 1 → 2 → 3
如果有环:
0 → 1
↑ ↓
└── 2
入度统计:
0: 入度1
1: 入度1
2: 入度1
所有节点入度都>0,没有入度为0的节点
无法开始拓扑排序 → 有环 ✗
4.3 代码实现
Java版本:
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 建图
List<Integer>[] graph = new ArrayList[numCourses];
for (int i = 0; i < numCourses; i++) {
graph[i] = new ArrayList<>();
}
int[] inDegree = new int[numCourses];
for (int[] pre : prerequisites) {
int from = pre[1];
int to = pre[0];
graph[from].add(to);
inDegree[to]++;
}
// BFS:入度为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++;
// 邻居的入度-1
for (int next : graph[course]) {
inDegree[next]--;
if (inDegree[next] == 0) {
queue.offer(next);
}
}
}
return count == numCourses; // 能学完所有课程
}
C++版本:
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]);
inDegree[pre[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 course = q.front();
q.pop();
count++;
for (int next : graph[course]) {
inDegree[next]--;
if (inDegree[next] == 0) {
q.push(next);
}
}
}
return count == numCourses;
}
Python版本:
def canFinish(numCourses, prerequisites):
from collections import defaultdict, deque
graph = defaultdict(list)
inDegree = [0] * numCourses
for to, frm in prerequisites:
graph[frm].append(to)
inDegree[to] += 1
queue = deque([i for i in range(numCourses) if inDegree[i] == 0])
count = 0
while queue:
course = queue.popleft()
count += 1
for next_course in graph[course]:
inDegree[next_course] -= 1
if inDegree[next_course] == 0:
queue.append(next_course)
return count == numCourses
五、图论算法总结
5.1 三种算法对比
| 算法 | 适用场景 | 复杂度 | 典型题目 |
|---|---|---|---|
| BFS | 无权图最短路 | O(V+E) | LeetCode 1091、102 |
| Dijkstra | 带权图最短路(非负权) | O((V+E)logV) | LeetCode 743、787 |
| 拓扑排序 | 有向无环图排序、判环 | O(V+E) | LeetCode 207、210 |
5.2 识别技巧
阿西噶阿西:
- 看到最短路径、最少步数,想BFS或Dijkstra
- 看到课程、依赖、先后顺序,想拓扑排序
- 看到是否有环,想拓扑排序或DFS
5.3 核心要点
南北绿豆:
- BFS用队列:层序遍历,第一次到达就是最短
- Dijkstra用优先队列:每次选距离最小的节点
- 拓扑排序用入度:BFS+入度为0的节点入队
哈吉米:"图论核心就是这三个算法。"
参考资料:
- 《算法第四版》- Robert Sedgewick
- 《算法导论》- Thomas H. Cormen
- LeetCode题解 - 图论专题