qinghuan
← 回到知识探索

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

已经找到便宜路线,为什么遇到负数后还不能提前结束?

一条成本为负的边就能打破 Dijkstra 提前定案的依据。用三点小图对照错误结果、输入拒绝与 Bellman-Ford。

展开本文目录
  1. 下载完整练习包
  2. 当前已知最便宜,不等于已经证明最便宜
  3. 三条边已经足够让提前返回出错
  4. 允许暂定答案继续改善,就需要另一套保证
  5. 运行完整程序,比较错误对照与正确前提
  6. 给所有边加一个常数,就能把负数变成正数吗?
  7. 先确认输入的含义,再谈速度
  8. 记住这三点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
松弛
发现一条更便宜的走法,就把当前暂定成本调小。
负环
沿某个有向圈走完一轮,成本总和为负;允许重复走时会不断降低成本。
打开完整术语小抄 ↗

从 S 直接到 A 要花 2,从 S 到 B 要花 5。如果后面每一步都只会继续增加成本,先把 A 的最优成本认定为 2 很合理。但若 B 到 A 还能返还 10,经过 B 的总成本就变成了 -5。

这里的负数可以表示抽象收益或抵扣,不是负的行车时间。问题在于算法何时有资格说“这个点已经确定,再也不会更好”,而不是优先队列有没有正确取出当前最小数字。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • dijkstra_negative.py:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B dijkstra_negative.py

解压到独立练习文件夹,在含 dijkstra_negative.py 的目录打开终端运行。整数边权的有向图算法实验,含故意去掉前提的错误对照、拒绝负权的Dijkstra和基础Bellman-Ford;没有测试地图服务、真实网络或大图性能。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

当前已知最便宜,不等于已经证明最便宜

搜索时为每个点保存的数字,是目前已经找到的一条路线的成本。用新边检查能否让这个数字更小,通常称为松弛。这个词只是说:发现一条更便宜的走法,就更新暂定答案。

Dijkstra 每次挑出暂定成本最小的未处理点,并据此完成确认。它的经典前提是边权非负:沿一条路线继续向前,累计成本不会下降。这样其他更贵的候选途中,无法再靠后续边绕回来制造更便宜的结果。零成本边可以存在,关键是不能下降。

三条边已经足够让提前返回出错

本例使用三个点 0、1、2,分别对应 S、A、B。边为 0→1 成本 2、0→2 成本 5、2→1 成本 -10。展开起点后,队列里先弹出成本 2 的目标 1。如果此时就返回,得到的是 2;真正更便宜的走法 0→2→1 还没有来得及展开。

这个图没有环,更没有负环。因此“没有负环”并不能把普通 Dijkstra 的非负前提补回来。代码把这个故意省掉前提的函数命名为 unsafe_early_target,只用于展示失败;供正常使用的 dijkstra_target 会先检查并拒绝负权边。

允许暂定答案继续改善,就需要另一套保证

本文的 Bellman-Ford 不按一次出队就永久定案,而是反复扫描全部边,继续传播更便宜的路线。在没有从起点可达的负环时,可以找到一条不重复顶点的最短路线,因此至多经过 V-1 条边;基础实现最多做 V-1 轮松弛,再额外检查是否还能变小。

如果额外一轮还发现从起点可达的改进,意味着可达负环破坏了相应距离的下界。本文这个返回整张距离表的接口会直接拒绝结果,而不是随便给出一个看似很小的数。这不表示图中每个顶点都受负环影响:不能从起点到达的负环,在本例中不会触发拒绝。

运行完整程序,比较错误对照与正确前提

保存为 dijkstra_negative.py,执行 python -B dijkstra_negative.py。unsafe_early_target 输出 2,bellman_ford_target 输出 -5,正常 Dijkstra 入口则明确拒绝这张图。所有运算使用整数,先避开浮点误差和溢出的额外话题。

程序枚举三顶点有向无环图的 64 种配置,每条候选边为不存在、-2、0 或 3;把 Bellman-Ford 与独立的简单路径枚举比较,并在全部边非负时额外核对 Dijkstra。加上可达/不可达负环、错误提前返回与统一加常数反例,合计 824 项核对。

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

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

小实验 · python
"""Integer weighted directed graphs; guarded Dijkstra and Bellman-Ford."""
import heapq
import itertools
import json
import math


def validate(n, edges, source):
    if type(n) is not int or n < 1 or not 0 <= source < n:
        raise ValueError("invalid graph/source")
    for a, b, weight in edges:
        if not (0 <= a < n and 0 <= b < n) or type(weight) is not int:
            raise ValueError("integer weights and valid vertices required")


def unsafe_early_target(n, edges, source, target):
    """Deliberately lacks the nonnegative precondition; used only as counterexample."""
    adjacency = [[] for _ in range(n)]
    for a, b, weight in edges:
        adjacency[a].append((b, weight))
    best, queue, settled = [math.inf] * n, [(0, source)], set()
    best[source] = 0
    while queue:
        cost, vertex = heapq.heappop(queue)
        if vertex in settled:
            continue
        settled.add(vertex)
        if vertex == target:
            return cost
        for neighbor, weight in adjacency[vertex]:
            candidate = cost + weight
            if neighbor not in settled and candidate < best[neighbor]:
                best[neighbor] = candidate
                heapq.heappush(queue, (candidate, neighbor))
    return math.inf


def dijkstra_target(n, edges, source, target):
    validate(n, edges, source)
    if not 0 <= target < n or any(weight < 0 for _, _, weight in edges):
        raise ValueError("Dijkstra requires nonnegative weights and a valid target")
    return unsafe_early_target(n, edges, source, target)


def bellman_ford(n, edges, source):
    validate(n, edges, source)
    distance = [math.inf] * n
    distance[source] = 0
    for _ in range(n - 1):
        changed = False
        for a, b, weight in edges:
            if distance[a] != math.inf and distance[a] + weight < distance[b]:
                distance[b] = distance[a] + weight
                changed = True
        if not changed:
            break
    for a, b, weight in edges:
        if distance[a] != math.inf and distance[a] + weight < distance[b]:
            raise ValueError("negative cycle reachable from source")
    return distance


def enumerate_simple_paths(n, edges, source, target):
    adjacency = [[] for _ in range(n)]
    for a, b, weight in edges:
        adjacency[a].append((b, weight))

    def visit(vertex, visited):
        if vertex == target:
            return 0
        return min((weight + visit(neighbor, visited | {neighbor})
                    for neighbor, weight in adjacency[vertex] if neighbor not in visited),
                   default=math.inf)
    return visit(source, {source})


def main():
    example = [(0, 1, 2), (0, 2, 5), (2, 1, -10)]
    wrong = unsafe_early_target(3, example, 0, 1)
    correct = bellman_ford(3, example, 0)[1]
    assert (wrong, correct) == (2, -5)
    checks = 1
    try:
        dijkstra_target(3, example, 0, 1)
        raise AssertionError("negative edge accepted")
    except ValueError:
        checks += 1
    # All 64 three-vertex DAGs with each edge absent or weight -2, 0, 3.
    slots = [(0, 1), (0, 2), (1, 2)]
    for weights in itertools.product([None, -2, 0, 3], repeat=3):
        edges = [(a, b, w) for (a, b), w in zip(slots, weights) if w is not None]
        for source in range(3):
            distances = bellman_ford(3, edges, source)
            for target in range(3):
                expected = enumerate_simple_paths(3, edges, source, target)
                assert distances[target] == expected
                checks += 1
                if all(w >= 0 for _, _, w in edges):
                    assert dijkstra_target(3, edges, source, target) == expected
                    checks += 1
    try:
        bellman_ford(3, [(0, 1, 1), (1, 2, -2), (2, 1, 0)], 0)
        raise AssertionError("reachable negative cycle ignored")
    except ValueError:
        checks += 1
    unreachable = bellman_ford(4, [(0, 1, 2), (2, 3, -1), (3, 2, 0)], 0)
    assert unreachable == [0, 2, math.inf, math.inf]
    checks += 1
    # Adding the same positive number per edge changes routes of different lengths.
    original_costs, shifted_costs = [1, 0 + 0], [1 + 2, (0 + 2) + (0 + 2)]
    assert original_costs[1] < original_costs[0] and shifted_costs[0] < shifted_costs[1]
    checks += 1
    print(json.dumps({"checks_passed": checks, "unsafe_early_target": wrong,
                      "bellman_ford_target": correct, "guard_rejects_negative": True,
                      "DAGs_checked": 64, "unreachable_negative_cycle": [0, 2, None, None],
                      "original_direct_and_two_edge_costs": original_costs,
                      "after_adding_two_per_edge": shifted_costs,
                      "boundary": "integer directed graphs; no real route measurement"}, indent=2))


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中执行 python -B dijkstra_negative.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。整数边权的有向图算法实验,含故意去掉前提的错误对照、拒绝负权的Dijkstra和基础Bellman-Ford;没有测试地图服务、真实网络或大图性能。

运行后对照结果
{
  "checks_passed": 824,
  "unsafe_early_target": 2,
  "bellman_ford_target": -5,
  "guard_rejects_negative": true,
  "DAGs_checked": 64,
  "unreachable_negative_cycle": [
    0,
    2,
    null,
    null
  ],
  "original_direct_and_two_edge_costs": [
    1,
    0
  ],
  "after_adding_two_per_edge": [
    3,
    4
  ],
  "boundary": "integer directed graphs; no real route measurement"
}

给所有边加一个常数,就能把负数变成正数吗?

可以让每条边的数字变大,但不一定保留哪条路线更便宜。原本一条直接边成本 1,另一条走两步、每步成本 0,后者总成本 0 更便宜。若每条边都加 2,直接路线变成 3,两步路线变成 4,顺序反过来了。

原因是不同路线经过的边数不同,得到的额外费用也不同。程序实际核对这个变化。某些专业重赋权方法能保持路径之间需要的关系,但它们有更严格的构造条件,不能用“随便给每条边加同一个数”替代。

先确认输入的含义,再谈速度

如果成本代表耗时、距离等非负量,带前提检查的 Dijkstra 很合适;若模型允许抵扣或收益,先检查是否会产生负权或负环,再选择算法。本文拒绝任何负权的 Dijkstra 入口是保守合同,即使负边位于不可达区域,也不会悄悄放行。

教学 Bellman-Ford 按轮扫描边,最坏时间随 V×E 增长;Dijkstra 的实际开销还与队列实现有关。本文验证的是小图结果和边界,没有做大图性能比较,也没有实现导航、网络选路或自动算法切换。

记住这三点

  • Dijkstra 的提前定案依赖非负边权,当前最小只是论证中的一步。
  • 负边不一定形成负环,但已经足以让普通提前返回的保证失效。
  • 改动权重必须检查路径总成本关系,不能只看单条边是否变正。

别把例子用过头

  • 认为只要没有负环,Dijkstra 就自动适用。
  • 发现个别带负边的输入仍算对,就把前提检查删掉。
  • 把给每条边加同一常数当作保持路线顺序的通用办法。

自己试一小步

请自己构造四个点的图,让一条负边存在,但某个目标的提前返回恰好仍然正确。

  1. 先画图并写出所有边权,区分可达与不可达点。
  2. 枚举到目标的路线,对比故意不检查前提的函数与Bellman-Ford。
  3. 只修改一条边,使原先恰好正确的目标也出错,并说明是哪一步推理失效。

做到这些,就算完成

  • 用实际路线成本支持答案,而不只记录两个函数输出。
  • 把某个样本正确与算法对全部合法输入的保证分开。
  • 说明有没有可达负环;本节不提供构图或结果答案。

查原始资料

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

  • MIT 6.006:Lecture 13 Dijkstra's Algorithm (新标签页)↗MIT OpenCourseWare / Erik Demaine、Jason Ku、Justin Solomon · 英文

    非负权时沿最短路径距离不下降,及出队确认正确性的论证前提。

    2026-10-17 实际打开核对:PDF 第1—3页的非负边权、算法步骤与正确性段落;未把讲义的队列实现复杂度直接套到本篇代码。

  • Sedgewick / Wayne:DijkstraSP.java (新标签页)↗Robert Sedgewick、Kevin Wayne / Algorithms 4e · 英文

    作者实现首先拒绝负边,再进行优先队列松弛。

    2026-10-17 实际打开核对:构造函数中的负权检查和队列循环;本文Python使用独立编写的目标查询版本。

  • Sedgewick / Wayne:BellmanFordSP.java (新标签页)↗Robert Sedgewick、Kevin Wayne / Algorithms 4e · 英文

    Bellman-Ford 对可达负环提供专门检测,距离接口在检测到该状态时拒绝正常返回。

    2026-10-17 实际打开核对:relax、hasNegativeCycle 与 distTo 契约;参考实现使用队列,本篇使用基础全边轮次版本,未逐行移植。