图把对象作为顶点,把关系作为边。先明确方向、是否带权、是否允许重边和自环,再选存储形式。邻接表占 O(V+E),适合稀疏图;邻接矩阵占 O(V²),查一条边方便,但遍历全部邻居要扫描整行。
BFS按距离层次扩展
无权图中,每条边的成本相同。BFS把起点先放入队列,反复取队首,再把尚未发现的邻居加入队尾。队列前方的节点距离不会大于后方节点,因此第一次发现一个顶点时,记录的边数已经最少。
from collections import deque
def shortest_hops(graph, source, target):
nodes = set(graph)
for neighbors in graph.values():
nodes.update(neighbors)
if source not in nodes or target not in nodes:
raise ValueError('unknown vertex')
root = object()
parent = {source: root}
queue = deque([source])
while queue:
vertex = queue.popleft()
if vertex == target:
path = []
while vertex is not root:
path.append(vertex)
vertex = parent[vertex]
return path[::-1]
for neighbor in graph.get(vertex, ()):
if neighbor not in parent:
parent[neighbor] = vertex
queue.append(neighbor)
return None
if __name__ == '__main__':
print(shortest_hops({'a': ['b', 'c'], 'b': ['d'], 'c': ['d'], 'd': []}, 'a', 'd'))
示例从 a 到 d 返回三顶点路径,表示走两条边。父指针在首次发现时写入,也充当“已经发现”的标记,防止一个节点被多个父节点重复入队。重建路径后反转即可。目标不可达返回 None;起点就是目标时返回单顶点路径。
邻接表中可能只有出边顶点作为键,代码先收集全部边端点。未知起点或目标明确报错,避免把拼写错误默认为一个孤立节点。一般邻接表存的是可反复遍历的列表或元组;一次性生成器若先被节点收集消耗,后续遍历会丢边,本例约定不用这种输入。
DFS的状态比一个布尔值丰富
DFS优先走深,再返回未完成的分支。递归实现自然,但很长的链可能超过Python调用栈限制;显式栈需要保存下一条待探索边等状态,才能复现退出时间。
有向图判环常区分“未访问、正在当前递归路径、已经完成”。遇到仍在当前路径的顶点说明存在回边;遇到已经完成的顶点不能直接判环。无向图中沿原边回到父节点是正常情况,还要排除父边。强连通分量可以用两次DFS的顺序关系或低链接值算法求解,含义是分量内部任意两点互相可达。
拓扑排序只对DAG成立
若 u→v 表示u必须先于v,可维护入度,把所有零入度节点放入队列;移除节点后降低后继入度。处理数量少于V说明仍有环。拓扑序不唯一,不应只把某一个固定排列当作唯一正确答案;需要固定展示顺序时才加入稳定排序规则。
本例BFS时间 O(V+E)、空间 O(V),路径恢复 O(路径长度)。只从一个起点出发无法覆盖不连通图,统计连通分量需在外层逐个启动尚未访问的顶点。
练习与面试自查
在图中加入自环、平行边、孤立节点和多条等长路径,验证返回的每条相邻关系存在,长度确实最少。再实现多源BFS:所有源点距离零并先入队,可以得到离最近源点的距离。
面试要能解释:为何在入队时标记,而不是出队时才标记;BFS为什么不能直接解决任意带权最短路;拓扑序与按名字排序有什么不同。带权问题见最短路径。
本地运行
示例仅使用 Python 标准库,适用于 Python 3.10 及以上。把 example.py 与 test_example.py 保存到同一个目录,在该目录运行:
python example.py
python -m unittest -v test_example.py
测试检查本文列出的边界;新增练习仍需自己补充断言。