“最短路”听起来像地图题,其实它是在练一件很通用的事:不断拿到一个更好的到达方案,并且知道什么时候可以不再怀疑它。这节先把这件事练顺手。
学完后,你能:
前置:图、队列、优先队列、整数加法。
dist[v] 初始设为无穷大,后来变小,这个数是答案还是“目前已知的上界”?画预设有向图,并在每个点旁留一个格子写 dist。初始只有起点 S 写 0,其余写 ∞。每发现一条路线,都要写成“从谁来 + 边权 = 新报价”;报价变小才允许覆盖旧值。纸上不要写“最短”二字,直到你能证明某点不可能再更便宜。
本节反复使用四栏:已确定的点、待处理的候选、每点当前报价 dist、一条边松弛后谁被重新安排。这比一开始记 BFS/Dijkstra 名字更重要。
从 S 到 T 有时有多条走法,甚至可以绕环无限走。先画无权图 S-A-T 与 S-B-T。列出所有长度至多 2 的路线,并把每个点第一次被碰到时的“走了几条边”写上。
停顿 0:若所有边都花 1,为什么队列里先放入的点一定不比后来放入的点远?请用“层”而不是算法名解释。
画 S→A(1), S→B(0), B→A(0)。请按普通 BFS 的邻接顺序先处理 S→A 再处理 S→B,写出 A 第一次的报价;随后走 B 的边,写出新报价。
停顿 1:A 已经“访问过”了,为什么仍必须允许它被改成更小的 0?如果拒绝更新,错的是哪条路线?
对每条边 $u\to v$、权 $w$,把下句抄在纸上,给 S→B(0) 与 B→A(0) 分别代数:
$$ \text{若 }dist[u]+w<dist[v]\text{,就用 }dist[u]+w\text{ 覆盖 }dist[v]. $$
请写出每次覆盖前后 A、B 的值。然后回答:这句话为什么不会把 dist[v] 压到真答案以下?
对下面三类边权,先在纸上给新报价贴标签:应当优先处理、稍后处理、还是可定案?
| 边权 | 新报价相对当前层 | 合理的容器 | 你要维护的顺序 |
|---|---|---|---|
| 全为 1 | |||
| 只有 0/1 | |||
| 非负任意整数 |
停顿 3:0 边的新点为什么应塞到双端队列前端?Dijkstra 为什么不允许负边?先用一张具体反例说明。
想象到同一地点 P 时,开关是开或关会决定可走的路。请写出:P,开 与 P,关 能否合并成一个 dist[P]?若不能,图中的一个点究竟代表什么?
从起点枚举所有走法,遇到环甚至会有无限多条路线;即使只枚举简单路径,数量也可能指数级。真正应该保存的不是“所有走法”,而是每个状态目前最便宜的到达价格。若一条新边让价格更低,就更新它;这一步叫松弛(relaxation)。
上图中从 $S$ 直接到 $A$ 要 1,但 $S\to B\to A$ 的总权是 0。普通 BFS 若把“先到”误当成“永远最好”,就会做出错误承诺。
若每次动作代价相同,距离等于经过边数。BFS 的队列让所有距离为 $d$ 的状态先于距离为 $d+1$ 的状态出队;第一次访问就可以定案。
dist[start] = 0
queue.push(start)
while queue not empty:
u = queue.pop_front()
for v in neighbors[u]:
if dist[v] is INF:
dist[v] = dist[u] + 1
queue.push_back(v)
对边 $u\to v$、权 $w$,若 dist[u] + w < dist[v],我们发现一条更短的真实路线,便更新:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
put v back into the work structure
不同算法的差别主要是“下一个处理谁”,而非这句松弛本身。
| 边权条件 | 适合的取出顺序 | 核心理由 |
|---|---|---|
| 都为 1 | FIFO 队列 | 层数单调 |
| 只有 0/1 | 双端队列 | 0 边放前面,1 边放后面 |
| 都非负 | 最小堆 | 当前最小距离不会被未来绕路压低 |
从堆中取出未定案且 dist 最小的 $u$。假设存在一条尚未发现的更短路线到 $u$,它从已定案区域第一次跨出的边会到达某个未定案点 $x$。由于边权非负,那条路线到 $x$ 的长度不大于到 $u$ 的长度,$x$ 本应更早被取出,矛盾。
负边会拆掉这个论证:一个先看起来较远的点能借负边回来,把已经定案的距离再压低。不是堆不够努力,是“非负”这张保证书被撕掉了。
对边 S→A(1), S→B(0), B→A(0), A→T(1), B→T(1),从 S 开始。先预测,再填写:
| 取出/处理谁 | 当前 dist |
尝试的边 | 是否松弛 | 新工作队列或堆 |
|---|---|---|---|---|
| S | S→A(1) |
|||
| S | S→B(0) |
|||
| B | B→A(0) |
|||
| B | B→T(1) |
然后把 A 以距离 1 的旧条目也画进最小堆;它晚些弹出时为何必须被跳过?
任何时刻 dist[v] 要么是无穷大,要么对应一条从起点到 $v$ 的实际路线,因此永远不会小于真答案。松弛只会让上界变紧。BFS、0-1 BFS、Dijkstra 分别在各自边权条件下安排处理顺序,保证某个点被正确时机取出后,其上界已经等于真最短路。
0-1 BFS 中,走 0 边的新状态应放队首,走 1 边放队尾;这模拟了按距离递增处理,不必每次使用堆。三者的图上时间通常为 $O(V+E)$、$O(V+E)$、$O((V+E)\log V)$。
INF 取足够大的 long long,常用 max/4,避免 INF+w 溢出。dist[u],跳过它。(位置, 开关状态) 看作不同点。运行 从乱走到松弛.py。可选择 BFS、0-1 BFS 或 Dijkstra;预设图会逐条打印松弛,以及状态进入队列、队首或堆的原因。
建议先选 0-1 BFS,预测 $A$ 会先以距离 1 被发现、后以距离 0 被改写;再选 Dijkstra,观察旧堆条目为何被跳过。
最短路的“状态扩点”会通向正式课中的免费切换、时间周期和差分约束。若存在可达负环,最短值可以无限下降,此时问题不再是“找一个有限最短路”;若图是 DAG,按拓扑序松弛能处理某些负边。