Personal Field Archive / Received 2026.09.24
图的最短路径:从问题出发推导 BFS 与 Dijkstra
从图的表示、处理顺序和状态维护出发,逐步推导无权图 BFS 与非负权图 Dijkstra,而不是背诵模板。
目标:即使此前没有学过图,也能从“要解决什么问题、为什么需要这些数据结构、每一步必须维护什么事实”逐步推导出代码,而不是背诵模板。
1. 从“关系”到“图”
很多问题表面上并不像数学题:社交网络中的好友关系、城市之间的道路、网页之间的链接、单词之间的一步转换,都具有同一种结构:存在一组对象,对象之间存在连接。
图(graph)用两个基本概念描述这种结构:
- 节点(vertex/node):被研究的对象,例如人、城市或网页;
- 边(edge):对象之间的连接,例如好友关系或道路。
例如:
A → B
A → C
B → D
C → D
在 Python 中可以用邻接表(adjacency list)表示:
graph = {
"A": ["B", "C"],
"B": ["D"],
"C": ["D"],
"D": [],
}
这里 graph["A"] 表示从 A 可以直接到达的所有节点。边若只有方向 A → B,这是有向图;若 A 与 B 能相互到达,则通常要同时记录 A → B 和 B → A。
2. “最短路径”其实有两种问题
给定起点和终点,我们常问:“怎样走最短?”但“最短”必须先被定义。
2.1 每条边代价相同:最少经过几条边
如果每走一步的代价完全相同,那么路径长度就是边数。例如社交关系中,A → B → D 有两条边,A → C → E → D 有三条边,前者更短。这类问题使用 BFS(Breadth-First Search,广度优先搜索)。
2.2 每条边代价不同:权重总和最小
道路可能具有不同距离或时间:
A ──10──> B
A ── 1──> C ──2──> D ──2──> B
A → B 只经过一条边,总代价却是 10;A → C → D → B 经过三条边,总代价是 5。如果目标是让权重总和最小,就要使用 Dijkstra 算法,而不是 BFS。
| 问题 | “最短”的定义 | 算法 |
|---|---|---|
| 无权图,或所有边权相同 | 边数最少 | BFS |
| 边权不同且均非负 | 权重总和最小 | Dijkstra |
两种算法真正的共同点是:它们都在逐步扩大“已经知道最优答案的区域”。区别在于,BFS 用层级顺序扩大区域;Dijkstra 用当前累计代价的大小扩大区域。
第一部分:从问题推导 BFS
3. 为什么普通遍历顺序不能保证最短路径
假设要从 A 找到 F:
A
/ \
B C
| |
D E
| |
F G
若沿着一条分支不断深入,就可能先找到一条很长的路径,却尚未检查另一条较短路径。要保证第一次找到目标时路径最短,必须按与起点的距离逐层检查:
第 0 层:A
第 1 层:B、C
第 2 层:D、E
第 3 层:F、G
这正是 BFS 的核心不变量:
在处理距离为
k + 1的节点之前,所有距离为k的节点都已经被处理。
因此,如果目标第一次在第 k 层被发现,就不可能还有一条少于 k 条边、但尚未被发现的路径。
4. 为什么需要 queue
为了实现“先发现的节点先处理”,需要 FIFO(First In, First Out)队列:
from collections import deque
queue = deque(["A"])
node = queue.popleft() # 从队首取出
queue.append("B") # 从队尾加入
当处理 A 时,先将 B、C 加入队尾;只有当前层排在前面的节点处理完毕,下一层节点才会轮到。这不是实现细节,而是 BFS 能按层搜索的直接原因。
5. 为什么需要 searched
图可能有环:
A → B → C → A
如果每次遇到邻居都重新入队,搜索会无限循环。即使没有环,一个节点也可能由多条路径到达:
A → B → D
A → C → D
因此需要集合记录已经发现的节点:
searched = {start}
发现一个新邻居时,应当在入队时立即标记:
if neighbor not in searched:
searched.add(neighbor)
queue.append(neighbor)
若等到出队时才标记,B 和 C 可能在 D 被处理前分别将它加入队列,造成重复。入队时标记保证每个节点最多入队一次。
6. 从职责直接得到“是否可达”的 BFS
现在已有全部必要条件:
queue:决定按层处理的顺序
searched:保证每个节点最多入队一次
while:只要还有待处理节点就继续
is_target:判断当前节点是否为目标
由此可以直接写出:
from collections import deque
from typing import Callable
def breadth_first_search(
graph: dict[str, list[str]],
start: str,
is_target: Callable[[str], bool],
) -> bool:
queue = deque([start])
searched = {start}
while queue:
node = queue.popleft()
if is_target(node):
return True
for neighbor in graph.get(node, []):
if neighbor not in searched:
searched.add(neighbor)
queue.append(neighbor)
return False
初始化使用 deque([start]),是因为起点本身也必须被检查;它是距离为 0 的节点。若初始化为 deque(graph[start]),搜索会跳过起点。
7. 为什么还原路径需要 parent
队列只能告诉我们接下来处理谁,searched 只能告诉我们谁已被发现;两者都没有保存“通过哪条路径到达”。若要返回具体路径,发现节点时必须记录它从哪里来:
parent[neighbor] = node
例如搜索形成:
parent = {
"A": None,
"B": "A",
"D": "B",
"F": "D",
}
含义是:B 从 A 被发现,D 从 B 被发现,F 从 D 被发现。找到 F 后反向追踪:
F → D → B → A
再反转即可得到正向路径:
A → B → D → F
parent 必须在发现邻居的当下记录,因为那一刻才知道是谁发现了它。BFS 第一次发现节点时得到的就是最短路径,所以以后不应覆盖其 parent。
8. 返回最短路径的完整 BFS
from collections import deque
from typing import Optional
def bfs_shortest_path(
graph: dict[str, list[str]],
start: str,
target: str,
) -> Optional[list[str]]:
queue = deque([start])
searched = {start}
parent: dict[str, Optional[str]] = {start: None}
while queue:
node = queue.popleft()
if node == target:
path = []
while node is not None:
path.append(node)
node = parent[node]
path.reverse()
return path
for neighbor in graph.get(node, []):
if neighbor not in searched:
searched.add(neighbor)
parent[neighbor] = node
queue.append(neighbor)
return None
这段代码可以从两个阶段理解:外层 while queue 建立从起点出发的搜索树;找到目标后,内层 while node is not None 沿 parent 从目标倒推回起点。
9. BFS 的正确性与复杂度
BFS 正确性的核心不是“使用了 queue”这一表面事实,而是 queue 维持了距离的非递减顺序。距离为 k 的节点,只能产生距离为 k + 1 的新节点;当这些新节点进入队尾时,所有更近且尚待处理的节点仍排在它们前面。因此目标第一次被发现时,其边数必然最少。
采用邻接表并在入队时标记后,每个可达节点最多入队和出队一次,成本为 O(V);每条可达边最多被检查一次,成本为 O(E)。总时间复杂度为:
[ O(V+E) ]
队列、searched 和 parent 最坏都可能保存接近全部节点,因此空间复杂度为 O(V)。
第二部分:为什么 BFS 不够,以及如何推导 Dijkstra
10. BFS 失败的根本原因
BFS 的层级代表“经过了多少条边”。当边权不同,边数不再等于总代价:
A ──10──> B
A ── 1──> C ──2──> D ──2──> B
BFS 会更早发现只隔一条边的 B,但代价 10 并不是最小值。要解决加权图的最短路径问题,处理顺序不能再由“谁先入队”决定,而必须由“谁当前离起点最近”决定。
这一步变化产生了 Dijkstra:
每次选择当前已知累计代价最小、且尚未最终处理的节点。
11. 为什么需要 costs
在 Dijkstra 中,一个节点第一次被发现时,路径不一定最优。因此不能像 BFS 那样只记录“是否见过”,而要记录目前找到的最低累计代价:
costs = {
"A": 0,
"B": float("inf"),
"C": float("inf"),
"D": float("inf"),
}
costs[node] 的准确含义是:
截至当前,已经发现的所有从起点到
node的路径中,权重总和的最小值。
起点到自身的代价是 0;尚未找到路径的节点记为正无穷 float("inf")。
12. 松弛:如何改进暂定答案
假设当前节点为 C,已经知道从起点到 C 的最低暂定代价是 3,边 C → E 的权重为 4。经过 C 到 E 的候选代价为:
new_cost = costs["C"] + 4 # 7
如果 7 < costs["E"],就找到了更便宜的路径,因此更新:
costs["E"] = 7
parent["E"] = "C"
这个“用经过当前节点的新路径尝试降低邻居代价”的过程叫 relaxation(松弛):
new_cost = costs[node] + weight
if new_cost < costs[neighbor]:
costs[neighbor] = new_cost
parent[neighbor] = node
costs 与 parent 必须同步更新:前者记录当前最优路径的代价,后者记录同一条路径上的前驱。若只更新 costs,最后可能得到正确代价,却还原出另一条不匹配的路径。
13. 为什么需要 processed,而不是 BFS 的 searched
BFS 中,节点第一次被发现时,最短路径已经确定,所以立刻加入 searched。Dijkstra 不同:第一次发现只得到暂定代价,未来仍可能降低。
例如:
A → B:6
A → C:2
C → B:3
处理 A 后,B 的暂定代价是 6;处理 C 后,又发现 A → C → B 的代价为 5,于是必须更新 B。如果第一次发现 B 时就禁止再次更新,答案会错误。
因此 Dijkstra 使用:
processed = set()
它表示的不是“已经发现”,而是:
该节点已作为当前代价最小的未处理节点被选中,其最短距离已经最终确定。
BFS 的 searched |
Dijkstra 的 processed |
|---|---|
| 已被发现并入队 | 最短距离已最终确定 |
| 第一次发现时标记 | 完成该节点的松弛后标记 |
parent 不再改变 |
未处理节点的 cost 和 parent 可以多次改变 |
14. 为什么选出最便宜的节点后可以确定它
这是 Dijkstra 正确性的核心,也解释了为什么它不允许负权边。
设 C 是当前所有未处理节点中暂定代价最小的节点,代价为 d。假设还存在一条尚未发现、代价小于 d 的路径到达 C。这条路径必定先经过某个尚未处理节点 X;但 C 已是最便宜的未处理节点,所以到 X 的暂定或最终可达代价不可能小于使整条路径突破 d 所需的程度。更直接地说,从当前边界继续向外走时,每条边都只会增加或保持总代价,因为权重非负,不可能绕道后反而产生一个小于当前最小值的结果。
如果允许负权边,上述推理失效。例如节点以代价 5 被确定后,未来可能通过一条权重 -10 的边把它降为 -2。因此:
Dijkstra 只适用于所有边权均非负的图。
负权边问题通常需要 Bellman–Ford 等其他算法。
15. 从职责推导 Dijkstra 的主循环
现在已有四个必要组成部分:
costs:记录每个节点当前已知的最低代价
parent:记录产生该最低代价的前驱
processed:记录最短距离已经最终确定的节点
选择规则:每次选当前 cost 最小的未处理节点
先写辅助函数:
from typing import Optional
def find_lowest_cost_node(
costs: dict[str, float],
processed: set[str],
) -> Optional[str]:
lowest_cost = float("inf")
lowest_cost_node = None
for node, cost in costs.items():
if node not in processed and cost < lowest_cost:
lowest_cost = cost
lowest_cost_node = node
return lowest_cost_node
主循环自然成为:
选出当前最便宜的未处理节点
→ 用它松弛所有邻居
→ 将它标记为已处理
→ 再选下一个节点
16. 完整的基础版 Dijkstra
加权邻接表可以表示为“节点 → 邻居及边权”:
graph = {
"A": {"B": 6, "C": 2},
"B": {"D": 1},
"C": {"B": 3, "D": 5},
"D": {},
}
完整算法如下:
from typing import Optional
def find_lowest_cost_node(
costs: dict[str, float],
processed: set[str],
) -> Optional[str]:
lowest_cost = float("inf")
lowest_cost_node = None
for node, cost in costs.items():
if node not in processed and cost < lowest_cost:
lowest_cost = cost
lowest_cost_node = node
return lowest_cost_node
def dijkstra(
graph: dict[str, dict[str, float]],
start: str,
) -> tuple[dict[str, float], dict[str, Optional[str]]]:
costs = {node: float("inf") for node in graph}
costs[start] = 0
parent: dict[str, Optional[str]] = {
node: None for node in graph
}
processed: set[str] = set()
node = find_lowest_cost_node(costs, processed)
while node is not None:
cost = costs[node]
for neighbor, weight in graph.get(node, {}).items():
new_cost = cost + weight
if new_cost < costs[neighbor]:
costs[neighbor] = new_cost
parent[neighbor] = node
processed.add(node)
node = find_lowest_cost_node(costs, processed)
return costs, parent
这个基础版本假设所有作为邻居出现的节点,也都作为外层键存在于 graph 中;没有出边的终点应写为 "D": {}。
17. 为 Dijkstra 加上路径还原
路径还原本身与 BFS 相同,因为两者的 parent 都表示当前最优路径上的前驱:
def reconstruct_path(
parent: dict[str, Optional[str]],
start: str,
target: str,
) -> Optional[list[str]]:
if target != start and parent[target] is None:
return None
path = []
node: Optional[str] = target
while node is not None:
path.append(node)
node = parent[node]
path.reverse()
return path
使用示例:
graph = {
"A": {"B": 6, "C": 2},
"B": {"D": 1},
"C": {"B": 3, "D": 5},
"D": {},
}
costs, parent = dijkstra(graph, "A")
path = reconstruct_path(parent, "A", "D")
print(costs["D"]) # 6
print(path) # ['A', 'C', 'B', 'D']
推演如下:
| 被处理节点 | 对邻居产生的变化 | 当前关键代价 |
|---|---|---|
A |
B = 6,C = 2 |
B: 6, C: 2, D: ∞ |
C |
B 从 6 降为 5;D = 7 |
B: 5, D: 7 |
B |
D 从 7 降为 6 |
D: 6 |
D |
无邻居 | 最终答案 6 |
与此同时,parent["D"] 先是 "C",之后随更优代价一起改为 "B",最终得到 A → C → B → D。
18. Dijkstra 的复杂度
上述教学版每次都线性扫描所有节点以寻找最低 cost,该选择最多执行 V 次,因此这部分是 O(V²);所有松弛合计检查 E 条边,因此总复杂度是:
[ O(V^2 + E) ]
通常简写为 O(V²)。实际工程和 LeetCode 中更常使用 min-heap(最小堆)快速选出当前最低代价节点,复杂度可优化为:
[ O((V+E)\log V) ]
或常写作 O(E log V)。不过在初学阶段,线性扫描版更能直接呈现算法逻辑;priority queue 是选择机制的优化,不改变松弛与正确性的本质。
第三部分:统一理解两种算法
19. 相同骨架,不同的“处理顺序”
两种算法可以统一理解为:维护一批候选节点,每次取出当前最值得确定的节点,再利用它扩展邻居。
| 组成部分 | BFS | Dijkstra |
|---|---|---|
| 解决问题 | 边数最少 | 非负权重总和最小 |
| 下一个处理谁 | FIFO 队首 | 暂定代价最小者 |
| 节点何时确定最优 | 第一次发现时 | 成为最低代价未处理节点时 |
| 防重复状态 | searched |
processed |
| 距离状态 | 可选的层数 | 必需的 costs |
| 路径状态 | parent,首次写入 |
parent,随更优代价更新 |
| 边的更新 | 未发现才加入 | new_cost 更小时松弛 |
BFS 可以被看作边权全部为 1 时的特殊最短路径算法。因为每扩展一条边,代价都恰好增加 1,FIFO 队列自然就能保证按累计代价从小到大处理;边权不同时,这种自然顺序消失,Dijkstra 才需要显式维护 costs 并选择当前最小值。
20. 从题目描述判断应该用谁
先问三个问题:
- 题目是否可以建模为“对象 + 对象之间的连接”?如果可以,就是图问题的候选。
- 题目要求的是可达性、最少步骤或最少边数吗?若每一步代价相同,优先考虑 BFS。
- 每条边是否有不同的非负代价,并要求总代价最小?若是,考虑 Dijkstra。
典型语言信号如下:
最少操作次数、最少跳数、最近层级、无权最短路 → BFS
最低费用、最短时间、最小距离,且边权非负 → Dijkstra
存在负权边 → 不使用 Dijkstra
21. 不背模板时,如何现场推导代码
推导 BFS
先写出逻辑句:
我要按距离逐层处理 → 需要 FIFO queue
图可能有环或汇合 → 需要 searched
我要返回具体路线 → 发现节点时记录 parent
然后代码骨架自然是:
queue = deque([start])
searched = {start}
parent = {start: None}
while queue:
node = queue.popleft()
# 判断目标
# 遍历邻居
# 未发现:标记、记录 parent、入队
推导 Dijkstra
同样先写逻辑句:
节点第一次出现未必最优 → 需要 costs 保存暂定答案
新路线可能更便宜 → 需要 relaxation
必须优先确定当前最便宜者 → 需要最小值选择机制
已确定的节点不再处理 → 需要 processed
我要返回具体路线 → cost 降低时同步更新 parent
代码骨架便是:
costs = 所有节点为无穷,起点为 0
parent = 所有节点为 None
processed = set()
node = 当前 cost 最小的未处理节点
while node is not None:
for neighbor, weight in graph[node].items():
new_cost = costs[node] + weight
if new_cost < costs[neighbor]:
更新 costs 和 parent
processed.add(node)
node = 下一个最低 cost 的未处理节点
只要能够解释每一份状态为何存在,就不需要死记完整代码。
22. 最容易混淆的细节
set.add() 与 deque.append()
searched.add(neighbor) # set:加入集合
queue.append(neighbor) # deque:加入队尾
BFS 的 searched 必须在入队时更新
这样才能防止同一节点在正式处理前被多条路径重复加入队列。
Dijkstra 的 parent 不是“最近检查它的人”
它是产生当前最低 cost 的前驱;只有成功松弛时才更新。
graph.get(node, []) 与 graph.get(node, {})
无权图的邻居通常是列表,因此默认值用 [];加权图的邻居与权重通常是字典,因此默认值用 {}。
不可达目标
BFS 会耗尽队列并返回 None 或 False;Dijkstra 中目标的 cost 会保持 float("inf"),其 parent 也不会形成从起点出发的链。
23. 最后的自检标准
真正掌握并不是能默写模板,而是能够回答:
- 为什么 BFS 的 FIFO 顺序能保证最少边数?
- 为什么
searched要在入队时更新? - 为什么 BFS 的
parent通常只写一次? - 为什么 Dijkstra 第一次发现节点时不能立即确定答案?
- relaxation 比较的为什么是累计代价,而不是单条边权?
- 为什么
costs和parent必须同时更新? - 为什么非负权重是 Dijkstra 正确性的必要条件?
- 面对一个新题,能否先明确“最短”的定义,再选择算法?
如果这些问题都能从算法维护的不变量出发解释,那么代码就不再是一组需要记忆的语句,而是问题约束的必然结果。