上周末给训练队的孩子做了一套CSP-J复赛模拟卷,打开第一题我就笑了——题目叫“贪心的小朋友”。题面不长、样例友好,看起来就是标准的送分题,但批改完发现AC率只有六成出头。问题基本都出在最基础的东西上:数据范围没看、贪心方向想反、样例过了就没测边界。这篇就把这道题从头拆到脚,从题面到贪心证明,再到代码细节和历年T1的考法,一次讲透。正在备考CSP-J的同学,或者刚接触贪心算法、想搞懂“为什么这题能贪心”的读者,可以直接照着这个思路过一遍。
1. 拿到题先别动手:题目到底在考什么
1.1 题面与样例速览
这道模拟题的题面非常简单,核心信息就几句话:
老师准备了 s 块糖果,班上有 n 个小朋友。第 i 个小朋友想要 a[i] 块糖果。如果一个小朋友实际拿到的糖果数不少于他想要的块数,这个小朋友就会很开心。现在问:老师最多能让多少个小朋友开心?
输入格式是常规的两行:
第一行两个正整数 s 和 n;
第二行 n 个正整数 a[1] 到 a[n],表示每个小朋友想要的糖果数。
样例给得也很克制:
输入:
10 4 6 2 3 5
输出:
3
样例解释很清楚:先满足想要2块的小朋友,剩8块;再满足想要3块的,剩5块;最后满足想要5块的,刚好花光。3个小朋友开心。如果一上来先满足想要6块的那个,剩4块,最多只能再满足2块或3块的小朋友,最后只有2个开心。所以答案是3。
这个手算过程看起来平凡,但它恰恰是整道题的核心:为什么从需求小的开始喂?因为糖果总量固定,想让数量最大化,必须让每一份糖果都发挥最大价值。后面我会用反证法严格说明这一点。
1.2 为什么CSP-J的T1会放一道“贪心+排序”的题
很多初学者对CSP-J第一题有误解,觉得T1就是白给题,随便模拟一下就能过。从近几年的真题看,T1确实不难,但已经不再是纯模拟的天下了。
稍微回忆一下历年复赛T1:2020年是“优秀的拆分”,考二进制拆分的理解;2021年是“分糖果”,本质是数学分类讨论加一点贪心思维;2022年是“乘方”,考快速幂或循环边界的控制;2023年是“小苹果”,表面是模拟取苹果,实际要你找规律,不能硬模拟;2024年是“扑克牌”,考集合去重和计数。你会发现,T1的稳定主题是“简单算法思维”,贪心、数学、模拟各占一部分,而贪心因为代码极短、坑点藏在思维里,特别适合当第一题。
这道“贪心的小朋友”就是典型代表。代码量不到20行,算法一句话讲完:排序后从小到大满足。但能不能想到这一点、敢不敢证明“从小到大一定对”,决定了你是花5分钟AC还是花30分钟反复怀疑人生。所以别嫌题简单,T1真正的考试目标不是算法,而是你有没有把基础吃透。
1.3 我拿到题后会先做的三件事
很多孩子做题第一反应是打开编译器写代码,这不是好习惯。我拿到这道题会先做三件事,这三件事对任何CSP-J T1都适用。
第一件事:读数据范围。看到 1≤n≤10^5、1≤s≤10^9、1≤a[i]≤10^9,我立刻意识到两件事:一是 O(n^2) 的算法不可能过,必须 O(n log n) 或 O(n);二是 s 和 a[i] 都可能到十亿,涉及累加时必须用 long long。这两个判断不做,后面必然出事。
第二件事:手算样例。不是照着题目给的解释看一遍,而是自己在草稿纸上演算。我会问自己:如果我先满足6块的小朋友会怎样?如果我先满足2块的呢?通过对比,算法的雏形就出来了——需求小的优先。
第三件事:确定算法复杂度是否可行。排序 O(n log n),总复杂度约 10^5 log 10^5,在CSP-J的时间限制下非常宽裕。确认可行后再动手写代码。整个过程不超过两分钟,但能避免绝大多数低级失误。
2. 贪心策略:为什么“先满足需求小的小朋友”一定正确
2.1 用生活逻辑建立直觉
贪心算法的名字听起来高大上,核心就一句话:每一步都做当前看起来最划算的选择,并希望局部最优能堆积成全局最优。放到这道题里,“当前最划算”是什么?当然是先满足那个要糖果最少的小朋友。
你可以把它想象成用有限的钱逛超市买零食,每包零食价格不同,你想买最多的包数。正常人都会先拿最便宜的几包,拿到钱不够为止。这就是贪心。但生活直觉归直觉,竞赛里必须给出严格证明,否则换个数据可能就翻车。
糖果题里的约束只有一个:糖果总数固定。每个小朋友只需要一个数字 a[i] 来刻画需求,没有重量、价值、优先级等额外维度。正是因为只有一个约束,贪心才成立。如果再加一个“每个小朋友必须获得特定颜色的糖果”之类的条件,问题就变复杂了。这也解释了为什么竞赛里很多贪心题都长这样:单约束、代价可排序、目标是数量最大化。
2.2 用反证法证明“从小到大选”的合理性
证明方法有很多,我推荐反证法加交换论证,这是竞赛里最常用的手段。
假设存在一个最优解,它没有选某个需求更小的小朋友 p,却选了需求更大的小朋友 q,且 a[p] < a[q]。现在我做一次交换:把分配给 q 的糖果配额 a[q] 里的 a[p] 份分给 p,同时从 p 那边挪走多出来的糖果。因为 a[p] < a[q],分配给 p 之后还有剩余,剩余糖果只会更多或不变,而开心的人数保持不变。
这说明了什么?任何“跳过小需求、满足大需求”的解,都能被改造成“大需求被替换成小需求且人数不劣”的解。反复做这种交换,最终一定存在一个最优解,满足的小朋友恰好是按需求从小到大依次选出来的。所以先排序再从小累加,一定能得到全局最优。
其实还可以更直观地理解:糖果总量 s 是预算,a[i] 是单价,目标是购买数量最大化。既然每件商品只能买一次且没有其他限制,最优策略当然是按单价从低到高买。排序后从前往后买,买到买不起为止,这不就和我钱包里的钱花在超市零食区的逻辑完全一致吗?
2.3 顺带说说:什么时候贪心会失效
每次讲完贪心,总有学生会问:是不是所有问题都能贪心?当然不是。为了说明白,我通常拿“01背包问题”做对比。
假设你有一个背包,容量10,有三件物品:A重量6价值8,B重量5价值6,C重量5价值6。如果按性价比贪心,A的价值重量比是1.33,B和C是1.2,你会先选A,然后剩余容量4,什么都装不下,总价值8。但最优解是选B和C,总价值12。为什么贪心失效?因为物品不可分割,而且有两个维度的信息(重量和价值)需要统筹。
回到糖果题,为什么贪心就成立?因为它只有一个维度——需求量 a[i]。所有小朋友没有附加价值,每个人对总答案的贡献都是1,且唯一区别就是代价不同。当目标是数量最大化时,选最小代价总没错。这背后是一种很常用的贪心模型:单约束下的最小代价覆盖。抓住这个模型,以后只要看到“固定资源、单位收益为1、求最多能完成几个”的题,第一反应就应该是排序贪心。
3. 完整实现:C++代码与每一步的细节
3.1 可以直接抄的满分代码
代码非常短,我直接贴完整版。这是我推荐在考场上写的形式,思路清晰,不容易出错。
#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); long long s; int n; cin >> s >> n; vector<long long> a(n); for (int i = 0; i < n; i++) { cin >> a[i]; } sort(a.begin(), a.end()); int ans = 0; for (int i = 0; i < n; i++) { if (s >= a[i]) { s -= a[i]; ans++; } else { break; } } cout << ans << '\n'; return 0; }核心逻辑就三块:读入、排序、从小到大累减并计数。很多同学写完后会有个疑问:要不要把已经满足的小朋友从数组里删掉?不需要。因为排序之后,我们只需要从前往后遍历,已经消耗的糖果通过变量 s 的减法体现,数组本身不需要改动。这里没有任何复杂的数据结构,vector 或者普通数组都能胜任,如果你更习惯静态数组,直接写成 long long a[100005] 也完全没问题。
3.2 三个容易翻车的关键细节
第一,long long 不能省。看到 s 和 a[i] 的最大范围都是 10^9,有的同学觉得 int 能存下,就只开了 int。这确实能存下单个数,但一旦在某个极端数据里把所有小朋友的 a[i] 加起来,或者 s 在循环中反复减法,中间结果完全可能超过 2^31-1。虽然这道题里单次判断 s >= a[i] 不会溢出,但为了避免在类似题里踩坑,我建议只要数据范围接近 10^9,涉及累加、累减、乘积的变量一律开 long long。CSP-J 里这几乎是一条保命原则。
第二,排序方向别搞反。贪心策略是“需求小的优先”,所以必须 sort(a.begin(), a.end()) 升序。有些同学写顺手了,可能用了 greater (),结果代码逻辑没变,却先满足需求最大的小朋友,样例大概率跑不过。如果忘了 sort 的用法,也可以写一个简单的 for 循环手动排序,但没必要,直接用 STL 的 sort 最稳。
第三,遇到第一个不能满足的就可以 break。因为数组已经升序排列,如果当前 s 连 a[i] 都满足不了,那后面的 a[i+1] 更大,更不可能满足。所以 else 分支里直接 break 退出循环,不仅节省时间,还让逻辑更清晰。有些同学不写 break,继续让循环跑完,结果会出现 s 变成负数、ans 却还算进去了的错误——虽然用 if 判断也能挡住,但 break 才是这个场景下最干净的做法。
3.3 复杂度分析:为什么 O(n log n) 稳过
排序的时间复杂度是 O(n log n),随后的遍历是 O(n),整体复杂度为 O(n log n)。在 n=10^5 时,排序操作大概做 10^5 乘以 16 次比较,量级在百万级别,任何评测机都能轻松承受。空间复杂度是 O(n),用来存 a 数组。
如果 n 再大一点,比如 10^6,O(n log n) 仍然可行;如果 n 到了 10^7,排序可能就吃力了,需要结合桶排序等思路优化。但CSP-J 的数据范围通常不会这么极端,这道模拟题的设定是非常良心的。值得一提的是,如果数据里 n 是 10^7 且 a[i] 范围很小,也可以用计数排序做到 O(n + maxA),但那属于进阶玩法,T1 阶段没必要。
4. 我批改时看到的高频错误与排查方法
4.1 样例过了,交上去却全WA
这是第一次参赛的人最容易遇到的情况。样例过只能说明代码在样例上没有错,不代表算法和边界没有问题。我让孩子们写完代码后做的第一件事,就是自己构造几组特殊数据来验证。
我常用的用例有这几类:n=1 且 s 刚好等于 a[0];n=1 且 s 小于 a[0];s 非常大,所有小朋友都能满足;所有 a[i] 都相同;a[i] 里有极端大的数。比如构造 s=1, n=1, a[0]=1,答案应该是1;构造 s=0 虽然题目范围可能不允许,但加上判断也无妨;构造 s=1000000000, n=100000, 所有 a[i]=1000000000,如果不开 long long,s 减去 a[i] 后变成0,看起来没问题,但如果你用 int 去存总和或某个中间值,大样例直接暴露,输出可能为负数或者错误数字。
另外我强烈建议初学者学一下“对拍”。写一个非常暴力的版本,枚举所有小朋友子集,计算出最多能满足几个,再和贪心代码的结果比对,随机生成小数据跑几百轮。小数据下暴力一定能跑出正确结果,如果贪心结果和暴力一致,说明贪心策略没问题。对拍脚本不需要复杂,哪怕用几行 Python 也能做。这不光是验证这一道题的方法,也是以后调试贪心题的通用思路。
4.2 评测环境下的“鬼故事”
CSP-J 复赛用的是 Linux 评测环境,而很多选手平时在 Windows 的 Dev-C++ 里写代码。环境差异可能会带来一些奇奇怪怪的坑。
最经典的就是 long long 的格式化输出。在 Windows 的 MinGW 环境下,printf 输出 long long 有时候要用 %I64d,而 Linux 下要用 %lld。如果格式串不对,输出会很诡异。我的建议是统一用 cout 输出,代码里加上 ios::sync_with_stdio(false) 和 cin.tie(nullptr),效率也够,格式问题完全不操心。其次要注意 freopen 重定向的使用,CSP-J 复赛要求从文件读入、向文件输出,考试时经常有人忘记写 freopen,或者只写了输入没写输出,结果本地能看到结果,评测机全是0分。写完代码务必要检查主函数里有没有:
freopen("candy.in", "r", stdin); freopen("candy.out", "w", stdout);最后,静态数组开小了会导致运行错误。如果题目给的 n 最大是 10^5,我习惯把数组开到 100005 甚至 200005,多留些余量。使用 vector 则完全不用操心这个问题,所以我个人推荐入门阶段直接学 vector,顺便树立“动态管理内存”的意识。
4.3 常见问题速查表
我整理了一张做题和批改过程中最高频的问题表,方便大家自查。
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 样例过、大样例全WA | 没开 long long,累加溢出 | s、a[i]、累加变量全部用 long long |
| 输出结果比正确答案小 | 排序方向写反,先从大需求开始 | sort 默认升序,不要加 greater |
| 循环结束后 s 变成负数 | 没有 break,继续处理后面的需求 | 遇到 s < a[i] 立刻 break |
| 本地能跑,评测0分 | 缺少 freopen 或文件名写错 | 检查输入输出重定向和文件名 |
| 运行错误 | 静态数组开小了 | 数组多开一些或用 vector |
| 输出格式问题 | 使用 printf 格式化 long long 不当 | 统一用 cout 输出 |
这张表里的问题,我几乎每年批改都能见到。它们都不难修,但考试时任何一条都可能让T1丢分。第一题都不能稳稳拿下,后面心态很容易崩。
5. 从这道模拟题看历年CSP-J T1的命题规律
5.1 历年复赛T1考点速览
把近五年CSP-J复赛第一题放在一起看,规律非常明显。
| 年份 | 题名 | 核心考点 | 难度特点 |
|---|---|---|---|
| 2020 | 优秀的拆分 | 位运算、二进制思维 | 判断奇偶后按位拆分 |
| 2021 | 分糖果 | 数学分类讨论、贪心思想 | 在区间内找余数最大化 |
| 2022 | 乘方 | 快速幂、循环边界控制 | 防溢出,结果超限输出-1 |
| 2023 | 小苹果 | 模拟、取整规律 | 不能硬模拟,要发现每轮取走1/3 |
| 2024 | 扑克牌 | 集合去重、计数 | 本质是统计不同花色点数 |
从这张表可以看出来,CSP-J 的T1并不考复杂数据结构,但也不是无脑模拟。它更倾向于用一个非常简单的背景包装一个基础算法或数学结论,考察你在紧张环境下能不能快速识别模型、准确处理边界。这道“贪心的小朋友”完美符合这个定位:算法名字听起来很高大上,实际就是排序后从小满足,代码量极小。
5.2 同款贪心模型的“变形题”怎么想
历年真题里和“贪心的小朋友”最像的,是2021年的“分糖果”。那题要求在区间 [L, R] 中选一个数 n,最大化 n mod k 的值。很多人当时连题都读不懂,其实思路也是贪心加分类讨论:如果区间里存在 k 的倍数减1,直接取那个值;否则就取 R,因为余数一定越大越好。你发现没有,核心还是“在当前可选范围内做局部最优选择”。所以别小看T1,它往往是一类题型的种子。
还有一个经典贪心题叫“跳跃游戏2”,问最少跳几次能跳到终点。每步能跳的距离是一个区间,贪心策略是每次找当前区间内能延伸最远的那个位置。它和糖果题看起来完全不同,但内在逻辑都是“每一步选局部最优,并用证明排除后顾之忧”。如果你把“糖果题”吃透了,再去看跳跃游戏2,会发现贪心不是玄学,而是一套能套用的思考方式:先确定约束、再构建局部策略、最后用反证或交换论证验证。
5.3 备考T1的三条实在建议
结合这些年带队的经验,我给备考CSP-J的同学三条具体建议。
第一条,T1控制在25分钟以内,不要死磕。如果你在15分钟内还没思路,大概率是模型识别出了问题,先跳过做后面的题,最后再回来看。一条路走到黑在比赛中是致命的。
第二条,平时做题别只追求AC。每做完一道题,多问自己三个问题:为什么这个算法对?边界数据是什么?如果数据范围扩大十倍还能不能过?这三问能帮你把一道T1训练成三道题的效果。
第三条,多练“手算样例”的能力。很多人写代码飞快,但演算能力差,题目稍微变化就不知道代码在干什么。竞赛说到底拼的是思维准确性,代码只是把思维落地的工具。手算样例能逼你真正理解题意,而且一旦样例手算和代码输出不一致,你能立刻定位是算法问题还是实现问题。
我个人在实际训练中的习惯是:每次模拟赛做完T1,都会用对拍脚本把小数据暴力解和贪心解跑一遍,确认没有边界问题后才算真正“拿下”这道题。因为T1太重要了,它决定了整场比赛的心理状态。第一题稳稳AC,后面的题你怎么写都有底气;第一题翻车,后面再简单也会疑神疑鬼。
所以这套模拟卷我特意把“贪心的小朋友”放在第一题,也是想让孩子们在比赛一开始就体会到:CSP-J的第一道题不需要很炫的技巧,它考验的是你愿不愿意静下心来读题、手算、证明,再用干净利落的代码把它实现出来。把这个过程练成肌肉记忆,你的CSP-J之路就稳了一大半。