ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Dijkstra算法实战:基于邻接矩阵求解城市间最短路径成本

Dijkstra算法实战:基于邻接矩阵求解城市间最短路径成本 1. 项目概述从业务需求到数学模型最近在帮一家公司的物流部门做成本优化咨询他们遇到了一个非常典型的实际问题公司在六个主要城市我们暂且称为C1到C6都设有分公司日常的人员出差、货物调拨、文件传递产生了大量的城际交通费用。管理层想知道在现有的交通网络下任意两个分公司之间的最低通行成本是多少以及具体应该走哪条路线。他们给了一张表里面密密麻麻地写着城市之间的直达费用有些城市之间甚至没有直达线路。这其实就是把一个具体的业务问题抽象成了一个我们熟悉的带权图最短路径问题。把城市看作图的“顶点”Vertex城市间的直达交通线路看作“边”Edge而通行费用就是边的“权值”Weight。他们给我的那张表在数学和计算机科学里有一个非常标准的名字——带权邻接矩阵。这个矩阵C 它的第i行第j列的元素C[i][j] 就代表了从城市Ci到城市Cj的直达费用。如果两个城市之间没有直达线路这个值通常用一个极大的数比如无穷大INF或者一个特殊标记来表示。这个矩阵就是我们对整个交通网络的数字化描述是所有后续计算的基础。这个项目的核心目标很明确输入是这个6x6的带权邻接矩阵输出是任意两个城市之间的最低成本路径及其总费用。这听起来简单但手动计算几乎不可能尤其是当我们需要考虑通过中间城市中转可能更便宜的情况时。这就需要引入经典的Dijkstra算法或者利用像Python的networkx这样的图分析库来高效解决。对于管理层来说这个模型的结果不仅能用于报销审核判断某条出差路线是否成本最优更能为未来的物流路线规划、中心仓库选址等战略决策提供数据支持。2. 核心概念解析邻接矩阵与带权图在动手写代码之前我们必须把几个关键概念吃透。很多人一上来就找算法代码但如果不理解数据是如何表示的很容易在后续遇到各种诡异的问题。2.1 邻接矩阵网络的“关系户口本”你可以把邻接矩阵想象成一张巨大的、描述“谁认识谁”的表格。对于我们有6个城市的图这个矩阵就是6行6列。矩阵中的每一个格子都回答了“从行城市到列城市有没有直接关系”这个问题。在无权图中只关心是否连通通常用0和1表示1代表有直接连接相邻0代表没有。但在我们这个项目里关系是有“成本”的所以升级成了带权邻接矩阵。矩阵中的数字不再仅仅是0或1而是具体的权重值这里是费用。假设我们得到的矩阵C如下这是一个示例实际值以客户提供为准C1 C2 C3 C4 C5 C6 C1 [ 0, 2, INF, 8, INF, INF ] C2 [ 2, 0, 3, 10, 5, INF ] C3 [INF, 3, 0, INF, 1, 7 ] C4 [ 8, 10, INF, 0, 4, 2 ] C5 [INF, 5, 1, 4, 0, 6 ] C6 [INF, INF, 7, 2, 6, 0 ]解读示例C[1][2] 2从C1到C2的直达费用是2注意通常我们索引从0开始但这里为方便理解城市编号与索引一致实际代码可能用0-5代表C1-C6。C[1][3] INF从C1到C3没有直达线路。INF无穷大是一个约定俗成的表示方式在计算中代表不可达。C[3][3] 0自己到自己的成本当然是0。重要特性如果交通费用是双向对称的即从C1到C2和从C2到C1费用相同那么这个矩阵是对称矩阵C[i][j] C[j][i]。但客户给的矩阵不一定对称比如航空机票往返价格可能不同。在动手前必须向客户确认这一点这直接影响后续算法对图的处理是无向图还是有向图。2.2 从矩阵到内存中的图结构计算机算法无法直接运算一个“表格”我们需要把这个邻接矩阵转化为合适的数据结构。常见的有两种邻接矩阵二维数组就是直接用二维数组如Python的list of lists C的vector of vectors在内存中存储这个矩阵。这是最直观的方式对于稠密图边很多查询两点是否相邻及其权重非常快时间复杂度是O(1)。但它的空间复杂度是O(V²)对于顶点数V很大的稀疏图边很少非常浪费空间。我们这个例子只有6个城市用邻接矩阵完全没问题清晰易懂。邻接表为每个顶点维护一个列表里面存储它所有邻居顶点及对应的边权重。这特别节省稀疏图的空间空间复杂度是O(VE)。但在查找某条特定边时需要遍历列表不如邻接矩阵快。在Python中可以用字典的字典来实现graph[‘C1’] {‘C2’: 2, ‘C4’: 8}。选择建议对于本项目这种小规模6顶点、且需要频繁查询任意两点间权重的场景我强烈建议直接使用邻接矩阵。代码更简洁更贴近问题本身的数学描述调试起来也方便。如果未来城市扩展到上百个且线路稀疏再考虑改用邻接表优化。3. 算法选型为什么是Dijkstra有了图的数据结构接下来要解决核心问题求单源最短路径。即给定一个起点城市求它到其他所有城市的最低成本。我们有很多选择为什么偏偏是Dijkstra算法Floyd-Warshall算法能一次性计算出所有顶点对之间的最短路径。它的思想是动态规划通过考虑每个顶点作为中转点逐步优化所有点对的距离。时间复杂度是O(V³)对于V6的情况6³216次运算完全可接受。它代码实现也很简洁。如果客户明确要求一次性知道所有6个城市两两之间的最短路径Floyd算法是一个非常好的选择。Bellman-Ford算法它能处理权重为负数的情况但时间复杂度是O(VE)比Dijkstra高。我们的交通费用都是正数不需要用它。Dijkstra算法解决边权非负的图的单源最短路径问题的经典贪心算法。它从起点开始逐步扩展到未确定的顶点中距离起点最近的那个并以此更新其他顶点的距离。时间复杂度取决于实现方式使用优先队列最小堆优化后可以达到O(E log V)效率很高。决策分析 客户的需求隐含了需要知道任意两个城市间的最短路径。有两种策略运行6次Dijkstra算法分别以每个城市为起点。运行1次Floyd-Warshall算法。当顶点数V6时两种方法的计算量差异微乎其微。我选择使用Dijkstra算法并运行6次。理由如下教学和理解的普适性Dijkstra算法是图论中最核心的算法之一思路清晰贪心应用极广。掌握它比掌握Floyd算法更有长远价值。输出更灵活Dijkstra算法天然输出从一个源点到其他所有点的最短路径。我们可以很容易地将其封装成一个函数输入起点输出到其他点的距离和路径。这比Floyd算法直接输出一个距离矩阵在获取具体路径上稍微直观一点虽然Floyd也可以记录路径。性能不是瓶颈6个顶点下讨论O(V³)和O(V * E log V)的差异没有实际意义。注意Dijkstra算法有一个致命前提——所有边的权值必须为非负数。在我们的场景中交通费用不可能是负数这完全满足。如果图中存在负权边比如某种“补贴”路线走一次反而赚钱Dijkstra算法会得出错误结果那时就必须改用Bellman-Ford算法。4. 实战用Python实现Dijkstra算法理论说够了我们直接上代码。这里我会提供两个版本一个纯手工实现的Dijkstra算法便于理解每一步另一个是使用networkx库的版本高效且功能强大。4.1 手工实现Dijkstra算法邻接矩阵版我们先定义好邻接矩阵。这里用float(‘inf’)代表无穷大INF。import sys def dijkstra(adj_matrix, start): 使用Dijkstra算法计算单源最短路径邻接矩阵实现 :param adj_matrix: 二维列表带权邻接矩阵 :param start: 起点索引0-based :return: dist距离列表, parent前驱节点列表 n len(adj_matrix) # 顶点数 dist [float(inf)] * n # 从起点到各点的最短距离估计 visited [False] * n # 是否已找到最短路径 parent [-1] * n # 记录最短路径上的前驱节点用于回溯路径 # 初始化起点 dist[start] 0 for _ in range(n): # 步骤1从未访问的顶点中选出距离起点最近的顶点u u -1 min_dist float(inf) for i in range(n): if not visited[i] and dist[i] min_dist: min_dist dist[i] u i if u -1: # 所有可达顶点都已处理完毕 break visited[u] True # 步骤2松弛操作更新u的所有邻居的距离 for v in range(n): # 如果u和v之间有边且通过u到v比已知的更短 weight adj_matrix[u][v] if weight ! float(inf) and not visited[v]: new_dist dist[u] weight if new_dist dist[v]: dist[v] new_dist parent[v] u # 记录v的前驱是u return dist, parent def get_path(parent, end): 根据parent列表回溯出从起点到end的路径 path [] while end ! -1: path.append(end) end parent[end] path.reverse() return path # 定义示例的带权邻接矩阵 (6x6) INF float(inf) C [ [0, 2, INF, 8, INF, INF], [2, 0, 3, 10, 5, INF], [INF, 3, 0, INF, 1, 7], [8, 10, INF, 0, 4, 2], [INF, 5, 1, 4, 0, 6], [INF, INF, 7, 2, 6, 0] ] # 计算以C1索引0为起点到所有城市的最短路径 start_city 0 # C1 distances, predecessors dijkstra(C, start_city) print(f从城市 C{start_city1} 出发到各城市的最低成本) for i in range(len(distances)): if distances[i] INF: print(f - C{i1}: 不可达) else: path get_path(predecessors, i) path_str - .join([fC{p1} for p in path]) print(f - C{i1}: 成本 {distances[i]}, 路径 {path_str}) # 如果需要所有城市两两之间的结果循环调用即可 print(\n 所有城市对之间的最短路径成本 ) n_cities len(C) all_pairs_dist [[INF]*n_cities for _ in range(n_cities)] all_pairs_parent [[-1]*n_cities for _ in range(n_cities)] for src in range(n_cities): dist, parent dijkstra(C, src) all_pairs_dist[src] dist all_pairs_parent[src] parent # 这里可以按需打印或存储代码关键点解析dist数组核心中的核心。它动态维护着从起点到每个顶点的“当前已知最短距离”。初始时只有起点自己是0其他都是无穷大。贪心选择for i in range(n): if not visited[i] and dist[i] min_dist:这一循环就是在找“未访问顶点中距离起点最近的那个”。这是Dijkstra贪心策略的体现既然所有边权非负那么当前距离起点最近的点其距离不可能再被其他路径更新得更小了。松弛操作if new_dist dist[v]: dist[v] new_dist。这是算法的动力源。当我们确定了u的最短路径后我们看从起点到u再从u到v会不会比之前记录的到v的路径更短。如果是就更新。parent数组这是一个非常实用的技巧。在更新dist[v]的同时我们记录parent[v] u意思是“到v的最短路径是从u过来的”。这样算法结束后我们可以从任意终点v一路回溯parent直到起点就能还原出整条最短路径。时间复杂度我们上面这个版本是O(V²)的因为有两层嵌套循环。对于V6这完全没问题。如果顶点数成千上万就需要用优先队列最小堆来优化选择u的过程将复杂度降至O(E log V)。4.2 使用NetworkX库快速求解如果你不想重复造轮子或者需要更复杂的图分析功能networkx是Python图论分析的不二之选。它内置了多种最短路径算法接口非常友好。import networkx as nx import matplotlib.pyplot as plt # 1. 创建一个有向图如果费用双向相同可以用无向图Graph G nx.DiGraph() # DiGraph 表示有向图 # 2. 添加节点城市 cities [C1, C2, C3, C4, C5, C6] G.add_nodes_from(cities) # 3. 根据邻接矩阵添加带权边 # 这里假设我们使用之前定义的矩阵C但需要将其转换为networkx能接受的边列表 # 我们手动添加对应上面的矩阵C edges_with_weights [ (C1, C2, 2), (C1, C4, 8), (C2, C1, 2), (C2, C3, 3), (C2, C4, 10), (C2, C5, 5), (C3, C2, 3), (C3, C5, 1), (C3, C6, 7), (C4, C1, 8), (C4, C2, 10), (C4, C5, 4), (C4, C6, 2), (C5, C2, 5), (C5, C3, 1), (C5, C4, 4), (C5, C6, 6), (C6, C3, 7), (C6, C4, 2), (C6, C5, 6), ] G.add_weighted_edges_from(edges_with_weights) # 4. 计算单源最短路径从C1出发 source C1 shortest_paths nx.single_source_dijkstra_path(G, sourcesource) shortest_path_lengths nx.single_source_dijkstra_path_length(G, sourcesource) print(f从 {source} 出发的最短路径) for target, path in shortest_paths.items(): length shortest_path_lengths[target] print(f 到 {target}: 路径 {path}, 总成本 {length}) # 5. 计算所有城市对之间的最短路径长度 print(\n所有城市对之间的最短路径成本矩阵) all_pairs_length dict(nx.all_pairs_dijkstra_path_length(G)) # 以表格形式打印 print( .join(cities)) for src in cities: row [f{all_pairs_length[src].get(dst, INF):4} for dst in cities] print(f{src}: .join(row)) # 6. 可选可视化图 plt.figure(figsize(10, 8)) pos nx.spring_layout(G, seed42) # 布局算法 nx.draw_networkx_nodes(G, pos, node_size500, node_colorlightblue) nx.draw_networkx_labels(G, pos) nx.draw_networkx_edges(G, pos, edgelistG.edges(), arrowstyle-, arrowsize15) edge_labels nx.get_edge_attributes(G, weight) nx.draw_networkx_edge_labels(G, pos, edge_labelsedge_labels) plt.title(分公司城市交通网络图带权有向) plt.axis(off) plt.tight_layout() plt.show()使用Networkx的优势代码极其简洁添加节点、边调用一个函数single_source_dijkstra_path就得到了路径和长度。功能全面除了最短路径还能轻松计算图的密度、中心性、连通分量等方便做更深入的网络分析。可视化集成配合matplotlib可以一键生成美观的网络图直观展示城市间的连接关系向非技术背景的同事或领导汇报时效果极佳。处理大规模图底层算法经过优化性能可靠。实操心得在快速原型验证和向业务方演示时我几乎总是先用networkx。它能让我在几分钟内就把模型跑起来看到结果验证想法的正确性。之后如果需要嵌入到更大的生产系统或者对性能有极致要求再考虑用numpy优化邻接矩阵运算或者用heapq实现更高效的Dijkstra。5. 结果分析与业务解读算法跑出来了输出了一堆数字和路径。但这还不是终点我们需要把这些冷冰冰的结果翻译成业务部门能懂、能用的洞察。假设我们运行了以C1为起点的算法得到如下结果基于示例矩阵C1 - C2: 成本 2 路径 C1 - C2C1 - C3: 成本 5 路径 C1 - C2 - C3C1 - C4: 成本 8 路径 C1 - C4C1 - C5: 成本 6 路径 C1 - C2 - C5C1 - C6: 成本 10 路径 C1 - C4 - C6给业务方的报告可以这样组织核心结论摘要“从总部C1到所有分公司均可达不存在孤立城市。”“成本最高的线路是C1到C6成本10成本最低的是C1到C2成本2。”关键发现与建议路径优化“从C1到C3直达不可行但通过C2中转总成本仅为5低于任何可能的其他间接路径。建议将C2作为通往C3的枢纽。”成本节约验证“检查历史报销单据如果发现有员工从C1到C3购买了直达机票假设存在且昂贵或绕行了更远路径系统可以据此提示更优路线预计可节省XX%交通费。”网络脆弱性分析“如果边C1-C4成本8因故中断去往C6的最短路径将变为C1-C2-C5-C6成本升至13。这条线路的冗余度需要关注。”输出物交付最短路径成本矩阵一个6x6的表格直接交给财务部门作为审核差旅成本的基准。路径查询工具可以做一个简单的命令行或网页小工具让员工输入出发地和目的地直接返回推荐路线和预估最低成本。可视化网络图用networkx生成的图一目了然地展示城市间的连接强度和成本用于汇报和战略讨论。6. 常见问题与排查技巧实录在实际编码和调试过程中我踩过不少坑。这里总结几个最常见的问题和解决方法。6.1 算法结果不对输出全是无穷大或0检查邻接矩阵的初始化这是最容易出错的地方。确保对角线元素自己到自己的距离是0。确保没有直接连接的城市之间的权重是INF一个足够大的数而不是0。如果误设为0算法会认为这两个城市距离为0导致整个计算崩溃。检查图的类型你定义的是有向图还是无向图如果交通是双向且费用相同但你在有向图中只添加了单向边那么反向路径就会被认为是不可达INF。根据业务实际决定使用nx.Graph()无向还是nx.DiGraph()有向。打印中间状态在手工实现Dijkstra时在循环里打印dist数组和visited数组看每一轮迭代后各个顶点的距离估计值是如何变化的。这能帮你快速定位是在哪一步出了逻辑错误。6.2 路径回溯parent数组出错得不到完整路径初始化确保parent数组初始化为-1或None表示没有前驱。更新时机一定要在松弛操作if new_dist dist[v]:成功时才更新parent[v] u。这意味着这条更短的路径是通过u到达v的。如果在松弛操作之外更新就会记录错误的路径信息。回溯逻辑get_path函数中while end ! -1这个循环条件要对应你初始化的值。回溯得到的路径是倒序的所以需要path.reverse()。6.3 如何处理“不可达”的情况在业务中两个分公司之间可能真的没有交通线路相连比如没有直达航班也没有可行的中转路线。在算法中这表现为终点的dist值仍然是初始的INF。结果呈现在输出时要特别判断if dist[target] INF:然后输出“不可达”或一个特定的标识而不是一个巨大的数字。业务含义如果出现不可达需要反馈给业务部门确认是数据录入错误漏掉了某些线路还是确实需要开辟新的交通方式这本身就是一个有价值的发现。6.4 当城市数量变大时程序变慢怎么办我们示例的O(V²) Dijkstra实现对于V1000可能就有点慢了。优化方法使用优先队列这是标准优化。Python可以用heapq模块。将(距离, 顶点)放入堆中每次从堆中弹出距离最小的顶点。这能将选择最小dist顶点的复杂度从O(V)降到O(log V)。使用邻接表对于稀疏图将邻接矩阵换成邻接表存储能节省大量内存和遍历时间。使用NetworkXnetworkx的内部实现是高度优化的通常比自己写的朴素版本快得多对于几千个节点的图性能也足够好。考虑其他算法如果需要所有顶点对的最短路径且图比较稠密Floyd-Warshall的O(V³)可能比跑V次Dijkstra (O(V * E log V))更简单。需要根据V和E的规模具体分析。6.5 权重不是距离而是时间、费用、概率等Dijkstra算法的核心是“贪心地选择当前最小的累积权重”。只要这个权重满足非负和可加从A到B再到C的权重等于A到B的权重加上B到C的权重这两个性质算法就适用。费用直接相加完全符合。时间通常也符合。概率如果边权重是“通过率”0到1之间求最大通过率路径就不能直接相加了。这时需要取对数将乘法转化为加法求最大乘积路径或者使用专门的最大概率路径算法。最后再分享一个小心得在把结果交付给业务部门前一定要用几个极端但合理的案例手动验算一下。比如算一下从最北边的城市到最南边城市的路径看看是不是你直觉上认为应该经过的那些枢纽城市。再比如故意修改矩阵中一条边的成本看最短路径是否如预期般改变。这种“沙盘推演”能极大增强你对模型结果的信心也能在业务方提问时从容应对。模型的价值最终体现在它能否经得起现实业务逻辑的拷问。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进