Skip to content

图算法全景解析 — 从遍历到最短路径与生成树

Published:
19 min read

图(Graph)是计算机科学中最通用、最强大的数据结构之一。社交网络中的人际关系、互联网中的网页链接、地图中的城市与公路、编译器中的函数调用关系——这些现实世界的问题都可以自然地抽象为图模型。掌握图算法,是从”编程入门”迈向”算法进阶”的关键一步。

本文将系统讲解图的基本概念、两种核心遍历方式(BFS 和 DFS)、三大最短路径算法(Dijkstra、Floyd-Warshall、Bellman-Ford)、两种最小生成树算法(Prim、Kruskal)以及拓扑排序。所有代码均使用 Python 编写并附有详细注释。

图的基本概念

有向图与无向图

带权图与无权图

图的存储方式

邻接矩阵(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  ]
]

邻接表(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)]
}

选择原则: 稀疏图(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  # 不可达

应用场景:

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

应用场景:

BFS vs DFS 对比

特性BFSDFS
数据结构队列栈/递归
空间复杂度O(V)(最坏情况存储一整层)O(V)(最坏情况递归深度为V)
最短路径保证(无权图)不保证
适合场景层次遍历、最短路径连通性、环检测、拓扑排序

最短路径算法

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'))

最短路径算法对比

算法类型时间复杂度能否处理负权适用场景
Dijkstra单源O((V+E) log V)不能非负权图、稀疏图
Floyd-Warshall全源O(V³)能(无负环)小规模图、全对最短路
Bellman-Ford单源O(VE)能(可检测负环)负权边、汇率套利检测

最小生成树

最小生成树(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_weight

Kruskal 算法

原理: 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 对比

特性PrimKruskal
策略从顶点出发,贪心扩展按边排序,贪心选边
数据结构优先队列并查集
时间复杂度O((V+E) log V)O(E log E)
适合场景稠密图(边多)稀疏图(边少)
起始要求需要指定起始顶点不需要

拓扑排序

定义: 拓扑排序是对**有向无环图(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)

各算法时间复杂度汇总

算法时间复杂度空间复杂度用途
BFSO(V + E)O(V)遍历、无权最短路径
DFSO(V + E)O(V)遍历、连通性、环检测
DijkstraO((V+E) log V)O(V)单源最短路径(非负权)
Floyd-WarshallO(V³)O(V²)全源最短路径
Bellman-FordO(VE)O(V)单源最短路径(负权)
PrimO((V+E) log V)O(V)最小生成树(稠密图)
KruskalO(E log E)O(V)最小生成树(稀疏图)
Kahn 拓扑排序O(V + E)O(V)DAG 线性排序

总结:不同场景下的算法选择

图算法的选择取决于问题的具体需求:

求最短路径:

求最小生成树:

求拓扑顺序:

检测环:

检测连通性:

图算法是算法学习中内容最丰富、应用最广泛的领域之一。扎实掌握本文介绍的这些基础算法,将为你理解更高级的图论问题(如网络流、二分匹配、最小费用流等)奠定坚实的基础。建议在学习过程中多动手画图、手动模拟算法执行过程,这比单纯看代码更有助于深入理解。