尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

蓝桥杯拔河问题:前缀和与有序集合优化O(n² log n)解法详解

蓝桥杯拔河问题:前缀和与有序集合优化O(n² log n)解法详解 1. 项目概述拔河问题的核心与挑战最近在复盘第十五届蓝桥杯C/CB组的真题其中一道“拔河”问题引起了我的注意。这道题乍一看像是简单的区间求和与比较但仔细琢磨后你会发现它巧妙地融合了前缀和、区间枚举和优化思想是区分选手对基础算法掌握深度和代码实现能力的典型题目。很多刚接触算法竞赛的同学可能会一头扎进暴力枚举的陷阱导致在数据规模稍大时就超时。今天我就结合自己的解题经验把这道题的来龙去脉、核心思路、多种解法以及避坑要点掰开揉碎了讲清楚。简单来说题目是这样的老师有n个学生每个学生有一个力量值。需要选出两个编号连续的队伍即两个连续子数组这两个队伍不能有重叠的学生并且要使得两个队伍的总力量值之差的绝对值最小。我们的目标就是找到这个最小的差值。数据范围n最大可以达到1000力量值最大是10^9。这直接排除了最无脑的O(n^4)暴力枚举枚举四个端点的可能性甚至O(n^3)的算法在n1000时也相当吃力。因此如何设计一个高效且正确的算法就是本题的核心挑战。理解并解决这个问题不仅能帮你拿下这道题的分数更能加深你对“连续子数组”类问题处理范式的理解这在很多场景下都非常有用。2. 问题解析与暴力解法的局限性首先我们必须彻底理解题目的每一个约束条件这是设计正确算法的前提。2.1 关键约束条件拆解两个队伍必须选出两个队伍不能多也不能少。编号连续每个队伍必须由原数组中一段连续的学生组成。这是“连续子数组”的典型特征。互不重叠两个队伍所包含的学生不能有重复。这意味着在原始的学生编号序列上这两个连续区间不能相交。最小化差距目标是两个队伍力量值总和的差的绝对值最小。注意是差的绝对值所以无论谁大谁小我们只关心这个差距的大小。基于这些条件最直观的想法就是暴力枚举。我们可以用四个循环变量l1, r1, l2, r2来分别表示第一个队伍的左右端点和第二个队伍的左右端点。枚举所有可能的组合然后检查它们是否满足“互不重叠”的条件即r1 l2或r2 l1计算两个区间的和并求差的绝对值最后更新最小值。这种方法的复杂度是O(n^4)。当n50时计算量大约是50^4 6.25百万尚可接受题目也指出对20%的数据n≤50。但当n1000时1000^4 10^12这是一个天文数字完全不可行。因此暴力枚举只能作为我们理解问题的起点绝不能作为最终解法。注意在思考暴力解法时一个常见的误区是认为两个区间必须“一左一右”不交叉即可。实际上题目只要求互不重叠并没有规定谁在前谁在后。所以区间A在左区间B在右r1 l2和区间B在左区间A在右r2 l1这两种情况都需要考虑。在暴力枚举中我们通过条件判断来涵盖在更优的算法中我们则需要通过巧妙的枚举顺序来保证不重不漏。2.2 前缀和优化计算的必备工具在涉及连续区间求和的问题中前缀和Prefix Sum是必须掌握的基础技巧。它的思想非常简单预处理一个数组prefix其中prefix[i]表示原数组a从第1个元素到第i个元素的和通常令prefix[0] 0。这样原数组中任意一段连续区间[l, r]的和就可以通过prefix[r] - prefix[l-1]在O(1)时间内得到。如果没有前缀和每次求区间和都需要遍历区间复杂度是O(n)这在多次查询时会成为性能瓶颈。对于本题无论采用何种优化算法第一步都应该是计算前缀和数组。这是将后续时间复杂度降低一个数量级的关键。#include iostream #include vector #include cmath #include climits using namespace std; int main() { int n; cin n; vectorlong long a(n1), prefix(n1, 0); // 使用long long防止求和溢出 for (int i 1; i n; i) { cin a[i]; prefix[i] prefix[i-1] a[i]; // 计算前缀和 } // ... 后续算法 }3. 核心算法思路枚举分割点与区间最值既然O(n^4)的暴力枚举行不通我们必须寻找更优的解法。一个核心的观察是由于两个区间不能重叠那么整个数轴学生队列可以被三个部分分割队伍A、中间的间隙、队伍B。这个“间隙”可以是空即两个队伍紧挨着也可以包含若干学生。这个观察引出了一个非常经典的思路枚举中间的分割点。3.1 算法框架设计我们可以想象在每两个学生之间包括最左端之前和最右端之后画一条分割线。对于任意一条分割线它将所有学生分成了左右两部分。那么我们所选的两个不重叠的队伍必然一个完全在分割线左侧另一个完全在分割线右侧。设我们在位置k处进行分割k介于0和n之间k0表示分割线在所有学生之前kn表示在所有学生之后。那么左半部分a[1...k]中我们可以任选一个连续子数组作为队伍A。右半部分a[k1...n]中我们可以任选一个连续子数组作为队伍B。我们的目标是|sum(A) - sum(B)|最小。对于固定的分割点k要最小化这个式子一个自然的想法是让sum(A)尽可能接近sum(B)。但A和B是独立选择的我们无法同时控制两者。然而我们可以换个角度思考对于左半部分我们预先计算出所有可能连续子数组的和并找到最接近某个目标值T的那个和。但T是什么它就是右半部分某个子数组的和这又回到了循环嵌套。这里需要更进一步的优化。一个关键的技巧是对于每个分割点我们分别计算出左半部分所有连续子数组的和以及右半部分所有连续子数组的和然后尝试配对。但直接配对又是O(n^2)的复杂度。3.2 引入“最接近值”预处理我们可以进一步优化。对于固定的分割点k假设我们已经知道了左半部分所有连续子数组的和的集合S_left以及右半部分所有连续子数组的和的集合S_right。我们的目标是找到x ∈ S_left和y ∈ S_right使得|x - y|最小。如果S_left和S_right都是有序的那么我们可以用双指针或二分查找的方法在近似O(m log m)m为集合大小的时间内找到最接近的配对。但问题在于对于每个分割点k我们都需要生成这两个集合而每个集合的大小是O(k^2)和O((n-k)^2)的总复杂度依然很高。我们需要一个更强的优化。注意到我们并不需要知道所有子数组的和我们只需要知道对于左半部分和最接近某个值的子数组和是多少。但右半部分的“某个值”也是变化的。这似乎陷入了僵局。3.3 突破性思路固定总和寻找最优配对让我们重新审视目标最小化|sum(A) - sum(B)|。 这个式子等价于对于所有可能的(sumA, sumB)配对求|sumA - sumB|的最小值。一个等价且更易处理的形式是枚举所有可能的队伍力量总和S然后检查是否能找到两个不重叠的连续子数组使得它们的和都等于S或者一个略大于S一个略小于S。但枚举S的复杂度可能很高。实际上本题最优雅且能通过全部数据的解法时间复杂度是O(n^2)。其核心思想是枚举第一个区间的右端点i然后动态维护第二个区间的最佳选择。具体算法流程如下预处理前缀和数组pre[]。用i枚举第一个区间的右端点从1到n。对于每个固定的i用j枚举第一个区间的左端点从1到i。这样我们就得到了第一个区间的所有可能[j, i]及其和sum1 pre[i] - pre[j-1]。现在我们需要在第一个区间[j, i]的右侧即下标大于i的区域找到一个连续子数组其和sum2与sum1的差的绝对值最小。问题转化为给定一个目标值sum1在一个固定的数组后缀a[i1...n]中找到一个连续子数组其和最接近sum1。对于数组后缀找最接近目标值的子数组和我们可以在枚举i的同时预处理出从每个位置start开始的所有后缀子数组的和并对其进行排序或使用其他数据结构如set来快速查找最接近值。但更巧妙的方法是在枚举i的过程中我们也可以枚举第二个区间的所有可能[p, q]其中p i但这样又成了O(n^3)。真正的O(n^2)解法基于以下观察当我们固定第一个区间的右端点i并让左端点j从i向1移动时sum1是在连续变化的。同时我们可以预先计算出所有可能的第二个区间即起始点大于i的区间的和并将其存储在一个有序集合中。当j移动导致sum1变化时我们就在这个有序集合中二分查找最接近sum1的值。算法步骤详解初始化答案ans为一个很大的数如LLONG_MAX。外层循环枚举分割点mid即第一个区间的右端点也从第二个区间左端点的前一个位置理解。mid从0到n。当mid0时表示第一个区间为空但题目要求两个队伍所以实际从mid1开始考虑且要保证右边有区间当midn时表示第二个区间为空。对于每个mid我们需要知道左边所有区间和以及右边所有区间和。但直接枚举左右区间是O(n^2)对于每个mid又是O(n^2)总体O(n^3)。优化我们可以用两个集合如C的multiset来动态维护左右两边的所有区间和。初始时左集合包含所有完全在[1, mid]内的区间和右集合包含所有完全在[mid1, n]内的区间和。构建这两个集合的复杂度是O(n^2)如果对每个mid都重建总复杂度O(n^3)。关键优化点当mid向右移动一位时mid增加1左集合和右集合的变化是有规律的。新的左集合等于旧的左集合加上所有以mid1为右端点的区间和。新的右集合等于旧的右集合减去所有以mid1为左端点的区间和。我们可以通过预处理每个位置作为端点时的区间和列表来O(n)地更新这两个集合。然而实现上述动态维护较为复杂。一个更清晰、同样能达到O(n^2)的实践方法是枚举第一个区间的右端点i然后枚举其左端点j得到sum1。接着我们只需要考虑第二个区间在i的右边。我们可以预先计算出所有“起始下标大于i”的区间和并放入一个有序数组。对于每个sum1在这个有序数组中二分查找最接近的值。如何“预先计算”所有起始下标大于i的区间和我们可以倒序枚举i。当i从n递减到1时我们可以动态地将所有以i为左端点的区间和即sum(a[i...k]),k从i到n加入到一个有序集合如set中。这样当处理到某个i时这个集合里存储的就是所有起始下标 i的区间和实际上我们只需要起始下标 i的即第二个区间必须在第一个区间右边所以加入集合的操作可以稍晚一步。让我们用具体的伪代码来描述这个O(n^2 log n)的算法因为使用了set的二分查找多了一个log但对于n1000n^2 log n ~ 1e6 * 10 1e7完全可行long long solve(vectorlong long a) { int n a.size() - 1; // a[1..n] vectorlong long pre(n1, 0); for (int i 1; i n; i) pre[i] pre[i-1] a[i]; long long ans LLONG_MAX; // 枚举第一个区间的右端点i for (int i 1; i n; i) { // 枚举第一个区间的左端点j for (int j 1; j i; j) { long long sum1 pre[i] - pre[j-1]; // 现在需要在右侧(i1..n)找一个区间和sum2使得|sum1-sum2|最小 // 我们可以预先将右侧所有区间和存入一个有序集合 } } return ans; }接下来的问题是如何高效获得“右侧所有区间和”。我们可以在外层循环开始前先预处理一个setlong long right_sums但它包含的是整个数组所有区间和我们需要的是起始点大于i的区间和。我们可以在i循环内部每次更新这个集合。更高效的做法是倒序枚举第一个区间的右端点i。初始化一个空的setlong long right_sums。从i ndown to1此时right_sums中存储的是所有起始下标大于i的区间和因为我们在处理完i之后才将起始点为i的区间加入。内层循环j从1到i计算sum1。在right_sums中二分查找与sum1最接近的值即lower_bound并计算差值更新答案。在开始下一个i即i-1的循环之前我们将所有以i为起点的区间和即sum(a[i...k]),k从i到n插入到right_sums中。这样当处理i-1时right_sums里就是起始下标 i的区间和即严格在i-1右侧的区间。long long solve(vectorlong long a) { int n a.size() - 1; vectorlong long pre(n1, 0); for (int i 1; i n; i) pre[i] pre[i-1] a[i]; long long ans LLONG_MAX; setlong long right_sums; // 初始时right_sums为空表示in时右侧没有区间符合逻辑 // 倒序枚举第一个区间的右端点i for (int i n; i 1; --i) { // 枚举第一个区间的左端点j for (int j 1; j i; j) { long long sum1 pre[i] - pre[j-1]; // 在右侧区间和集合中查找最接近sum1的值 if (!right_sums.empty()) { auto it right_sums.lower_bound(sum1); if (it ! right_sums.end()) { ans min(ans, abs(sum1 - *it)); } if (it ! right_sums.begin()) { --it; ans min(ans, abs(sum1 - *it)); } } } // 将本轮i作为左端点的所有区间和加入right_sums供下一个i即i-1使用 for (int k i; k n; k) { right_sums.insert(pre[k] - pre[i-1]); } } return ans; }这个算法的时间复杂度分析外层i循环n次。内层j循环对于每个i循环i次。所以i和j的总枚举次数是12...n O(n^2)。对于每个(i, j)对我们在set中进行一次二分查找O(log M)其中M是set的大小最大为O(n^2)所以log M约为2 log n。在每轮i循环结束时有一个k循环用于插入区间和k从i到n平均O(n)次插入每次插入O(log M)。总复杂度约为O(n^2 log n n^2 log n) O(n^2 log n)。对于n1000计算量在千万级别可以在1秒内完成。4. 算法实现细节与代码精讲理解了算法框架我们来看具体的代码实现。我将提供一个清晰、完整且带有详细注释的C解决方案。4.1 完整代码实现#include bits/stdc.h using namespace std; using ll long long; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin n; vectorll a(n 1), pre(n 1, 0); for (int i 1; i n; i) { cin a[i]; pre[i] pre[i - 1] a[i]; // 计算前缀和 } ll ans LLONG_MAX; // 初始化答案为极大值 setll right_sums; // 存储所有“起始下标严格大于当前i”的区间和 // 倒序枚举第一个区间的右端点i for (int i n; i 1; --i) { // 枚举第一个区间的左端点j得到区间[j, i]的和sum1 for (int j 1; j i; j) { ll sum1 pre[i] - pre[j - 1]; // 在右侧区间和集合中寻找与sum1最接近的值 if (!right_sums.empty()) { auto it right_sums.lower_bound(sum1); // 检查大于等于sum1的第一个元素 if (it ! right_sums.end()) { ans min(ans, abs(sum1 - *it)); } // 检查小于sum1的第一个元素如果存在 if (it ! right_sums.begin()) { --it; ans min(ans, abs(sum1 - *it)); } } } // 将本轮i作为左端点的所有区间和加入集合 // 区间为[i, k], k从i到n for (int k i; k n; k) { right_sums.insert(pre[k] - pre[i - 1]); } } cout ans endl; return 0; }4.2 关键代码段解析前缀和计算pre[i] pre[i-1] a[i]。这是所有区间求和操作的基础务必熟练掌握。set的使用我们使用C STL中的set来维护右侧区间和的集合。set会自动将元素排序并去重。去重在这里是安全的因为即使有多个区间和相同我们只关心值本身不关心其来源。二分查找right_sums.lower_bound(sum1)返回第一个大于等于sum1的元素的迭代器。最接近sum1的值只可能是这个迭代器指向的值或者它的前一个值如果存在。因此我们需要检查这两个位置。倒序枚举的妙处这是本解法的精髓。当i从n递减到1时right_sums集合始终维护着起始下标大于当前i的所有区间和。在每轮i循环的j循环结束后我们才将起始下标为i的区间加入集合。这样保证了在计算以i为右端点的第一个区间时我们查找的right_sums完全位于它的右侧满足题目“不重叠”的要求。边界条件处理当right_sums为空时例如初始in时意味着右侧没有可选的区间此时跳过查找。算法最终会覆盖所有可能的(i, j)和对应的右侧区间。4.3 复杂度与正确性分析时间复杂度如前所述为O(n^2 log n)。三重循环i,j,k但总操作次数是O(n^2)级别的加上set的O(log n)操作。对于n ≤ 1000完全足够。空间复杂度O(n^2)因为set在最坏情况下需要存储O(n^2)个区间和当所有区间和都不同时。这在n1000时最大可能存储约50万个long long类型的数据占用内存约4MB可以接受。正确性算法枚举了所有可能的第一个区间[j, i]并为每个这样的区间在它右侧的所有可能区间中找到了和它最接近的一个。由于set中包含了右侧所有可能的区间和并且二分查找找到了最接近的值因此对于每个(j, i)我们都得到了以它为第一个区间时的局部最优解。全局最优解必然包含在其中。5. 常见错误与调试技巧在实际实现和调试这道题时我遇到过不少坑。这里总结一下希望能帮你避开。5.1 易错点清单整数溢出力量值a_i最大为10^9n最大为1000区间和最大可能达到10^9 * 1000 10^12这超出了int型约21亿的表示范围。必须使用long long64位整数来存储前缀和、区间和以及答案。忽略绝对值题目要求的是力量值之差的绝对值最小。在更新答案ans min(ans, sum1 - sum2)时必须写成ans min(ans, abs(sum1 - sum2))。区间重叠判断错误在暴力枚举思路中容易错误地认为只需要保证l1 l2且r1 r2即可但实际上两个区间只要不相交即可它们的位置关系可以是[A]...[B]也可以是[B]...[A]。在我们优化的算法中通过固定第一个区间在左侧第二个区间在右侧通过倒序枚举和集合维护来保证巧妙地避免了重叠判断的复杂性。集合为空时的访问在set中执行lower_bound或begin()/end()操作前一定要检查集合是否为空。对空集合进行这些操作会导致未定义行为。二分查找的边界使用lower_bound找到的是第一个大于等于目标值的元素。最接近的值可能是它也可能是它的前一个元素如果存在。因此需要检查it和it--两种情况。同时要注意迭代器的有效性it ! end()和it ! begin()。算法选择不当试图用O(n^3)的暴力枚举通过全部数据会导致超时。必须设计出O(n^2)或O(n^2 log n)的算法。5.2 调试与测试策略小数据测试自己构造一些小规模的测试用例n5以内手动计算答案与程序输出对比。这是发现逻辑错误最有效的方法。示例1n2, a[1, 100]。只能选两个单独的学生队伍力量分别为1和100差为99。示例2n4, a[1, 2, 3, 4]。可以选[1,2]和[3,4]和为3和7差为4选[1]和[2]差为1选[2,3]和[4]和为5和4差为1。最小差是1。随机数据对拍写一个暴力但正确的O(n^4)程序仅用于n≤50的小数据然后生成大量随机数据同时运行你的优化程序和暴力程序比较结果是否一致。这是验证算法正确性的黄金标准。边界条件测试n2的情况。所有力量值都相等的情况。力量值非常大接近10^9的情况检查是否溢出。力量值按递增或递减顺序排列的情况。使用调试输出在开发过程中可以在内层循环打印出i, j, sum1以及从set中找到的最接近值观察程序运行过程是否符合预期。性能测试当n1000时你的程序应该在1秒内完成。可以在本地构造一个n1000的随机数据测试运行时间。5.3 一个更优的O(n^2)解法思路上述基于set的解法是O(n^2 log n)。实际上存在一种纯O(n^2)的解法思路更加直接虽然常数可能略大但更易于理解。思路枚举两个区间的分界点k。对于每个分界点kk从0到n表示第一个区间在[1, k]第二个区间在[k1, n]我们需要分别找出左半部分和右半部分的所有连续子数组的和。然后我们需要从这两个和的集合中找出一对值使其差的绝对值最小。如果对两个集合都排序然后用双指针找最接近对复杂度是O(m^2 log m)其中m是半区长度。更优的方法是对于每个分界点k我们预处理出左半部分所有子数组和并排序。然后对于右半部分的每一个子数组和sum_right在左半部分排序好的数组中进行二分查找找到最接近sum_right的值。这样对于每个k复杂度是O(L^2 R * log L)其中L是左半部分长度R是右半部分长度。总复杂度是O(n^3)需要仔细分析。实际上更经典的O(n^2)解法是预处理前缀和pre[]。枚举第一个区间的右端点i。对于每个i再枚举第一个区间的左端点j得到sum1。然后我们需要在i的右边找到一个区间和sum2最接近sum1。我们可以预处理出所有以某个下标p为起点的区间和并对于每个起点p将其所有的区间和对应不同的终点q存储在一个数组中并排序。当我们需要在起点大于i的区间中查找时我们需要查询多个有序数组。这可以通过将所有右侧区间和合并到一个大数组并排序来实现但这样每次i变化都需要重建成本高。一个实现起来相对简单且确实是O(n^2)的算法如下思路类似于双指针首先枚举第一个区间[l1, r1]计算其和sum1。这一步是O(n^2)。然后我们用双指针技术在数组的剩余部分即r11之后寻找一个区间[l2, r2]使其和sum2尽可能接近sum1。如何用双指针对于固定的l2我们可以移动r2使得sum2逼近sum1。因为数组元素都是正数题目约定a_i ≥ 1当r2增加时sum2单调递增。因此对于每个l2我们可以找到使sum2最接近sum1的r2。而l2和r2的移动总共是O(n)的。因此对于每个[l1, r1]我们可以在O(n)时间内找到最佳的[l2, r2]。总复杂度O(n^3)。看来对于n1000O(n^3)是10^9依然会超时。所以我们之前基于set的O(n^2 log n)解法在实现和效率上是一个很好的平衡。6. 总结与举一反三拔河问题作为一道经典的竞赛题其价值不仅在于答案本身更在于它训练了我们多方面的能力问题转化能力将“最小化两个不重叠连续子数组和的差”这一原始问题转化为“枚举一个区间并在另一侧寻找和其最接近的区间和”的模型。这种“固定一部分优化另一部分”的思想非常普遍。前缀和的应用这是处理连续区间求和问题的标配工具必须做到信手拈来。数据结构优化使用set有序集合来维护动态增加的区间和并支持快速二分查找将匹配操作的复杂度从O(n)降为O(log n)。这体现了数据结构在优化算法中的关键作用。枚举顺序的巧妙设计倒序枚举第一个区间的右端点从而能够动态构建和维护右侧区间和的集合保证了不重叠性也避免了重复计算。这种“从后向前”处理并累积信息的技巧在很多DP和区间问题中都有应用。边界与细节处理对set空集的判断、二分查找的上下界检查、long long的使用等都是写出正确、健壮代码的必备素养。举一反三问题变种1如果要求两个队伍的力量值之和相等该如何修改算法判断abs(sum1-sum2)0即可但可能无解问题变种2如果队伍数量不止两个而是要求分成k个不重叠的连续队伍使得它们力量值之和的最大值与最小值之差最小这就是一个更复杂的划分问题可能用到动态规划或二分答案。相关题型在力扣LeetCode上“和接近目标值的子数组”、“分割数组的最大值”、“将数组分成和相等的三个部分”等问题都运用了类似的前缀和、双指针或二分查找思想。最后在竞赛中遇到此类题目我的建议是先确保暴力思路正确拿到部分分数再仔细观察数据范围寻找优化枚举的突破口最后选择合适的数据结构如前缀和、滑动窗口、二分查找、有序集合等将复杂度降低到可接受的范围。多练习、多总结这种对区间问题的敏感度和优化能力就会逐渐培养起来。
返回列表