BFS/DFS遍历、Dijkstra/Floyd最短路径、Prim/Kruskal最小生成树、拓扑排序完整解析
图(Graph)是计算机科学中最通用、最强大的数据结构之一。社交网络中的人际关系、互联网中的网页链接、地图中的城市与公路、编译器中的函数调用关系——这些现实世界的问题都可以自然地抽象为图模型。掌握图算法,是从"编程入门"迈向"算法进阶"的关键一步。
本文将系统讲解图的基本概念、两种核心遍历方式(BFS 和 DFS)、三大最短路径算法(Dijkstra、Floyd-Warshall、Bellman-Ford)、两种最小生成树算法(Prim、Kruskal)以及拓扑排序。所有代码均使用 Python 编写并附有详细注释。
图的基本概念
有向图与无向图
- 无向图(Undirected Graph):边没有方向,表示双向关系。如社交网络中的"好友关系"。
- 有向图(Directed Graph / Digraph):边有方向,表示单向关系。如微博中的"关注关系"。
带权图与无权图
- 无权图:所有边的权重相同(通常视为 1)。
- 带权图(Weighted Graph):每条边附带一个数值(权重),表示距离、费用、时间等。
图的存储方式
邻接矩阵(Adjacency Matrix):使用二维数组 graph[i][j] 存储节点 i 到节点 j 的边信息。
# 邻接矩阵表示(无向带权图)INF = float('inf')graph_matrix = [ [0, 4, INF, INF, INF], [4, 0, 8, INF, INF], [INF, 8, 0, 7, INF], [INF, INF, 7, 0, 9 ], [INF, INF, INF, 9, 0 ]]- 优点:查询任意两点是否相邻为 O(1)
- 缺点:空间 O(V²),对稀疏图浪费严重
邻接表(Adjacency List):每个节点维护一个列表,存储其所有邻居及边权。
# 邻接表表示(无向带权图)graph_list = { 0: [(1, 4)], 1: [(0, 4), (2, 8)], 2: [(1, 8), (3, 7)], 3: [(2, 7), (4, 9)], 4: [(3, 9)]}- 优点:空间 O(V + E),遍历邻居高效
- 缺点:查询两点是否相邻需 O(degree) 时间
选择原则: 稀疏图(E 远小于 V²)用邻接表,稠密图(E 接近 V²)用邻接矩阵。实际工程中,邻接表是更常见的选择。
图的遍历
图的遍历是几乎所有图算法的基础,分为两种经典方式:广度优先搜索(BFS)和深度优先搜索(DFS)。
BFS — 广度优先搜索
原理: BFS 从起始节点出发,逐层向外扩展。它先访问所有距离为 1 的节点,再访问距离为 2 的节点,以此类推。BFS 使用**队列(Queue)**来维护待访问节点的顺序。
核心特性: 在无权图中,BFS 找到的路径一定是最短路径。
from collections import deque def bfs(graph, start): """ 广度优先搜索 :param graph: 邻接表,graph[u] = [(v1, w1), (v2, w2), ...] 或 [v1, v2, ...] :param start: 起始节点 :return: BFS 遍历顺序 """ visited = set() queue = deque([start]) visited.add(start) order = [] while queue: node = queue.popleft() order.append(node) for neighbor in graph[node]: # 兼容带权和无权邻接表 v = neighbor[0] if isinstance(neighbor, tuple) else neighbor if v not in visited: visited.add(v) queue.append(v) return order # BFS 求无权图最短路径def bfs_shortest_path(graph, start, end): """ 在无权图中求从 start 到 end 的最短路径 """ visited = {start} queue = deque([(start, [start])]) while queue: node, path = queue.popleft() if node == end: return path for neighbor in graph[node]: v = neighbor[0] if isinstance(neighbor, tuple) else neighbor if v not in visited: visited.add(v) queue.append((v, path + [v])) return None # 不可达应用场景:
- 无权图的最短路径
- 层序遍历(二叉树的层次遍历本质就是 BFS)
- 连通分量检测
- 网络爬虫的广度抓取策略
DFS — 深度优先搜索
原理: DFS 从起始节点出发,沿着一条路径尽可能深入,直到无法继续时回溯到上一个分叉点。DFS 可以使用递归或显式栈实现。
def dfs_recursive(graph, start, visited=None): """ 深度优先搜索(递归实现) """ if visited is None: visited = set() visited.add(start) order = [start] for neighbor in graph[start]: v = neighbor[0] if isinstance(neighbor, tuple) else neighbor if v not in visited: order.extend(dfs_recursive(graph, v, visited)) return order def dfs_iterative(graph, start): """ 深度优先搜索(栈实现) """ visited = set() stack = [start] order = [] while stack: node = stack.pop() if node in visited: continue visited.add(node) order.append(node) # 逆序压栈以保证访问顺序与递归一致 for neighbor in reversed(graph[node]): v = neighbor[0] if isinstance(neighbor, tuple) else neighbor if v not in visited: stack.append(v) return order应用场景:
- 连通性判断(图是否连通)
- 环检测
- 拓扑排序
- 路径搜索与回溯算法
- 强连通分量(Tarjan 算法、Kosaraju 算法)
BFS vs DFS 对比
最短路径算法
Dijkstra 算法
原理: Dijkstra 算法是解决单源最短路径问题的经典贪心算法。它维护一个已确定最短距离的节点集合,每次从未确定集合中选取距离最小的节点,用该节点更新其邻居的距离估计。
适用条件: 边权必须为非负数。
优先队列优化版本的时间复杂度: O((V + E) log V)
import heapq def dijkstra(graph, start): """ Dijkstra 单源最短路径算法 :param graph: 邻接表,graph[u] = [(v, weight), ...] :param start: 起始节点 :return: dist(最短距离字典), prev(前驱节点字典,用于路径还原) """ dist = {node: float('inf') for node in graph} dist[start] = 0 prev = {node: None for node in graph} # 优先队列:(距离, 节点) pq = [(0, start)] while pq: d, u = heapq.heappop(pq) # 如果取出的距离已过时,跳过 if d > dist[u]: continue for v, weight in graph[u]: new_dist = dist[u] + weight if new_dist < dist[v]: dist[v] = new_dist prev[v] = u heapq.heappush(pq, (new_dist, v)) return dist, prev def reconstruct_path(prev, start, end): """根据前驱节点字典还原路径""" path = [] current = end while current is not None: path.append(current) current = prev[current] path.reverse() return path if path[0] == start else [] # 使用示例graph = { 'A': [('B', 4), ('C', 2)], 'B': [('A', 4), ('C', 1), ('D', 5)], 'C': [('A', 2), ('B', 1), ('D', 8), ('E', 10)], 'D': [('B', 5), ('C', 8), ('E', 2)], 'E': [('C', 10), ('D', 2)]} dist, prev = dijkstra(graph, 'A')print(f"A 到各点最短距离: {dist}")print(f"A 到 E 的最短路径: {reconstruct_path(prev, 'A', 'E')}")Floyd-Warshall 算法
原理: Floyd-Warshall 算法求解全源最短路径,即所有节点对之间的最短距离。核心思想是动态规划:依次考虑每个节点 k 作为中转点,看是否能缩短 i 到 j 的距离。
时间复杂度: O(V³)
适用场景: 图规模较小(V ≤ 几百)、需要全部节点对的最短路径、可处理负权边(但不能有负权环)。
def floyd_warshall(graph_matrix): """ Floyd-Warshall 全源最短路径 :param graph_matrix: V×V 的邻接矩阵,graph_matrix[i][j] 为边权,无边为 INF :return: dist 矩阵 """ V = len(graph_matrix) # 复制矩阵避免修改原数据 dist = [row[:] for row in graph_matrix] # 核心三重循环:k 为中转点 for k in range(V): for i in range(V): for j in range(V): if dist[i][k] + dist[k][j] < dist[i][j]: dist[i][j] = dist[i][k] + dist[k][j] return dist # 使用示例INF = float('inf')matrix = [ [0, 3, INF, 7 ], [8, 0, 2, INF], [5, INF, 0, 1 ], [2, INF, INF, 0 ]]result = floyd_warshall(matrix)for row in result: print(row)Bellman-Ford 算法
原理: Bellman-Ford 算法也是单源最短路径算法,但它能处理负权边。算法对所有边进行 V-1 轮松弛操作,每轮检查每条边是否能缩短距离。如果第 V 轮仍然能松弛,则说明图中存在负权环。
时间复杂度: O(VE)
def bellman_ford(vertices, edges, start): """ Bellman-Ford 单源最短路径(可处理负权边) :param vertices: 节点列表 :param edges: 边列表,[(u, v, weight), ...] :param start: 起始节点 :return: dist 字典,若存在负权环返回 None """ dist = {v: float('inf') for v in vertices} dist[start] = 0 # V-1 轮松弛 for _ in range(len(vertices) - 1): for u, v, w in edges: if dist[u] != float('inf') and dist[u] + w < dist[v]: dist[v] = dist[u] + w # 第 V 轮检测负权环 for u, v, w in edges: if dist[u] != float('inf') and dist[u] + w < dist[v]: print("图中存在负权环!") return None return dist # 使用示例vertices = ['A', 'B', 'C', 'D', 'E']edges = [ ('A', 'B', -1), ('A', 'C', 4), ('B', 'C', 3), ('B', 'D', 2), ('B', 'E', 2), ('D', 'B', 1), ('D', 'C', 5), ('E', 'D', -3)]print(bellman_ford(vertices, edges, 'A'))最短路径算法对比
最小生成树
最小生成树(Minimum Spanning Tree,MST)是一棵包含图中所有顶点的无环连通子图,且所有边的权重之和最小。MST 在网络设计(如最低成本布线)中有重要应用。
Prim 算法
原理: Prim 算法从任意一个顶点出发,每次选择连接已选顶点集合和未选顶点集合之间权重最小的边,将对应的未选顶点加入集合。这是一种贪心策略。
时间复杂度: O((V + E) log V)(使用优先队列)
import heapq def prim(graph, start): """ Prim 最小生成树算法 :param graph: 邻接表,graph[u] = [(v, weight), ...] :param start: 起始节点 :return: MST 的边列表和总权重 """ mst_edges = [] total_weight = 0 visited = set() # 优先队列:(权重, 当前节点, 来源节点) pq = [(0, start, None)] while pq and len(visited) < len(graph): weight, u, from_node = heapq.heappop(pq) if u in visited: continue visited.add(u) total_weight += weight if from_node is not None: mst_edges.append((from_node, u, weight)) for v, w in graph[u]: if v not in visited: heapq.heappush(pq, (w, v, u)) return mst_edges, total_weightKruskal 算法
原理: Kruskal 算法将所有边按权重从小到大排序,依次考虑每条边——如果加入该边不会形成环(通过并查集判断),就将其加入 MST。
时间复杂度: O(E log E)(排序为主要开销)
class UnionFind: """并查集(路径压缩 + 按秩合并)""" def __init__(self, n): self.parent = list(range(n)) self.rank = [0] * n 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): px, py = self.find(x), self.find(y) if px == py: return False # 已在同一集合,加入会形成环 # 按秩合并 if self.rank[px] < self.rank[py]: px, py = py, px self.parent[py] = px if self.rank[px] == self.rank[py]: self.rank[px] += 1 return True def kruskal(vertices, edges): """ Kruskal 最小生成树算法 :param vertices: 节点列表 :param edges: 边列表 [(u, v, weight), ...] :return: MST 的边列表和总权重 """ # 将节点映射为整数索引 v_map = {v: i for i, v in enumerate(vertices)} uf = UnionFind(len(vertices)) # 按权重排序 sorted_edges = sorted(edges, key=lambda e: e[2]) mst_edges = [] total_weight = 0 for u, v, w in sorted_edges: if uf.union(v_map[u], v_map[v]): mst_edges.append((u, v, w)) total_weight += w if len(mst_edges) == len(vertices) - 1: break # MST 已有 V-1 条边 return mst_edges, total_weight # 使用示例vertices = ['A', 'B', 'C', 'D', 'E']edges = [ ('A', 'B', 4), ('A', 'C', 2), ('B', 'C', 1), ('B', 'D', 5), ('C', 'D', 8), ('C', 'E', 10), ('D', 'E', 2)]mst, weight = kruskal(vertices, edges)print(f"最小生成树: {mst}")print(f"总权重: {weight}")Prim vs Kruskal 对比
拓扑排序
定义: 拓扑排序是对**有向无环图(DAG)**中所有顶点的一种线性排序,使得对于每条有向边 (u, v),u 在排序中出现在 v 之前。
应用场景: 课程先修关系、任务调度、编译依赖分析、包管理器的安装顺序。
Kahn 算法(BFS 方式)
原理: 维护所有入度为 0 的节点的队列。每次取出一个入度为 0 的节点,将其加入结果,并将其所有邻居的入度减 1。如果某个邻居的入度变为 0,加入队列。
from collections import deque def topological_sort_kahn(graph, vertices): """ Kahn 算法实现拓扑排序(BFS) :param graph: 有向图邻接表,graph[u] = [v1, v2, ...] :param vertices: 所有顶点列表 :return: 拓扑排序结果,若存在环返回 None """ # 计算所有节点的入度 in_degree = {v: 0 for v in vertices} for u in graph: for v in graph[u]: in_degree[v] = in_degree.get(v, 0) + 1 # 将所有入度为 0 的节点入队 queue = deque([v for v in vertices if in_degree[v] == 0]) result = [] while queue: node = queue.popleft() result.append(node) for neighbor in graph.get(node, []): in_degree[neighbor] -= 1 if in_degree[neighbor] == 0: queue.append(neighbor) # 如果结果长度不等于节点数,说明存在环 if len(result) != len(vertices): print("图中存在环,无法拓扑排序!") return None return result # 使用示例:课程选修顺序course_graph = { '高等数学': ['线性代数', '概率论'], '线性代数': ['机器学习'], '概率论': ['机器学习', '统计学'], '编程基础': ['数据结构'], '数据结构': ['算法设计', '机器学习'], '算法设计': [], '机器学习': [], '统计学': []}vertices = list(course_graph.keys())print(topological_sort_kahn(course_graph, vertices))时间复杂度: O(V + E)
各算法时间复杂度汇总
总结:不同场景下的算法选择
图算法的选择取决于问题的具体需求:
求最短路径:
- 非负权图、单源 → Dijkstra
- 含负权边、单源 → Bellman-Ford
- 全部节点对之间、小规模 → Floyd-Warshall
- 无权图 → 直接 BFS
求最小生成树:
- 稠密图 → Prim
- 稀疏图 → Kruskal
求拓扑顺序:
- DAG → Kahn 算法(BFS)或 DFS 后序反转
检测环:
- 无向图 → DFS 或并查集
- 有向图 → DFS(检测回边)或 Kahn 算法(拓扑排序失败说明有环)
检测连通性:
- 无向图 → BFS/DFS 或并查集
- 有向图强连通分量 → Tarjan 算法或 Kosaraju 算法
图算法是算法学习中内容最丰富、应用最广泛的领域之一。扎实掌握本文介绍的这些基础算法,将为你理解更高级的图论问题(如网络流、二分匹配、最小费用流等)奠定坚实的基础。建议在学习过程中多动手画图、手动模拟算法执行过程,这比单纯看代码更有助于深入理解。
