1. 图论基础与核心概念
图(Graph)作为离散数学的重要分支,是描述事物间关系最强大的建模工具之一。在计算机科学领域,我们通常用G=(V,E)表示一个图,其中V是顶点集合,E是边集合。根据边的方向性可分为有向图和无向图,根据边是否带权值可分为加权图和非加权图。
图的常见存储方式有三种:
- 邻接矩阵:用二维数组存储顶点间的连接关系,适合稠密图
- 邻接表:为每个顶点维护一个链表存储其邻接点,适合稀疏图
- 边列表:直接存储所有边的集合,适合某些特定算法
实际应用中,邻接表因其空间效率和灵活性成为最常用的存储方式,特别是当图中顶点度数差异较大时优势明显。
图的遍历是大多数图算法的基础,主要分为深度优先搜索(DFS)和广度优先搜索(BFS)两种范式。DFS采用"一条路走到黑"的策略,适合探索图的纵深结构;BFS则采用"层层推进"的方式,适合寻找最短路径等场景。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 图的遍历:DFS与BFS实现与优化
2.1 深度优先搜索(DFS)实战
DFS的核心思想是递归地探索图的纵深结构,其非递归实现通常借助栈来完成。以下是DFS的标准实现模板(以邻接表存储的无向图为例):
python复制def dfs(graph, start):
visited = set()
stack = [start]
while stack:
vertex = stack.pop()
if vertex not in visited:
visited.add(vertex)
# 处理顶点(如打印、计算等)
print(vertex)
# 将未访问的邻接点逆序压栈(保证顺序)
for neighbor in reversed(graph[vertex]):
if neighbor not in visited:
stack.append(neighbor)
DFS的时间复杂度为O(V+E),空间复杂度取决于递归深度,最坏情况下为O(V)。在实际应用中,DFS常用于:
- 拓扑排序
- 寻找连通分量
- 检测图中的环
- 解决迷宫类问题
当图规模极大时,递归实现的DFS可能导致栈溢出。此时应改用显式栈的非递归实现,或调整系统栈大小限制。
2.2 广度优先搜索(BFS)及其变种
BFS采用队列实现层级遍历,是寻找无权图最短路径的基础算法。其标准实现如下:
python复制from collections import deque
def bfs(graph, start):
visited = set()
queue = deque([start])
visited.add(start)
while queue:
vertex = queue.popleft()
print(vertex) # 处理当前顶点
for neighbor in graph[vertex]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
BFS的时间复杂度同样为O(V+E),空间复杂度取决于队列大小。BFS的典型应用场景包括:
- 社交网络中的好友推荐(三度人脉)
- 网页爬虫的URL抓取策略
- 棋盘类游戏的最短步数计算
- 网络广播路由
在加权图中,BFS不能直接用于寻找最短路径,此时需要Dijkstra等更复杂的算法。一个常见的优化是多源BFS(Multi-source BFS),可同时从多个起点出发遍历,适用于如"多个火源蔓延"等场景。
3. 最小生成树:Kruskal与Prim算法对比
3.1 Kruskal算法实现细节
Kruskal算法采用贪心策略,通过排序所有边并逐步添加不形成环的边来构建最小生成树。其核心是并查集(Disjoint Set Union,DSU)数据结构的应用:
python复制class DSU:
def __init__(self, size):
self.parent = list(range(size))
self.rank = [0]*size
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
xroot = self.find(x)
yroot = self.find(y)
if xroot == yroot:
return False
if self.rank[xroot] < self.rank[yroot]:
self.parent[xroot] = yroot
else:
self.parent[yroot] = xroot
if self.rank[xroot] == self.rank[yroot]:
self.rank[xroot] += 1
return True
def kruskal(edges, num_vertices):
edges.sort(key=lambda x: x[2]) # 按权重排序
dsu = DSU(num_vertices)
mst = []
for u, v, w in edges:
if dsu.union(u, v):
mst.append((u, v, w))
if len(mst) == num_vertices - 1:
break
return mst
Kruskal的时间复杂度主要来自排序步骤,为O(ElogE),适合边数相对较少的稀疏图。实际应用中需要注意:
- 边权重比较函数应处理浮点数精度问题
- 并查集的路径压缩和按秩合并优化必不可少
- 当图不连通时只能得到最小生成森林
3.2 Prim算法的实现与优化
Prim算法从单个顶点开始,逐步扩展生成树。其标准实现使用优先队列(最小堆):
python复制import heapq
def prim(graph, start):
mst = []
visited = set([start])
edges = [
(cost, start, to)
for to, cost in graph[start].items()
]
heapq.heapify(edges)
while edges and len(visited) < len(graph):
cost, frm, to = heapq.heappop(edges)
if to not in visited:
visited.add(to)
mst.append((frm, to, cost))
for to_next, cost2 in graph[to].items():
if to_next not in visited:
heapq.heappush(edges, (cost2, to, to_next))
return mst
使用斐波那契堆可将时间复杂度优化到O(E+VlogV),适合稠密图。Prim算法在实际应用中有几个关键点:
- 初始点的选择不影响最终总权重,但影响生成树形态
- 堆中存储的是候选边而非顶点,这与Dijkstra算法不同
- 当存在负权边时算法依然有效
在边数E接近V²的稠密图中,Prim的堆优化版本通常优于Kruskal;而对于稀疏图,Kruskal的简洁实现更具优势。
4. 单源最短路径:Dijkstra与Bellman-Ford
4.1 Dijkstra算法及其正确性证明
Dijkstra算法是解决非负权图单源最短路径问题的最优选择。其核心思想是通过贪心策略逐步确定到各顶点的最短距离:
python复制import heapq
def dijkstra(graph, start):
distances = {vertex: float('infinity') for vertex in graph}
distances[start] = 0
pq = [(0, start)]
while pq:
current_dist, current_vertex = heapq.heappop(pq)
if current_dist > distances[current_vertex]:
continue
for neighbor, weight in graph[current_vertex].items():
distance = current_dist + weight
if distance < distances[neighbor]:
distances[neighbor] = distance
heapq.heappush(pq, (distance, neighbor))
return distances
Dijkstra的时间复杂度取决于优先队列的实现:
- 数组实现:O(V²)
- 二叉堆:O((V+E)logV)
- 斐波那契堆:O(E+VlogV)
Dijkstra算法的正确性依赖于非负权边的假设。当图中存在负权边时,可能产生错误结果。例如在下图中:
code复制A --2--> B --(-3)--> C
\------1-----/
从A到C的最短路径应为A→B→C(总权重-1),但Dijkstra会错误地选择A→C(总权重1)。
4.2 Bellman-Ford算法处理负权边
Bellman-Ford算法通过松弛所有边V-1次来确保找到最短路径,能检测负权环:
python复制def bellman_ford(edges, num_vertices, start):
distances = [float('inf')] * num_vertices
distances[start] = 0
for _ in range(num_vertices - 1):
updated = False
for u, v, w in edges:
if distances[u] + w < distances[v]:
distances[v] = distances[u] + w
updated = True
if not updated:
break
# 检测负权环
for u, v, w in edges:
if distances[u] + w < distances[v]:
raise ValueError("图中存在负权环")
return distances
Bellman-Ford的时间复杂度为O(VE),适合以下场景:
- 图中存在负权边
- 需要检测负权环
- 分布式系统中的路由计算
在实际应用中,SPFA(Shortest Path Faster Algorithm)作为Bellman-Ford的优化版本,通过队列减少不必要的松弛操作,在随机稀疏图上表现优异。
5. 全源最短路径:Floyd-Warshall算法
Floyd-Warshall算法通过动态规划解决全源最短路径问题,能处理负权边(但不能有负权环):
python复制def floyd_warshall(graph):
n = len(graph)
dist = [[float('inf')] * n for _ in range(n)]
# 初始化距离矩阵
for i in range(n):
dist[i][i] = 0
for j, w in graph[i].items():
dist[i][j] = w
# 动态规划核心
for k in range(n):
for i in range(n):
for j in range(n):
if dist[i][j] > dist[i][k] + dist[k][j]:
dist[i][j] = dist[i][k] + dist[k][j]
# 检测负权环
for i in range(n):
if dist[i][i] < 0:
raise ValueError("图中存在负权环")
return dist
Floyd-Warshall的时间复杂度为O(V³),空间复杂度可通过技巧优化到O(V²)。其典型应用包括:
- 交通网络中的中心性计算
- 图像处理中的最短路径变换
- 有向图的传递闭包计算
对于稠密图(E接近V²),Floyd-Warshall的实际性能可能优于运行V次Dijkstra;而对于稀疏图,Johnson算法(结合Dijkstra和Bellman-Ford)通常是更好的选择。
6. 算法选择指南与性能对比
6.1 应用场景决策树
根据具体问题特点选择最合适的算法:
code复制是否为全源最短路径问题?
├─ 是 → 图规模?
│ ├─ 小规模(V<100) → Floyd-Warshall
│ └─ 大规模 → Johnson算法
└─ 否 → 是否有负权边?
├─ 是 → Bellman-Ford/SPFA
└─ 否 → Dijkstra(优先队列优化)
最小生成树问题的选择:
code复制图的特点?
├─ 稠密图 → Prim(斐波那契堆优化)
└─ 稀疏图 → Kruskal
6.2 性能实测数据参考
以下是在不同规模图上的近似运行时间比较(单位:ms):
| 顶点数 | 边数 | Dijkstra(堆) | Bellman-Ford | Floyd-Warshall | Kruskal | Prim(堆) |
|---|---|---|---|---|---|---|
| 100 | 500 | 1.2 | 3.8 | 15 | 0.8 | 1.1 |
| 1000 | 5000 | 25 | 120 | 1500 | 12 | 30 |
| 5000 | 2M | 180 | 超时 | 超时 | 150 | 250 |
6.3 常见陷阱与优化技巧
-
优先级队列的实现差异:
- Python的
heapq模块不支持直接更新优先级 - 解决方案:将新距离直接插入堆,通过
visited集合过滤过时项
- Python的
-
浮点数权重比较:
python复制# 不安全的比较 if a < b: # 安全的浮点数比较 if a < b - 1e-10: -
稀疏图存储优化:
python复制# 低效的邻接矩阵 graph = [[0]*V for _ in range(V)] # 高效的邻接表 graph = defaultdict(dict) -
并行化潜力:
- Floyd-Warshall的最内层循环可并行化
- Kruskal的边排序阶段可并行加速
我在实际项目中发现,图算法的性能往往受数据布局影响极大。例如在社交网络分析中,将热点用户的数据放在连续内存区域可使缓存命中率提升40%以上。另一个经验是:当需要反复运行Dijkstra算法时,预先分配并重用距离数组比每次新建更高效。
