1. 项目概述
今天要分享的是我在算法学习过程中整理的图论专题笔记,重点聚焦最短路算法和典型题目解析。这个专题包含三个核心部分:LeetCode第97题"小明逛公园"的解法思路、第127题"骑士的攻击"的图论建模,以及最短路算法的系统总结和比较。作为算法竞赛和面试中的高频考点,图论问题往往让初学者感到棘手,特别是如何将实际问题抽象为图论模型,以及不同场景下的算法选型。
我在刷题过程中发现,很多图论题目看似复杂,但核心都是对基础算法的灵活运用。比如"小明逛公园"本质上是最短路径问题的变种,而"骑士的攻击"则需要巧妙构建图模型。本文将用实际代码演示如何拆解这些问题,并附上我整理的算法对比表格和复杂度分析。对于正在准备算法面试或竞赛的同学,这些内容能帮你快速建立图论问题的解决框架。
2. 核心算法解析
2.1 最短路算法全家桶
最短路算法是图论中的基石,根据图的特点和问题需求,我们通常会在以下四种算法中选择:
-
Dijkstra算法:
- 适用条件:无负权边的加权图
- 时间复杂度:普通队列O(V²),二叉堆优化O(E log V)
- 核心思想:贪心策略,每次扩展当前最短路径
python复制def dijkstra(graph, start): dist = {node: float('inf') for node in graph} dist[start] = 0 heap = [(0, start)] while heap: current_dist, u = heapq.heappop(heap) if current_dist > dist[u]: continue for v, weight in graph[u].items(): if dist[v] > dist[u] + weight: dist[v] = dist[u] + weight heapq.heappush(heap, (dist[v], v)) return dist -
Bellman-Ford算法:
- 适用条件:允许负权边,能检测负权环
- 时间复杂度:O(VE)
- 优势:可以处理带负权的情况
-
SPFA算法:
- 适用条件:Bellman-Ford的队列优化版本
- 平均时间复杂度:O(E),最坏O(VE)
- 实际表现:在稀疏图上效率接近Dijkstra
-
Floyd-Warshall算法:
- 适用条件:全源最短路径
- 时间复杂度:O(V³)
- 特点:实现简单但复杂度较高
提示:在面试中,90%的情况使用Dijkstra就能解决问题,但当出现"花费可以是负数"这类描述时,要立即想到Bellman-Ford。
2.2 算法选择决策树
根据题目特征快速选择算法的流程:
code复制if 需要求所有节点对的最短路径:
使用Floyd-Warshall
elif 图中存在负权边:
if 需要检测负权环:
使用Bellman-Ford
else:
使用SPFA(如果允许)
else:
if 是稠密图且V较小:
使用普通Dijkstra
else:
使用堆优化Dijkstra
3. 题目实战解析
3.1 LeetCode 97 - 小明逛公园
这是一道典型的最短路径计数问题。题目描述小明每天要从家(S)到学校(T)穿过公园,公园有N个景点和M条道路,问有多少条不同的最短路径。
解题步骤:
- 使用Dijkstra算法计算从S到所有节点的最短距离dist[]
- 构建最短路图:只保留满足dist[u] + w == dist[v]的边
- 在新图上进行拓扑排序后动态规划计数
python复制def countShortestPaths(n, edges, S, T):
graph = defaultdict(list)
for u, v, w in edges:
graph[u].append((v, w))
graph[v].append((u, w)) # 假设是无向图
# Dijkstra计算最短距离
dist = {i: float('inf') for i in range(n)}
dist[S] = 0
heap = [(0, S)]
while heap:
d, u = heapq.heappop(heap)
if u == T: break
if d > dist[u]: continue
for v, w in graph[u]:
if dist[v] > d + w:
dist[v] = d + w
heapq.heappush(heap, (dist[v], v))
# 构建最短路图
dag = defaultdict(list)
for u in range(n):
for v, w in graph[u]:
if dist[u] + w == dist[v]:
dag[u].append(v)
# 动态规划计数
dp = [0] * n
dp[S] = 1
nodes = sorted(range(n), key=lambda x: dist[x]) # 按距离排序
for u in nodes:
for v in dag[u]:
dp[v] += dp[u]
return dp[T]
易错点:
- 忘记处理重边的情况
- 动态规划顺序错误(必须按拓扑序)
- 没有考虑大数取模的情况(当路径数可能很大时)
3.2 LeetCode 127 - 骑士的攻击
这道题要求计算国际象棋骑士在无限棋盘上,经过k步后仍留在棋盘内的概率。看似是概率问题,实则是图论中的可达性统计。
建模思路:
- 将每个棋盘位置视为图节点
- 骑士的8种走法构成图的边
- 使用动态规划统计k步后在棋盘内的路径数
python复制def knightProbability(n, k, row, column):
moves = [(-2,-1),(-1,-2),(1,-2),(2,-1),
(2,1),(1,2),(-1,2),(-2,1)]
dp = [[[0]*n for _ in range(n)] for __ in range(k+1)]
dp[0][row][column] = 1
for step in range(1, k+1):
for i in range(n):
for j in range(n):
for di, dj in moves:
ni, nj = i + di, j + dj
if 0 <= ni < n and 0 <= nj < n:
dp[step][i][j] += dp[step-1][ni][nj] / 8
return sum(map(sum, dp[k]))
优化技巧:
- 使用滚动数组将空间复杂度从O(kn²)降到O(n²)
- 提前处理边界情况(k=0或n<=2时)
4. 图论常见问题模式
4.1 经典问题分类
-
连通性问题:
- DFS/BFS遍历
- 连通分量计数
- 强连通分量(Tarjan算法)
-
路径问题:
- 欧拉路径/回路
- 哈密尔顿路径(通常NP难)
- 最短路径的各种变体
-
网络流问题:
- 最大流(Ford-Fulkerson)
- 最小割
- 二分图匹配
-
特殊图算法:
- 拓扑排序
- 最小生成树(Prim/Kruskal)
- 关键路径
4.2 建图技巧总结
-
邻接表 vs 邻接矩阵:
- 稀疏图优先用邻接表(大多数LeetCode题目)
- 稠密图或需要快速查询边时用矩阵
-
虚拟节点技巧:
- 当问题中有多个起点/终点时,添加超级源点/汇点
- 例:多源BFS问题
-
状态扩展建图:
- 将节点状态作为图的一部分
- 例:带限制的最短路径(如"最多经过k条边")
-
分层图技术:
- 复制多层图处理状态变化
- 例:处理"可以消除k个障碍"这类问题
5. 面试实战建议
5.1 解题框架
-
问题分析:
- 确认是否属于图论问题
- 识别节点和边的定义
- 判断问题类型(最短路径、连通性等)
-
算法选择:
- 根据图的特点(加权/无权,有无负权等)选择算法
- 考虑时间/空间复杂度约束
-
实现细节:
- 确定图的表示方式
- 处理边界情况(空图、单节点等)
-
验证测试:
- 设计小规模测试用例
- 检查特殊输入(如完全图、星型图等)
5.2 常见陷阱
-
无限循环:
- 在有权图中忘记标记已访问节点
- 解决方案:维护visited集合或距离数组
-
优先级队列误用:
- 未处理队列中的过期条目
- 正确做法:比较当前距离与队列中存储的距离
-
负权边处理:
- 错误地在有负权边的图中使用Dijkstra
- 补救:改用Bellman-Ford或SPFA
-
大数溢出:
- 路径计数时未考虑取模
- 建议:即使题目没要求,也主动询问是否需要处理大数
6. 效率优化技巧
6.1 预处理技巧
-
邻接表压缩:
- 对于静态图,使用扁平数组存储边
- 减少内存碎片和提高缓存命中率
-
双向BFS:
- 当起点和终点都已知时
- 特别适用于状态空间大的问题
-
启发式搜索:
- A*算法中的启发函数设计
- 例:网格图中使用曼哈顿距离
6.2 语言特定优化
Python中的优化技巧:
python复制# 使用collections.deque代替list实现队列
from collections import deque
q = deque([start])
# 使用heapq实现优先队列时,避免频繁插入删除
# 可以批量处理同优先级节点
# 对于静态图,考虑使用numpy数组存储邻接矩阵
import numpy as np
adj_matrix = np.zeros((n, n))
C++中的优化技巧:
cpp复制// 使用vector代替list存储邻接表
vector<vector<pair<int, int>>> graph(n);
// 使用priority_queue时自定义比较函数
auto cmp = [](const auto& a, const auto& b) { return a.second > b.second; };
priority_queue<pair<int, int>, vector<pair<int, int>>, decltype(cmp)> pq(cmp);
7. 扩展学习资源
7.1 推荐题目列表
-
基础练习:
-
- Clone Graph (图遍历)
-
- Course Schedule (拓扑排序)
-
- Network Delay Time (Dijkstra)
-
-
进阶挑战:
-
- Reconstruct Itinerary (欧拉路径)
-
- Swim in Rising Water (最短路径变种)
-
- Shortest Path in a Grid with Obstacles Elimination (分层图)
-
-
竞赛经典:
- 费用流问题
- 最小树形图(朱刘算法)
- 支配树相关题目
7.2 学习资料推荐
-
书籍:
- 《算法导论》图论章节
- 《算法竞赛入门经典》图论专题
- 《图论算法及其应用》
-
在线资源:
- VisuAlgo.net 的图算法可视化
- TopCoder 图论教程
- LeetCode 图论标签下的精选题目
-
实战平台:
- LeetCode 周赛图论题
- Codeforces 的图论标签
- AtCoder 的典型图论问题
在实际刷题过程中,我发现图论问题的难点往往不在于算法本身,而在于如何将实际问题抽象为图论模型。建议从简单题目开始,逐步培养图论思维,遇到新题型时先手动画图分析,再考虑用哪种算法解决。
