7.3 最小生成树 & 拓扑排序

7364 字
37 分钟
7.3 最小生成树 & 拓扑排序

7.3 最小生成树 & 拓扑排序#

一句话理解:最小生成树 (MST) 解决的是”用最小总代价把所有节点连通”的问题;拓扑排序解决的是”给有向无环图 (DAG) 排出一个合法执行顺序”的问题。两者是图论中最经典的两类算法,面试高频、游戏常用。


7.3.1 最小生成树 —— 概念与直觉#

什么是生成树?#

原图 (5个节点, 7条边): 一棵生成树 (5个节点, 4条边):
1 ---3--- 2 1 ---3--- 2
/| |\ |
7 1 6 2 6
/ | | \ |
0 +----5----+ 4 0 --1-- 3 ---2--- 4
\ | /
4 2 3
\ | /
3-+------+
生成树 = 包含所有 V 个节点、恰好 V-1 条边的连通无环子图
一个图可以有多棵不同的生成树
概念定义
生成树包含图中所有顶点,恰好 V-1 条边,连通且无环
最小生成树 (MST)所有生成树中边权之和最小的那棵
存在条件图必须连通(否则是最小生成森林
唯一性如果所有边权互不相同,MST 唯一;否则可能有多棵

生活类比#

问题: 城市之间要修公路, 保证所有城市互通, 总里程最短
城市 = 节点
候选公路 = 边 (带权重 = 里程)
MST = 用最少总里程连通所有城市
为什么不需要 "最短路径"?
→ 最短路径关心的是 A→B 的最优路线
→ MST 关心的是 "让所有节点连通" 的最小总代价
→ 两个完全不同的问题!

7.3.2 Kruskal 算法 —— 贪心选边 + 并查集#

核心思想#

Kruskal 是一个贪心算法:把所有边按权重从小到大排序,依次选边。如果选的边不会形成环(两端点不在同一个连通分量内),就加入 MST。用并查集快速判断连通性。

Kruskal 算法流程:
1. 所有边排序: [1, 2, 2, 3, 3, 4, 5, 6, 7]
2. 依次尝试每条边:
边(3,1) w=1 → 3和1不连通 → 选 ✓ 并查集合并 {1,3}
边(0,3) w=2 → 0和3不连通 → 选 ✓ 并查集合并 {0,1,3}
边(2,4) w=2 → 2和4不连通 → 选 ✓ 并查集合并 {2,4}
边(1,2) w=3 → 1和2不连通 → 选 ✓ 并查集合并 {0,1,2,3,4}
已选 4 条边 = V-1 → MST 完成!
剩余边 [3,4,5,6,7] 全部跳过 (会形成环)
MST 总权重 = 1 + 2 + 2 + 3 = 8

并查集回顾#

Kruskal 的核心依赖是并查集 (Union-Find)——判断两个节点是否已连通、合并两个连通分量。这里给出精简实现,详细分析见第 9 章:

class UnionFind {
std::vector<int> _parent, _rank;
public:
UnionFind(int n) : _parent(n), _rank(n, 0) {
std::iota(_parent.begin(), _parent.end(), 0); // parent[i] = i
}
int find(int x) {
if (_parent[x] != x) {
_parent[x] = find(_parent[x]); // 路径压缩
}
return _parent[x];
}
bool unite(int x, int y) {
int rx = find(x), ry = find(y);
if (rx == ry) return false; // 已连通,不合并
// 按秩合并
if (_rank[rx] < _rank[ry]) std::swap(rx, ry);
_parent[ry] = rx;
if (_rank[rx] == _rank[ry]) ++_rank[rx];
return true; // 合并成功
}
bool connected(int x, int y) { return find(x) == find(y); }
};

Kruskal C++ 实现#

#include <vector>
#include <algorithm>
#include <numeric>
struct Edge {
int u, v, weight;
bool operator<(const Edge& other) const { return weight < other.weight; }
};
// Kruskal —— 返回 {MST 的总权重, MST 的边集}
// 如果图不连通, 返回的边数 < V-1
std::pair<int, std::vector<Edge>> kruskal(int n, std::vector<Edge>& edges) {
// 1. 按权重排序
std::sort(edges.begin(), edges.end());
// 2. 初始化并查集
UnionFind uf(n);
int total_weight = 0;
std::vector<Edge> mst_edges;
// 3. 贪心选边
for (const auto& e : edges) {
if (uf.unite(e.u, e.v)) {
// 两端点不在同一连通分量 → 选这条边
total_weight += e.weight;
mst_edges.push_back(e);
// 优化: 选够 V-1 条边就可以停了
if (static_cast<int>(mst_edges.size()) == n - 1) break;
}
}
return {total_weight, mst_edges};
}

图解#

graph LR A["0"] ---|7| B["1"] A ---|4| D["3"] B ---|1| D B ---|3| C["2"] B ---|5| D C ---|6| D C ---|2| E["4"] D ---|2| A style A fill:#2d6a4f,stroke:#40916c,color:white style B fill:#2d6a4f,stroke:#40916c,color:white style C fill:#2d6a4f,stroke:#40916c,color:white style D fill:#2d6a4f,stroke:#40916c,color:white style E fill:#2d6a4f,stroke:#40916c,color:white
排序后的边: (1,3,1) (0,3,2) (2,4,2) (1,2,3) (0,3,4) (1,3,5) (2,3,6) (0,1,7)
✅选 ✅选 ✅选 ✅选 ❌环 停止
MST 边: {1-3, 0-3, 2-4, 1-2}, 总权重 = 1+2+2+3 = 8

Kruskal 的特性#

特性说明
时间O(E log E) ← 排序主导。并查集操作近 O(1)
空间O(V + E)
适用稀疏图(E 较小时排序快)
正确性切割性质 (Cut Property):任意切割中权重最小的边一定在 MST 中
额外能力可以求最小生成森林(不连通图自动处理)

💡 Kruskal 的正确性来自贪心的切割性质:每次选最小的不形成环的边,等价于每次在某个切割中选最小边。这可以用反证法严格证明。


7.3.3 Prim 算法 —— 贪心扩点 + 最小堆#

核心思想#

Prim 从一个节点出发,不断把”与已选集合相邻的最小边”加入 MST。像 Dijkstra 一样用最小堆维护候选边。

Prim 算法流程 (从节点 0 出发):
初始: MST = {0}, 候选边 = 0 的所有邻边
Step 1: 候选边中最小的 = (0,3,2) → 选 ✓
MST = {0, 3}, 加入 3 的邻边到候选
Step 2: 候选边中最小的 = (3,1,1) → 选 ✓
MST = {0, 3, 1}, 加入 1 的邻边
Step 3: 候选边中最小的 = (1,2,3) → 选 ✓
MST = {0, 3, 1, 2}, 加入 2 的邻边
Step 4: 候选边中最小的 = (2,4,2) → 选 ✓
MST = {0, 3, 1, 2, 4}
V-1 = 4 条边选完 → MST 完成!
Prim 的思路: "从已有的岛屿出发, 每次找最便宜的桥连到新节点"

C++ 实现#

#include <vector>
#include <queue>
// Prim —— 邻接表版, 返回 MST 总权重
// graph[u] = {{v, w}, ...}
int prim(const std::vector<std::vector<std::pair<int,int>>>& graph) {
int n = graph.size();
std::vector<bool> in_mst(n, false);
// 最小堆: {边权, 目标节点}
std::priority_queue<std::pair<int,int>,
std::vector<std::pair<int,int>>,
std::greater<>> pq;
int total_weight = 0;
int edges_added = 0;
// 从节点 0 开始
in_mst[0] = true;
for (auto [v, w] : graph[0]) {
pq.push({w, v});
}
while (!pq.empty() && edges_added < n - 1) {
auto [w, u] = pq.top();
pq.pop();
if (in_mst[u]) continue; // 已在 MST 中, 跳过
// 纳入 MST
in_mst[u] = true;
total_weight += w;
++edges_added;
// 把 u 的邻边加入候选
for (auto [v, wt] : graph[u]) {
if (!in_mst[v]) {
pq.push({wt, v});
}
}
}
return total_weight;
}

Prim 的特性#

特性说明
时间O((V + E) log V)(最小堆版)
空间O(V + E)
适用稠密图(E 较大时优于 Kruskal 的排序)
与 Dijkstra 的关系几乎完全相同的代码!只是 Dijkstra 松弛 dist[u]+w,Prim 直接用 w

7.3.4 Kruskal vs Prim#

维度KruskalPrim
思路出发,贪心选最小边出发,贪心扩展最近邻
数据结构排序 + 并查集最小堆
时间O(E log E)O((V+E) log V)
稀疏图 (E ≈ V)更优(排序 O(V log V))O(V log V) 差不多
稠密图 (E ≈ V²)O(V² log V)(排序慢)更优 O(V² log V),但可用邻接矩阵版 O(V²)
实现难度需要并查集只需最小堆 (STL 现成)
断开的图自动产生最小生成森林只连通起点所在分量
面试选择边列表输入时首选邻接表输入时方便
选择策略:
题目给你 边列表 (edges = [[u,v,w], ...]) → Kruskal 更自然
题目给你 邻接表/矩阵 → Prim 更自然
不确定时: Kruskal 代码更短、更好写 (面试推荐)

💡 面试中 Kruskal 出现频率更高,因为 MST 面试题通常给边列表,且 Kruskal 的”排序 + 并查集”逻辑更清晰。但一定要知道 Prim 的存在和适用场景。

Prim vs Dijkstra 代码对比#

这两个算法的代码几乎一模一样,但含义不同:

// Dijkstra: 松弛的是 "从起点到 v 的总距离"
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w; // 累加路径代价
pq.push({dist[v], v});
}
// Prim: 关心的是 "连接 v 到 MST 的最小边权"
if (w < key[v]) { // 只看单条边的权重
key[v] = w;
pq.push({w, v});
}
直觉区别:
Dijkstra: "从起点到你最近要多远?" → 路径总代价
Prim: "把你接入已有网络最便宜要多少?" → 单边代价

7.3.5 拓扑排序 —— 概念与直觉#

什么是拓扑排序?#

拓扑排序是对有向无环图 (DAG) 的顶点排列,使得对每条有向边 u→v,u 在排列中都在 v 前面。

课程依赖图 (DAG):
高数 → 线代 → 机器学习
高数 → 概率论 → 机器学习
编程基础 → 数据结构 → 算法
合法的拓扑序:
[编程基础, 高数, 数据结构, 线代, 概率论, 算法, 机器学习]
[高数, 编程基础, 线代, 概率论, 数据结构, 机器学习, 算法]
... (多种合法排列)
不合法:
[机器学习, 高数, ...] ← 机器学习依赖高数, 不能排在前面!
如果图中有环:
A → B → C → A ← 循环依赖, 不存在拓扑序!
概念说明
DAG有向无环图 (Directed Acyclic Graph)
拓扑排序DAG 的线性排列,满足所有依赖方向
唯一性不唯一(除非 DAG 是一条链)
存在条件当且仅当图是 DAG(无环)
入度 (In-degree)指向该节点的边数。入度为 0 = 无前置依赖
出度 (Out-degree)从该节点出发的边数。出度为 0 = 无后续依赖

💡 拓扑排序 = 无环判定 + 依赖排序:如果拓扑排序成功,图是 DAG;如果失败(排出的节点数 < V),图中有环。


7.3.6 Kahn 算法 —— BFS + 入度#

核心思想#

Kahn 算法是BFS 式的拓扑排序:

  1. 计算所有节点的入度
  2. 把所有入度为 0 的节点入队(没有前置依赖的先执行)
  3. 每次出队一个节点,把它的所有邻居入度 -1
  4. 如果邻居入度变为 0,入队
  5. 如果最终排出的节点数 = V,则成功;否则有环
图: 入度:
5 → 2 0: 0
5 → 0 1: 0
4 → 0 2: 2 (来自 5,3)
4 → 1 3: 1 (来自 4)
2 → 3 4: 0
3 → 1 5: 0
Step 1: 入度=0 的入队: [5, 4, 1, 0] (多个合法起点)
Step 2: 出队 5 → 排列=[5]
5→2: 入度[2]=1 5→0: 入度[0]已为0且已入队
Step 3: 出队 4 → 排列=[5,4]
4→0: 入度[0]已处理 4→1: 入度[1]已为0
... (继续)
最终: [5, 4, 0, 1, 2, 3] 或其他合法序列

C++ 实现#

#include <vector>
#include <queue>
// Kahn 拓扑排序 —— BFS + 入度
// 返回拓扑序列; 如果有环, 返回的序列长度 < n
std::vector<int> topological_sort_kahn(
int n, const std::vector<std::vector<int>>& graph)
{
// 1. 计算入度
std::vector<int> in_degree(n, 0);
for (int u = 0; u < n; ++u) {
for (int v : graph[u]) {
++in_degree[v];
}
}
// 2. 所有入度为 0 的节点入队
std::queue<int> q;
for (int i = 0; i < n; ++i) {
if (in_degree[i] == 0) {
q.push(i);
}
}
// 3. BFS
std::vector<int> topo_order;
while (!q.empty()) {
int u = q.front();
q.pop();
topo_order.push_back(u);
for (int v : graph[u]) {
--in_degree[v];
if (in_degree[v] == 0) {
q.push(v);
}
}
}
// 4. 如果排出的节点数 < n, 说明有环
// topo_order.size() == n → DAG, 排序成功
// topo_order.size() < n → 有环, 排序失败
return topo_order;
}

Kahn 的特性#

特性说明
时间O(V + E)
空间O(V + E)
环检测排出节点数 < V → 有环
层级信息BFS 天然按”依赖深度”分层,可以求最长路径
字典序最小把 queue 换成 priority_queue → 拓扑序中字典序最小

7.3.7 DFS 拓扑排序 —— 后序翻转#

核心思想#

利用 DFS 的后序遍历:一个节点在所有后继都访问完后才被加入结果。将后序结果翻转,就是拓扑序。

DFS 拓扑排序的直觉:
一个节点 "完成" 意味着它的所有依赖项都已探索完毕
后序: 先完成的节点排在后面 → 翻转后就是拓扑序
等价于: 7.1 中三色标记的 BLACK 顺序 的翻转

C++ 实现#

#include <vector>
#include <algorithm>
// DFS 拓扑排序
class TopologicalSortDFS {
std::vector<std::vector<int>> _graph;
std::vector<int> _color; // 0=WHITE, 1=GRAY, 2=BLACK
std::vector<int> _order;
bool _has_cycle = false;
void _dfs(int u) {
if (_has_cycle) return;
_color[u] = 1; // GRAY: 正在访问
for (int v : _graph[u]) {
if (_color[v] == 1) {
_has_cycle = true; // 遇到 GRAY → 环!
return;
}
if (_color[v] == 0) {
_dfs(v);
}
}
_color[u] = 2; // BLACK: 访问完毕
_order.push_back(u); // 后序: 完成时加入
}
public:
// 返回拓扑序列; 如果有环, 返回空数组
std::vector<int> sort(int n, const std::vector<std::vector<int>>& graph) {
_graph = graph;
_color.assign(n, 0);
_order.clear();
_has_cycle = false;
for (int i = 0; i < n; ++i) {
if (_color[i] == 0) {
_dfs(i);
}
}
if (_has_cycle) return {};
std::reverse(_order.begin(), _order.end()); // 后序翻转 = 拓扑序
return _order;
}
};

Kahn vs DFS 拓扑排序#

维度Kahn (BFS)DFS 后序翻转
数据结构队列 + 入度数组递归栈 + 颜色标记
环检测排出节点数 < V遇到 GRAY 节点
层级信息✅ 天然按层(可求最长路径)❌ 不直接提供
字典序换 priority_queue 可得字典序最小不方便
代码量略长(需要预计算入度)更简洁
面试选择更常用(逻辑清晰、代码直觉)已经有 DFS 框架时方便

💡 面试中默认用 Kahn。Kahn 的 BFS 逻辑与”层层推进”的直觉一致,面试官更容易理解。DFS 版本在需要环检测的同时输出拓扑序时比较方便(一套三色标记两用)。


7.3.8 复杂度速查表#

MST 算法#

算法时间空间适合场景数据结构
KruskalO(E log E)O(V + E)稀疏图、边列表输入排序 + 并查集
Prim (堆)O((V+E) log V)O(V + E)稠密图、邻接表输入最小堆
Prim (矩阵)O(V²)O(V²)稠密图 + 邻接矩阵数组扫描

拓扑排序#

算法时间空间特点
Kahn (BFS)O(V + E)O(V + E)入度驱动,可求层级/最长路径
DFS 后序O(V + E)O(V + E)三色标记,环检测+拓扑一体

7.3.9 面试高频题#

连接所有点的最小费用 (LeetCode 1584)#

给定 n 个点的坐标,连接所有点的最小代价(两点之间代价 = 曼哈顿距离)。

经典 Kruskal,但 n 最大 1000 → E = n(n-1)/2 ≈ 50 万条边:

int minCostConnectPoints(std::vector<std::vector<int>>& points) {
int n = points.size();
std::vector<Edge> edges;
// 建完全图的边列表
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
int dist = std::abs(points[i][0] - points[j][0])
+ std::abs(points[i][1] - points[j][1]);
edges.push_back({i, j, dist});
}
}
// Kruskal
std::sort(edges.begin(), edges.end());
UnionFind uf(n);
int total = 0, count = 0;
for (const auto& [u, v, w] : edges) {
if (uf.unite(u, v)) {
total += w;
if (++count == n - 1) break;
}
}
return total;
}
// 时间 O(n² log n), 空间 O(n²)

💡 这道题也可以用 Prim O(n²) 更优——因为是完全图(E = n²),Prim 的邻接矩阵版 O(V²) 比 Kruskal 的 O(E log E) = O(n² log n) 更快。面试中两种都能过。

Prim 解法(O(n²) 无需建边):

int minCostConnectPoints_prim(std::vector<std::vector<int>>& points) {
int n = points.size();
std::vector<int> min_cost(n, INT_MAX); // 连接到 MST 的最小代价
std::vector<bool> in_mst(n, false);
min_cost[0] = 0;
int total = 0;
for (int i = 0; i < n; ++i) {
// 找不在 MST 中的最小 cost 节点
int u = -1;
for (int j = 0; j < n; ++j) {
if (!in_mst[j] && (u == -1 || min_cost[j] < min_cost[u])) {
u = j;
}
}
in_mst[u] = true;
total += min_cost[u];
// 更新所有非 MST 节点到 MST 的最小代价
for (int v = 0; v < n; ++v) {
if (!in_mst[v]) {
int dist = std::abs(points[u][0] - points[v][0])
+ std::abs(points[u][1] - points[v][1]);
min_cost[v] = std::min(min_cost[v], dist);
}
}
}
return total;
}
// 时间 O(n²), 空间 O(n) —— 比 Kruskal 更优!

课程表 II (LeetCode 210)#

给定课程数和前置依赖,返回一个合法的学习顺序。如果有环返回空数组。

Kahn 拓扑排序模板题

std::vector<int> findOrder(int numCourses,
std::vector<std::vector<int>>& prerequisites)
{
std::vector<std::vector<int>> graph(numCourses);
std::vector<int> in_degree(numCourses, 0);
for (auto& p : prerequisites) {
graph[p[1]].push_back(p[0]); // p[1] → p[0]
++in_degree[p[0]];
}
std::queue<int> q;
for (int i = 0; i < numCourses; ++i) {
if (in_degree[i] == 0) q.push(i);
}
std::vector<int> order;
while (!q.empty()) {
int u = q.front();
q.pop();
order.push_back(u);
for (int v : graph[u]) {
if (--in_degree[v] == 0) {
q.push(v);
}
}
}
// 有环 → 排出的节点数 < numCourses
return static_cast<int>(order.size()) == numCourses ? order : std::vector<int>{};
}
// 时间 O(V+E), 空间 O(V+E)

💡 LC 207(课程表)vs LC 210(课程表 II):207 只需判断有无环(7.1 已讲),210 需要输出拓扑序。代码几乎一样,210 多了个 order 数组。

外星字典 (LeetCode 269)#

给定一个字母按外星语言排序的字典,推导字母的排列顺序。

🧠 思路推导(面试时怎么想到的)

Step 1: 理解题意
输入的单词列表是按"外星字典序"排列的
我们要从这个排列顺序中反推出字母的先后关系
Step 2: 关键观察
两个相邻单词的第一个不同字符, 暗示了字母的偏序
例: words = ["wrt", "wrf"]
→ 第一个不同: 't' vs 'f' → 在外星语中 't' < 'f'
这就像比较两个中文词的拼音序, 从差异字推导拼音表!
Step 3: 建模
"字母 A 排在字母 B 前面" → 有向边 A → B
多个这样的偏序关系 → 有向图
求所有字母的总排序 → 拓扑排序!
Step 4: 边界
- "abc" 排在 "ab" 前面 → 不合法 (短的应该在前)
- 有环 → 不合法
- 多个合法答案 → 任选其一 (Kahn 自然给出一个)

思路:从相邻单词的差异中提取字母的偏序关系 → 建有向图 → 拓扑排序。

std::string alienOrder(std::vector<std::string>& words) {
// 1. 收集所有出现的字符
std::unordered_map<char, std::unordered_set<char>> graph;
std::unordered_map<char, int> in_degree;
for (auto& w : words) {
for (char c : w) {
if (graph.find(c) == graph.end()) {
graph[c] = {};
in_degree[c] = 0;
}
}
}
// 2. 从相邻单词的第一个不同字符提取偏序
for (int i = 0; i + 1 < static_cast<int>(words.size()); ++i) {
const auto& w1 = words[i];
const auto& w2 = words[i + 1];
// 特殊情况: "abc" 在 "ab" 前面 → 不合法
if (w1.size() > w2.size()
&& w1.substr(0, w2.size()) == w2)
{
return "";
}
int len = std::min(w1.size(), w2.size());
for (int j = 0; j < len; ++j) {
if (w1[j] != w2[j]) {
// w1[j] → w2[j]: w1[j] 排在 w2[j] 前面
if (graph[w1[j]].insert(w2[j]).second) {
++in_degree[w2[j]];
}
break; // 只看第一个不同的字符
}
}
}
// 3. Kahn 拓扑排序
std::queue<char> q;
for (auto& [c, deg] : in_degree) {
if (deg == 0) q.push(c);
}
std::string result;
while (!q.empty()) {
char c = q.front();
q.pop();
result += c;
for (char next : graph[c]) {
if (--in_degree[next] == 0) {
q.push(next);
}
}
}
// 有环 → 排出的字符数 < 总字符数
return result.size() == in_degree.size() ? result : "";
}
// 时间 O(C), C = 所有字符串的总长度

💡 外星字典的核心:(1) 相邻单词第一个不同字符给出偏序 a < b;(2) 建图 + 拓扑排序;(3) 注意边界:前缀关系 (“abc” < “ab” 不合法)、多个合法答案任选其一。

最小高度树 (LeetCode 310)#

给定无向无环图(树),找出使树高度最小的根节点。

思路:像剥洋葱一样,反复去掉叶节点(度为 1 的节点),最后剩下的 1-2 个节点就是答案。本质是拓扑排序的变体——对无向图做”度数驱动的 BFS”。

std::vector<int> findMinHeightTrees(int n, std::vector<std::vector<int>>& edges) {
if (n == 1) return {0};
std::vector<std::unordered_set<int>> adj(n);
for (auto& e : edges) {
adj[e[0]].insert(e[1]);
adj[e[1]].insert(e[0]);
}
// 叶节点入队 (度 = 1)
std::queue<int> leaves;
for (int i = 0; i < n; ++i) {
if (adj[i].size() == 1) leaves.push(i);
}
int remaining = n;
while (remaining > 2) {
int leaf_count = leaves.size();
remaining -= leaf_count;
for (int i = 0; i < leaf_count; ++i) {
int leaf = leaves.front();
leaves.pop();
// 删除叶节点及其边
int neighbor = *adj[leaf].begin();
adj[neighbor].erase(leaf);
if (adj[neighbor].size() == 1) {
leaves.push(neighbor); // 新叶节点
}
}
}
// 剩余 1 或 2 个节点 = 答案
std::vector<int> result;
while (!leaves.empty()) {
result.push_back(leaves.front());
leaves.pop();
}
return result;
}
// 时间 O(V), 空间 O(V)

💡 为什么最后剩 1-2 个? 树的”中心”最多有 2 个(在最长路径的中点)。不断剥叶节点等价于”从两端向中心收缩”,最终收敛到中心。

冗余连接 (LeetCode 684)#

给定 n 条边的无向图(本该是树,多了一条边),找出多余的那条边。

并查集模板:加边时如果两端点已连通 → 这条边是多余的。

std::vector<int> findRedundantConnection(std::vector<std::vector<int>>& edges) {
int n = edges.size();
UnionFind uf(n + 1); // 节点编号 1~n
for (auto& e : edges) {
if (!uf.unite(e[0], e[1])) {
return e; // 已连通还要加边 → 多余!
}
}
return {};
}
// 时间 O(n × α(n)) ≈ O(n), 空间 O(n)

💡 这道题和 Kruskal 用了相同的并查集逻辑——“加边时判断是否形成环”。只不过 Kruskal 是跳过形成环的边,这道题是返回它。

平行课程 (LeetCode 1136)#

每学期可以同时修所有无前置依赖的课程,求修完所有课程的最少学期数

Kahn 拓扑排序 + 层序 BFS:每一层 = 一个学期。

int minimumSemesters(int n, std::vector<std::vector<int>>& relations) {
std::vector<std::vector<int>> graph(n + 1);
std::vector<int> in_degree(n + 1, 0);
for (auto& r : relations) {
graph[r[0]].push_back(r[1]);
++in_degree[r[1]];
}
std::queue<int> q;
for (int i = 1; i <= n; ++i) {
if (in_degree[i] == 0) q.push(i);
}
int semesters = 0, count = 0;
while (!q.empty()) {
++semesters;
int size = q.size();
for (int i = 0; i < size; ++i) {
int u = q.front();
q.pop();
++count;
for (int v : graph[u]) {
if (--in_degree[v] == 0) {
q.push(v);
}
}
}
}
return count == n ? semesters : -1; // -1 = 有环
}
// 时间 O(V+E), 空间 O(V+E)

💡 DAG 的最长路径 = 拓扑排序的层数。每一”层”的节点都可以并行执行,层数就是关键路径的长度——项目管理中的经典问题。


7.3.10 🎮 游戏实战场景#

技能树 / 科技树 (DAG + 拓扑排序)#

// RPG 游戏中的技能依赖系统
// 技能之间有前置要求: "学了火球术才能学陨石火雨"
// 本质: DAG + 拓扑排序
struct Skill {
int id;
std::string name;
int level_required;
std::vector<int> prerequisites; // 前置技能 ID
bool unlocked = false;
};
class SkillTree {
std::vector<Skill> _skills;
std::vector<std::vector<int>> _graph; // 依赖图: prereq → skill
int _n;
public:
SkillTree(int n) : _n(n), _skills(n), _graph(n) {}
void add_dependency(int prereq, int skill) {
_graph[prereq].push_back(skill);
_skills[skill].prerequisites.push_back(prereq);
}
// 检查技能是否可学习
bool can_learn(int skill_id) const {
for (int prereq : _skills[skill_id].prerequisites) {
if (!_skills[prereq].unlocked) return false;
}
return true;
}
// 获取当前可学习的所有技能 (入度为 0 的未解锁技能)
std::vector<int> available_skills() const {
std::vector<int> result;
for (int i = 0; i < _n; ++i) {
if (!_skills[i].unlocked && can_learn(i)) {
result.push_back(i);
}
}
return result;
}
// 验证技能树是否有循环依赖 (开发阶段的完整性检查)
bool validate() const {
auto topo = topological_sort_kahn(_n, _graph);
return static_cast<int>(topo.size()) == _n;
}
// 计算到学会目标技能的最短路径 (需要学多少个前置技能)
std::vector<int> learning_path(int target) const {
// 反向 BFS: 从 target 出发, 找到所有必须先学的技能
std::vector<bool> needed(_n, false);
std::queue<int> q;
q.push(target);
needed[target] = true;
while (!q.empty()) {
int s = q.front();
q.pop();
for (int prereq : _skills[s].prerequisites) {
if (!needed[prereq] && !_skills[prereq].unlocked) {
needed[prereq] = true;
q.push(prereq);
}
}
}
// 对 "needed" 子图做拓扑排序 → 学习顺序
std::vector<int> path;
// ... (拓扑排序, 只包含 needed=true 的节点)
return path;
}
};
// 使用示例:
// SkillTree tree(10);
// tree.add_dependency(FIREBALL, METEOR); // 火球术 → 陨石
// tree.add_dependency(FIREBALL, FIRE_WALL); // 火球术 → 火墙
// tree.add_dependency(FIRE_WALL, INFERNO); // 火墙 → 地狱火
// tree.add_dependency(METEOR, INFERNO); // 陨石 → 地狱火
//
// auto avail = tree.available_skills(); // 当前可学: [FIREBALL, ICE_BOLT, ...]

资源加载依赖图 (拓扑排序)#

// 游戏启动时, 资源之间有加载依赖
// Shader 编译 → Material 创建 → Mesh 渲染
// 纹理加载 → Material 创建
// 本质: DAG 拓扑排序, 确定加载顺序; 层级信息 → 并行加载
struct Resource {
std::string name;
std::string type; // "texture", "shader", "material", "mesh"
};
class ResourceLoader {
int _n;
std::vector<Resource> _resources;
std::vector<std::vector<int>> _deps; // deps[i] = i 依赖的资源
public:
// 用 Kahn 拓扑排序, 确定加载顺序
// 同一层的资源可以**并行**加载!
std::vector<std::vector<int>> get_loading_batches() {
std::vector<int> in_degree(_n, 0);
std::vector<std::vector<int>> graph(_n);
for (int i = 0; i < _n; ++i) {
for (int dep : _deps[i]) {
graph[dep].push_back(i);
++in_degree[i];
}
}
std::queue<int> q;
for (int i = 0; i < _n; ++i) {
if (in_degree[i] == 0) q.push(i);
}
std::vector<std::vector<int>> batches;
while (!q.empty()) {
std::vector<int> batch;
int size = q.size();
for (int i = 0; i < size; ++i) {
int r = q.front();
q.pop();
batch.push_back(r);
for (int next : graph[r]) {
if (--in_degree[next] == 0) {
q.push(next);
}
}
}
batches.push_back(std::move(batch));
}
return batches;
// batches[0] = 无依赖, 先加载 (并行)
// batches[1] = 依赖 batch 0 的, 再加载 (并行)
// ...
}
};
// 实际效果:
// Batch 0: [base_shader, stone_texture, wood_texture] ← 并行加载
// Batch 1: [wall_material, floor_material] ← 依赖 batch 0
// Batch 2: [level_mesh] ← 依赖 batch 1
// 总加载时间 = max(batch时间) 之和, 远优于串行加载

地图随机生成 —— MST 保证连通#

// Roguelike 地图生成: 随机放置房间, 用 MST 保证连通
// 步骤:
// 1. 随机生成 N 个房间的中心点
// 2. 所有房间之间建完全图 (距离 = 曼哈顿/欧几里得)
// 3. Kruskal 求 MST → 保证所有房间互通
// 4. 随机额外添加几条非 MST 边 → 增加路径多样性
// 5. 沿选中的边挖走廊
struct Room {
int cx, cy; // 中心坐标
int width, height;
};
class DungeonGenerator {
public:
struct Corridor {
int room_a, room_b;
};
std::vector<Corridor> generate_corridors(const std::vector<Room>& rooms) {
int n = rooms.size();
// 1. 建完全图的边列表
std::vector<Edge> edges;
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
int dist = std::abs(rooms[i].cx - rooms[j].cx)
+ std::abs(rooms[i].cy - rooms[j].cy);
edges.push_back({i, j, dist});
}
}
// 2. Kruskal 求 MST
std::sort(edges.begin(), edges.end());
UnionFind uf(n);
std::vector<Corridor> corridors;
std::vector<Edge> non_mst;
for (const auto& e : edges) {
if (uf.unite(e.u, e.v)) {
corridors.push_back({e.u, e.v}); // MST 边 → 必要走廊
} else {
non_mst.push_back(e); // 非 MST 边
}
}
// 3. 随机添加 10-20% 的额外走廊 (增加路径多样性)
int extra = std::max(1, static_cast<int>(non_mst.size()) / 5);
// 随机选 extra 条非 MST 边加入
std::shuffle(non_mst.begin(), non_mst.end(),
std::mt19937{std::random_device{}()});
for (int i = 0; i < extra && i < static_cast<int>(non_mst.size()); ++i) {
corridors.push_back({non_mst[i].u, non_mst[i].v});
}
return corridors;
}
};
// 结果:
// MST 走廊保证所有房间可达 (不会有死角)
// 额外走廊增加分支路径 (玩家体验更好)
// 这是 Roguelike 游戏中最经典的地图生成算法之一

任务链前置依赖 (DAG)#

// MMO 的任务系统: 任务之间有前置关系
// "完成主线 1-3 才能接主线 4"
// "完成支线 A 和 B 才能接隐藏任务"
struct Quest {
int id;
std::string name;
std::vector<int> prerequisites;
bool completed = false;
};
class QuestManager {
std::vector<Quest> _quests;
public:
// 玩家当前可接的任务 = 入度为 0 的未完成任务
std::vector<int> available_quests() const {
std::vector<int> result;
for (const auto& q : _quests) {
if (q.completed) continue;
bool all_prereq_done = true;
for (int p : q.prerequisites) {
if (!_quests[p].completed) {
all_prereq_done = false;
break;
}
}
if (all_prereq_done) result.push_back(q.id);
}
return result;
}
// 完成任务 → 可能解锁新任务
std::vector<int> complete_quest(int quest_id) {
_quests[quest_id].completed = true;
return available_quests(); // 返回新的可接任务列表
}
// 估算完成某个目标任务需要做多少个前置任务
// = 从目标任务反向 BFS 到所有未完成的前置
int estimate_effort(int target) const {
std::unordered_set<int> needed;
std::queue<int> q;
q.push(target);
while (!q.empty()) {
int cur = q.front();
q.pop();
for (int p : _quests[cur].prerequisites) {
if (!_quests[p].completed && needed.insert(p).second) {
q.push(p);
}
}
}
return needed.size();
}
};

游戏场景总结#

游戏系统图模型核心算法
技能树 / 科技树DAG: 技能 = 节点,前置 = 有向边拓扑排序(可学技能 = 入度 0)
任务链DAG: 任务 = 节点,前置 = 有向边拓扑排序(可接任务 = 入度 0)
资源加载DAG: 资源 = 节点,依赖 = 有向边拓扑排序(分层并行加载)
地图生成完全图: 房间 = 节点,距离 = 边权Kruskal MST(保证连通)
网络拓扑无向加权图: 设备 = 节点,链路 = 边Prim / Kruskal(最小代价布线)
电力布线无向加权图: 设施 = 节点,线缆 = 边MST(最小成本覆盖)

7.3.11 面试题速查表#

题号题目核心算法难度
LC 1584连接所有点的最小费用Kruskal / PrimMedium
LC 210课程表 IIKahn 拓扑排序Medium
LC 269外星字典建图 + 拓扑排序Hard
LC 310最小高度树拓扑排序变体(剥叶节点)Medium
LC 684冗余连接并查集(同 Kruskal 逻辑)Medium
LC 1136平行课程Kahn + 层数 = 最长路径Medium
LC 1135最低成本联通所有城市Kruskal / PrimMedium
LC 1489找到最小生成树里的关键边和伪关键边枚举边 + KruskalHard
LC 329矩阵中的最长递增路径拓扑排序 / 记忆化 DFSHard
LC 802找到最终的安全状态反向图 + 拓扑排序Medium

7.3.12 本节小结#

核心要点#

概念要点
Kruskal排序 + 并查集选边。O(E log E),适合稀疏图/边列表输入
Prim最小堆扩点。O((V+E)logV),适合稠密图/邻接表输入
MST 性质连通图的最小权重生成树。V 个点恰好 V-1 条边
KahnBFS + 入度。O(V+E)。排出 < V 则有环。可求层数/最长路径
DFS 拓扑后序翻转。O(V+E)。三色标记兼顾环检测
DAG = 拓扑 OK有拓扑序 ⟺ 无环 ⟺ DAG

面试 30 秒速答#

Q:Kruskal 和 Prim 的区别?什么时候用哪个?

A:都是求最小生成树的贪心算法。Kruskal出发——按权重排序所有边,用并查集判断是否形成环,时间 O(E log E)。Prim出发——用最小堆每次扩展最近的邻居,时间 O((V+E) log V)。稀疏图用 Kruskal(边少排序快),稠密图用 Prim(可优化到 O(V²))。面试中题目给边列表就用 Kruskal,给邻接表就用 Prim。

Q:拓扑排序是什么?如何判断有环?

A:拓扑排序是对 **DAG(有向无环图)**的节点排列,使得所有有向边 u→v 中 u 都在 v 前面。Kahn 算法用 BFS——先把入度为 0 的节点入队(无依赖),每处理一个节点就把邻居入度 -1,入度变 0 就入队。如果最终排出的节点数 < V,说明有环(环上的节点入度永远不会变 0)。

Q:MST 和最短路径有什么区别?

A:最短路径关心的是 A→B 的最优路线(一条链),MST 关心的是用最小总代价连通所有节点(一棵树)。例如:修公路连接所有城市用 MST;导航从 A 到 B 用 Dijkstra。两个问题的最优解通常不同。

Q:Roguelike 游戏怎么保证地图连通?

A:随机生成房间后,对房间中心点建完全图(边权 = 距离),用 Kruskal 求 MST——MST 保证所有房间互通且走廊总长度最小。然后随机添加 10-20% 的非 MST 边,增加分支路径和探索性。这是最经典的 Roguelike 地图生成算法。


📖 返回总览:第七章 图 —— 总览与导航

📖 上一节:7.2 最短路径:Dijkstra & A* —— 四大最短路径算法和游戏寻路实战

📖 下一章:第八章 字典树:前缀的力量 —— 前缀共享、Trie 实现、单词搜索、自动补全与游戏敏感词过滤。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

7.3 最小生成树 & 拓扑排序
https://firefly-7a0.pages.dev/posts/data_structure/graph/07_03_mst_topo/
作者
lonelystar
发布于
2026-04-14
许可协议
CC BY-NC-SA 4.0
相关文章 智能推荐
1
第七章 图:万物皆可连
数据结构笔记 **面试突击 · 图(总览)。** 图是最通用的数据结构——社交网络、地图导航、任务依赖、网络拓扑,一切「关系」都可以用图建模。本章拆分为三个子章节:图的表示与遍历、最短路径与 A* 寻路、最小生成树与拓扑排序。
2
7.1 图的表示与遍历
数据结构笔记 **面试突击 · 图基础。** 从图的分类与术语到邻接矩阵/邻接表两种存储,手写 BFS 与 DFS 的完整实现,掌握连通分量、环检测、岛屿数量等高频面试题。
3
第八章 字典树:前缀的力量
数据结构笔记 **面试突击 · 字典树。** 从标准 Trie 到压缩 Trie (Radix Tree),手写插入/查找/前缀匹配的完整实现,剖析数组 vs 哈希两种子节点存储的工程取舍,掌握单词搜索 II、自动补全设计等高频面试题,深入游戏敏感词过滤与控制台命令补全实战。
4
6.2 二叉搜索树 BST
数据结构笔记 **面试突击 · BST。** 从二叉搜索树的有序性质到增删查的完整 C++ 实现,剖析 BST 退化问题与 std::map/std::set 的选型原因,手撕验证 BST、第 K 小元素、删除节点等高频面试题。
5
6.1 二叉树基础与遍历
数据结构笔记 **面试突击 · 二叉树。** 从树的基本术语到四种遍历(递归/迭代/Morris),涵盖最大深度、对称性判断、翻转二叉树、路径总和、最近公共祖先、序列化等 15+ 道高频面试题的完整解析。
随机文章 随机推荐

评论区

Profile Image of the Author
LonelyStar
Hello, I'm LonelyStar.
公告
欢迎来到我的博客!
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
119
分类
11
标签
346
总字数
226,548
运行时长
0
最后活动
0 天前

目录