开始前,会这些就够了
- 知道图由点和边组成,路径成本是经过边的成本相加
- 会阅读 Python 列表、循环和简单函数
- 需要补基础时:先认识图的点、边与遍历
- 需要理解分组时:回看并查集保留什么信息
遇到不熟悉的词,先看这 2 个解释
打开完整术语小抄 ↗有三个房间 A、B、C。A—B 需要 2 米线,B—C 需要 3 米,A—C 需要 4 米。只要房间之间都能通过已有线连通,选择前两条共花 5 米就够了;但沿这两条从 A 到 C 要经过 5 米,比直接连接的 4 米更长。
“把所有地方接通最省”与“从一个起点到各处都尽量短”用了相同的图,却要求不同答案。本篇分别构造两种树,把总连线成本与起点到各点的路径成本并排记录。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- mst_versus_paths.py:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B mst_versus_paths.py解压到独立练习文件夹,在含 mst_versus_paths.py 的目录打开终端运行。正整数权无向简单图的最小生成森林与单源最短路线对照,主函数最多64顶点,独立穷举参考最多5顶点;不处理真实布线约束、冗余容错、负权路线或大图性能。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先决定你要最小化哪一份账本
树是一张连通且没有环的无向图。生成树使用原图中的边,覆盖全部顶点;最小生成树在这些生成树里,选择边权总和最小的一棵。边权在本题中就是铺设这条线的成本,每条选中的边只向总账贡献一次。
单源最短路树先指定一个起点,让树中从它到每个可达点的路径,都达到原图中的最短成本。它关注每个目的地的路线,不要求全部所选边加起来最少。一个是整个连通方案的建造总价,一个是从指定起点出发的路线清单。
用三个点把两个目标拆开
对 A—B=2、B—C=3、A—C=4,最小生成树选择 A—B 与 B—C,总价为 5。从 A 出发的距离表是 A:0、B:2、C:5。另一个方案选 A—B 与 A—C,总价为 6,但距离表变成 A:0、B:2、C:4,正好保留从 A 到每个点的最短路线。
最小生成树没有选“错误的边”:它完成了总价最小的任务。最短路树也没有因为总价较高就失败:它保住了各目的地的路线要求。改变起点只会直接改变最短路问题;最小生成树的定义本身没有起点。
Kruskal 为什么先看便宜边,还必须检查是否成环?
Kruskal 把所有候选边按成本从小到大检查。如果一条边两端已经通过已选边连通,再加入它就形成环,于是跳过;否则把它加入,并合并两端所属的连通组。并查集只负责回答“这两端当前是否已连通”,选出来的真实边仍单独保存。
为什么这一步能保留最优方案?设已有选择属于某棵最优树。当前最便宜的可用边连接两个不同连通组;那棵树若没用它,也必须有另一条边跨过这个组与外面的边界。加入新边会产生一个环,从环上移走那条跨界边仍然接通全部点,而且替换不会更贵。因为当前组由此前选中的边构成,跨界的旧边不属于已选集合,替换不会拆掉先前选择。于是能继续保留一棵包含当前选择的最优树。
完整程序同时核对总价和路线
保存为 mst_versus_paths.py,执行 python -B mst_versus_paths.py。输出把 0、1、2 映射为 A、B、C;minimum_spanning_tree 的 total_cost 是 5,distances_from_A 是 [0,2,5],shortest_path_tree_from_A 的对应结果是 6 和 [0,2,4]。总价与距离数组是不同字段,核对时不要相互替代。
程序对四个顶点的六条可能边,分别选择不存在、成本 1、成本 2,共检查 729 张小图。独立参考把需要的边子集全部列出,用图遍历检查连通分组,再找最低总价;路线参考则列出不重复顶点的所有简单路径。它们不复用并查集或 Dijkstra 的优先队列。连同边界共通过 5,119 项检查。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Minimum total construction cost and single-source path cost are different."""
import heapq
import itertools
import json
def validate(n, edges):
edges = tuple(edges) # Input validation must not consume a one-use iterator.
if type(n) is not int or not 0 <= n <= 64:
raise ValueError("vertex count must be an integer from 0 to 64")
seen = set()
for u, v, weight in edges:
if any(type(x) is not int for x in (u, v, weight)):
raise ValueError("vertices and weights must be integers")
if not 0 <= u < v < n or weight <= 0:
raise ValueError("require 0 <= u < v < n and positive weight")
if (u, v) in seen:
raise ValueError("parallel edges are outside this model")
seen.add((u, v))
return edges
def minimum_forest(n, edges):
edges = validate(n, edges)
parent, size = list(range(n)), [1] * n
def root(vertex):
while parent[vertex] != vertex:
parent[vertex] = parent[parent[vertex]]
vertex = parent[vertex]
return vertex
chosen = []
for u, v, weight in sorted(edges, key=lambda e: (e[2], e[0], e[1])):
a, b = root(u), root(v)
if a == b:
continue # This edge would close a cycle in the selected forest.
if size[a] < size[b]:
a, b = b, a
parent[b] = a
size[a] += size[b]
chosen.append((u, v, weight))
return chosen
def shortest_paths(n, edges, source):
edges = validate(n, edges)
if type(source) is not int or not 0 <= source < n:
raise ValueError("unknown source")
adjacency = [[] for _ in range(n)]
for u, v, weight in edges:
adjacency[u].append((v, weight))
adjacency[v].append((u, weight))
distance, parent = [None] * n, [None] * n
distance[source] = 0
queue = [(0, source)]
while queue:
cost, u = heapq.heappop(queue)
if distance[u] != cost:
continue
for v, weight in adjacency[u]:
candidate = cost + weight
if distance[v] is None or candidate < distance[v]:
distance[v] = candidate
parent[v] = (min(u, v), max(u, v), weight)
heapq.heappush(queue, (candidate, v))
return distance, sorted(edge for edge in parent if edge is not None)
def components(n, edges):
# Independent connectivity oracle uses graph traversal, not union-find.
adjacency = [set() for _ in range(n)]
for u, v, _ in edges:
adjacency[u].add(v)
adjacency[v].add(u)
remaining = set(range(n))
groups = set()
while remaining:
todo, reached = [min(remaining)], set()
while todo:
u = todo.pop()
if u not in reached:
reached.add(u)
todo.extend(adjacency[u] - reached)
remaining -= reached
groups.add(frozenset(reached))
return groups
def exhaustive_minimum_cost(n, edges):
if n > 5:
raise ValueError("exhaustive oracle is limited to at most five vertices")
target = components(n, edges)
required = n - len(target)
valid_costs = [sum(e[2] for e in subset)
for subset in itertools.combinations(edges, required)
if components(n, subset) == target]
return min(valid_costs)
def exhaustive_distances(n, edges, source):
if n > 5:
raise ValueError("exhaustive oracle is limited to at most five vertices")
adjacency = [[] for _ in range(n)]
for u, v, weight in edges:
adjacency[u].append((v, weight))
adjacency[v].append((u, weight))
best = [None] * n
def visit(u, seen, cost):
if best[u] is None or cost < best[u]:
best[u] = cost
for v, weight in adjacency[u]:
if v not in seen:
visit(v, seen | {v}, cost + weight)
visit(source, {source}, 0)
return best
def main():
checks = 0
def check(condition):
nonlocal checks
if not condition:
raise AssertionError("graph check failed")
checks += 1
triangle = [(0, 1, 2), (1, 2, 3), (0, 2, 4)]
forest = minimum_forest(3, triangle)
original_distances, shortest_tree = shortest_paths(3, triangle, 0)
tree_distances, _ = shortest_paths(3, forest, 0)
check(sum(e[2] for e in forest) == 5)
check(sum(e[2] for e in shortest_tree) == 6)
check(original_distances == [0, 2, 4])
check(tree_distances == [0, 2, 5])
check(minimum_forest(3, iter(triangle)) == forest)
check(shortest_paths(3, iter(triangle), 0)[0] == original_distances)
check(minimum_forest(0, []) == [])
check(minimum_forest(1, []) == [])
check(components(4, minimum_forest(4, triangle)) ==
{frozenset((0, 1, 2)), frozenset((3,))})
check(shortest_paths(4, triangle, 0)[0] == [0, 2, 4, None])
pairs = list(itertools.combinations(range(4), 2))
graph_count = 0
for weights in itertools.product((0, 1, 2), repeat=len(pairs)):
# Zero here means absent edge, never a zero-weight edge.
edges = [(u, v, weight) for (u, v), weight in zip(pairs, weights) if weight]
graph_count += 1
selected = minimum_forest(4, edges)
check(sum(e[2] for e in selected) == exhaustive_minimum_cost(4, edges))
check(components(4, selected) == components(4, edges))
check(len(selected) == 4 - len(components(4, edges)))
for source in range(4):
distances, _ = shortest_paths(4, edges, source)
check(distances == exhaustive_distances(4, edges, source))
invalid = [(2, [(0, 1, -1)]), (2, [(0, 0, 1)]),
(2, [(0, 1, 1), (0, 1, 2)]), (2, [(0, 2, 1)]),
(2, [(0, 1, True)]), (True, [])]
for n, edges in invalid:
try:
minimum_forest(n, edges)
except ValueError:
check(True)
else:
raise AssertionError("invalid graph accepted")
print(json.dumps({"checks_passed": checks, "exhaustive_small_graphs": graph_count,
"vertices": {"0": "A", "1": "B", "2": "C"},
"available_edges": triangle,
"minimum_spanning_tree": {"edges": forest, "total_cost": 5,
"distances_from_A": tree_distances},
"shortest_path_tree_from_A": {"edges": shortest_tree, "total_cost": 6,
"distances_from_A": original_distances},
"isolated_D_distances": [0, 2, 4, None],
"scope": "positive integer undirected simple graphs; construction cost differs from path cost"},
ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
怎样运行:在独立练习文件夹中执行 python -B mst_versus_paths.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。正整数权无向简单图的最小生成森林与单源最短路线对照,主函数最多64顶点,独立穷举参考最多5顶点;不处理真实布线约束、冗余容错、负权路线或大图性能。
运行后对照结果
{
"checks_passed": 5119,
"exhaustive_small_graphs": 729,
"vertices": {
"0": "A",
"1": "B",
"2": "C"
},
"available_edges": [
[
0,
1,
2
],
[
1,
2,
3
],
[
0,
2,
4
]
],
"minimum_spanning_tree": {
"edges": [
[
0,
1,
2
],
[
1,
2,
3
]
],
"total_cost": 5,
"distances_from_A": [
0,
2,
5
]
},
"shortest_path_tree_from_A": {
"edges": [
[
0,
1,
2
],
[
0,
2,
4
]
],
"total_cost": 6,
"distances_from_A": [
0,
2,
4
]
},
"isolated_D_distances": [
0,
2,
4,
null
],
"scope": "positive integer undirected simple graphs; construction cost differs from path cost"
}有孤立房间时,不能假装已经接通所有地方
如果增加 D,却不给它任何候选连线,就不存在覆盖 A、B、C、D 的一棵生成树。Kruskal 会为每个能连通的部分各造一棵树,合起来叫最小生成森林;D 自己也是一个分量。程序明确保留这个区别,不能看见有几条边就报告全图已连通。
从 A 出发也到不了 D,所以距离结果是 [0,2,4,null]。null 表示没有路线,不是成本为零。只有确认原图连通,才能把生成森林称作覆盖全图的生成树;非空连通图的树边数应为顶点数减一。
相同的边权符号,不代表相同的现实目标
本例只接收最多 64 个顶点、正整数边权的无向简单图,每条边按 u<v 记录,拒绝自环、平行边和非正权。这样两种算法可以在同一输入范围直接比较。最小生成树理论允许更宽的权值情况,但本程序没有为了这个对照放开负权;也没有把有向路线直接当作无向连线。
铺线省钱并不自动满足带宽、容错、施工权限或线路失效后的备用需求。树中删掉任意一条边都会把原连通部分分开;需要冗余连线时,优化目标已经增加了条件。本文只证明小图算法输出符合已说明的目标,不是现实网络设计方案或性能测试。
把算法与独立核对的成本分开
用 V 表示顶点数、E 表示边数。Kruskal 先初始化 V 个点,再给 E 条边排序。并查集每次把较小的组挂到较大组下:一条向上的路线每多一级,其所在组至少翻倍,所以不做路径压缩时,查找层数也只按 log(V+1) 增长。本例因此可给出 O(V+E log(E+1)+E log(V+1)) 的时间上界;路径压缩还能减少重复查找,但不会省掉前面的排序。
独立枚举参考故意只允许至多 5 个顶点,因为边子集和简单路径的数量会迅速增长。它用于小规模找错,不是可扩展替代算法。Dijkstra 的堆实现也会保留等待处理的候选项,本篇没有把源码行数或检查次数当作运行性能证据。
记住这三点
- 最小生成树最小化所选边总价,单源最短路树保持从指定起点到各点的最短成本。
- Kruskal 加入便宜边时必须防止成环,分组状态与实际选中边分别保存。
- 不连通图得到森林;到不了的点不能记成零成本。
别把例子用过头
- 把总边成本最低当成任意两点路线都最短。
- 只排序取前 V-1 条边,不检查环或连通性。
- 有孤立点仍把森林称为覆盖全图的生成树,或把不可达填成零。
自己试一小步
增加一个房间和三条候选线后,两份账本还会给相同建议吗?
- 保留原三角形,增加 D,并加入 A—D=7、B—D=1、C—D=2。
- 先写出一个最低总价连通方案,再写以 A 为起点的最短路线方案;分别记录所选边与距离。
- 运行自己的变体核对;再把起点改为 C,观察哪些结果应该跟着改变。
做到这些,就算完成
- 确认所选边确实连通所需顶点,而不只是数量合适。
- 总连线成本每条选中边只加一次,路径成本按经过边计算。
- 若存在同价方案,说明你采用的确定顺序,不把它误称为唯一答案;本节不附结果。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- MIT 6.046:最小生成树定义与贪心交换论证 (新标签页)↗
支持生成树目标、跨分组最轻边的交换论证与 Kruskal 算法。
- Algorithms 作者实现:KruskalMST.java (新标签页)↗
支持排序后用并查集跳过成环边、返回最小生成森林及总成本核对。
- Algorithms 作者实现:DijkstraUndirectedSP.java (新标签页)↗
支持无向非负权图的单源最短路目标、松弛和前驱边记录。