Lecture 15: 图:概念、表示、DFS/BFS 与拓扑排序(Graphs: Concepts, Representations, Traversals & Topological Sort)(对应课程真实讲座 L25)
Lecture 15: 图:概念、表示、DFS/BFS 与拓扑排序(Graphs: Concepts, Representations, Traversals & Topological Sort)(对应课程真实讲座 L25)
概述
链表与树之后,本讲迎来本季最“万金油”的数据结构——图(graph):由顶点(vertex)与连接顶点的边(edge)构成,凡是“一堆东西之间存在两两关系”的问题几乎都能建模成图,如社交网络、路网、课程先修、网页链接。全讲覆盖三块:图的基本术语与“口味”(有向/无向、带权/无权、连通性)、三种表示法(邻接矩阵、邻接表、边表)的取舍,以及两种遍历(DFS、BFS)与只适用于有向无环图的拓扑排序(topological sort)。官方对应:L25(2026 年 8 月 5 日,周三,Graphs;本讲内容被官方列为期末考试重点,而 A6 之后没有图作业,主要靠练习与讲义)。
核心概念与算法原理
什么是图? 图是“节点式(linked)结构”:一堆顶点 + 连接它们的边。它和链表、树同宗同源——链表可以看作“每条边只指向下一个节点”的特殊图,树是“无环、有单一根、边有层级方向”的特殊图。但图挣脱了两重束缚:图不一定只有一个入口(链表有头节点、树有根节点,图可以有很多起点);图的关系不必有顺序或层级(可以不是 next/child 关系),而且可以有环(cycle)——这是树严格禁止的。顶点可以带编号,边可以带权重(weight,如距离、费用、耗时),可以带方向。
术语表(官方明确:期末考试可能直接考术语,务必熟记):
- 路径(path):一串顶点,相邻两个之间都有边相连;路径长度 = 经过的边数。
- 环/回路(cycle):起点与终点相同的路径;自环(loop) 是一条从某顶点指向它自己的边。
- 相邻/邻居(adjacent/neighbor):两个顶点之间有边。
- 可达(reachable):存在从 A 到 B 的路径,则称 B 从 A 可达。
- 连通(connected):无向图中任意两顶点互相可达;否则称不连通(disconnected)。有向图中“任意两顶点互相可达”则称强连通(strongly connected)。
- 完全图(complete graph):任意两顶点之间都有边。
- 稠密(dense)与稀疏(sparse):边的数量相对于可能的最大边数(n 个顶点最多约 n²/2 条无向边)而言是“很多”还是“很少”。
- 有向(directed):边是单行箭头;无向(undirected):边双向通行。
- 带权(weighted):边上带数值;无权(unweighted):只有“有没有边”。
三种表示法对比。设 n 为顶点数、E 为边数、deg(v) 为顶点 v 的度数(邻居个数):
| 表示法 | 空间 | 查“u、v 是否相邻” | 列 u 的所有邻居 | 适用场景 |
|---|---|---|---|---|
| 邻接矩阵 adjacency matrix(n×n 二维数组,格子存 0/1 或权重) | O(n²) | O(1)(直接看格子) | O(n)(扫一整行) | 稠密图;查边极频繁 |
| 邻接表 adjacency list(每顶点一条邻居列表) | O(n + E)(稀疏图近似 O(n)) | O(deg)(需在列表里找) | O(deg(v)) | 稀疏图;遍历邻居极频繁 |
| 边表 edge list(把所有 (u,v,w) 三元组存进一个列表) | O(E) | O(E) | O(E) | 需要按边整体处理的算法(如 Kruskal 求最小生成树) |
邻接矩阵还有个“对称浪费”:无向图的矩阵沿主对角线对称,每条边被存了两遍。官方的结论一句话:稀疏图用邻接表,稠密图用邻接矩阵;而遍历类算法(DFS/BFS/最短路径)几乎总在“列邻居”,所以邻接表是图算法的主场。官方课程自带 Stanford Graph 类(BasicGraph),本笔记一律用标准库自行实现同一思想。
最小生成树(MST)一句话带过:在带权无向图中找一棵连接全部顶点、总边权最小的树,经典算法有 Prim 与 Kruskal;官方说明本季不考,留作面试与 CS161 储备。
无权图最短路径一句话带过:BFS 天然给出无权图中“边数最少 / 换乘最少”的路径(逐层推进保证首次到达即最短);而 DFS 只保证“若存在路径则能找到一条”,不保证最短(详细论证见 L26 之后的最短路径章节)。
深度优先搜索(DFS)。递归版逻辑极简:访问当前顶点并打上 visited 标记,然后依次对每个未访问的邻居递归深入——撞到死胡同就回溯。正是“一条道走到黑、撞墙再回头”。也可以用显式栈写成迭代版,效果等价。visited 标记不可或缺:图有环,不标记就会在环里无限打转。下图是示例图的 DFS 访问顺序(从 0 出发,按邻居顺序):
DFS 访问序(数字是第几步):
0
/ \
1 2 0(1) → 1(2) → 3(3) → 2(4) → 4(5) → 5(6)
| | \ 输出: 0 1 3 2 4 5
3 - 2 4
\ |
\ 5
广度优先搜索(BFS)。用队列逐层扩散:先把起点入队并标记;每次出队一个顶点 u,把 u 的所有未访问邻居标记并入队。因为严格按“层”推进,第一次访问到某个顶点时的路径就是无权图最短路径。下图是同一张图的 BFS 分层示意(从 0 出发):
第 0 层: 0
第 1 层: 1, 2 (0 的邻居)
第 2 层: 3, 4 (1、2 的邻居,且未访问)
第 3 层: 5 (3、4 的邻居)
BFS 输出: 0 1 2 3 4 5
拓扑排序(topological sort)。问题:有向图中,给所有顶点排一个线性顺序,要求“若有边 u→v(u 是 v 的前置/先修),u 必须排在 v 前面”。经典应用:课程表(边 = “x 是 y 的先修课”)、任务依赖(“先买面粉才能烤饼干”)、编译依赖。要点:
- 拓扑序不必是图中的一条路径——它只是把所有“箭头”整理成“一律朝右”的排列。
- 只有有向无环图(DAG)才有拓扑序:一旦有环(如 A 依赖 B、B 又依赖 A),就永远排不出谁先谁后。官方期末考试口径明确:有环 ⇔ 无拓扑序(两者互为充要)。
- 合法拓扑序通常不止一个(只要满足所有前置约束即可),Kahn 算法只输出其中某一个。
- 实现思路有二:Kahn 入度法(本讲代码采用):不断挑出“入度为 0 = 无未满足前置”的顶点输出,并抹掉它发出的边(把邻居入度减 1),周而复始;若最终输出的顶点数少于总数,说明图中存在环。另一种是 DFS 完成序倒序:递归 DFS 记录每个顶点“访问完毕”的顺序,逆序即一个拓扑序(补充材料提及,本季不作要求)。
Kahn 过程示意(课程先修图:A 是 B、C 的先修,B、C 是 D 的先修,C 还是 E 的先修):
初始入度: A:0 B:1 C:1 D:2 E:1
出队 A → 输出 A,B、C 入度减 1 → 都变 0,入队
出队 B → 输出 B,D 入度 2→1
出队 C → 输出 C,D 入度 1→0、E 入度 1→0,D、E 入队
出队 D、E → 输出 D、E
拓扑序: A B C D E (若图中存在环,队列会提前耗尽、输出不满 n 个)
代码示例与实现详解
示例 1:邻接表建图 + 递归 DFS + 队列 BFS。(用 vector<vector<int>> 直接当邻接表,顶点编号 0..n−1;如需带权或标签,可换成 struct Edge { int to; int weight; }; 的向量。)
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
// 无权无向图:adj[u] 是 u 的所有邻居
struct Graph {
int n; // 顶点数
vector<vector<int>> adj; // 邻接表
explicit Graph(int vertices) : n(vertices), adj(vertices) {}
void addUndirectedEdge(int u, int v) {
adj[u].push_back(v);
adj[v].push_back(u); // 无向边 = 两条有向边
}
};
// DFS(递归):访问 u,再逐个深入未访问的邻居
void dfs(int u, const Graph& g, vector<bool>& visited) {
visited[u] = true;
cout << u << " ";
for (int v : g.adj[u]) {
if (!visited[v]) dfs(v, g, visited);
}
}
// BFS(队列):从 start 逐层扩散
void bfs(int start, const Graph& g) {
vector<bool> visited(g.n, false);
queue<int> q;
visited[start] = true; // 入队即标记,防止重复入队
q.push(start);
while (!q.empty()) {
int u = q.front();
q.pop();
cout << u << " ";
for (int v : g.adj[u]) {
if (!visited[v]) {
visited[v] = true;
q.push(v);
}
}
}
}
int main() {
Graph g(6);
g.addUndirectedEdge(0, 1);
g.addUndirectedEdge(0, 2);
g.addUndirectedEdge(1, 3);
g.addUndirectedEdge(2, 3);
g.addUndirectedEdge(2, 4);
g.addUndirectedEdge(3, 5);
g.addUndirectedEdge(4, 5);
cout << "DFS 从 0 出发: ";
vector<bool> visited(g.n, false);
dfs(0, g, visited);
cout << "\nBFS 从 0 出发: ";
bfs(0, g);
cout << "\n";
return 0;
}
【代码做什么】 main 先搭出上节图示的 6 顶点无向图,然后分别从 0 出发跑 DFS 与 BFS。预期输出:DFS 打出 0 1 3 2 4 5,BFS 打出 0 1 2 3 4 5——同一张图、同一入口,两种策略给出截然不同的访问顺序,是体会“深度 vs 广度”的最佳实验。
【实现机制解说】 ① DFS 把“访问过”这一事实通过引用传递的 visited 传给所有递归分支共享,否则每个分支各持一份拷贝,标记等于白做;这是图版“传引用”的典型场景。② BFS 的标记时机是入队时而非出队时:若出队才标记,同一顶点可能被多个邻居重复入队,队列膨胀、甚至死循环。③ 想把 DFS 改成迭代版:把递归调用栈换成显式 std::stack,压栈前标记即可,访问顺序会略有不同但仍是合法 DFS。④ 若图不连通,单次 dfs/bfs 只能访问起点所在连通分量;外层再包一层“对所有未访问顶点依次启动遍历”就能覆盖全图。
示例 2:Kahn 拓扑排序(含环检测)。
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
// 对 n 个顶点(0..n-1)的有向图做 Kahn 拓扑排序。
// 成功返回 true,order 装一个合法拓扑序;发现环返回 false。
bool topologicalSort(int n, const vector<vector<int>>& adj,
vector<int>& order) {
vector<int> indegree(n, 0);
for (int u = 0; u < n; ++u)
for (int v : adj[u]) ++indegree[v]; // 统计每个顶点的入度
queue<int> ready; // 入度为 0 = “前置全部满足”
for (int u = 0; u < n; ++u)
if (indegree[u] == 0) ready.push(u);
while (!ready.empty()) {
int u = ready.front();
ready.pop();
order.push_back(u);
for (int v : adj[u]) { // “解除”u 带来的一个前置约束
if (--indegree[v] == 0) ready.push(v);
}
}
return order.size() == n; // 输出不满 n 个 ⇔ 存在环
}
int main() {
// 测试 1:DAG,边 A→B、A→C、B→D、C→D、C→E(顶点编号 A=0 … E=4)
vector<vector<int>> g1(5);
g1[0] = {1, 2};
g1[1] = {3};
g1[2] = {3, 4};
vector<int> order1;
cout << "测试1(DAG): "
<< (topologicalSort(5, g1, order1) ? "成功" : "失败(有环)") << " → ";
for (int x : order1) cout << char('A' + x) << " ";
cout << "\n";
// 测试 2:加一条边 E→A,构成环 A→C→E→A,应检测失败
vector<vector<int>> g2 = g1;
g2[4].push_back(0);
vector<int> order2;
cout << "测试2(有环): "
<< (topologicalSort(5, g2, order2) ? "成功" : "失败(有环)") << "\n";
return 0;
}
【代码做什么】 测试 1 的图正是核心概念里走查过的先修图,期望输出一个合法拓扑序(A B C D E 或 A C B D E 等,取决于队列顺序——注意这里显式展示了“合法拓扑序不唯一”);测试 2 在 E 与 A 之间补一条反向边造出环 A→C→E→A,Kahn 结束后队列必然提前空掉,order.size() != n 触发失败分支,打印“失败(有环)”。
【实现机制解说】 ① 入度统计只需一遍全图扫描(O(n+E));“入度为 0”的含义是“所有指向我的前置都已被处理完”,这正是拓扑序要求的精确翻译。② 用队列装“就绪顶点”而不是数组,是因为队列入/出队都是 O(1),且天然满足“谁先就绪谁先走”;换个容器(栈/优先队列)只会改变输出的具体顺序,不改变合法性。③ 环检测零成本:有环时环上每个顶点的入度永远减不到 0,队列耗尽后 order 缺员,比较 size 即可判定——官方 L25/L27 都强调“有环 ⇔ 无拓扑序”,此实现把判定落到了实处。④ 若把队列换成随机选点,就能随机生成一个合法拓扑序(官方练习之一)。
复杂度分析
设 n = 顶点数、E = 边数。对邻接表表示(示例代码采用):
| 操作 | 时间复杂度 | 空间复杂度 | 原因 |
|---|---|---|---|
| 建图(加一条边) | O(1)(无向为两次 push_back) | O(n + E) | 邻接表每顶点一条列表 |
| DFS 遍历 | O(n + E) | 递归栈最深 O(n) | 每个顶点标记一次、每条边看一次 |
| BFS 遍历 | O(n + E) | 队列最多 O(n) | 每顶点入队出队各一次 |
| Kahn 拓扑排序 | O(n + E) | O(n)(入度表 + 队列 + 结果) | 每条边被扫描两次(统计入度 + 解除约束) |
| 邻接矩阵:查相邻 | O(1) | O(n²) | 直接读格子 |
| 邻接矩阵:列邻居 | O(n) | O(n²) | 必须扫整行 |
DFS/BFS 的 O(n+E) 来自“每个顶点至多标记一次、每条边至多被它的两个端点各遍历一次”;对稀疏图(E 远小于 n²)这比矩阵版的 O(n²) 划算得多——这也是图算法偏爱邻接表的原因。
关键要点
- 链表、树都是特殊图;图的自由在于多入口、无层级、可有环——遍历时必须用 visited 标记防死循环。
- 表示法按密度选:稀疏用邻接表(O(n+E) 空间、列邻居快),稠密用邻接矩阵(查边 O(1)),边表留给“按边处理”的算法。
- DFS 用递归(或显式栈)一探到底,适合判断连通性、找任意路径;BFS 用队列逐层扩散,无权图首次到达即最短(最少边/最少换乘)。
- 拓扑排序只对 DAG 有效,“有环 ⇔ 无拓扑序”;Kahn 法用入度表 + 队列,输出数不足 n 即判定有环。
- 术语(路径/环/连通/强连通/稠密/稀疏/带权/有向)与两种表示法的取舍是官方点名的期末考试范围。
常见陷阱与注意事项
- 遍历忘打 visited 标记:图有环时 DFS/BFS 无限递归或死循环。规避:入栈/入队/递归前先标记,且标记须在所有分支间共享(传引用)。
- BFS 在出队时才标记:同一顶点被多个邻居重复入队。规避:入队瞬间就标记。
- 对无向图跑 Kahn 拓扑排序:无向图每条边双向计数,几乎任何图都“有环”,结果无意义。规避:拓扑排序只用于有向图。
- DFS 求无权图最短路径:DFS 找到的只是“某一条”路径,不保证最短。规避:无权最短路径请用 BFS。
- 拓扑排序把结果直接当路径打印:拓扑序只是一般性排列,顶点之间未必相邻,别误读成一条通路。
- 邻接矩阵忘了无向图存两遍:只填上三角会导致查边不对称、遍历漏邻居。规避:要么存两遍,要么查询时按对称规则处理。
- 自环与平行边没想清楚:自环(u→u)在拓扑排序里直接构成环、在 BFS 里会让自己“已访问”而跳过,建模时要想清楚它们是否合法。
- 不连通图只遍历一次:从 0 出发的 DFS/BFS 覆盖不到另一分量。规避:需要全图遍历时外层循环启动所有未访问顶点。
思考题(带答案)
问题 1:给定任意一张无向图和一个起点,为什么 BFS 第一次“碰到”某顶点时经过的路径必然边数最少? 答案:BFS 严格按层扩散:第 k 层的顶点必然是“经过恰好 k 条边、且首次可达”的顶点。若存在一条更短的边数为 m < k 的路径,该顶点应出现在第 m 层,矛盾。DFS 则可能顺着一条长路先到达,故不保证最短。
问题 2:为什么“有环 ⇔ 无拓扑序”?请用环上的顶点论证。 答案:若图中有环 u₁→u₂→…→uₖ→u₁,环上每个顶点都是“别人的前置”:u₁ 要求排在 uₖ 后面、uₖ 又要求排在 uₖ₋₁ 后面……传递下去形成 u₁ 必须排在 u₁ 后面的矛盾,任何线性顺序都无法满足。反之若无环(DAG),Kahn 算法总能不断找到入度为 0 的顶点并推进,最终排完所有顶点。
问题 3:邻接表与邻接矩阵在“检查 u、v 是否相邻”上的代价分别是多少?这对“边查询密集”的应用意味着什么? 答案:邻接矩阵 O(1)(直接看 matrix[u][v]),邻接表需在 adj[u] 里线性找 v、代价 O(deg(u))。因此若算法主体是海量“任意两点是否相邻”查询(如部分动态规划),矩阵更合适;而 DFS/BFS/Dijkstra 这类“拿到一个点就扫它所有邻居”的算法,邻接表每步只花 O(deg),整体 O(n+E),是更优选择——这正对应官方“稠密图用矩阵、稀疏图用邻接表”的结论。
