ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

蓝桥杯国赛“补给”题解:TSP变种与状压DP实战

蓝桥杯国赛“补给”题解:TSP变种与状压DP实战 1. 项目概述从“补给”到“最短路径”的算法实战看到“第十一届蓝桥杯国赛——补给”这个标题很多参加过算法竞赛的朋友可能会心一笑。这可不是一个关于后勤保障或者物资管理的项目而是一道经典的、在算法竞赛圈子里广为人知的图论与动态规划结合题。它考察的核心是如何在给定条件下规划一条访问所有目标点并返回起点的最短路径同时还要考虑一个关键限制你的“油箱”容量是有限的不能一次走太远必须在特定的“补给站”进行“加油”即补给。这道题完美地将现实世界的约束续航能力抽象为算法问题是检验选手对旅行商问题TSP变种、状态压缩动态规划状压DP以及最短路径预处理综合应用能力的试金石。如果你正在准备蓝桥杯、ACM-ICPC等算法竞赛或者对图论和动态规划的实际应用感兴趣那么深入理解这道“补给”题其价值远超解决一道题本身。它能帮你建立起处理“带约束的路径规划”这类问题的通用思维框架。本文我将以一个过来人的视角拆解这道题的解题全流程从问题抽象、算法选型、核心实现到调试技巧分享我踩过的坑和总结的经验目标是让你不仅能看懂题解更能掌握独立分析和解决同类问题的能力。2. 问题核心与数学模型抽象2.1 题意翻译把故事变成公式题目通常会这样描述在一个二维平面上有N个点其中第1个点是基地起点兼终点。你有一架无人机或小车它的最大续航距离是D。这意味着在不进行补给的情况下它连续飞行的最远距离不能超过D。在N个点中有部分点被指定为“补给点”当无人机到达补给点时可以瞬间将续航重置为满状态D。你的任务是从基地点1出发访问所有N个点每个点至少一次最后回到基地并使得总飞行距离最短。我们需要立刻将文字转化为数学模型图GraphN个点构成一个完全图的顶点集。任意两点i和j之间有一条边边的权重w(i, j)是它们之间的欧几里得距离。状态State我们需要记录“哪些点已经访问过”以及“当前位于哪个点”。访问集合可以用一个N位的二进制数mask表示状态压缩mask的第k位为1表示第k1个点已访问。当前点u是0到N-1的整数。决策与约束从状态(mask, u)出发我们可以选择下一个未访问的点v进行转移。转移的可行性条件是从u到v的距离必须小于等于无人机当前的剩余续航。如果v是补给点那么到达v后剩余续航重置为D否则剩余续航减去dist(u, v)。目标找到从初始状态(1, 0)只访问了基地点0位于点0剩余续航为D到最终状态((1N)-1, 0)所有点都访问过并回到点0的路径使得路径总距离最小。注意这里有一个关键的思维转换。直接记录“剩余续航”作为状态维度会导致状态爆炸因为续航是连续值。标准的优化技巧是我们不直接记录剩余续航而是在状态转移时判断“能否通过若干次补给从 u 到达 v”。这就需要预处理出任意两点之间在续航限制D下最少需要多少次补给才能到达或者更直接地判断“能否在不违反续航限制的前提下直达”。2.2 算法选型为什么是状压DP最短路预处理面对“访问所有点后回到起点”的问题我们首先想到的是旅行商问题TSP这是一个NP-Hard问题。N通常不超过20蓝桥杯国赛题的典型范围这提示我们可以用指数级算法。状压DP是解决小规模TSP的利器其状态dp[mask][u]表示访问了集合mask中的点并且最后停留在点u时的最短路径长度。但经典的TSP没有“续航”限制。如何融入这个限制呢一个朴素的想法是给DP状态增加一维剩余油量fuel但fuel可能是0到D的整数状态数会变成(2^N * N * D)在N20, D10000时不可接受。因此更优雅且高效的做法是将续航约束的判断前置通过图论预处理来解决。具体分为两步构建可达图根据续航D我们预处理出任意两个点i和j之间是否可以一次飞行到达即距离 D。这样我们就得到了一个原始的无向图G。计算最短补给路径在现实情况中即使i和j不能直达也可能通过途径其他补给点中转到达。因此我们需要计算在考虑补给点的情况下从任意点i到任意点j的最短可行距离。这可以通过以所有补给点和起点为“加油站”使用Floyd算法或多次Dijkstra算法来计算经过这些中转点的最短路径。最终我们得到一个完全图G‘其中G‘[i][j]表示从i到j在遵守续航规则下的最短可行距离。如果G‘[i][j]为无穷大则说明在续航限制下无法从i走到j。然后我们在这个新图G‘上运行标准的、无续航约束的状压DP TSP算法即可。这个“预处理DP”的两阶段模型是解这道题的核心框架也是处理类似带资源约束路径规划问题的通用思路。3. 核心实现步骤拆解3.1 第一步数据输入与距离计算首先我们需要读取所有点的坐标(x_i, y_i)和每个点是否是补给点的标记supply[i]通常点1基地默认为补给点。# 假设输入格式第一行 N, D接下来N行每行 x, y, isSupply(0/1) N, D map(int, input().split()) points [] supply [False] * N for i in range(N): x, y, s map(int, input().split()) points.append((x, y)) supply[i] (s 1) # 确保起点是补给点 supply[0] True接着计算任意两点间的欧氏距离并初始化直接可达矩阵direct。import math dist [[0.0]*N for _ in range(N)] direct [[False]*N for _ in range(N)] # 是否满足 dist D for i in range(N): for j in range(N): dx points[i][0] - points[j][0] dy points[i][1] - points[j][1] d math.sqrt(dx*dx dy*dy) dist[i][j] d direct[i][j] (d D 1e-9) # 浮点数比较容差实操心得浮点数比较一定要使用容差如1e-9或1e-12因为sqrt运算可能产生精度误差。判断d D时用d D eps更安全。这是算法竞赛中处理几何距离的常见坑点。3.2 第二步预处理最短可行路径关键这一步是整个算法的精髓目标是构建上文提到的完全图G‘。我们称其为min_dist矩阵。这里提供两种主流方法方法一基于补给点的Floyd算法既然补给点可以“重置续航”那么我们可以将问题转化为无人机只能在补给点包括起点之间进行“长距离”移动而在两个补给点之间移动时必须确保路径上的每一段直线距离都不超过D。首先用dist和D判断初始化一个图graph其中graph[i][j] dist[i][j]如果direct[i][j]为真否则为无穷大(inf)。然后只考虑补给点之间的连通性。使用Floyd算法计算所有点对之间的最短路径但注意这个路径的每一段都必须满足direct为真。这实际上计算的是“任意两点间只通过距离D的边相连的最短路径”。如果两个补给点之间的最短路径是有限的那么无人机就可以通过这条路径在中间点可能是非补给点进行“技术经停”最终从一个补给点飞到另一个补给点。得到的graph矩阵中graph[i][j]就表示从i到j的、满足任意分段都不超过D的最短路径距离。对于任意点对(i, j)只要graph[i][j]不是inf就说明在续航限制下可以从i走到j。这个距离就是min_dist[i][j]。INF float(inf) # 初始化图 graph [[INF]*N for _ in range(N)] for i in range(N): for j in range(N): if direct[i][j]: graph[i][j] dist[i][j] graph[i][i] 0 # Floyd算法 for k in range(N): for i in range(N): if graph[i][k] INF: continue for j in range(N): if graph[k][j] INF: # 注意这里不需要额外判断因为graph中的边本身已满足distD # Floyd只是找最短路径不改变边的性质 if graph[i][k] graph[k][j] graph[i][j]: graph[i][j] graph[i][k] graph[k][j] # 此时graph[i][j] 就是 min_dist[i][j] (如果 ! INF) min_dist graph方法二构建补给点可达图后计算最短路另一种更直观的思路是既然续航只在补给点重置那么无人机的有效移动可以看作是在补给点之间跳跃。对于任意两个点i和j不一定是补给点它们之间的可行路径必须满足路径上任意两个相邻的补给点或起点/终点之间的距离 D。我们创建一个只包含所有补给点索引集合S的完全图G_supply。对于G_supply中的任意两点a和ba, b ∈ S如果它们的直线距离dist[a][b] D则它们之间有一条边权重为dist[a][b]。在这个补给点网络上运行Floyd算法得到任意两个补给点之间的最短距离supply_dist[a][b]。对于任意起点i和终点j如果i和j都是补给点那么min_dist[i][j] supply_dist[i][j]。如果i不是补给点那么从i出发必须先走到一个补给点s满足dist[i][s] D然后从s走到离j最近的补给点t最后从t走到j满足dist[t][j] D。我们需要枚举所有可能的(s, t)补给点对找到dist[i][s] supply_dist[s][t] dist[t][j]的最小值作为min_dist[i][j]。如果j不是补给点逻辑类似。如果i和j都不是补给点且彼此距离 D也可能直达。这种方法逻辑清晰但实现稍复杂需要多次枚举。在竞赛中方法一对所有点运行Floyd更常见且编码简单因为N 20O(N^3)的Floyd算法完全可接受。注意事项无论用哪种方法预处理后一定要检查min_dist[0][0]是否为0以及从起点0到其他各点是否都有可达路径 (min_dist[0][i] INF)。如果存在不可达的点那么整个任务无法完成根据题目要求可能输出-1或特定值。3.3 第三步状态压缩动态规划求解TSP经过预处理我们得到了一个“完全图”min_dist现在问题简化为在这个完全图上从点0出发访问所有点后回到点0的最短路径长度。这就是经典的TSP问题。定义dp[mask][u]表示已经访问过的点集合为mask二进制表示并且当前停留在点u时所走过的最短路径长度。初始状态dp[1][0] 0。mask1表示只有点0二进制第0位被访问。状态转移考虑当前状态(mask, u)我们要去一个未访问的点v即mask的第v位为0。转移条件是min_dist[u][v]不为无穷大即可达。转移方程为dp[mask | (1 v)][v] min(dp[mask | (1 v)][v], dp[mask][u] min_dist[u][v])最终答案dp[(1 N) - 1][0]即所有点都访问过 (mask全为1)并且最后回到了点0。注意我们最终必须回到点0所以答案不是min(dp[(1N)-1][:])而是特指回到0的状态。# 初始化DP数组 dp [[INF] * N for _ in range(1 N)] dp[1][0] 0 # 从点0出发 # 遍历所有状态mask for mask in range(1 N): # 遍历所有可能的当前点u for u in range(N): if dp[mask][u] INF: continue # 当前状态不可达跳过 # 遍历所有未访问的点v for v in range(N): if mask (1 v): # v点已访问 continue if min_dist[u][v] INF: # 从u到v不可达 continue new_mask mask | (1 v) new_cost dp[mask][u] min_dist[u][v] if new_cost dp[new_mask][v]: dp[new_mask][v] new_cost # 最终答案所有点都访问过且最后在点0 ans dp[(1 N) - 1][0] if ans INF: print(-1) # 无法完成全程 else: # 通常需要保留两位小数输出 print(f{ans:.2f})3.4 第四步路径记录与方案还原可选但建议对于学习和调试能够还原出最短路径的具体走法非常重要。我们可以在DP转移时用一个pre[mask][u]数组记录到达状态(mask, u)的前一个状态(prev_mask, prev_u)。在DP结束后从最终状态((1N)-1, 0)逆向回溯就能得到完整的访问序列。pre [[(-1, -1)] * N for _ in range(1 N)] # 记录前驱状态 # ... 在DP转移更新dp值时同时更新pre ... if new_cost dp[new_mask][v]: dp[new_mask][v] new_cost pre[new_mask][v] (mask, u) # 记录是从哪个状态转移来的 # 回溯路径 def get_path(mask, u): path [] while u ! -1: path.append(u) mask, u pre[mask][u] return path[::-1] # 反转得到正序 if ans INF: final_path get_path((1 N) - 1, 0) print(最短路径顺序:, final_path)通过路径还原你可以验证你的算法是否真的找到了一条可行的、满足续航约束的路径这对于复杂调试至关重要。4. 调试技巧与常见问题实录即使理解了算法实现时也难免遇到各种问题。下面是我在多次实现和教学中总结的常见坑点。4.1 浮点数精度误差处理这是最隐蔽的Bug来源之一。距离计算、比较d D、以及最终的答案累加都涉及浮点数。比较永远不要用或直接、比较浮点数。要使用容差eps。eps 1e-9 def le(a, b): # a b return a b eps direct[i][j] le(dist[i][j], D)初始化无穷大INF float(inf)是安全的。但在某些语言或判断中INF参与运算后可能变成NaN需要注意。输出题目通常要求保留两位小数。使用print(f{ans:.2f})。如果ans是INF需要先判断。4.2 状态定义与转移的完整性起点补给点务必确认起点通常是点0被标记为补给点。否则无人机一开始就无法出发。DP初始化dp[1][0] 0是正确的表示访问了集合{0}并在点0。不要错误地初始化所有dp[1i][i]。最终状态答案必须是dp[全1][0]因为你最终必须回到起点。如果你输出min(dp[全1])那就变成了“访问所有点后停在任意点”的最短路径不符合题意。不可达判断在DP循环中如果dp[mask][u]是INF可以直接continue避免无效的遍历这是一个重要的剪枝。4.3 预处理逻辑的正确性验证预处理阶段min_dist矩阵计算错误会导致后续DP全盘皆输。如何验证构造简单测试用例例如3个点A(补给)、B(非补给)、C(补给)D设置得让A-B-C可行但A-C不可达。手动计算min_dist[A][C]应该等于dist(A,B)dist(B,C)。打印中间矩阵对于小规模N如5在调试时打印出dist、direct和最终的min_dist矩阵肉眼检查。特别是检查min_dist[i][j]是否可能小于dist[i][j]这是合理的因为可能绕路以及是否所有预期的连通关系都正确。检查对称性由于距离是无向的min_dist矩阵应该是对称的min_dist[i][j] min_dist[j][i]。如果不相等可能是Floyd算法实现有误或者direct矩阵不对称这种情况不应该发生因为距离是无向的。4.4 性能优化与小技巧虽然N20时O(2^N * N^2)的DP是主流但仍有优化空间枚举子集优化标准的TSP DP写法是三层循环for mask-for u-for v。可以稍微优化在内层循环v时只枚举mask的补集中的位。可以用not_visited ((1 N) - 1) ^ mask然后循环v在not_visited的二进制位中。内存优化dp数组是2^N * N。对于N20大约是1M * 20 * 8字节 ≈ 160MB在部分内存限制严格的场景可能需要注意。可以使用listofarray(d)或者注意编程语言的特性。提前终止如果题目只要求判断是否可行或者求一个可行解可以在DP过程中提前找到答案就退出。4.5 一个完整的自测用例假设输入4 5 0 0 1 0 3 0 4 0 0 4 3 1解释4个点D5。点1(0,0)和点4(4,3)是补给点。点1到点2距离3点2到点4距离5点1到点4距离5。点1到点3距离4点3到点4距离3。理想路径0(起点)-2-4(补给)-3-1(终点)。总距离3 5 3 5 16。如果预处理正确min_dist[0][2]3,min_dist[2][3]8经点4min_dist[3][0]5。DP计算出的总长应为16。用这个简单用例可以快速验证你的代码逻辑。如果输出不是16就一步步调试预处理矩阵和DP值。这道“补给”题就像算法竞赛路上的一个经典驿站它综合了图论、动态规划和状态压缩的思想。掌握它不仅能帮你应对竞赛更能提升你将复杂现实约束抽象为清晰数学模型的能力。在实际工作中物流配送、无人机巡检、网络路由等很多问题其内核都与这道题相似。多思考“为什么这样建模”比死记硬背代码模板重要得多。最后在编码时养成先写注释理清步骤、然后分模块测试的习惯能极大减少调试时间。尤其是浮点数处理和预处理逻辑往往是成败的关键务必细心。
返回列表