qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

把所有房间接起来最省线,为什么从门口走到每间却不一定最短?

三条候选连线成本是 2、3、4。最小生成树花 5,起点最短路树花 6;它们优化的是两份不同账本。

展开本文目录
  1. 下载完整练习包
  2. 先决定你要最小化哪一份账本
  3. 用三个点把两个目标拆开
  4. Kruskal 为什么先看便宜边,还必须检查是否成环?
  5. 完整程序同时核对总价和路线
  6. 有孤立房间时,不能假装已经接通所有地方
  7. 相同的边权符号,不代表相同的现实目标
  8. 把算法与独立核对的成本分开
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 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 项检查。

展开收起代码 · 小实验python · 183 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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 条边,不检查环或连通性。
  • 有孤立点仍把森林称为覆盖全图的生成树,或把不可达填成零。

自己试一小步

增加一个房间和三条候选线后,两份账本还会给相同建议吗?

  1. 保留原三角形,增加 D,并加入 A—D=7、B—D=1、C—D=2。
  2. 先写出一个最低总价连通方案,再写以 A 为起点的最短路线方案;分别记录所选边与距离。
  3. 运行自己的变体核对;再把起点改为 C,观察哪些结果应该跟着改变。

做到这些,就算完成

  • 确认所选边确实连通所需顶点,而不只是数量合适。
  • 总连线成本每条选中边只加一次,路径成本按经过边计算。
  • 若存在同价方案,说明你采用的确定顺序,不把它误称为唯一答案;本节不附结果。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • MIT 6.046:最小生成树定义与贪心交换论证 (新标签页)↗MIT OpenCourseWare · 英文

    支持生成树目标、跨分组最轻边的交换论证与 Kruskal 算法。

    2026-10-17 实际打开核对:第 1 页定义、第 3 页 Greedy-Choice Property 证明和第 5 页 Kruskal;文本抽取的伪代码符号没有直接复制,实际连通判断另查作者实现。

  • Algorithms 作者实现:KruskalMST.java (新标签页)↗Robert Sedgewick / Kevin Wayne · 英文

    支持排序后用并查集跳过成环边、返回最小生成森林及总成本核对。

    2026-10-17 实际打开核对:类契约、构造器排序/合并过程和 check 中连通与最优性检查;本篇 Python 是原创教学实现。

  • Algorithms 作者实现:DijkstraUndirectedSP.java (新标签页)↗Robert Sedgewick / Kevin Wayne / Nate Liu · 英文

    支持无向非负权图的单源最短路目标、松弛和前驱边记录。

    2026-10-17 实际打开核对:类契约、负权校验、构造器和 relax;没有把作者 Java 实现的复杂度直接套到本篇全部核对程序。