qinghuan
← 回到知识探索

计算机趣闻 / 动手实验

边的费用只有0和1,为什么第一次到达一个点还不能定案?

走两条免费边可以胜过一条收费边:用双端队列观察距离怎样被改小,并识别队列里已经过期的旧记录。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 一条边更少,不保证总费用更低
  4. 费用0放队首,费用1放队尾
  5. 同一节点可以有旧记录,取出时先检查版本
  6. 0和1是费用条件,不是边必须很少的意思
  7. 演示快照和真正的双端队列,分别算开销
  8. 完整程序:用独立方法核对结果
  9. 先画队列,再判断哪条旧记录会过期
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
松弛
发现一条更便宜的走法,就把当前暂定成本调小。
双端队列
允许从两端加入或取出元素的队列。
打开完整术语小抄 ↗

从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 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先发现一条路,就能把它的费用固定下来吗?

正在准备实验控件…

有向边:A→B:1;A→C:0;C→B:0。

已知费用:A=0;B=尚未到达;C=尚未到达

队首→队尾(节点/入队时费用):A/0

起点费用为 0,其他位置尚未找到路线。

有向边费用只能为 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 执行都通过。输出直接取自本次运行;小范围穷举核对实现,不能代替前文对一般情况的解释。

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

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

小实验 · python
"""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)中,哪次更新会让已有队列记录过期?

  1. 按输入顺序写出起点处理后的队列。
  2. 每次记录当前费用、更新目标和加入哪一端。
  3. 用所有简单路径的费用作独立参考。

做到这些,就算完成

  • 首次发现费用与最终费用分开记录。
  • 相等候选不重复加入队列。
  • 练习不预填最终距离或过期项。

查原始资料

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

  • UNSW CPMSoc 2026 T1W10:0-1 BFS (新标签页)↗UNSW Competitive Programming and Mathematics Society · 英文

    0/1边权下的两个相邻费用层,以及费用0加队首、费用1加队尾的双端队列方法。

    已阅读226页动画展开PDF的第137至147页文本,重点为印刷页码39至40的0-1 BFS问题与解法。本次未检查页面图像,也未通读整份讲义。

  • Python collections.deque:两端追加与弹出 (新标签页)↗Python Software Foundation · 英文

    deque两端追加和弹出近似O(1),以及列表头部操作与之不同。该文档只支撑容器操作,不单独证明0-1 BFS正确性。

    已阅读deque类说明、append、appendleft和popleft相关正文;实际运行CPython 3.14.0,3.14分支文档可能继续更新。