1. 从一道经典题看贪心算法的实战拆解
陶陶摘苹果这个题目,很多刷过算法题的朋友应该都不陌生。原版是入门级的模拟题,而升级版加入了体力消耗这个约束条件,一下子把难度从“照着做就行”拉到了“得动脑子选策略”的层面。我第一次看到升级版的时候,直觉告诉我这题没那么简单——果然,它本质上是一道披着模拟外衣的贪心题。
这篇文章适合谁看?如果你正在学贪心算法,或者刷题刷到“知道要用贪心但不知道怎么证明”的阶段,那这篇内容应该能帮到你。我会从题目理解、策略选型、代码实现、边界处理几个维度,把这道题彻底拆开讲透。不是那种“贴个代码就完事”的水文,而是把每一步为什么这么做都讲清楚。
先简单说下题目背景:陶陶有个板凳,站在板凳上能够到某个高度,苹果长在树上的不同高度,摘每个苹果需要消耗一定体力,陶陶总共有一定体力值。问最多能摘多少个苹果。升级版的核心变化就是多了体力这个维度,让原本“能碰到就摘”的简单逻辑变成了“在体力约束下做最优选择”的决策问题。
2. 题目核心要素与贪心策略推导
2.1 关键变量梳理与问题建模
拿到这道题,先把所有变量列清楚:
- 板凳高度:陶陶站在板凳上能额外够到的高度
- 陶陶身高:加上板凳高度就是能够到的最大高度
- 苹果数量:树上总共有多少个苹果
- 总体力值:陶陶能消耗的体力上限
- 每个苹果的高度:决定陶陶能不能够到
- 每个苹果的消耗体力:摘这个苹果需要花多少力气
够得到的苹果才在候选范围内,够不到的直接排除,这个逻辑很直观。问题就变成了:在够得到的苹果集合里,选出最多的苹果,使得总消耗体力不超过上限。
用数学语言描述就是:给定集合S(够得到的苹果),每个元素有消耗值c_i,在sum(c_i) <= 体力上限的约束下,最大化选取元素的个数。
2.2 为什么贪心是最优解
这里的关键决策是:优先摘消耗体力最少的苹果。
为什么这个贪心策略是对的?可以用交换论证法来理解。假设最优解选了k个苹果,如果这k个苹果不是消耗体力最小的k个,那我们一定可以用一个消耗更小的未选苹果替换掉其中消耗较大的已选苹果,替换后总数不变但总体力消耗更少,仍然满足约束。这说明“选消耗最小的k个”一定不会比任何其他方案差。
换个生活化的说法:你兜里有10块钱,要去菜市场买尽可能多的菜。每样菜单价不同,你肯定先挑便宜的买,这样才能买最多品种。这个直觉就是贪心的核心。
注意:这里的贪心策略之所以成立,是因为每个苹果的“价值”都是一样的——都只算一个。如果苹果有不同的价值权重,那就变成背包问题了,贪心就不一定对了。
2.3 排序策略的选择与复杂度分析
既然确定了贪心方向,实现上就很简单了:
- 筛选出所有够得到的苹果
- 按消耗体力从小到大排序
- 从最小的开始逐个累加,直到体力不够为止
- 统计摘到的苹果数量
排序的时间复杂度是O(n log n),遍历是O(n),整体复杂度可以接受。对于题目给定的数据规模来说,这个效率完全够用。
这里有个细节值得注意:需不需要把所有够得到的苹果都存下来再排序?其实可以在读入的时候就直接判断能否够到,够不到的直接跳过,这样能减少排序的数据量。虽然对复杂度没有本质影响,但实际跑起来会快一些。
3. 完整代码实现与逐行解析
3.1 核心代码框架
#include <iostream> #include <algorithm> using namespace std; int main() { int n, s; int a, b; cin >> n >> s; cin >> a >> b; int reachHeight = a + b; int costs[5005]; int cnt = 0; for (int i = 0; i < n; i++) { int height, cost; cin >> height >> cost; if (height <= reachHeight) { costs[cnt++] = cost; } } sort(costs, costs + cnt); int ans = 0; for (int i = 0; i < cnt; i++) { if (s >= costs[i]) { s -= costs[i]; ans++; } else { break; } } cout << ans << endl; return 0; }这段代码的逻辑非常清晰:读入数据的同时筛选够得到的苹果,然后排序,最后贪心选取。
3.2 关键步骤的细节说明
筛选逻辑:height <= reachHeight这个判断是核心入口。注意这里用的是小于等于而不是小于,因为题目说的是“能够到”,刚好等于高度也算能够到。这个边界条件很容易搞错,我见过不少人在这里WA。
排序方向:从小到大排序,因为我们要优先选消耗小的。如果从大到小排,那就变成优先选消耗大的,显然不对。
体力扣减:每次选取后从剩余体力中扣除对应消耗。这里用减法而不是累加消耗再比较,可以少维护一个变量,代码更简洁。
提前终止:当遇到第一个消耗大于剩余体力的苹果时,直接break。因为数组已经排好序了,后面的消耗只会更大,不可能再选了。
3.3 不同语言实现的对比
Python版本会更简洁一些:
n, s = map(int, input().split()) a, b = map(int, input().split()) reach = a + b costs = [] for _ in range(n): h, c = map(int, input().split()) if h <= reach: costs.append(c) costs.sort() ans = 0 for c in costs: if s >= c: s -= c ans += 1 else: break print(ans)Python的列表推导和sort方法让代码更短,但核心逻辑完全一样。如果你习惯用Python刷题,注意输入读取的效率,数据量大的时候sys.stdin会比input()快不少。
4. 边界情况与常见错误排查
4.1 容易踩的坑
坑一:体力刚好用完。比如剩余体力是5,苹果消耗也是5,这时候应该能摘。判断条件必须是s >= costs[i]而不是s > costs[i]。这个错误非常隐蔽,因为大部分测试数据可能不会卡这个点,但一旦遇到就WA。
坑二:一个苹果都够不到。这时候cnt为0,排序和遍历都不会执行,直接输出0。代码逻辑上没问题,但要确保数组不会越界访问。
坑三:体力为0。如果初始体力就是0,那一个苹果都摘不了。代码里s >= costs[i]在costs[i] > 0时自然为false,输出0,正确。
坑四:苹果高度刚好等于能够到的最大高度。前面说过,用<=就没问题。
4.2 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 结果偏小 | 边界判断用了<而不是<= | 检查高度和体力的比较符号 |
| 结果偏大 | 忘记筛选够不到的苹果 | 确认是否在筛选后才排序 |
| 运行超时 | 排序数据量过大 | 确认是否在读入时就筛选 |
| 数组越界 | 数组大小开得不够 | 检查题目数据范围,留足余量 |
| 答案错误 | 贪心策略用错 | 确认是按消耗升序排列 |
4.3 测试用例设计建议
自己造测试数据的时候,重点覆盖这几类:
- 所有苹果都够不到
- 所有苹果都够得到但体力只够摘一部分
- 体力刚好够摘完所有够得到的苹果
- 体力刚好差一点摘不了下一个
- 只有一个苹果的情况
我一般会先用手算几个简单case验证代码逻辑,再用随机数据对拍。对拍就是写一个暴力枚举的版本,小数据量下枚举所有子集找最优解,和大数据的贪心结果对比。虽然这道题贪心正确性很直观,但对拍能帮你发现实现层面的bug。
5. 从这道题延伸出的贪心思维
5.1 贪心算法的适用条件
这道题能用的贪心策略,本质上依赖于两个性质:
贪心选择性质:每一步的最优选择能导致全局最优。在这道题里,选当前消耗最小的苹果,不会让后续的选择变差。
最优子结构:问题的最优解包含子问题的最优解。摘完一个苹果后,剩下的问题还是在剩余体力下摘最多苹果,结构完全一样。
这两个性质不是所有问题都满足的。比如如果苹果有不同的“价值”,那就变成0-1背包问题,贪心就不适用了。判断一道题能不能用贪心,关键看能不能证明交换论证——任意最优解都能通过交换变成贪心解。
5.2 类似题目的解题套路
这类“在约束下选最多物品”的题目,通常有几种变体:
- 每个物品价值相同:直接贪心选消耗最小的,就是这道题
- 每个物品价值不同:变成背包问题,需要动态规划
- 物品之间有依赖关系:可能需要拓扑排序加贪心
- 约束条件不止一个:可能需要更复杂的策略或者DP
识别题目类型的关键是看“价值”是否统一。统一就是贪心,不统一就要考虑DP。
5.3 贪心与动态规划的边界
很多人分不清什么时候用贪心什么时候用DP。一个简单的判断方法:如果局部最优能推出全局最优,用贪心;如果局部最优不一定导致全局最优,用DP。
这道题里,每个苹果的“收益”都是1,所以选消耗最小的那个永远不会亏。但如果苹果有不同的收益值,比如摘一个大苹果算3分,小苹果算1分,那就要比较“性价比”了,而性价比排序贪心在0-1背包里也不一定对,必须用DP。
实操心得:刷题的时候不要只记结论,多问自己“为什么这个策略对”和“换个条件还对不对”。这样遇到变体题才不会慌。
6. 性能优化与代码风格建议
6.1 输入输出优化
对于C++来说,如果数据量特别大,可以用scanf/printf代替cin/cout,或者加上ios::sync_with_stdio(false)来加速。这道题的数据规模通常不大,但养成习惯没坏处。
Python的话,用sys.stdin.read().split()一次性读入所有数据再处理,比逐行input()快很多。尤其是数据量上万的时候,差距非常明显。
6.2 代码可读性
变量命名要见名知意。reachHeight比h好,costs比arr好。虽然刷题时代码短,但清晰的命名能帮你快速定位bug。
逻辑分块要明确:读入、筛选、排序、贪心选取,每块之间空一行,加个简单注释。这样调试的时候一眼就能看出是哪块出了问题。
6.3 内存使用
这道题只需要存够得到的苹果的消耗值,不需要存高度。数组开多大?题目一般会给出n的范围,开n+5就够了。如果不确定,开大一点比如10005,多占几KB内存无所谓,总比越界强。
7. 从刷题到实战的思维迁移
这道题虽然是个算法题,但它背后的思维模式在实际工作中很常见。比如你有一笔预算,要买尽可能多的设备,每台设备价格不同,你肯定先买便宜的。再比如你有一段时间,要安排尽可能多的会议,每个会议时长不同,你肯定先安排短的。
贪心思维的核心是:在资源有限的情况下,优先满足“成本最低”的需求,从而最大化完成的任务数量。这个思路在任务调度、资源分配、缓存淘汰等场景都有应用。
当然,实际场景往往比这道题复杂得多。可能每个任务的“价值”不同,可能有时间窗口约束,可能任务之间有依赖关系。但理解了最基础的贪心模型,遇到复杂问题时至少知道从哪个方向去思考。
我个人的经验是,刷题的价值不在于记住多少道题的解法,而在于培养一种“看到问题先分析结构”的习惯。这道题如果只是背个代码,下次换个背景可能就不认识了。但如果理解了“同价值选最小消耗”这个核心,那不管题目怎么包装,你都能一眼看穿。
最后分享一个小技巧:做完一道题后,试着把题目条件改一改,看看原来的解法还成不成立。比如把“每个苹果消耗不同体力”改成“每个苹果消耗体力相同但价值不同”,解法就完全变了。这种主动变形的练习,比被动刷十道题都管用。