资讯动态

追债之旅NC14700:单源最短路问题Dijkstra算法详解与实战

发布时间:2026/10/15 5:24:44 来源:尧图企业网站定制
1. 从标题拆解这道题到底在考什么“追债之旅”这个标题乍一看像是个剧情向的模拟经营游戏但后面跟着的“单源最短路问题”直接暴露了它的本质——这是一道披着故事外衣的图论算法题。题号NC14700熟悉在线编程训练平台的朋友应该能猜到这大概率出自某个国内算法练习平台的题单难度定位在中等偏上核心考点就是单源最短路径。先把这道题的核心需求翻译成人话你手里有一张图图上有若干个节点和带权边现在需要从某个固定的起点出发计算到其他所有节点的最短距离。但“追债”这个场景设定往往意味着题目会在标准最短路的基础上叠加一些额外约束比如时间限制、路径上的特殊代价、或者需要在多条最短路之间做二次决策。这类题目的典型套路是先用Dijkstra或SPFA求出基础最短路再在最短路的框架上做动态规划或贪心选择。为什么这类题在算法训练中反复出现因为单源最短路是整个图论体系的基石。你后面要学的多源最短路、最小生成树、网络流、差分约束几乎都建立在“从一个点出发以最小代价到达另一个点”这个基本模型之上。把这道题吃透等于把图论的第一块地基打牢了。这篇文章适合谁看如果你刚学完Dijkstra的模板但不知道怎么应对变形题如果你一看到“最短路额外约束”就发懵如果你想知道竞赛选手拿到一道图论题时脑子里在想什么那接下来的内容应该能帮到你。我会从题目建模、算法选型、代码实现、调试技巧四个层面把这道题彻底拆开讲清楚。2. 题目建模把故事翻译成图论语言2.1 识别节点、边和权值的真实含义拿到任何一道图论题第一步永远是建模。所谓建模就是把题目里的自然语言描述映射成“节点是什么、边是什么、边的权值是什么”这三个基本要素。在“追债之旅”这个场景里常见的设定是这样的有若干个欠债人分布在不同的地点你从某个固定位置出发每在两个地点之间移动需要消耗一定的时间或金钱目标是找到到达某个特定欠债人或所有欠债人的最短路径。这里的“地点”就是节点“两地之间的路”就是边“消耗的时间或金钱”就是权值。但题目往往不会这么直白。它可能会说“每经过一个地点需要支付过路费”这时候权值就不在边上而在节点上。处理节点权值的标准做法是拆点把一个节点拆成入点和出点入点到出点连一条权值为节点代价的边原来的边则从出点连到下一个节点的入点。这样就把节点权值转化成了边权值套用标准最短路算法即可。还有一种常见变形是“经过某条边需要额外等待”或者“不同时间段边的权值不同”。前者需要在松弛操作时加上额外代价后者可能需要分层图或者时间扩展图。具体到这道题由于标题只说了“单源最短路问题”我倾向于认为它的核心模型是标准的带权有向图或无向图额外约束的复杂度不会太高。2.2 为什么单源最短路是这道题的核心骨架单源最短路的定义很简洁给定一个源点s求s到图中所有其他节点的最短路径长度。这个“所有其他节点”是关键——它不是只求到某一个终点的最短路而是求到全图的最短路。这个特性决定了算法选型你需要一个能一次性算出所有距离的算法而不是针对单个终点做搜索。为什么不用BFS因为BFS只能处理边权全为1的情况。一旦边权不相等BFS找到的“层数最少”路径就不等于“总权值最小”路径。为什么不用Floyd因为Floyd是求所有点对之间的最短路时间复杂度O(n³)对于单源问题来说太浪费了。所以候选算法就剩下Dijkstra和SPFA或Bellman-Ford。这里有一个选型决策树如果边权全部非负优先选Dijkstra如果存在负权边但不存在负权环选SPFA或Bellman-Ford如果存在负权环那最短路问题本身可能无解需要先判环。这道题既然叫“追债之旅”权值大概率是正数时间、金钱不可能为负所以Dijkstra是首选。2.3 数据范围决定了你该用哪种实现方式算法选型不能只看理论复杂度还要看数据范围。同样是Dijkstra朴素实现是O(n²)堆优化实现是O(m log n)。如果节点数n只有几百朴素Dijkstra反而更快因为常数小如果n到了十万级别就必须用堆优化。我一般会先扫一眼题目的数据范围然后做如下判断节点数n边数m推荐实现理由n ≤ 500任意邻接矩阵朴素Dijkstra代码简单常数小n ≤ 10⁵m ≤ 2×10⁵邻接表堆优化Dijkstra标准竞赛配置n ≤ 10⁵m ≤ 10⁵邻接表SPFA稀疏图且可能有负权n ≤ 10³m ≤ 10⁴邻接表SPFA数据小SPFA够用这道题的具体数据范围我没有拿到但从“追债之旅”这种偏应用场景的命名来看n大概率在10³到10⁵之间。保险起见直接上邻接表堆优化Dijkstra这个组合能覆盖绝大多数情况而且代码模板成熟不容易写错。3. 核心算法拆解Dijkstra到底在干什么3.1 贪心策略的直觉解释Dijkstra算法的核心思想可以用一句话概括每次从尚未确定的节点中选出当前距离源点最近的那个它的距离就已经是最终答案了。为什么这个贪心是对的因为所有边权非负。假设你当前选出的节点u距离源点是d[u]如果存在另一条从源点经过某个未确定节点v再到u的路径更短那么这条路径的长度至少是d[v]w(v,u)。由于w(v,u)≥0所以d[v]w(v,u)≥d[v]。而d[v]≥d[u]因为u是当前最近的所以这条路径不可能比d[u]更短。这就保证了贪心选择的正确性。用生活化的例子来说你站在起点周围有若干条路通向不同的城市。你每次都选择“目前已知的、从起点出发总花费最小的那个城市”作为下一个确定的对象。因为所有路费都是正数所以不可能出现“绕远路反而更便宜”的情况。3.2 松弛操作最短路算法的灵魂“松弛”这个词听起来很学术其实做的事情非常简单如果从源点走到u再从u走到v比目前已知的到v的距离更短那就更新到v的距离。用代码表示就是if dist[u] weight(u, v) dist[v]: dist[v] dist[u] weight(u, v)这个操作之所以叫“松弛”是因为它放松了对v的距离上界。初始时除了源点距离为0其他所有点的距离都设为无穷大。每做一次成功的松弛就相当于找到了一条更短的路径距离上界被“拉紧”了一点。Dijkstra的完整流程就是不断取出当前距离最小的未确定节点对它所有邻居做松弛操作直到所有节点都被确定。堆优化的作用是用优先队列快速找到“当前距离最小的未确定节点”把这一步从O(n)降到O(log n)。3.3 为什么不能处理负权边如果存在负权边Dijkstra的贪心策略就失效了。原因很简单你当前选出的“最近节点”u它的距离d[u]可能不是最终答案因为后面可能存在一条经过某个更远节点v、但总权值更小的路径。负权边让“绕远路更便宜”成为可能贪心选择的前提被打破了。举个例子源点s到a的距离是5到b的距离是10。按照Dijkstra先确定a。但如果存在一条边a→b的权值是-10那么s→a→b的总距离是-5比直接到b的10小得多。而Dijkstra在确定a之后才会去松弛b此时b的距离从10更新为-5。但问题是如果b还能继续松弛其他节点而b本身还没被确定整个算法的正确性就无法保证。所以遇到负权边要么用SPFA要么用Bellman-Ford。这道题如果权值都是正数Dijkstra就是最优选择。4. 完整实操从读入数据到输出答案4.1 图的存储方式选择在动手写代码之前先确定图的存储结构。常见的三种方式邻接矩阵graph[i][j]表示从i到j的边权。优点是查询O(1)缺点是空间O(n²)稀疏图浪费严重。邻接表每个节点维护一个列表存储所有出边。空间O(nm)适合稀疏图。链式前向星用数组模拟链表空间效率最高但代码稍复杂。对于竞赛场景我一般直接用邻接表Python里就是defaultdict(list)或者list of lists。每个元素存(邻居, 权值)的元组。这样遍历某个节点的所有出边非常方便。import heapq def dijkstra(n, graph, start): # 初始化距离数组全部设为无穷大 INF float(inf) dist [INF] * (n 1) dist[start] 0 # 优先队列存储(距离, 节点编号) pq [(0, start)] while pq: d, u heapq.heappop(pq) # 如果当前取出的距离已经大于已知最短距离跳过 if d dist[u]: continue # 遍历所有邻居 for v, w in graph[u]: if dist[u] w dist[v]: dist[v] dist[u] w heapq.heappush(pq, (dist[v], v)) return dist这段代码是堆优化Dijkstra的标准模板几乎可以原封不动地套用到任何单源最短路问题上。关键点有三个一是if d dist[u]: continue这个剪枝避免处理已经过期的堆元素二是松弛条件dist[u] w dist[v]必须是严格小于三是每次更新后立即入堆保证优先队列里始终有最新的候选。4.2 处理“追债”场景的额外约束如果题目只是在标准最短路基础上加了一个“必须经过某个节点”或者“必须在某个时间之前到达”的约束处理方式会有所不同。必须经过某个节点k最短路有一个重要性质——最短路径的子路径也是最短路径。所以从s到t且必须经过k的最短路等于s到k的最短路加上k到t的最短路。分别跑两次Dijkstra即可。必须在时间T之前到达这就变成了带时间约束的最短路可能需要记录“到达每个节点时的最小时间”然后在松弛时加上时间判断。如果时间约束比较复杂可能需要用分层图把“时间”作为图的一个维度。路径上的最大边权最小这不是标准最短路而是“最小瓶颈路”可以用Kruskal重构树或者二分答案最短路判定来做。具体到这道题由于标题只说了“单源最短路问题”我推测额外约束不会太复杂大概率是标准的单源最短路或者只需要跑两次Dijkstra的简单变形。4.3 完整代码实现与逐行注释下面给出一个完整的、可直接运行的Python实现假设输入格式是第一行n和m表示节点数和边数接下来m行每行三个整数u、v、w表示一条从u到v权值为w的边最后一行是起点s。import sys import heapq def solve(): input_data sys.stdin.read().split() idx 0 n int(input_data[idx]); idx 1 m int(input_data[idx]); idx 1 # 建图1-indexed graph [[] for _ in range(n 1)] for _ in range(m): u int(input_data[idx]); idx 1 v int(input_data[idx]); idx 1 w int(input_data[idx]); idx 1 graph[u].append((v, w)) # 如果是无向图加上下面这行 # graph[v].append((u, w)) start int(input_data[idx]); idx 1 INF float(inf) dist [INF] * (n 1) dist[start] 0 pq [(0, start)] while pq: d, u heapq.heappop(pq) if d dist[u]: continue for v, w in graph[u]: nd d w if nd dist[v]: dist[v] nd heapq.heappush(pq, (nd, v)) # 输出从start到所有节点的最短距离 for i in range(1, n 1): if dist[i] INF: print(f节点{i}: 不可达) else: print(f节点{i}: {dist[i]}) if __name__ __main__: solve()这段代码有几个值得注意的细节。第一用sys.stdin.read().split()一次性读入所有输入比逐行读快很多适合大数据量。第二graph用列表的列表而不是字典因为节点编号是连续的整数列表的随机访问更快。第三输出时区分了“不可达”和具体距离方便调试。4.4 参数计算与复杂度分析假设n10⁵m2×10⁵堆优化Dijkstra的时间复杂度是O((nm) log n)。具体计算每次堆操作是O(log n)最多进行m次松弛所以总操作次数约m次入堆和m次出堆总计O(m log n)。代入数值2×10⁵ × log₂(10⁵) ≈ 2×10⁵ × 17 ≈ 3.4×10⁶次基本操作Python在1秒内完全可以跑完。空间复杂度是O(nm)邻接表存m条边距离数组和优先队列各O(n)。对于n10⁵、m2×10⁵的数据规模内存占用大约几十MB完全在限制范围内。如果数据范围更大比如n10⁶那就需要考虑用C实现或者优化Python的常数。但在大多数在线编程平台上Python配合堆优化Dijkstra足以应对n≤10⁵的题目。5. 常见问题与排查技巧实录5.1 优先队列里为什么会有过期元素这是初学者最容易困惑的地方。在Dijkstra的堆优化实现中同一个节点可能被多次入堆。原因是每次发现更短路径时我们都会把新的距离入堆但旧的距离仍然留在堆里。当旧的距离被弹出时它已经大于dist[u]所以用if d dist[u]: continue跳过。这个设计是故意的不是bug。因为从堆中删除任意元素需要O(n)时间而“懒惰删除”只需要在弹出时判断一下总代价更低。堆中最多有m个元素所以空间复杂度是O(m)可以接受。5.2 距离数组初始化为无穷大的坑Python里用float(inf)表示无穷大很方便但要注意float(inf) 任何正数 float(inf)所以松弛操作不会误更新不可达节点。但如果用一个大整数比如10**18代替无穷大就要小心加法溢出。在Python里整数不会溢出但在C里需要用long long并确保INF INF不会溢出。另一个坑是如果图中有节点的真实最短距离恰好等于你设置的“无穷大”值就会误判为不可达。所以用float(inf)比用大整数更安全。5.3 有向图还是无向图题目如果没有明确说明一定要仔细读题。有向图和无向图的区别在于建图时是否加反向边。无向图相当于每条边都是双向的建图时graph[u].append((v, w))和graph[v].append((u, w))都要执行。有向图只执行前者。我踩过的坑有一次做一道“道路建设”的题默认以为是无向图结果题目说的是“单向通行”导致样例都过不了。后来仔细读题才发现是“从u到v有一条单向路”。所以建图之前一定要确认方向性。5.4 节点编号从0开始还是从1开始这也是一个高频错误点。有些题目节点编号是0到n-1有些是1到n。如果搞错了轻则数组越界重则答案错误。我的习惯是读入n之后先看样例里的节点编号范围。如果出现0就用0-indexed如果最小是1就用1-indexed。统一之后所有数组都开n1的大小避免边界问题。5.5 常见问题速查表问题现象可能原因排查方法答案偏大漏掉了某些边或权值读错打印建图结果检查边数和权值答案偏小多加了边或权值重复计算检查是否误加了反向边部分节点不可达图不连通或建图方向错误检查连通性确认有向/无向运行超时用了朴素Dijkstra或SPFA被卡换成堆优化Dijkstra内存超限用了邻接矩阵换成邻接表结果不稳定优先队列比较逻辑有误确保元组第一个元素是距离6. 从这道题延伸出去的知识网络6.1 单源最短路与多源最短路的区别单源最短路是“一个起点到所有点”多源最短路是“多个起点到所有点”。后者有一个经典技巧建立一个超级源点把它到所有真实源点的距离设为0然后跑一次单源最短路。这样就把多源问题转化成了单源问题。这个技巧在“追债之旅”这类场景里也很有用。比如题目说“有多个债主同时出发追债”那就可以建一个超级源点连向所有债主的位置边权为0然后跑一次Dijkstra。6.2 最短路与动态规划的交叉很多最短路变形题本质上是在图上做动态规划。比如“最多经过k条边的最短路”可以用Bellman-Ford的思想迭代k次每次更新所有边。或者用矩阵快速幂优化把“经过k条边”变成矩阵乘法。再比如“每个节点有容量限制的最短路”需要在状态里加入“剩余容量”这一维变成分层图最短路。这类题目的通用解法是把状态定义成(节点, 额外维度)然后在状态之间连边跑最短路。6.3 最短路在真实工程中的应用虽然这是一道算法题但单源最短路的实际应用非常广泛。地图导航软件计算从当前位置到目的地的最短路线网络路由协议选择数据包传输的最优路径物流系统规划配送路线甚至社交网络里的“六度分隔”验证底层都是最短路算法。工程实现和竞赛实现的区别在于工程上更关注动态更新边权变化时如何快速重新计算、大规模并行如何利用多核加速、以及近似算法在可接受误差内快速给出结果。但核心思想是一样的松弛操作和贪心选择。7. 我踩过的坑和调试心得第一次做这类题的时候我犯了一个很蠢的错误把优先队列里的元素写成了(节点, 距离)而不是(距离, 节点)。结果Python的元组比较先比较第一个元素导致优先队列按节点编号排序而不是按距离排序Dijkstra直接退化成随机顺序答案完全错误。这个bug我调了将近一个小时才找到因为代码逻辑看起来完全正确只是元组顺序反了。后来我养成了一个习惯优先队列里的元组第一个元素永远是排序依据。在Dijkstra里就是距离在BFS里就是步数在A*里就是f值。这样就不会搞混。另一个坑是Python的递归深度限制。虽然Dijkstra本身是迭代的但如果你用DFS来建图或者做其他预处理递归深度可能超过默认的1000。解决办法是sys.setrecursionlimit(10**6)或者干脆用迭代代替递归。还有一个经验先用小数据验证再用大数据测性能。我一般会自己造一个5个节点、6条边的小图手动算出答案然后跑代码对比。确认逻辑正确后再生成10⁵级别的大数据测运行时间。这样能把“逻辑错误”和“性能问题”分开排查效率高很多。最后分享一个调试技巧如果答案不对先把dist数组完整打印出来看看哪些节点的距离和预期不符。然后沿着那个节点的入边反向追踪看看是哪次松弛出了问题。十有八九是建图时漏了边或者权值读错了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑