开始前,会这些就够了
- 能把点和有向边读成一张路线图
- 会给一条路线累加成本
- 了解列表与for循环
- 需要补基础时:先补图、顶点与遍历
- 需要补基础时:再认识最小堆与优先队列
从 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 项核对。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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 就自动适用。
- 发现个别带负边的输入仍算对,就把前提检查删掉。
- 把给每条边加同一常数当作保持路线顺序的通用办法。
自己试一小步
请自己构造四个点的图,让一条负边存在,但某个目标的提前返回恰好仍然正确。
- 先画图并写出所有边权,区分可达与不可达点。
- 枚举到目标的路线,对比故意不检查前提的函数与Bellman-Ford。
- 只修改一条边,使原先恰好正确的目标也出错,并说明是哪一步推理失效。
做到这些,就算完成
- 用实际路线成本支持答案,而不只记录两个函数输出。
- 把某个样本正确与算法对全部合法输入的保证分开。
- 说明有没有可达负环;本节不提供构图或结果答案。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- MIT 6.006:Lecture 13 Dijkstra's Algorithm (新标签页)↗
非负权时沿最短路径距离不下降,及出队确认正确性的论证前提。
- Sedgewick / Wayne:DijkstraSP.java (新标签页)↗
作者实现首先拒绝负边,再进行优先队列松弛。
- Sedgewick / Wayne:BellmanFordSP.java (新标签页)↗
Bellman-Ford 对可达负环提供专门检测,距离接口在检测到该状态时拒绝正常返回。