开始前,会这些就够了
- 知道有向边的起点、终点和费用
- 可先读 最短路为什么需要检查边权条件
- Python 3.10+;使用标准库
collections.deque
从0出发,直接走到1要花1;先从0免费到2,再从2免费到1,总费用却是0。第一次看到节点1时已经有一条路线,但它还不是最便宜的路线。
普通BFS按边数逐层推进。把每条边都当成同等的一步是它成立的前提;费用有0和1时,需要按总费用安排处理顺序,而不是照搬“第一次发现就不再修改”的规则。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- demo.py:完整可运行源码
- README.md:运行步骤与练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和运行验证记录
- SHA256SUMS.txt:文件摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B demo.py解压后,在含 demo.py 的目录打开终端。命令分别回车执行。Python 标准库的独立 0-1 BFS 实验;固定小图加 729 张图的 2187 个起点对照。未在生产网络或大规模数据上测试,教学快照开销与核心算法分开。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先发现一条路,就能把它的费用固定下来吗?
正在准备实验控件…
有向边费用只能为 0 或 1,起点为 A。错误模式照搬普通 BFS 的 FIFO 和首次发现即固定规则;正确模式允许更便宜的路线更新,并将 0 费用边放队首、1 费用边放队尾。界面保存小图快照,不用于比较真实运行速度。
一条边更少,不保证总费用更低
小图只有三条有向边:0→1费用1,0→2费用0,2→1费用0。输入顺序先列0→1。若用普通先进先出队列,把第一次发现的费用锁住,就会得到距离0、1、0。
然而路线0→2→1总费用为0,比直接路线更便宜。这里故意展示的错误程序,是给普通BFS的首次发现规则添上边权后仍不允许更新;并非声称普通BFS本来就能求任意带权最短路。
我们要维护dist[v],表示目前找到的到v的最小费用。若从u经过费用w的边得到dist[u]+w,且这个候选严格小于dist[v],就更新它。这个尝试改小距离的动作叫松弛。
费用0放队首,费用1放队尾
起点0的费用为0。处理0时,先发现到1费用1,把(1,1)放队尾;再发现到2费用0,把(2,0)放队首。括号第二项是这次入队时记下的费用。
接着先处理2,就能把到1的费用从1改成0,并把新的(1,0)放到队首。于是更便宜的记录先被处理,较贵的旧记录仍暂时留在队列里。
为什么只需前后两端?当前取出的有效费用是d,沿边只能得到d或d+1。免费边的新候选属于当前费用层,放前面;费用1的新候选属于下一层,放后面。这个有限的费用差让双端队列足够,不需要在此例中维护通用优先队列。
同一节点可以有旧记录,取出时先检查版本
小图里会出现节点1的两条记录:(1,1)和(1,0)。当前dist[1]已经是0,最后取出旧的(1,1)时,记录费用1与当前值0不一致,就直接跳过。程序实际记录了一次这种过期项。
过期队列记录不是另一条需要继续扩展的最佳路径。把入队时的费用随节点一起保存,可以直观看见是哪一条记录过时;本文没有依靠在队列中间搜索并删除旧项。
更新条件使用严格变小。若一个费用0的环让你绕回原处,总费用没有降低,就不会因相等候选无限入队。到不了的节点用None或浏览器中的未到达表示,不能把它们的距离误写为0。
0和1是费用条件,不是边必须很少的意思
算法允许有向图、有环、自环和不可达节点;示例为便于看清输入,限制1到12个节点且同一有序节点对最多一条边。输入费用只能是0或1,其他费用会被拒绝。
若所有边费用都是1,0-1 BFS就表现为通常的按边数分层。若边费用可能是任意非负值,需要换用适当的最短路算法;若包含负权,必须进一步考虑负环和算法条件。不能只因为代码还能运行,就沿用这里的处理顺序证明。
有效记录按当前最小费用处理,使一个节点在有效取出后无需再以更小费用重新扩展;每条边只需在其起点的有效处理时尝试。过期记录数量受成功更新次数约束。因此使用真正双端队列的核心过程为O(V+E)时间、O(V+E)空间。
演示快照和真正的双端队列,分别算开销
Python使用collections.deque,官方文档说明两端追加和弹出接近O(1)。不过本文还为每次事件复制整张距离表,用于打印和核对;这部分轨迹最多额外产生O(V(V+E))的时间和存储。不能把完整教学程序宣称为不带记录的线性实现。
浏览器为了方便显示,使用小数组模拟队列,并保存队列与距离快照。数组的shift和unshift不是这里所要求的常数时间双端操作;加上轨迹复制,一个保守总开销界为O((V+E)²+V(V+E))。交互只用小图,性能结论针对标准核心算法。
完整程序:用独立方法核对结果
保存下方全部代码为 demo.py,执行 python demo.py。程序只使用 Python 标准库,本次实际运行环境是 Windows / CPython 3.14.0;另通过 Python 3.10 语法解析,未在 3.10 解释器中执行。
枚举3个节点、不含自环的全部729张图:每个有序点对可无边、费用0或费用1。每张图分别从3个起点运行,共2187组;参考独立枚举所有简单路径求最小费用。因为边权非负,删掉重复节点之间的环不会增加费用,所以只看简单路径足够。另检查零费用自环、不可达节点和6类无效输入。
检查使用显式 AssertionError,正常执行和 python -O 执行都通过。输出直接取自本次运行;小范围穷举核对实现,不能代替前文对一般情况的解释。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""A first-discovered route need not be cheapest when some edges cost zero."""
from collections import deque
from itertools import product
def require(ok, message):
if not ok:
raise AssertionError(message)
def validate(n, edges, start):
if type(n) is not int or not 1 <= n <= 12 or type(start) is not int or not 0 <= start < n:
raise ValueError("1..12 nodes and an existing integer start required")
if not isinstance(edges, (list, tuple)) or len(edges) > 144:
raise ValueError("at most 144 directed edges required")
seen = set()
for edge in edges:
if not isinstance(edge, (list, tuple)) or len(edge) != 3:
raise ValueError("edge must be (from, to, cost)")
a, b, weight = edge
if any(type(x) is not int for x in edge) or not (0 <= a < n and 0 <= b < n) or weight not in (0, 1):
raise ValueError("known integer nodes and cost 0 or 1 required")
if (a, b) in seen:
raise ValueError("duplicate directed edge")
seen.add((a, b))
def distances(n, edges, start=0, first_only=False):
validate(n, edges, start)
if type(first_only) is not bool:
raise ValueError("first_only must be bool")
graph = [[] for _ in range(n)]
for a, b, cost in edges:
graph[a].append((b, cost))
dist = [None] * n
dist[start] = 0
queue = deque([(start, 0)])
trace = []
while queue:
node, recorded = queue.popleft()
if recorded != dist[node]:
trace.append(("stale", node, recorded, list(dist)))
continue
trace.append(("pop", node, recorded, list(dist)))
for target, cost in graph[node]:
proposed = recorded + cost
better = dist[target] is None or proposed < dist[target]
if better and (not first_only or dist[target] is None):
dist[target] = proposed
entry = (target, proposed)
if cost == 0 and not first_only:
queue.appendleft(entry)
else:
queue.append(entry)
trace.append(("relax", target, proposed, list(dist)))
return dist, trace
def simple_path_reference(n, edges, start):
best = [None] * n
def walk(node, cost, seen):
if best[node] is None or cost < best[node]:
best[node] = cost
for a, b, weight in edges:
if a == node and b not in seen:
walk(b, cost + weight, seen | {b})
walk(start, 0, {start})
return best
def main():
edges = [(0, 1, 1), (0, 2, 0), (2, 1, 0)]
good, trace = distances(3, edges)
bad, _ = distances(3, edges, first_only=True)
print(f"edges={edges}; start=0")
print(f"first_discovery_only={bad}; zero_one_bfs={good}")
print(f"stale_queue_records={sum(row[0] == 'stale' for row in trace)}")
require(good == [0, 0, 0] and bad == [0, 1, 0], "first-discovery counterexample")
pairs = [(a, b) for a in range(3) for b in range(3) if a != b]
cases = 0
for choices in product((-1, 0, 1), repeat=len(pairs)):
edges = [(a, b, cost) for (a, b), cost in zip(pairs, choices) if cost >= 0]
for start in range(3):
actual, _ = distances(3, edges, start)
require(actual == simple_path_reference(3, edges, start), "all simple paths")
cases += 1
require(distances(2, [(0, 0, 0)])[0] == [0, None], "zero self-loop and unreachable node")
invalid = [(0, [], 0), (3, [(0, 1, 2)], 0), (3, [(0, 1, -1)], 0),
(3, [(0, 1, True)], 0), (3, [(0, 3, 0)], 0), (3, [], True)]
for args in invalid:
try:
distances(*args)
except ValueError:
pass
else:
raise AssertionError("invalid input accepted")
print(f"graphs=729; start_cases={cases}; invalid_inputs=6")
print("PASS: independent enumeration of simple paths, zero cycles and unreachable nodes")
if __name__ == "__main__":
main()
怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-26 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。Python 标准库的独立 0-1 BFS 实验;固定小图加 729 张图的 2187 个起点对照。未在生产网络或大规模数据上测试,教学快照开销与核心算法分开。
运行后对照结果
edges=[(0, 1, 1), (0, 2, 0), (2, 1, 0)]; start=0
first_discovery_only=[0, 1, 0]; zero_one_bfs=[0, 0, 0]
stale_queue_records=1
graphs=729; start_cases=2187; invalid_inputs=6
PASS: independent enumeration of simple paths, zero cycles and unreachable nodes先画队列,再判断哪条旧记录会过期
练习不预填答案:4个节点,边为0→1费用1、0→2费用0、2→3费用0、3→1费用0。先列出每一次队首和队尾,再判断节点1第一次入队的费用是否会保持到最后。
再把3→1改为费用1,观察相等候选为什么不应再次入队。最后增加一个没有相连边的节点,说明它与费用为0的可达节点在结果中如何区分。
记住这些要点
- 费用最少与经过边数最少是不同目标。
- 松弛允许后来发现的更便宜路线改小当前距离。
- 费用0的候选放队首,费用1放队尾,使相邻费用层按序处理。
- 旧队列记录需要核对入队费用,零费用环需要严格变小的更新条件。
别把例子用过头
- 沿用首次发现即锁定,忽略后来出现的免费绕路。
- 把所有更新一律放队尾,却仍声称0-1 BFS的处理顺序。
- 用小于等于更新,导致零费用环反复入队。
- 用数组模拟双端队列,却把完整浏览器实现宣称为线性时间。
自己试一小步
0→1(1)、0→2(0)、2→3(0)、3→1(0)中,哪次更新会让已有队列记录过期?
- 按输入顺序写出起点处理后的队列。
- 每次记录当前费用、更新目标和加入哪一端。
- 用所有简单路径的费用作独立参考。
做到这些,就算完成
- 首次发现费用与最终费用分开记录。
- 相等候选不重复加入队列。
- 练习不预填最终距离或过期项。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- UNSW CPMSoc 2026 T1W10:0-1 BFS (新标签页)↗
0
/1边权下的两个相邻费用层,以及费用0加队首、费用1加队尾的双端队列方法。 - Python collections.deque:两端追加与弹出 (新标签页)↗
deque两端追加和弹出近似
O(1),以及列表头部操作与之不同。该文档只支撑容器操作,不单独证明0-1 BFS正确性。