Part 4.1:图论入门——图的表示与遍历 (BFS & DFS)

引言:万物皆可“图”

我们生活在一个充满连接的世界里。社交网络中,人与人是朋友关系;地图上,城市与城市通过道路相连;互联网中,网页与网页通过超链接互通。这些“实体”与“关系”构成的网络,在计算机科学中可以被抽象为一种强大的数据结构——**图 (Graph)**。

图论是算法领域皇冠上的一颗明珠,它为我们提供了一套完整的理论和工具,来分析和解决各种连接性问题。无论是寻找最短路径、规划网络布线,还是进行任务调度,都离不开图论的知识。

本系列文章将带你系统地学习图论的核心算法。作为开篇,我们将从最基础的问题入手:

  1. 如何在代码中表示一个图?
  2. 如何不重不漏地访问图中的所有节点?

图的基本概念

一个图 G顶点集 V (Vertices)边集 E (Edges) 组成,记为 G = (V, E)

  • **顶点 (Vertex)**:图中的一个节点,如社交网络中的用户、地图上的城市。
  • **边 (Edge)**:连接两个顶点的线,代表它们之间的关系。

根据边的性质,图可以分为:

  • **无向图 (Undirected Graph)**:边没有方向,(u, v)(v, u) 是同一条边。
  • **有向图 (Directed Graph)**:边有方向,(u, v) 表示从 uv 的一条边,与 (v, u) 不同。
  • **带权图 (Weighted Graph)**:每条边都有一个权重或成本,如城市间的距离。

图的表示方法

要在程序中处理图,我们首先需要一种方式来存储它。最常用的两种方法是邻接矩阵邻接表

1. 邻接矩阵 (Adjacency Matrix)

邻接矩阵是一个二维数组 adj,其中 adj[i][j] 表示从顶点 i 到顶点 j 是否有边。

  • 对于无向图,如果 ij 之间有边,则 adj[i][j] = adj[j][i] = 1
  • 对于有向图,如果存在从 ij 的边,则 adj[i][j] = 1
  • 对于带权图,adj[i][j] 可以存储边的权重。

优点

  • 实现简单,直观。
  • 查询两个顶点之间是否有边非常快,时间复杂度为 O(1)

缺点

  • 空间复杂度高,为 O(V²),其中 V 是顶点数。对于稀疏图(边数远小于 ),会造成巨大的空间浪费。
  • 遍历一个顶点的所有邻居需要 O(V) 的时间。

2. 邻接表 (Adjacency List)

邻接表是为图中的每个顶点维护一个列表,该列表存储了所有与该顶点相邻的顶点。

优点

  • 空间复杂度低,为 O(V + E),其中 E 是边数。对于稀疏图非常高效。
  • 遍历一个顶点的所有邻居非常快。

缺点

  • 查询两个顶点之间是否有边需要 O(degree(v)) 的时间,其中 degree(v) 是顶点的度(邻居数量)。

在算法竞赛和大多数实际应用中,邻接表是更常用和更高效的选择。

图的遍历

图的遍历是指从图的某个顶点出发,系统地访问图中的每一个顶点,且每个顶点只被访问一次。

1. 广度优先搜索 (Breadth-First Search, BFS)

BFS 是一种层序遍历,它从起始顶点开始,首先访问所有距离为 1 的邻居,然后是距离为 2 的邻居,以此类推,就像水波纹一样向外扩散。

核心思想:使用一个队列来实现。

步骤

  1. 创建一个队列,并将起始顶点入队。
  2. 标记起始顶点为已访问。
  3. 当队列不为空时,循环执行:
    a. 将队头顶点出队。
    b. 访问该顶点的所有未被访问过的邻居。
    c. 将这些邻居标记为已访问,并依次入队。

应用

  • 无权图的最短路径:BFS 找到的从起点到任何其他顶点的路径,都是最短的(边数最少)。
  • 拓扑排序(将在后续文章中介绍)。

2. 深度优先搜索 (Depth-First Search, DFS)

DFS 是一种“一条路走到黑”的遍历方式。它从起始顶点开始,沿着一条路径不断深入,直到无法再前进时,才回溯到上一个节点,选择另一条路径继续探索。

核心思想:使用递归来实现。

**步骤 (递归实现)**:

  1. 从起始顶点 u 开始,标记 u 为已访问。
  2. 对于 u 的每一个邻居 v
    a. 如果 v 未被访问,则对 v 进行深度优先搜索。

应用

  • 寻找路径:判断两个顶点之间是否存在路径。
  • 检测环路:在有向图和无向图中检测是否存在环。
  • 寻找连通分量
  • 拓扑排序

Python 实现

下面我们使用邻接表来实现 BFS 和 DFS。

from collections import deque, defaultdict

class Graph:
    def __init__(self):
        # 使用邻接表表示图
        self.adj = defaultdict(list)
    
    def add_edge(self, u, v):
        """添加一条从 u 到 v 的边 (对于无向图,应双向添加)"""
        self.adj[u].append(v)

    def bfs(self, start_node):
        """从 start_node 开始进行广度优先搜索"""
        if start_node not in self.adj:
            print("起始节点不存在")
            return

        visited = set()
        queue = deque([start_node])
        visited.add(start_node)
        
        result = []
        while queue:
            node = queue.popleft()
            result.append(node)
            
            for neighbor in self.adj[node]:
                if neighbor not in visited:
                    visited.add(neighbor)
                    queue.append(neighbor)
        
        print(f"BFS 遍历结果: {result}")
        return result

    def dfs(self, start_node):
        """从 start_node 开始进行深度优先搜索"""
        if start_node not in self.adj:
            print("起始节点不存在")
            return
        
        visited = set()
        result = []
        
        def _dfs_recursive(node):
            visited.add(node)
            result.append(node)
            
            for neighbor in self.adj[node]:
                if neighbor not in visited:
                    _dfs_recursive(neighbor)
        
        _dfs_recursive(start_node)
        print(f"DFS 遍历结果: {result}")
        return result

# --- 案例测试 ---
# 创建一个有向图
g = Graph()
g.add_edge('A', 'B')
g.add_edge('A', 'C')
g.add_edge('B', 'D')
g.add_edge('B', 'E')
g.add_edge('C', 'F')

#      A
#     / \
#    B   C
#   / \   \
#  D   E   F

print("从 'A' 开始遍历:")
g.bfs('A')  # BFS 遍历结果: ['A', 'B', 'C', 'D', 'E', 'F']
g.dfs('A')  # DFS 遍历结果: ['A', 'B', 'D', 'E', 'C', 'F'] (可能因邻接表顺序而异)

总结

图的表示和遍历是整个图论算法的基石。几乎所有高级的图论算法,都是在 BFS 或 DFS 的框架上进行扩展和修改的。

本文核心要点

图的表示:邻接矩阵(O(V²) 空间,O(1) 查边)和邻接表(O(V+E) 空间,更常用)。
广度优先搜索 (BFS):使用队列,按层次遍历,常用于求解无权图的最短路径
深度优先搜索 (DFS):使用递归,一条路走到底,常用于路径查找环检测连通性问题。

掌握了这两种基本的遍历方法,你就拿到了开启图论算法大门的钥匙。在下一篇文章中,我们将学习图论中的一个经典问题——最小生成树,看看如何用最少的成本连接所有顶点。