USACO P2849题解:前缀和思想与曼哈顿距离下的最短路径优化
1. 项目概述从“马拉松”到“最短路径”的思维跃迁最近在带学生刷USACO的题目P2849 [USACO14DEC] Marathon S这道题被不少同学标记为“有点意思但容易想复杂”。乍一看标题“马拉松”很容易联想到动态规划求最短路径这思路没错但USACO的题目往往在简单的描述下藏着对算法思维和代码实现细节的双重考验。这道题的核心其实是要求我们在一个给定的点序列中允许跳过中间的一个检查点但不能跳过起点和终点来寻找从起点到终点的最短曼哈顿距离总和。它本质上是一个对“前缀和”与“简单模拟”思想的巧妙应用非常适合用来巩固基础算法思维和提升C代码的稳健性。很多刚接触信奥的同学一看到题目来自USACO心里可能就先“咯噔”一下觉得会不会很难。其实不然USACO的铜组和部分银组题目非常注重对基础数据结构和算法思想的考察而不是一味追求高深的算法模板。P2849这道题就是一个典型例子它不要求你掌握复杂的图论算法但要求你能清晰地理解题意并高效地组织计算。用C来实现重点在于如何清晰地表达计算过程以及如何处理输入输出。接下来我就结合自己多次AC这道题的经验把其中的核心思路、代码实现细节以及常见的“坑点”彻底拆解清楚。2. 核心思路解析为什么不是动态规划2.1 题意转化与问题简化题目描述是有N个检查点必须按顺序访问。从点i到点j的距离是曼哈顿距离即 |xi - xj| |yi - yj|。现在允许你跳过其中一个检查点除了第一个和最后一个目标是让总路程最小。求这个最小的总路程。首先最直接的想法是暴力枚举枚举跳过的那个检查点k2 k N-1然后计算跳过它之后的总距离。计算总距离需要将剩下的点顺序连接起来。对于每一次枚举我们都需要重新计算一次从点1到点N跳过k的路径和。如果直接模拟这个过程时间复杂度是O(N²)因为对于每个k都需要一个O(N)的循环来累加距离。当N最大为10^5时这个复杂度是无法接受的。那么优化的关键在哪里在于我们能否避免重复计算。仔细思考路径总和的计算如果不跳过任何点总距离total是固定的即从点1到点2、点2到点3……一直到点N-1到点N的所有曼哈顿距离之和。如果我们跳过了点k会发生什么总距离变成了从点1到点k-1的路径和 从点k-1直接到点k1的距离 从点k1到点N的路径和。这里从点1到点k-1的路径和以及从点k1到点N的路径和其实是原总路径total的两个连续子段的和。这立刻让我们联想到前缀和思想。我们可以预处理一个前缀和数组prefix其中prefix[i]表示从点1走到点i所经过的路径距离即前i-1段距离的和。这样从点a到点b的路径和按顺序经过中间所有点就可以用prefix[b] - prefix[a]快速得到。2.2 算法设计前缀和的妙用基于以上分析我们可以设计一个O(N)的算法数据读取与存储用两个数组x[N1],y[N1]存储每个检查点的坐标。计算总距离与前缀和初始化total 0。计算相邻点间的距离dist(i, i1) |x[i]-x[i1]| |y[i]-y[i1]|。同时构建前缀和数组prefixprefix[1] 0起点到自己的距离为0prefix[i] prefix[i-1] dist(i-1, i)。这样prefix[N]就等于不跳过任何点的总距离total。枚举跳过点并计算节省距离对于每一个可能的跳过点k(2 k N-1)如果不跳过经过点k的路径是从点k-1到点k再从点k到点k1。这段距离是dist(k-1, k) dist(k, k1)。如果跳过点k路径变为从点k-1直接到点k1距离是dist(k-1, k1)。因此跳过点k所能节省的距离saving(dist(k-1, k) dist(k, k1)) - dist(k-1, k1)。跳过点k后的总距离 total - saving。寻找最优解遍历所有可能的k计算对应的total - saving取最小值。这个最小值就是答案。这个算法的核心在于我们通过计算“节省的距离”来快速得到新总距离而无需重新累加整条路径。计算saving只需要用到点k-1, k, k1三个点的坐标是O(1)的操作。因此整体算法复杂度是O(N)的预处理加上O(N)的枚举完全能够处理N10^5的数据规模。注意这里有一个非常关键的思维陷阱。有同学可能会想跳过点k后从点1到点k-1和从点k1到点N的路径并没有改变改变的只是点k-1到点k1这一小段。因此总距离的变化量完全由这一小段路径的变化决定。这个理解是正确的也是我们能够用O(1)计算节省距离的根本原因。千万不要去重新模拟计算整个路径那样就绕了远路。3. C实现详解与关键代码理解了算法代码实现就是水到渠成的事情。但其中仍有不少细节值得深究这些细节往往决定了代码是简洁优雅还是冗长易错。3.1 数据结构选择与输入处理对于存储坐标最直接的就是用两个vectorint或者普通数组。由于题目中N最大为10^5使用vector在动态大小上更灵活但使用定长数组在栈上分配如果N很大比如10^5两个int数组就是大约800KB可能在某些环境下需要注意栈空间限制。更稳妥的做法是使用vector或者在堆上动态分配。这里我们使用vector清晰且安全。#include iostream #include vector #include cmath // 用于abs函数也可以自己实现 using namespace std; int main() { int N; cin N; vectorint x(N), y(N); // 使用0-based索引更符合C习惯 for (int i 0; i N; i) { cin x[i] y[i]; } // ... 后续计算 }使用0-based索引即下标从0开始是C标准库容器的惯例这样在循环时会更自然。后续计算距离时点i和点i1就对应原题中的点i1和点i2需要稍微注意一下下标的转换。3.2 距离计算与前缀和构建曼哈顿距离计算很简单但要注意使用abs()函数它包含在cstdlib或cmath头文件中。为了代码清晰我们可以定义一个内联函数inline int manhattan(int x1, int y1, int x2, int y2) { return abs(x1 - x2) abs(y1 - y2); }接下来计算总距离total和节省距离。根据之前的分析我们不需要显式地构建一个完整的前缀和数组prefix来求子段和因为我们的计算方式已经简化为对每个点k计算局部节省。直接计算即可// 计算不跳过任何点的总距离 int total 0; for (int i 0; i N - 1; i) { total manhattan(x[i], y[i], x[i1], y[i1]); } int max_saving 0; // 记录最大的节省值 // 枚举跳过的点注意起点(0)和终点(N-1)不能跳过 for (int k 1; k N - 1; k) { // k对应原题的第k1个点 // 原路径从点k-1到k再从k到k1 int original_segment manhattan(x[k-1], y[k-1], x[k], y[k]) manhattan(x[k], y[k], x[k1], y[k1]); // 跳过k后的路径从点k-1直接到k1 int new_segment manhattan(x[k-1], y[k-1], x[k1], y[k1]); int saving original_segment - new_segment; if (saving max_saving) { max_saving saving; } } int answer total - max_saving; cout answer endl;这段代码清晰体现了我们的核心思想总距离减去能省下的最大距离就是最短距离。max_saving初始化为0是合理的因为可能跳过任何一个点都不会节省距离甚至增加距离但题目要求我们必须跳一个所以即使增加也得跳此时saving可能为负但max_saving保持为0answer就等于total即跳过一个不节省的点后的最“优”结果就是原距离。但根据题意new_segment可能比original_segment大吗从几何上看三角形两边之和大于第三边在曼哈顿距离度量下这个不等式依然成立吗这是一个需要仔细思考的点。3.3 核心难点曼哈顿度量下的“三角不等式”在欧几里得空间中三角形两边之和大于第三边。在曼哈顿距离L1距离下这个性质同样成立吗我们验证一下对于三点A, B, C曼哈顿距离d(A,B) d(B,C) d(A,C) 是否恒成立 考虑坐标A(0,0), B(1,0), C(0,1)。d(A,B)1, d(B,C)|1-0||0-1|2, d(A,C)1。左边3右边1显然成立且远大于。实际上曼哈顿距离是满足三角不等式的而且是度量空间的一种。因此original_segment(d(k-1, k) d(k, k1)) 一定是大于等于new_segment(d(k-1, k1)) 的。也就是说saving永远大于等于0。实操心得这个数学性质非常关键。它意味着max_saving至少为0而我们初始化它为0是安全的。更重要的是它保证了我们算法的正确性——我们总是在寻找节省最多的那个点来跳过。如果saving可能为负那么我们的逻辑“总距离-最大节省”就不一定得到最小总距离因为跳过某个点可能导致总距离增加而我们强制跳过一个点这时应该选择“增加最少”的那个点即saving最小的那个点可能是负数。但根据曼哈顿距离的三角不等式saving非负所以“节省最多”和“增加最少”是等价的。这简化了我们的代码和逻辑。在实际做题时花几分钟验证或思考一下这个数学背景能极大增强代码的信心。所以最终的答案就是total - max_saving。如果所有点的saving都是0比如所有点都在一条直线上那么max_saving0答案就等于total这与跳过任意一个点结果都一样的直观认知相符。4. 完整AC代码与逐行分析结合以上所有讨论我们可以给出一个完整、高效且健壮的C解决方案。#include iostream #include vector #include cmath // 使用abs计算绝对值 #include algorithm // 使用max函数 using namespace std; // 计算曼哈顿距离的辅助函数 inline int manhattan(int x1, int y1, int x2, int y2) { return abs(x1 - x2) abs(y1 - y2); } int main() { // 加速输入输出对于大量数据读取很重要 ios::sync_with_stdio(false); cin.tie(nullptr); int N; cin N; vectorint x(N), y(N); // 读取所有检查点坐标 for (int i 0; i N; i) { cin x[i] y[i]; } // 计算不跳过任何点的总距离 int total_distance 0; for (int i 0; i N - 1; i) { total_distance manhattan(x[i], y[i], x[i 1], y[i 1]); } // 寻找跳过某个点所能节省的最大距离 int max_saving 0; // 注意循环范围跳过第2个到第N-1个点对应下标1到N-2 for (int skip_idx 1; skip_idx N - 1; skip_idx) { // 原路径中经过skip_idx点的两段距离 int original_two_segments manhattan(x[skip_idx - 1], y[skip_idx - 1], x[skip_idx], y[skip_idx]) manhattan(x[skip_idx], y[skip_idx], x[skip_idx 1], y[skip_idx 1]); // 跳过skip_idx点后直接连接其前后两点的距离 int new_direct_segment manhattan(x[skip_idx - 1], y[skip_idx - 1], x[skip_idx 1], y[skip_idx 1]); // 节省的距离 int saving original_two_segments - new_direct_segment; // 更新最大节省值 max_saving max(max_saving, saving); } // 最终答案原总距离减去最大节省值 int answer total_distance - max_saving; cout answer endl; return 0; }逐行分析关键点ios::sync_with_stdio(false); cin.tie(nullptr);这是C竞赛编程中常见的输入输出优化语句。它解除了C标准流与C标准流的同步并解除了cin和cout的绑定可以显著提升大量数据读写的速度。对于USACO这种单个测试点数据量可能较大的平台加上这个优化是很好的习惯。vectorint x(N), y(N);使用vector一次性分配好空间避免后续push_back可能带来的不必要的内存重新分配。循环变量命名在计算总距离的循环中我用i在枚举跳过点的循环中我用skip_idx。清晰的变量名能让代码更易读尤其是在逻辑稍复杂的部分。循环边界for (int skip_idx 1; skip_idx N - 1; skip_idx)。这里skip_idx从1开始到N-2结束因为skip_idx N-1。这正好对应原题中第2个点到第N-1个点下标从0开始所以是1到N-2。确保起点和终点不被跳过。max_saving的更新使用max(max_saving, saving)而不是if(saving max_saving)代码更简洁。需要包含algorithm头文件。答案计算直接输出total_distance - max_saving直观反映了我们的解题逻辑。5. 常见错误与深度调试指南即使思路正确实现时也可能遇到各种问题。下面我总结几个常见的“坑”并给出排查方法。5.1 数组下标越界这是最常见的问题之一。在我们的循环中计算original_two_segments和new_direct_segment时用到了skip_idx - 1和skip_idx 1。必须确保skip_idx的取值范围使得这些下标是有效的。错误示例如果skip_idx从0开始循环那么skip_idx-1就是-1访问x[-1]会导致未定义行为通常是运行时错误或错误数据。正确做法正如我们代码所示skip_idx的范围是[1, N-2]。当N3时skip_idx只能为1这是合理的跳过中间那个点。当N2时题目失去意义没有点可跳过但根据题意N1我们代码中skip_idx的循环条件1 N-1在N2时不会进入max_saving保持为0答案输出total_distance也是合理的。5.2 整数溢出问题题目中坐标的绝对值范围没有明确给出但在USACO中通常是在32位有符号整数(int)范围内的。曼哈顿距离是两个绝对值的和最大可能值约为210^9如果坐标在±10^9而总距离total_distance是N-1个这样的距离之和N最大为10^5。那么total_distance的最大值可能达到210^9 * 10^5 210^14这已经超出了32位int最大值约210^9的范围。风险计算过程中total_distance或中间变量可能溢出导致结果错误。解决方案使用long long类型来存储总距离和最大节省值。这是竞赛中处理累加和、距离和等涉及大量数据求和时的标准做法。修正后的代码关键部分long long total_distance 0; // 改为long long for (int i 0; i N - 1; i) { total_distance manhattan(x[i], y[i], x[i 1], y[i 1]); } long long max_saving 0; // 改为long long for (int skip_idx 1; skip_idx N - 1; skip_idx) { int original_two_segments ...; // 这部分是相邻两段值不会太大用int即可 int new_direct_segment ...; long long saving original_two_segments - new_direct_segment; // 转为long long计算 max_saving max(max_saving, saving); } long long answer total_distance - max_saving; cout answer endl;注意manhattan函数返回int两个int相加减结果仍在int范围但赋值给long long是安全的。为了彻底避免在计算saving时可能出现的溢出虽然本题中saving本身不会太大也可以将original_two_segments和new_direct_segment声明为long long或者将manhattan函数返回值改为long long。最稳妥的做法是将所有与总距离相关的变量都升级为long long。5.3 对“必须跳过一个点”的理解偏差题目明确要求“跳过其中一个检查点”。这意味着即使跳过任何点都不会节省距离所有saving为0或者跳过某个点反而增加距离在曼哈顿距离下不会我们都必须执行一次跳过操作。在我们的算法中max_saving最小为0所以answer total_distance - max_saving最小就等于total_distance。这对应了跳过那个“最不坏”的点节省为0的情况。如果错误地认为可以不跳过那么答案可能会输出total_distance减去一个负数导致错误。5.4 测试用例设计自己设计测试用例是调试的必备技能。针对这道题可以设计以下几类最小情况N3。这是能跳过点的最小情况。手动计算验证。所有点共线例如点(0,0), (1,0), (2,0), (3,0)。此时任意跳过中间一个点节省距离都是0因为曼哈顿距离下共线时经过中间点不会增加路程。答案应为原总距离3。跳过点节省最大设计一个点跳过它能“抄近道”。例如点A(0,0), B(10,0), C(0,10)。不跳过B的总距离是d(A,B)d(B,C)102030。跳过B直接从A到C距离是20。节省了10。答案应为20。大数据随机测试用程序生成大量随机点用你的O(N)算法和暴力O(N²)算法小N时对比结果确保正确性。6. 算法扩展与思维提升虽然这道题用O(N)的贪心思想解决了但它可以引发出一些更深入的思考帮助我们提升算法能力。6.1 如果允许跳过多个点呢这是很自然的扩展。如果允许跳过最多M个点MN求最短总距离。这时我们的O(N)方法就不适用了因为跳过点之间会相互影响。这变成了一个典型的动态规划问题。我们可以定义状态dp[i][j]表示从起点点1到达点i并且已经跳过了j个点不包括点i的最短距离。 状态转移方程需要考虑到达点i时上一个点可能是i-1没有跳过i-1也可能是i-2跳过了i-1依此类推但需要结合j的变化。 方程大致为dp[i][j] min( dp[i-1][j] dist(i-1, i), dp[i-2][j-1] dist(i-2, i), ... )同时要保证下标和跳过的点数合法。 这需要O(N * M)的时间复杂度。如果M也很大可能需要更优的优化。这个扩展问题很好地连接了基础贪心和动态规划。6.2 距离度量方式的变化本题使用的是曼哈顿距离。如果换成欧几里得距离呢三角不等式依然成立所以“节省距离非负”的结论仍然成立我们同样的贪心策略跳过节省最多的点还正确吗仔细想想在欧几里得距离下d(A,B)d(B,C) d(A,C)仍然成立。但是当我们跳过点B时路径从A-B-C变为A-C节省的距离saving d(A,B)d(B,C)-d(A,C)。我们的策略是选择saving最大的点跳过。这仍然是正确的因为总距离total - max_saving确实最小。所以对于任意满足三角不等式的距离度量这个贪心策略都是正确的。这是一个很重要的发现它说明了算法的通用性。6.3 在信奥学习中的定位P2849这道题在USACO中属于铜组/银组难度它完美地诠释了竞赛编程中“化繁为简”的思维。它没有考察复杂的数据结构而是聚焦于对问题的抽象、转化以及利用基础工具前缀和思想、循环、条件判断高效解决问题的能力。对于信奥初学者来说这类题目是训练思维严谨性和代码实现能力的绝佳材料。通过这道题可以深刻体会到审题的重要性准确理解“跳过一点”对整体路径的影响。避免无效计算从O(N²)的暴力模拟到O(N)的高效计算关键在于发现了路径变化的局部性。数学知识辅助曼哈顿距离的三角不等式保证了算法逻辑的简洁。代码细节决定成败数据类型的选择、循环边界的控制、输入输出的优化这些看似微小的点在竞赛中往往是区分AC与WA的关键。在刷题时不要满足于AC。多问几个“为什么”为什么这样想是对的有没有反例数据范围如何影响实现如果条件变化了怎么办这样的思考比单纯多刷十道题更有价值。