news 2026/7/24 3:47:24

JOI算法题解析:前缀和与单调性优化解决区间和问题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JOI算法题解析:前缀和与单调性优化解决区间和问题

1. 项目概述:从一道JOI预选题看算法竞赛中的“购物”思维

最近在带学生备赛信奥(信息学奥林匹克)和准备一些算法面试时,我重新翻看了JOI2024预选赛的题目。其中P14293这道“购物2”(Shopping 2)让我觉得特别有意思。它不像一些纯考数据结构的题那样直接,也不像一些数学题那样烧脑,而是非常典型地考察了选手将实际问题抽象为算法模型,并选择合适策略进行优化的能力。说白了,这就是在考你“会不会买东西”——当然,是用程序员的思维去买。

题目的大意是,你有N种商品要买,每种商品有一个价格。商店正在进行“K元优惠”活动,具体规则是:你可以选择任意一段连续的商品序列(子数组),如果这段序列中商品的价格总和大于等于K,那么你就可以为整段序列支付(总价 - K)的费用,相当于立减K元。你只能使用一次这个优惠。现在需要你计算,在最优地使用(或不用)这次优惠的情况下,购买所有商品的最小总花费是多少。

刚拿到题,可能有的同学会想,这不就是求所有子数组的和,然后判断是否大于等于K,再计算优惠后的价格,最后取最小值吗?这个思路完全正确,也是这道题最直接的暴力解法,时间复杂度是O(N²)。但JOI的题目,N的规模往往在10^5级别,O(N²)显然会超时。所以,这道题的核心就从“怎么做”变成了“如何高效地做”。它要求我们在O(N log N)甚至O(N)的时间内找到那个能让总花费最小化的最优优惠区间。这背后涉及的是对前缀和、单调性以及双指针(尺取法)等基础但至关重要的算法工具的熟练运用。接下来,我就结合这道题,拆解一下这类“区间选择优化”问题的通用解题框架和我在实战中总结的一些技巧。

2. 核心思路解析:为什么暴力不行以及如何优化

2.1 问题重述与暴力解法

首先,让我们把问题用数学语言更清晰地定义一下,这是解题的第一步。

设商品价格数组为A[1..N]。 设所有商品的总价为total_sum = sum(A[1..N])。 设优惠券的门槛值为K

我们的目标是:选择一个区间[l, r](1 ≤ l ≤ r ≤ N),使得花费最小。 花费的计算公式为:cost = total_sum - discount其中,discount(折扣额)的计算方式是:如果区间[l, r]的和sum(l, r) >= K,那么discount = K;否则discount = 0

因此,总花费可以写成:cost = total_sum - (sum(l, r) >= K ? K : 0)

由于total_sum是固定值,要让cost最小,实际上就是要让discount最大。而discount要么是K,要么是0。所以,问题的本质转化为:是否存在一个区间,其和大于等于K?如果存在,我们就能获得K的折扣,此时最小花费就是total_sum - K;如果不存在,那么最小花费就是total_sum,即原价购买

等等,那岂不是太简单了?只要遍历所有区间,找到一个和≥K的区间,答案就是total_sum - K?这里有一个关键的陷阱:题目要求的是购买所有商品的最小总花费。使用优惠券时,你只是对选中的区间[l, r]支付sum(l, r) - K,而对于区间外的商品,你仍然需要支付它们的原价。所以,总花费的公式应该是:

总花费 = (区间外商品总价) + (区间内商品总价 - K) (如果区间和≥K)= (total_sum - sum(l, r)) + (sum(l, r) - K)= total_sum - K

你看,化简之后,结果确实是total_sum - K,并且与所选的具体区间[l, r]无关,只要它的和≥K。这个化简过程非常精彩,它揭示了本题的第一个核心洞察:只要存在任何一个和≥K的区间,使用优惠券就一定比不用更划算,并且最终花费是固定的total_sum - K

那么,暴力解法就清晰了:

  1. 计算total_sum
  2. 遍历所有可能的区间[l, r],计算其和sum(l, r)
  3. 如果存在某个sum(l, r) >= K,则输出total_sum - K;否则输出total_sum

这个算法的时间复杂度是 O(N²),对于 N 最大为 2×10^5 的数据范围,计算量高达 4×10^10,必然超时。

2.2 优化关键:从O(N²)到O(N)的思维跃迁

既然暴力枚举所有区间不行,我们必须寻找更高效的方法来判断“是否存在一个和≥K的区间”。这实际上是一个经典的“子数组和”问题。

一个常用的优化技巧是使用前缀和。定义prefix[i] = A[1] + A[2] + ... + A[i],那么区间[l, r]的和可以快速计算为prefix[r] - prefix[l-1]。我们的目标就变成了:是否存在一对下标(l, r),满足prefix[r] - prefix[l-1] >= K,其中1 <= l <= r <= N

转换一下不等式:prefix[r] >= K + prefix[l-1]

对于固定的r,我们需要判断是否存在一个l(满足l <= r),使得prefix[l-1] <= prefix[r] - K。换句话说,我们需要在prefix[0]prefix[r-1]中(注意l-1的范围是0r-1),找到一个最小值min_prefix,然后检查是否满足prefix[r] - min_prefix >= K

为什么找最小值?因为如果连最小的prefix[l-1]都能满足prefix[r] - min_prefix >= K,那么对于其他更大的prefix[l-1],这个不等式更成立。这利用了前缀和的单调性(不一定严格单调,但找最小值这个操作是合理的)。

于是,算法可以优化为:

  1. 计算前缀和数组prefix,其中prefix[0] = 0
  2. 初始化min_prefix = 0(因为prefix[0] = 0)。
  3. 从左到右遍历r从 1 到 N: a. 计算当前区间和的下界:current_sum = prefix[r] - min_prefix。 b. 如果current_sum >= K,那么我们就找到了一个满足条件的区间,可以立即得出结论:存在,并输出total_sum - K。 c. 更新min_prefix = min(min_prefix, prefix[r]),为下一个r做准备。

这个算法只需要一次遍历,时间复杂度是 O(N),完美解决了大规模数据的问题。空间复杂度是 O(1),因为我们只需要维护一个min_prefix和当前的prefix_r(可以边读入边计算,无需保存整个数组)。

注意:这里有一个非常关键的细节,就是min_prefix的初始化和更新时机。min_prefix必须在判断之后更新。因为对于当前的r,我们寻找的l必须满足l <= r,即l-1 <= r-1。所以用来计算current_summin_prefix,必须是prefix[0..r-1]中的最小值。如果我们先更新min_prefix(用prefix[r]去更新),再判断,那就相当于允许了l = r+1的情况,这显然是不合法的区间。这个顺序是很多初学者容易出错的地方。

2.3 算法实现框架与边界处理

基于以上的分析,我们可以给出清晰的算法步骤和C++实现框架。

算法步骤:

  1. 读入 N 和 K。
  2. 初始化total_sum = 0,prefix_sum = 0,min_prefix = 0,found = false
    • prefix_sum是动态计算的前缀和,相当于prefix[r]
    • min_prefixprefix[0..r-1]的最小值。
  3. 循环读入 N 个商品价格price: a.total_sum += price。 b.prefix_sum += price。 // 此时prefix_sumprefix[r]c. 判断:如果prefix_sum - min_prefix >= K,则设置found = true可以提前结束循环(因为已经找到答案)。 d. 更新:min_prefix = min(min_prefix, prefix_sum)。 // 为下一个r准备
  4. 根据found输出结果:
    • 如果found为真,输出total_sum - K
    • 如果found为假,输出total_sum

边界情况考虑:

  • K=0:根据题意,如果区间和≥0,则可以使用优惠。任何区间都满足,所以答案一定是total_sum - 0 = total_sum。我们的算法也能正确处理,因为prefix_sum - min_prefix >= 0恒成立(min_prefix是历史前缀和最小值,可能为负吗?商品价格是非负整数,前缀和单调不减,min_prefix就是prefix[0]=0,所以差值为非负,条件成立)。
  • 所有商品价格之和小于K:显然不存在任何区间和≥K,算法会遍历完所有商品,found为假,输出total_sum
  • N=1:算法依然有效,第一次循环就会判断单个商品是否≥K。

3. C++代码实现与逐行解读

理解了算法,代码实现就水到渠成了。这里我给出一个清晰、高效且带有详细注释的C++实现。

#include <iostream> #include <algorithm> // 用于 min 函数 using namespace std; int main() { // 关闭同步,提升大规模数据读入速度,这是竞赛常用技巧 ios::sync_with_stdio(false); cin.tie(nullptr); int N; long long K; // 注意K的范围可能很大,用long long cin >> N >> K; long long total_sum = 0; // 所有商品总价 long long prefix_sum = 0; // 当前前缀和,即 prefix[r] long long min_prefix = 0; // prefix[0..r-1] 的最小值,初始为 prefix[0]=0 bool found = false; // 标记是否找到满足条件的区间 for (int i = 0; i < N; ++i) { long long price; cin >> price; total_sum += price; // 步骤1: 更新当前前缀和 prefix_sum += price; // 现在 prefix_sum 代表 prefix[r] // 步骤2: 判断以当前i为结尾的区间,是否存在和>=K // 即判断 prefix[r] - min(prefix[0..r-1]) >= K // 此时的 min_prefix 存储的就是 min(prefix[0..r-1]) if (prefix_sum - min_prefix >= K) { found = true; // 找到后可以提前结束,但需要读完本行输入?不,可以直接break。 // 但为了代码清晰和避免后续输入混乱,这里选择设置标志位,继续读完(或break)。 // 在竞赛中,因为找到答案后后续计算无关紧要,可以直接break以节省时间。 // break; // 可以选择直接跳出循环 } // 步骤3: 更新 min_prefix 为 prefix[0..r] 的最小值,供下一个i使用 // 注意更新必须在判断之后,确保min_prefix代表的是r之前的历史最小值 min_prefix = min(min_prefix, prefix_sum); } // 输出结果 if (found) { cout << total_sum - K << endl; } else { cout << total_sum << endl; } return 0; }

代码关键点解读:

  1. 数据类型选择:题目虽未明确给出价格和K的上限,但根据JOI题目的惯例和防止溢出考虑,使用long long是更稳妥的做法。int在极端情况下(如所有价格都很大,N也很大)可能会溢出。
  2. 输入输出优化ios::sync_with_stdio(false);cin.tie(nullptr);是C++竞赛代码的标配。它们解除了C++标准流与C标准流的同步,并解除了cincout的绑定,可以大幅提升输入输出效率,在面对大量数据时效果显著。
  3. 核心逻辑循环
    • prefix_sum动态维护,相当于我们算法描述中的prefix[r]
    • if (prefix_sum - min_prefix >= K)这行代码是整个算法的灵魂。它检查了以当前下标i为区间右端点r时,是否存在一个左端点l(由min_prefix对应的历史位置隐含),使得区间和≥K。
    • min_prefix = min(min_prefix, prefix_sum);这行代码在判断之后执行,保证了min_prefix始终是“过去”的最小值,符合l <= r的要求。
  4. 提前终止:在发现found = true后,我们可以直接break跳出循环,因为答案已经确定。这里为了演示的完整性,我让循环继续执行完毕。在实际竞赛中,使用break是更优的,可以节省不必要的读入和计算时间。但需要注意,如果提前break,后续的商品价格将不会被读入,total_sum也就不完整了。因此,如果选择break,必须在循环外使用另一个循环或方法跳过剩余的输入,或者重新计算total_sum。一个更简洁的做法是:在找到答案后,继续读入但不处理(或者简单累加到total_sum),因为输出只依赖于foundtotal_sum。但既然找到了,total_sum - K就是答案,后续价格不影响结果。所以直接break并输出total_sum - K是安全的,前提是total_sumbreak前已经累计了所有已读入商品的价格。在我们的代码逻辑中,total_sum的累加发生在循环开头,如果break在累加之后、判断之前,那么total_sum是包含当前商品价格的,没问题。但为了绝对稳妥和代码清晰,示例中使用了found标记。

4. 算法正确性证明与复杂度分析

4.1 为什么这个贪心策略是正确的?

有些同学可能会疑惑,我们一直在维护min_prefix,并只用它来判断,这会不会漏掉一些情况?比如,是否存在一个区间[l, r],其和≥K,但是prefix[r] - min(prefix[0..r-1])却小于K?

我们来证明一下。假设存在一个满足条件的区间[l, r],即S = prefix[r] - prefix[l-1] >= K

根据定义,min(prefix[0..r-1])prefix[0]prefix[r-1]这些数中的最小值。那么一定有:min(prefix[0..r-1]) <= prefix[l-1]

将这个不等式两边同乘以-1并加上prefix[r],得到:prefix[r] - min(prefix[0..r-1]) >= prefix[r] - prefix[l-1] = S >= K

所以,如果区间[l, r]满足条件,那么prefix[r] - min(prefix[0..r-1])也一定满足条件。反之,如果对于某个r,有prefix[r] - min(prefix[0..r-1]) >= K,那么我们就找到了一个具体的区间:左端点l就是使得prefix[l-1]等于那个min(prefix[0..r-1])的索引l(注意可能有多个位置等于最小值,取任何一个都行)。这个区间[l, r]的和就是prefix[r] - min(prefix[0..r-1]) >= K

因此,我们的算法“检查是否存在r使得prefix[r] - min(prefix[0..r-1]) >= K”与“检查是否存在区间[l, r]使得其和≥K”是完全等价的。算法没有遗漏任何可能的情况。

4.2 时间与空间复杂度

  • 时间复杂度:我们只对数组进行了一次线性扫描。在扫描过程中,每个元素被读入一次,进行常数次算术运算和比较操作(累加、减法、比较、取最小值)。因此,总的时间复杂度是O(N)
  • 空间复杂度:我们只使用了几个固定数量的long long型变量和一个bool变量来存储中间状态,没有使用任何与N成比例的数组(除了输入缓冲区)。因此,空间复杂度是O(1)。这是一个非常优雅的原地算法。

对比暴力O(N²)的算法,O(N)的算法在N=200,000时,运算次数大约是20万次,而O(N²)则是400亿次,效率天壤之别。这也正是算法竞赛的魅力所在——通过巧妙的思维,将不可能变为可能。

5. 常见错误与调试技巧

在实际实现和调试这道题时,我见过学生们踩过不少坑。这里总结几个最常见的:

错误1:min_prefix更新时机错误这是最经典的错误。如前面所述,如果先更新min_prefix再判断,代码可能如下:

min_prefix = min(min_prefix, prefix_sum); // 错误!先更新了 if (prefix_sum - min_prefix >= K) { // 此时min_prefix可能包含了prefix_sum本身 found = true; }

这样会导致判断时,min_prefix可能等于当前的prefix_sum(如果它比历史值都小),那么prefix_sum - min_prefix = 0,从而可能漏掉一些有效的区间(特别是当区间就是单个元素且其值≥K时)。务必记住:判断用的是“历史”最小值,更新是为“未来”做准备。

错误2:整数溢出题目没有明确给出数字范围,但商品数量和单价都可能很大。如果使用int类型来存储total_sum,prefix_sum,K,在累加或比较时很容易溢出,导致结果错误甚至程序运行异常。在信奥竞赛中,对于涉及求和、累积的问题,只要数据范围没有明确说明很小,无脑使用long long通常是安全的选择。

错误3:忽略K=0的情况虽然我们的算法能正确处理K=0,但有些同学的思路可能不同。例如,有人可能会想“区间和必须严格大于0才能优惠”,那就错了。题目条件是>= K,所以K=0时,任意区间都满足,折扣就是0,总价不变。我们的算法中,min_prefix初始为0,prefix_sum - 0 >= 0恒成立(价格非负),所以会立即找到并输出total_sum - 0,结果是正确的。

错误4:输入输出效率在本地测试时,数据量小,感觉不到差别。但提交到在线评测系统(OJ),面对大量测试数据,低效的I/O会成为性能瓶颈。务必养成使用ios::sync_with_stdio(false); cin.tie(nullptr);的习惯。同时,对于C++,使用cin/cout而不是scanf/printf时,这两行代码至关重要。

调试技巧:

  1. 小数据测试:自己构造一些小的测试用例,包括边界情况。
    • N=1, price < K
    • N=1, price >= K
    • N=2, 价格组合各种情况(都小于K,和大于K但单个小于K,等等)
    • K=0
    • K非常大(大于所有商品总和)
  2. 打印中间变量:在怀疑逻辑出错时,可以在循环内打印prefix_sum,min_prefix,prefix_sum - min_prefix的值,观察其变化是否符合预期。
  3. 对比暴力算法:写一个O(N²)的暴力程序,用于对小规模随机数据(N<100)进行对拍。生成随机数据,分别运行你的优化程序和暴力程序,比较输出结果是否一致。这是竞赛中验证算法正确性非常有效的方法。

6. 举一反三:同类问题与扩展思考

P14293这道题的本质是“判断是否存在子数组和大于等于给定值K”。这是一个非常基础且重要的模型,可以衍生出许多变体问题。

变体1:寻找和大于等于K的最短子数组长度如果问题变成:求一个和≥K的连续子数组的最小长度。我们的算法可以很容易地扩展。在维护min_prefix的同时,我们还需要记录取得这个最小前缀和时的下标索引min_index。当prefix[r] - min_prefix >= K时,我们就找到了一个以r结尾的、满足条件的区间,其长度为r - min_index。我们只需要在所有满足条件的r中,取这个长度的最小值即可。这依然可以在O(N)时间内完成。

变体2:寻找和小于等于K的最大子数组和思路类似,我们可能需要维护一个“最大前缀和”的单调队列,或者使用二分查找。核心还是利用前缀和将区间和问题转化为两个前缀和之差的问题。

变体3:商品价格有正有负本题假设价格是非负的,所以前缀和数组是非递减的,min_prefix就是prefix[0],问题退化为简单的遍历。但如果价格可以是负数,前缀和就不再单调。此时,要判断是否存在和≥K的区间,我们上面的算法依然有效!因为我们的算法并不依赖前缀和的单调性,它正确维护了prefix[0..r-1]的最小值。这是一个很强的性质。但是,如果要求最短长度等,情况会复杂一些,可能需要借助数据结构如平衡树或线段树来维护前缀和的有序性,以便进行二分查找。

扩展思考:双指针(尺取法)的适用性有些同学可能会想到用双指针。双指针(尺取法)通常适用于寻找“和恰好等于K”或“和小于等于K”的最长/最短区间,并且要求数组元素都是正数。对于本题“和≥K”,如果数组元素都是正数,双指针也是可行的:移动右指针扩大和,当和≥K时,记录并尝试移动左指针缩小区间,同时更新最小长度(如果是求长度)。但我们的前缀和+最小值维护的方法更通用,不要求元素为正,且代码非常简洁。

通过这道JOI预选题,我们不仅学会了一个具体的O(N)算法,更重要的是掌握了“前缀和转化”和“维护历史极值以加速当前判断”这一强大的思维工具。在解决诸如最大子数组和、区间和满足某种条件的区间查找等问题时,这个工具会反复出现。下次遇到类似问题,不妨先想想:能不能计算前缀和?能不能通过维护某个历史信息(最小值、最大值、索引等)来避免内层循环?

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/24 3:44:28

Gemini 3.1 Pro技术解析与工程实践指南

1. Gemini 3.1 Pro核心升级解析谷歌最新发布的Gemini 3.1 Pro标志着AI技术进入了一个新阶段。作为长期关注AI发展的从业者&#xff0c;我第一时间对其技术文档进行了深度剖析。这个版本最引人注目的是其128k的超长上下文窗口支持&#xff0c;这意味着它可以处理整本小说长度的连…

作者头像 李华
网站建设 2026/7/24 3:41:06

西安自营商城系统开发实战指南:公司选择、流程详解

西安自营商城系统开发实战指南&#xff1a;公司选择、流程详解 在数字化浪潮席卷各行各业的今天&#xff0c;越来越多的西安本地企业开始寻求自建线上商城以实现品牌独立、流量私有化和利润化。然而&#xff0c;面对市场上众多的技 合同应明确交付物&#xff1a;源码、数据库文…

作者头像 李华
网站建设 2026/7/24 3:39:29

2026年AI技术矩阵:大模型、多模态与具身智能的融合

1. 2026年AI技术矩阵全景解析当大模型遇上多模态感知和具身交互&#xff0c;AI技术正在经历一场前所未有的范式转移。作为从业者&#xff0c;我亲历了从单一任务模型到通用智能体的技术跃迁过程。2026年的AI技术矩阵将围绕三个核心支柱展开&#xff1a;参数规模突破万亿的大模型…

作者头像 李华
网站建设 2026/7/24 3:39:14

AI编程助手如何提升代码理解与维护效率

1. 当AI成为编程搭档&#xff1a;代码理解困境的破局之道 最近在技术社区看到一个很有意思的讨论&#xff1a;当程序员连自己写的代码都看不懂时&#xff0c;AI编程工具能帮上什么忙&#xff1f;这个问题戳中了现代开发者的痛点——随着项目复杂度提升和团队更替&#xff0c;代…

作者头像 李华
网站建设 2026/7/24 3:38:23

Kimi K3 AI模型集成开发指南:从环境配置到代码实战

最近AI圈有个热门话题&#xff1a;智谱股价在短短两天内暴跌40%&#xff0c;不少分析将原因指向了Kimi K3的发布。作为技术开发者&#xff0c;我们更关心的是Kimi K3到底是什么、怎么用、在哪些平台可以集成&#xff0c;以及它背后的技术逻辑。本文将围绕Kimi K3的技术实现、集…

作者头像 李华