动态规划处理多段图最短路径,是我在算法课程和实际项目里反复用到的一类经典模型。它表面上看是一个“求最短路径”的问题,但实际上背后藏着动态规划最核心的几个概念:阶段划分、状态定义、决策转移和无后效性。把这个例子吃透,后面再去理解背包问题、序列决策问题、资源分配问题,都会轻松很多。这篇文章就从多段图这个具体场景出发,把动态规划怎么设计、怎么实现、怎么排查问题,完整走一遍。
多段图(Multi-stage Graph)是指一个有向无环图,顶点被划分成若干个不相交的集合,称为“段”,边只存在于相邻两段之间。求从起点到终点的最小代价路径,是这类图的标准问题。适合看这篇文章的人:正在学算法、准备面试刷题的学生,或者工作中遇到分阶段决策优化问题的开发人员。我会用一个具体的例子把递推过程、代码实现、路径回溯全部讲透,再补充我踩过的坑和常见的错误排查方向。
1. 从问题开始:什么是多段图的最短路径
1.1 用一张图理清定义
先看一个最典型的场景。假设我要从某个源点S出发,到达汇点T,中间必须经过若干“关卡”,每一关都有几个候选节点,只能从当前关跳到下一关,不能回头,也不能跨关跳。把所有节点按关卡分组,就形成了段。
举个例子:
- 段0:只有节点S
- 段1:A1, A2, A3, A4
- 段2:B1, B2
- 段3:C1, C2
- 段4:只有节点T
边只存在于相邻段之间,比如S可以到A1、A2、A3、A4,A1可以到B1、B2,但A1不能直接到C1,也不能回到S。每条边有一个权值,表示通过这条边的代价。
我们要找一条从S到T的路径,使得所有边的权值之和最小。这就是多段图的最短路径问题。
这个模型在现实中非常常见。比如一个跨城运输问题,货物从起点到终点,中间要经过几个分拨中心,每个分拨中心有多个可选站点,每个站点的转运费用不同;又比如一个多层软件架构里,请求从入口到数据库,每层有多个可选的处理节点,每个节点的处理成本不同。分层决策、逐段优化的场景,几乎都可以抽象成多段图。
1.2 为什么暴力搜索不划算
如果直接用枚举法,把每段选一个节点连起来,路径总数会非常可观。假设每段有p个节点,总共有k段,暴力枚举的路径数量大约是p的(k-1)次方。在上面的例子里段1有4个节点、段2有2个、段3有2个,路径总数是4乘以2乘以2等于16条,还算少。但实际生产中每层几十个节点、五六层的情况很常见,路径总数就会膨胀到几十万甚至上亿条。
暴力搜索的浪费在于大量路径共享了相同的“尾部”子路径。比如从S到A1再到B1,和从S到A2再到B1,后面的B1到T部分是同样的选择。换句话说,前面的决策不影响后面怎么做。这种“前因不影响后果”的特征,就是动态规划说的无后效性。
如果保留B1到T的最短距离,那么不管前面从哪个节点到B1,后面都只需要复用这个值,完全没有必要每次重新计算。这就是用空间换时间的核心思路:把重复的子问题存下来,一次计算、多次使用。
2. 动态规划的核心:状态、转移、顺序
2.1 状态定义与最优子结构
用动态规划解多段图,第一步是定义状态。我用dp[i]表示从节点i到终点T的最短距离。这里说的“节点i”可以是任意一段里的任意一个节点,而不只是起点。dp[T] = 0,因为终点到终点的距离是0。
然后思考状态转移。假设当前节点i在第k段,它所有出边都指向第k+1段的节点j,那么从i到T的最短距离,一定是从i走到某个j的边权,加上从j到T的最短距离,再取最小值。写成公式:
dp[i] = min { weight(i, j) + dp[j] },其中j是i的所有邻接点。
这个公式看起来简单,但它隐含了一个重要的前提:如果dp[i]的全局最优路径经过j,那么从j到T的后半段路径,也必须是j到T的最短路径。如果不是,我可以用一条更短的j到T的路径替换它,从而得到一条更短的i到T路径,这就矛盾了。这种性质叫最优子结构,是动态规划能成立的根本依据。
理解了这一点,解题时就不容易犯“贪心”的毛病。贪心算法是每步选当下最小的边权就往前走,但当下最小的边权可能导致后续付出更大的代价。比如节点S到A2的边权是7,到A3的边权是3,如果只看第一步就选A3,确实没错;但关键是到了A3之后不能只看下一步最小,必须看“边权+未来最短距离”的总和最小,这就是动态规划和贪心最本质的区别。
2.2 无后效性如何保证
动态规划对状态转移顺序有要求:计算dp[i]的时候,dp[j]必须是已经确定的值,其中j是i的下一段邻接点。换句话说,计算必须从后往前逐段推进,先算靠近T的段,再算靠近S的段。
为什么必须这样?因为多段图的边只存在于相邻两段之间,而且方向固定是从第k段到第k+1段。这意味着节点i的所有后继节点j,都在i的右侧、更靠近T。如果先算前面的段,后面的dp[j]还是未知数,公式没法用。
实际上这个过程是在按照图的拓扑序进行计算。多段图是有向无环图,而且经过节点分段后,拓扑序天然存在,就是从段0到段4的顺序。动态规划本质上就是在有向无环图上按拓扑序递推。
不少人会把“从后往前算”理解为只能从终点往起点算,其实只要满足“先算后继、再算前驱”,都可以。多段图方向固定,所以通常写成从后往前的形式。如果我把图反转,把T当起点、S当终点,同样可以写一套从前往后的递推,不过路径方向和语义都要反过来。工程上不要贪图对称而混淆语义,抓住“先算后继”这个原则就行。
2.3 递推顺序的选择依据
在实际实现里,我习惯按段倒序处理。从最后一段的前一段开始,逐个节点计算dp值,一直推到段0的S。
还可以换一种视角:设f[i]为从起点S到节点i的最短距离,这样就是正推。公式为f[i] = min { f[h] + weight(h, i) },其中h是i在前一段的所有前驱。这样算完所有节点之后,答案就是f[T]。
正推和逆推本质上是等价的,选哪个看使用场景。如果只需要从S到T的最短距离,逆推写起来更自然,因为答案就是dp[S]。如果想输出整条路径,正推在回溯时需要记录每个节点的前驱,逆推则需要记录每个节点的后继,两者都会用到。我个人更习惯逆推,因为答案直接落在dp[S]上,而且多段图中“从后往前”更符合“先确定后面的最优值”的直觉。
3. 手把手实现一遍
3.1 图的表示与数据准备
多段图规模通常不大,我用邻接表就够。每个节点存一个列表,列表里是(邻接点,边权)的元组。为了方便定位节点属于哪一段,我把节点编号和段分开管理。
用前面那个例子:
# 节点编号从0开始 # 段信息,方便按段遍历 stages = [ [0], # 段0: S [1, 2, 3, 4], # 段1: A1, A2, A3, A4 [5, 6], # 段2: B1, B2 [7, 8], # 段3: C1, C2 [9] # 段4: T ] # 邻接表 edges[i] = [(j, weight)] edges = { 0: [(1, 9), (2, 7), (3, 3), (4, 2)], 1: [(5, 1), (6, 3)], 2: [(5, 2), (6, 9)], 3: [(5, 6), (6, 4)], 4: [(5, 5), (6, 7)], 5: [(7, 8), (8, 7)], 6: [(7, 3), (8, 8)], 7: [(9, 4)], 8: [(9, 2)], 9: [] }注意,这个例子里S到A1的权是9,S到A2是7,S到A3是3,S到A4是2。为了让结果有意思,故意设成近的边不一定全局最优,方便看动态规划怎么取舍。
3.2 动态规划递推实现
逆推代码非常短:
def shortest_path(stages, edges, start, target): # 先找出所有节点 all_nodes = set(edges.keys()) # 初始化dp,未知值用无穷大 INF = float('inf') dp = {node: INF for node in all_nodes} dp[target] = 0 # 按照段倒序处理,不包括终点所在的段 for k in range(len(stages) - 2, -1, -1): for node in stages[k]: best = INF for nxt, w in edges.get(node, []): if dp[nxt] != INF: tmp = w + dp[nxt] if tmp < best: best = tmp dp[node] = best return dp[start]这个实现的关键点在于按段倒序,只要段信息正确,就能保证计算dp[node]时,所有后继节点的dp值都已经确定。因为多段图的边绝对不会从第k段指向第k段或更早的段,所以在段倒序的循环里,天然满足拓扑序。
跑一下例子:
- 段3的C1和C2:C1只有一个后继T,dp[7] = 4 + 0 = 4;C2同理,dp[8] = 2。
- 段2的B1:后继是C1和C2,dp[5] = min(8 + 4, 7 + 2) = 9;B2:dp[6] = min(3 + 4, 8 + 2) = 7。
- 段1的A1:min(1 + 9, 3 + 7) = 10;A2:min(2 + 9, 9 + 7) = 11;A3:min(6 + 9, 4 + 7) = 11;A4:min(5 + 9, 7 + 7) = 14。
- 段0的S:min(9 + 10, 7 + 11, 3 + 11, 2 + 14) = 14。
最终dp[S] = 14,最短路径是S -> A3 -> B2 -> C2 -> T。
注意这里S到A3是3,A3到B2是4,B2到C2是8,C2到T是2,总代价是17?等等,重算一下:3 + 4 = 7,7 + 8 = 15,15 + 2 = 17。不对,再仔细看。S到A3是3,A3到B2是4,B2到C2是8,C2到T是2,总共是3 + 4 + 8 + 2 = 17,不是14。那我刚才算dp[S] = min(9+10=19, 7+11=18, 3+11=14, 2+14=16) = 14,说明S -> A3时,dp[A3] = 11,不是7。哦,dp[A3] = min(6 + dp[B1] = 15, 4 + dp[B2] = 4 + 7 = 11)。所以路径S -> A3,然后A3 -> B2(4),B2 -> C2(8),C2 -> T(2),总代价 = 3(S到A3)+ 11(A3到T的dp值),也就是3 + 4 + 8 + 2 = 17?等一下,3 + 11 = 14,但是3 + 4 + 8 + 2 = 17,这明显不一致。问题出在哪里?
我再检查dp[B2] = min(3 + dp[C1] = 3 + 4 = 7, 8 + dp[C2] = 8 + 2 = 10) = 7,意味着B2到T的最短路是B2 -> C1 -> T = 3 + 4 = 7。所以路径是S -> A3 -> B2 -> C1 -> T,总代价 = 3 + 4 + 3 + 4 = 14。对,是C1不是C2,我刚才写错了。所以最短路径是S -> A3 -> B2 -> C1 -> T。
这个例子的价值就在这里:如果贪心选局部最小边,从S出来会选A4(权2),但从A4往后走反而绕远。动态规划计算的dp值把“当前段节点到终点的完整代价”比较出来了,而不是只看下一步。
3.3 路径回溯:不只要最短距离
只看距离有时不够,实际项目中往往还要求输出具体路径。动态规划计算dp值的同时,可以记录每个节点选择了哪个后继节点。
def shortest_path_with_path(stages, edges, start, target): INF = float('inf') all_nodes = set(edges.keys()) dp = {node: INF for node in all_nodes} next_node = {node: None for node in all_nodes} dp[target] = 0 for k in range(len(stages) - 2, -1, -1): for node in stages[k]: best = INF best_next = None for nxt, w in edges.get(node, []): if dp[nxt] != INF: tmp = w + dp[nxt] if tmp < best: best = tmp best_next = nxt dp[node] = best next_node[node] = best_next # 从起点沿着next_node回溯 path = [] cur = start while cur is not None: path.append(cur) cur = next_node[cur] return dp[start], path回溯的思路很简单:从S开始,每次跳到记录下来的后继节点,一直跳到T。这种记录方式有点像链表,内存开销也就是每个节点一个整型,非常小。
需要特别注意:如果存在多条最短路径,上面的代码只会记录其中一条,取决于遍历邻接顺序。如果业务上有“多条最短路径都可行,需要排序或按字典序选择”的要求,就不能只保存一个best_next,而要保存一个候选列表,再按你的业务规则筛选。这在面试题里也经常出现,问到“输出所有最短路径”时,就要在dp基础上做一次递归搜索。
3.4 复杂度分析
多段图最短路径的复杂度非常好看。假设图里有n个节点、e条边,逆推过程里每个节点会检查一次所有出边,因此时间复杂度和空间复杂度都是O(n + e)。相比暴力枚举O(p的(k-1)次方),这是质的飞跃。
这个复杂度不随段的划分方式变化。也就是说,哪怕段内节点很多、段数很少,复杂度依然和图的规模线性相关。这在实际工程里非常重要,因为分层多、节点多的场景非常常见,如果动态规划不是线性的,那实用价值会大打折扣。
空间上如果不仅需要距离,还需要记录路径,额外多一个next数组,依然是O(n)。对于成千上万节点的图,内存完全不是问题。除非图特别大,比如百万级节点,才需要考虑用数组而不是字典来存储dp和next,但算法本质不变。
4. 常见问题与排查技巧实录
4.1 段编号出错导致递推失效
最常见的问题就是段信息划分错误。比如把某个节点放进了错误的段,或者忘了给起点单独设一段,程序跑出来结果不对甚至死循环。
我调试时一般会在每个节点计算完dp值后打印出来,人工核对一遍关键节点的dp值。多段图的问题规模通常不大,把整张表打出来,一眼就能看出哪一段没按预期更新。比如如果某个节点始终是INF,说明它的后继节点没算出来,要么段顺序写错了,要么邻接表里用了不存在的节点编号。
另一种隐蔽的问题是节点编号不是按段递增的。我的建议是不管实际图里的节点编号多乱,在代码里做一套编号映射,把起点映射到0、终点映射到n-1,其他节点按段顺序分配编号。这样不仅好调试,而且段倒序遍历时还能直接用数组下标,性能更好。
4.2 把无后效性搞丢的情况
如果图不是严格分段,而是“跨段连边”,比如从段1的节点直接连到段3的节点,那多段图的定义就被破坏了。这种时候不能直接套用上面的按段倒序算法,因为计算段2节点时,它可能依赖段3节点,但段1节点也可能跨段依赖段3节点,拓扑序需要重新算。
我之前在真实项目里就遇到过类似问题。某个流程管理系统的状态机虽然看起来是分阶段的,但存在“跳过中间环节”的连边。直接套多段图模板算出的结果有时对有时错,后来把所有状态重新按拓扑排序,再把递推顺序改成“按拓扑序从后往前”,问题就消失了。
排查方法是:检查每条边的起点段编号和终点段编号,如果出现终点段编号减起点段编号大于1的情况,就说明图不是标准多段图。此时需要用拓扑排序来保证递推顺序,而不是简单按段数倒序。
4.3 边界条件和初始化细节
初始化dp[T] = 0,这个没问题。但其他节点如果没有任何后继(出度为0,且不是终点),理论上它无法到达T,dp值会一直是INF。如果最终dp[S]是INF,说明起点根本到不了终点,或者图结构有问题。
有一个细节容易踩:节点的数量不是固定的,如果你用字典存dp,遍历edge列表时用了不存在的键,Python会直接抛KeyError。稳健的做法是先用set把所有节点收集全,再统一初始化。不要偷懒只初始化邻接表的键,因为有的节点是纯入点,不出现在邻接表的键里。
另外要注意浮点数权值的情况。如果边权是浮点数,用float('inf')没问题;如果是大整数,float可能损失精度。工程上我建议把INF设成一个很大的整数,比如10的18次方,避开浮点比较的精度坑。特别是在做产品时需要输出路径明细,一旦比较出错,记录的后继节点就错了。
4.4 负权边能不能处理
很多资料说Dijkstra不能处理负权边,那多段图动态规划能不能处理?
先说结论:只要图仍然是分层的多段图,没有环,负权边也能处理。原因在于动态规划不依赖“已确定节点不再更新”的贪心假设,它是在拓扑序上一次性递推,每个节点只算一次,计算时用到的是后继节点已经算好的值,不管权值是正是负都不影响公式成立。
但要注意,如果图里有环,哪怕环的总权值是正的,多段图的“段”概念就不成立了,这时动态规划可能陷入互相依赖。比如两个节点互相指向对方,dp[i]依赖dp[j],dp[j]又依赖dp[i],这就没法按简单顺序计算。解决思路仍然是先判断图是否有环,如果无环,可以用拓扑排序后的逆序递推,依然能处理负权边。
我实际遇到过一个带负权边但无环的图,当时用同一套动态规划代码跑出来结果正确,加深了这个结论的印象。如果面试中问到负数场景,可以补一句:SPFA或Bellman-Ford能处理有环的负权图,但多段图的动态规划在无环前提下更高效,没必要搬复杂的算法。
5. 多段图最短路径的变种与实际应用
5.1 把它看成资源分配问题
多段图不只是图论里的概念,本质上是一个“多阶段决策优化”模板。最典型的是资源分配:有K个周期,每个周期要把资源投到不同项目中,每个项目的收益取决于当前投入,目标是让总收益最大。如果把“投入资源量”离散化成几个档位,每周期选一个档位,就构成了多段图,“最短路径”就变成了“最大收益”。
在这个场景里,阶段就是时间周期,状态就是周期初的资源量,转移就是投入资源后到下一周期的收益和剩余资源。这种映射关系很有意思:多段图动态规划的代码几乎不用改,只需要把min改成max,把“边权”解释成收益,运行结果就是最优分配方案。
我做过的某个排产优化需求就是这样。把一个月分成几个时间段,每段时间的产能档位映射成节点,档位之间的切换成本映射成边权,最终求总成本最小的排产路径。结果是原先用Excel枚举排产需要半小时,用动态规划几毫秒就算出来了。这个例子让我深刻体会到,多段图更像是一种“思想的容器”,而不是固定的算法模板。
5.2 项目调度中的关键路径思想
如果把边权改成“持续时间”,多段图最短路径问题就变成了最长路径问题,这时候求的是整个项目的最短完工时间,也就是关键路径。这在项目管理软件里非常常见:活动网络图是分阶段推进的,求最长路径等于找关键路径,进而确定哪些活动绝对不能延误。
实现上只需要把dp[i]的公式改成:
dp[i] = max { weight(i, j) + dp[j] },其中j是后继节点。
然后初始化dp[T] = 0。算出来的dp[S]就是项目总工期。再看哪些节点的决策候选被选中,就能得到关键路径上的活动序列。
这种问题在面试中也常以“任务依赖”“课程安排”等面目出现。很多人以为要背关键路径算法,其实理解了多段图最长路径,就理解了一大半。
5.3 与Dijkstra、Floyd的适用边界
很多刚接触图算法的人会问:最短路径不是有Dijkstra吗?为什么还要单独学多段图的动态规划?
这里要说明白:Dijkstra适合求解“单源到所有点”的最短路径,不要求图分层;它的复杂度是O((n+e)log n),在稀疏图上非常好用。但它依赖“当前最短距离最小的点不会再被更新”的贪心性质,所以要求边权非负。如果图是稠密且带负权,Floyd或Bellman-Ford更合适。
多段图动态规划的特点是:牺牲了对一般图的通用性,换来了线性的复杂度。它的前提是有向无环且按段分层,一旦问题天然具备“阶段推进”的特征,用动态规划往往比Dijkstra快一个数量级,而且代码更简单。
在实际选型时,我不会一上来就用Dijkstra。如果业务状态机是严格的逐层流转,我会先考虑动态规划;如果图结构复杂、边跨越层次很多,那就转用Dijkstra。一种工程判据是:把所有节点按业务阶段编号后,看看是否存在跨段边。没有跨段边,多段图DP就是最优解;有跨段边,就要升级算法。
5.4 如何扩展成“输出所有最短路径”和“次短路径”
在有些面试场景或项目需求里,不只要求一条最短路径,而是要求所有最短路径,或者次短路径。
输出所有最短路径的思路是在DP之后做回溯枚举。回溯时如果当前节点的某个后继节点满足dp[node] == weight(node, nxt) + dp[nxt],就走这个后继。因为可能有多个后继满足这个等式,就递归地枚举所有分支。这样每个节点最多枚举它的出边数量,整体还是O(n+e),只是返回结果的数量可能很大。
次短路径则更进阶。一种办法是记录每个节点的最优值和次优值,然后做一次类似的递推。这个有点绕,但本质还是动态规划,只不过状态量从一维变成二维:dp_min[i]和dp_second[i]。转移时把后续候选取前两个最小,就能求出次短路径。在需要容灾或多方案对比的系统里,这种扩展很有用。
尾声:一个值得再想想的细节
最后分享一个我在实际编码中的亲身经验。刚开始写多段图DP时,我习惯把所有节点放在一个大循环里,用visited标记来控制计算顺序,结果代码又长又容易错。后来我改成按段信息来循环,代码量少一半,bug率也直线下降。这个改动看起来微不足道,但它强迫我把“阶段”这个业务语义显式地放进代码,而不是隐式地依赖节点编号或访问顺序。
另外,每次实现完,我建议用一个小规模的图,把动态规划的结果和暴力枚举的结果做交叉验证。我遇到的多数“看似逻辑正确但结果不对”的问题,都是边界初始化或节点编号映射出的偏差。暴力枚举虽然慢,但作为正确性参照,价值无可替代。之后你再把它扩展到千万级节点的图,心里才有底。