Hello Algo 图论练习题全解:图的两种表示、BFS/DFS 遍历序与路径可达性判断
本篇文章以《Hello Algo》英文版「Graph」章节的课后练习(en/docs/chapter_graph/exercises.md)为骨架,逐题给出推导过程与标准答案,并把每道题背后的知识点映射到正文章节与仓库源码上。读者学完后,既能独立完成"邻接表/邻接矩阵互转、写 BFS/DFS 访问序、数连通分量"这类笔试题,也能上手实现"判断无向图中两个顶点是否存在路径"这一经典算法题。
1. 练习在整章中的定位
在《Hello Algo》的图(Graph)章节中,正文按如下顺序组织知识:
- 图基础概念与两种表示法:顶点/边、有向/无向、连通/非连通、加权图,以及邻接矩阵与邻接表的定义与性质;
- 图的基本操作:两种表示下增删边、增删顶点的复杂度对比表;
- 图的遍历:广度优先搜索(BFS)与深度优先搜索(DFS)的队列/递归实现与复杂度分析。
本章的 exercises.md 正是承接上述三段正文的"概念巩固 + 上机实践"。练习题强调三件事:手写两种存储结构、手推两种遍历序列、用代码判断可达性,与正文互为印证。
2. 概念题一:同一张图,两种表示
一个无向图有 4 个顶点
A, B, C, D,边为A-B, A-C, B-C, C-D。
2.1 写出它的邻接表
邻接表为每个顶点维护一个"邻居链表/列表",只记录真实存在的边。本题的邻接表为:
A: B, C
B: A, C
C: A, B, D
D: C
因为是无向图,每条边要在两端各记录一次,所以 A-B 同时出现在 A 与 B 的列表中;A 与 D 之间没有边,彼此不互为邻居。这一"边存两次"的做法在仓库的 Python 实现里看得一清二楚——graph_adjacency_list.py 中 add_edge 同时向 vet1 和 vet2 的列表追加对方,即 self.adj_list[vet1].append(vet2) 与 self.adj_list[vet2].append(vet1)。
2.2 用 0/1 填出邻接矩阵
邻接矩阵按"行列各对应一个顶点"的方式预留 个位置,M[i][j] = 1 表示顶点 、 之间有边。本题填表如下:
| A | B | C | D | |
|---|---|---|---|---|
| A | 0 | 1 | 1 | 0 |
| B | 1 | 0 | 1 | 0 |
| C | 1 | 1 | 0 | 1 |
| D | 0 | 0 | 1 | 0 |
两点值得注意,也与正文 graph.md 中"邻接矩阵的性质"完全对应:
- 主对角线为 0:简单图中顶点不能连到自己,主对角线元素无意义;
- 关于主对角线对称:
M[A][B] == M[B][A] == 1,这是无向图"边无方向"在矩阵上的直接体现。
仓库里的 graph_adjacency_matrix.py 在 add_edge 中同时执行 self.adj_mat[i][j] = 1 和 self.adj_mat[j][i] = 1,正是在维护这种对称性。
2.3 判断"A 与 D 是否直接相连",哪种表示只需查一个存储单元?
邻接矩阵。只需读一次 M[A][D](或其对称位置 M[D][A]),单次数组访问,。
在邻接表中,要确认 A 与 D 是否相连,需要遍历 A 的整条邻居列表去查找 D。邻接表在 graph_operations.md 的效率对比表中记作 (若用哈希表组织邻居可达 ),而邻接矩阵判定邻接关系恒为 。
2.4 "顶点多、边少"的稀疏图,哪种表示更省空间?
邻接表。它只为真实存在的边分配存储,空间约 ;邻接矩阵为每一对顶点都预留位置,空间恒为 。当顶点很多而边很少时(例如社交网络中大量孤立用户的场景),,邻接表的优势非常明显。这与正文效率对比表(邻接矩阵 、邻接表 )结论一致,即邻接矩阵"以空间换时间",邻接表"以时间换空间"。
3. 概念题二:写出 BFS 与 DFS 的遍历顺序
无向图顶点为
A, B, C, D, E,边为A-B, A-C, B-D, C-D, D-E,从A出发,遇到多个未访问邻居时按字母序选择。
3.1 广度优先遍历(BFS)的访问顺序
BFS 的核心理念是"由近及远、逐层扩散":先把距起点 1 条边的顶点全部访问完,再去访问距起点 2 条边的顶点。
从 A 出发:
- 访问
A,入队其邻居B, C(字母序); - 出队
B,B的邻居D未访问,入队; - 出队
C,C的邻居D已入队/已访问,跳过; - 出队
D,D的邻居E未访问,入队; - 出队
E,结束。
最终 BFS 顺序为 A, B, C, D, E——先访问距 A 一跳的 B, C,再访问更远的 D, E。
需要提醒的是,同一距离内的顶点顺序可以任意打乱。正文 graph_traversal.md 明确指出 BFS 序列不唯一:即使没有"字母序"约束,B 与 C、后续不同分支的顶点之间也完全可以互换访问次序。
3.2 递归深度优先遍历(DFS)的访问顺序
DFS 的核心理念是"一头扎到底,走投无路再回头"。结合"选未访问邻居时按字母序":
从 A 出发,优先字母序最小的邻居:
- 访问
A→ 进入B; - 访问
B→B的邻居中D未访问,进入D; - 访问
D→D的邻居E未访问,进入E; - 访问
E→E无未访问邻居,回溯到D; D的邻居中C未访问,进入C;- 访问
C→ 无未访问邻居,一路回溯,全部结束。
DFS 顺序为 A, B, D, E, C,前序路线为 A → B → D → E,回溯到 D 后再拐向 C。
官方答案给出的是
A, B, D, C, E(即先沿A → B → D → C走到底再访问E),两者差异源于"遍历D的邻居时先选谁"。它恰好印证了正文的结论:DFS 遍历序列同样不唯一,只要满足"优先深入"的原则、邻居遍历顺序可任意调整,均属合法深度优先遍历(如同树的先序/中序/后序三种不同优先级都属于 DFS)。做题时务必以题面给定的邻居选择规则为准。
3.3 为什么两种遍历都必须记录"已访问"?
因为图中存在环(cycle),例如 A-B-D-C-A 构成一个闭合回路。若不记录已访问顶点:
- BFS 中,
B与C会反复把对方(以及D)重新入队,队列永不清空; - DFS 中,递归会沿着环无限下钻,
A → B → D → C → A → B → …无法停止。
因此必须用哈希集合 visited 记录访问过的顶点,遇到已访问顶点直接跳过。仓库的 Python 实现正是如此:graph_bfs 中每次入队即打标(visited.add(adj_vet)),见 graph_bfs.py;graph_dfs 的辅助函数进入顶点时立刻 visited.add(vet),见 graph_dfs.py。两者的 visited 都使用哈希集合,从而让"查重"在 内完成。
3.4 一道练习背后的两段源码
这道手推题与仓库 codes/python/chapter_graph 下 graph_bfs.py、graph_dfs.py 的运行逻辑一致,可以用它们来验证手推结果:
- BFS 用
deque维护 FIFO 队列,while循环内"队首出队 → 记录 → 未访问邻居入队"; - DFS 用递归函数
dfs实现"进入 → 打标 → 逐邻居递归"的深入与回溯。
如果你想实际运行验证,可执行仓库内脚本(Python 版示例):
python codes/python/chapter_graph/graph_bfs.py
python codes/python/chapter_graph/graph_dfs.py
4. 概念题三:一次 BFS 能走遍全图吗?
无向图顶点为
A, B, C, D, E, F,边只有A-B, B-C, D-E。
4.1 从 A 出发的一次 BFS 能访问哪些顶点?
只能访问 A, B, C。因为从 A 出发,能到达的顶点集合由"与 A 之间存在路径"决定,而 A 所在连通块只有 A-B-C 这条链。
4.2 这次 BFS 访问到全部顶点了吗?
没有。D, E 组成另一个连通块,F 是孤立顶点(度为 0)。它们与 A 之间没有任何路径,因此从 A 发起的单次遍历永远无法触及。这对应正文 graph.md 中"非连通图:从某个顶点出发,至少有一个顶点无法到达"的定义。
4.3 按字母序扫描、遇到未访问顶点就再启动一次 BFS,起点分别是什么?图被分成几个互不相连的部分?
按字母序扫描:A 未访问 → 从 A 发起 BFS,访问 {A, B, C};接着 D 未访问 → 从 D 发起 BFS,访问 {D, E};最后 F 未访问 → 从 F 发起 BFS,访问 {F}。
- 三次 BFS 的起点分别是
A、D、F; - 该图被分成 3 个连通分量(connected components):
{A, B, C}、{D, E}、{F}。
这个"扫描所有顶点 + 对未访问者重开遍历"的过程,正是统计图中连通分量数量的通用套路,也是下一节"可达性判断"在多分量、多起点场景下的直接扩展。
5. 编程练习:判断无向图中是否存在路径
5.1 题目描述
给定一个无向图,共有 个顶点,编号为 到 。数组 edges 中每个元素 [u, v] 表示顶点 u 与 v 之间的一条无向边。另给起点 source 与终点 destination,请先根据 edges 建出邻接表,再用 BFS 或 DFS 判断从 source 能否到达 destination:存在路径返回 true,否则返回 false。图中可能含环,也可能不连通。
题面输入条件回顾:
| 条件 | 对算法的影响 |
|---|---|
| 无向边 | 每条边 [u, v] 必须在邻接表中双向添加:u 的邻居加 v,v 的邻居加 u |
| 图可能含环 | 必须用 visited 记录已访问顶点,防止绕环死循环 |
| 图可能不连通 | 只要 source 与 destination 属于同一连通分量即可达,否则 false |
| 特例 | 若 source == destination,直接返回 true(空路径也构成路径) |
5.2 题目给出的三条提示逐条解读
- 把每条无向边在两个方向上都加入邻接表——这是"无向边"对建图的要求,同仓库
GraphAdjList.add_edge的双向追加写法; - 图可能含环,必须记录已访问顶点——对应 3.3 节的分析,环使朴素遍历无法终止;
- 从 source 出发,遇到 destination 即返回
true;遍历结束仍未遇到则返回false——利用"可达即同连通分量"的性质做提前终止,无需遍历完整张图。
5.3 参考实现(Python,BFS 版)
先按邻接表建图,再用队列做 BFS:
from collections import deque
def build_adj_list(n: int, edges: list[list[int]]) -> list[list[int]]:
"""按无向边规则构建邻接表:n 个顶点,edges 中的每条边双向添加"""
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
return graph
def valid_path(n: int, edges: list[list[int]], source: int, destination: int) -> bool:
if source == destination:
return True # 起点即终点,空路径也合法
graph = build_adj_list(n, edges) # 建邻接表,O(n + m)
visited = [False] * n # 用布尔数组记录已访问(本题顶点为整数下标)
que = deque([source])
visited[source] = True
while que:
cur = que.popleft()
for nxt in graph[cur]:
if nxt == destination:
return True # 遇到终点,提前返回
if not visited[nxt]:
visited[nxt] = True # 入队即打标,防环 + 防重复入队
que.append(nxt)
return False # 遍历完整个连通分量仍未遇到终点
5.4 参考实现(Python,DFS 版)
DFS 可用递归,也可用显式栈避免深链递归栈溢出:
def valid_path_dfs(n: int, edges: list[list[int]], source: int, destination: int) -> bool:
if source == destination:
return True
graph = build_adj_list(n, edges)
visited = [False] * n
stack = [source] # 显式栈,等价于递归调用栈
visited[source] = True
while stack:
cur = stack.pop()
for nxt in graph[cur]:
if nxt == destination:
return True
if not visited[nxt]:
visited[nxt] = True
stack.append(nxt)
return False
BFS 与 DFS 在此题中都可选:题目只关心"是否可达",不要求最短路径或某种特定遍历序,因此只要在搜索过程中命中 destination 即可提前返回。若图中的连通分量包含超长链,建议优先使用显式栈/队列的迭代写法,避免递归深度过大。
5.5 复杂度分析
- 建图:创建 个空列表并遍历 条边双向写入,时间与空间均为 ;
- 遍历:BFS/DFS 中每个顶点至多入队/入栈一次,每条无向边在两端各被检查一次,故时间 ;
- 空间:邻接表 ,
visited数组 ,队列/栈最多同时容纳 个顶点,总空间 。
与正文 graph_traversal.md 中 BFS/DFS 的复杂度结论一致:无论广度还是深度,单次遍历均为 时间、 的辅助空间(此处建图额外引入 的邻接表存储)。
5.6 从"一笔可达"到"全图可达"的推广
若把题目扩展为"求整张图的所有连通分量",只需套用 4.3 节的扫描法:外层遍历所有顶点,遇到未访问者便从它发起一次 BFS/DFS,启动几次遍历就有几个连通分量;source 与 destination 可达,当且仅当它们属于同一个连通分量。这与《Hello Algo》"图的遍历"章节中 visited 只记录单次遍历、由调用方决定是否对每个顶点重开遍历的设计是一脉相承的。
6. 自查清单:做完练习后你应该能确认
做完本章练习后,可用以下清单自测掌握程度:
- 给定一张无权无向图,能否互不依赖地写出它的邻接表与邻接矩阵(并注意到矩阵的主对角线全 0 与关于主对角线对称);
- 能否在给定邻居选择规则下,手推出 BFS 与 DFS 的访问序列,并解释序列为何不唯一;
- 能否解释
visited记录在含环图中为何是遍历正确终止的必要条件; - 能否解释单次遍历的覆盖范围=一个连通分量,并写出"扫描全体顶点数连通分量"的算法;
- 能否独立实现"判断路径是否存在",正确处理无向边双向建表、环与不连通图、以及
source == destination的边界情况。
如需回顾正文以加深理解,可回到 图基础概念、图的基本操作与复杂度对比 和 图的 BFS/DFS 遍历;若想研究多语言实现细节,仓库每种语言的 chapter_graph 目录(如 codes/python/chapter_graph、codes/java/chapter_graph)均提供邻接表、邻接矩阵、BFS、DFS 四个可运行的示例程序。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00