图论算法:邻接表与遍历
图论算法入门:从邻接表到图的遍历
图是计算机科学中最强大、最灵活的数据结构之一,它能表示网络、社交关系、地图路径等几乎所有存在“关联”的场景。要驾驭图算法,首先要解决两个基础问题:如何存储图,以及如何系统地访问图中的节点。本文将带你掌握最实用的存储方式——邻接表,以及两种核心遍历策略——深度优先搜索(DFS)和广度优先搜索(BFS)。
为什么选择邻接表?理解图的存储
存储图的方式主要有两种:邻接矩阵和邻接表。邻接矩阵用 V x V 的二维数组表达边,虽然查询任意两点是否相连只需 O(1) 的时间,但对于稀疏图(边的数量远小于节点数量的平方),它会浪费大量空间。邻接表正是为解决这一痛点而生的,它只为实际存在的边分配空间,空间复杂度为 O(V + E),是大多数图算法的默认存储结构。
邻接表的底层结构
邻接表通常由一个数组或列表组成,其中每个位置对应图中的一个节点。每个元素又是一个链表、数组或动态列表,用于存放从该节点出发直达的所有邻居节点。
无向图的邻接表:边 (u, v) 会使 u 的列表中出现 v,v 的列表中出现 u。
有向图的邻接表:仅在有向边 u -> v 的方向上,将 v 加入 u 的邻居列表。
带权图的邻接表:列表中的元素不再只是目标节点编号,而是存储(目标节点, 权重)这样的对。
代码实现:构建邻接表
以下示例用Python展现,思路可以轻松迁移到任何语言。
# 假设节点编号从0到V-1
class Graph:
def __init__(self, vertices):
self.V = vertices
# 初始化一个列表,其中有V个空列表
self.adj = [[] for _ in range(vertices)]
# 添加无向边
def add_edge(self, u, v):
self.adj[u].append(v)
self.adj[v].append(u)
# 添加有向边(如果有向)
def add_directed_edge(self, u, v):
self.adj[u].append(v)
# 打印邻接表,方便查看
def print_graph(self):
for i in range(self.V):
print(f"顶点 {i}: {self.adj[i]}")
对于一个拥有5个顶点和边(0-1, 0-4, 1-2, 1-3, 1-4, 2-3, 3-4)的无向图,输出的邻接表直观展示了每个节点的邻居:
顶点 0: [1, 4]
顶点 1: [0, 2, 3, 4]
顶点 2: [1, 3]
顶点 3: [1, 2, 4]
顶点 4: [0, 1, 3]
不同语言的邻接表实现
邻接表思想不变,但不同语言有各自的惯用方式:
- C++:
vector<vector<int>> adj;,或需要权重时用vector<vector<pair<int, int>>> adj; - Java:
List<List<Integer>> adj = new ArrayList<>();,并逐个初始化内部ArrayList - JavaScript:可以用数组的数组,或使用
Map键为节点、值为邻居数组,对非连续编号友好
无论哪种语言,核心都是将图映射为“节点->邻居列表”的结构。
图的遍历:访问节点的艺术
有了存储结构,下一个自然的问题是:如何不重复、不遗漏地访问图中所有节点?这就是图的遍历,也是路径查找、连通性判断、拓扑排序等复杂算法的基石。我们通常有两种遍历思想:深度优先搜索(DFS)和广度优先搜索(BFS)。
深度优先搜索(DFS):“一条路走到黑”
DFS的策略是从起点出发,沿着一条路径尽可能深入,直到无法继续,然后回溯到上一个分叉点,继续探索其他分支。就像走迷宫时始终贴着左手墙走,直到碰壁再回头换另一条路。
递归实现最契合DFS的自然回溯特性:
def dfs_recursive(graph, start, visited=None):
if visited is None:
visited = set()
visited.add(start)
print(start, end=' ') # 访问节点
for neighbor in graph.adj[start]:
if neighbor not in visited:
dfs_recursive(graph, neighbor, visited)
return visited
迭代实现使用显式的栈来模拟递归过程(需要手动管理访问状态和回溯):
def dfs_iterative(graph, start):
visited = set()
stack = [start]
while stack:
vertex = stack.pop()
if vertex not in visited:
visited.add(vertex)
print(vertex, end=' ')
# 注意:为了保持顺序一致,可以将邻居逆序压栈,或按需调整
for neighbor in reversed(graph.adj[vertex]):
if neighbor not in visited:
stack.append(neighbor)
DFS的时间复杂度为 O(V + E),因为它会访问每个顶点一次,并遍历所有顶点的邻接边总计 2E 次(无向图)或 E 次(有向图)。空间复杂度取决于递归调用栈深度或显式栈的大小,最坏情况下可能达到 O(V)。
广度优先搜索(BFS):“逐层扩展”
BFS则采用完全不同的策略:从起点开始,先访问所有距离为1的邻居,再访问距离为2的邻居,以此类推。这种“层次化”的进展天然适合寻找最短路径(在无权图中)或按层处理信息。BFS的核心数据结构是队列。
from collections import deque
def bfs(graph, start):
visited = {start}
queue = deque([start])
while queue:
vertex = queue.popleft() # 取出队首
print(vertex, end=' ')
for neighbor in graph.adj[vertex]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
BFS同样具有 O(V + E) 的时间复杂度,每个顶点和每条边都被处理一次。空间方面,队列中可能同时保存最多的一层节点,这在宽而浅的图中可能接近 O(V)。
DFS vs BFS:场景选择指南
| 特征 | DFS | BFS |
|---|---|---|
| 核心结构 | 栈(隐式或显式) | 队列 |
| 遍历顺序 | 深度优先,路径型 | 层次型,距离型 |
| 空间需求 | 递归调用栈深度 O(h) | 最宽层宽度 O(w) |
| 典型应用 | 连通分量、拓扑排序、寻找路径(任意) | 无权图最短路径、层序遍历、广播 |
| 实现难度 | 递归简单,迭代需注意顺序 | 迭代直观 |
选择建议:
- 需要找最短路径(边数最少)时,用 BFS。
- 需要遍历所有可能性、回溯搜索(如排列组合、迷宫所有路径)时,DFS 递归写法更简洁。
- 检测环路、拓扑排序、强连通分量等问题,DFS 的性质使其更易实现。
遍历算法的实战深度:不止于打印
仅仅遍历并打印节点只能算是入门。真正的图算法中,DFS 和 BFS 是作为骨架嵌入更复杂逻辑中。以下是两个关键扩展技巧:
记录遍历层级与父节点(BFS 变体)
在 BFS 过程中,我们可以顺带记录每个节点到起点的距离,以及它在搜索树中的父节点,从而重建最短路径。
def bfs_shortest_path(graph, start, target):
visited = {start}
queue = deque([start])
parent = {start: None} # 父节点字典
distance = {start: 0} # 距离字典
while queue:
current = queue.popleft()
if current == target:
break
for neighbor in graph.adj[current]:
if neighbor not in visited:
visited.add(neighbor)
parent[neighbor] = current
distance[neighbor] = distance[current] + 1
queue.append(neighbor)
# 重建路径
path = []
if target in parent:
step = target
while step is not None:
path.append(step)
step = parent[step]
path.reverse()
return path, distance.get(target, -1)
检测连通分量(DFS / BFS 均可)
一个图可能由多个互不连通的子图组成。我们可以通过多次调用遍历算法,标记每个顶点所属的连通分量。
def connected_components(graph):
visited = set()
components = []
for v in range(graph.V):
if v not in visited:
# 开始一个新的遍历,收集该分量所有节点
comp = set()
# 使用 DFS 或 BFS 均可,这里用 DFS 递归收集
def dfs_collect(node):
visited.add(node)
comp.add(node)
for nb in graph.adj[node]:
if nb not in visited:
dfs_collect(nb)
dfs_collect(v)
components.append(comp)
return components
小结:从工具到思维
邻接表与两种遍历算法,是图论世界的通用语言。当你能熟练使用邻接表表示任意图,并能灵活运用 DFS 的“纵深探索”和 BFS 的“层次推进”,就获得了解决绝大多数图问题的基本框架。后续无论是学习最小生成树、最短路径(Dijkstra、Floyd),还是网络流、强连通分量,你都会发现它们不过是这两种核心思想在各种约束下的精妙变形。
将下面的练习作为思维检验:
- 用邻接表实现一个有向图,并尝试用 DFS 检测有向图中是否存在环。
- 使用 BFS 解决“单词阶梯”问题:从一个单词变换为另一个,每次只能改变一个字母,且中间词必须在给定词典中,求最短变换路径长度。
牢固的基础将让你在图算法的道路上越走越远。