ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

贪心算法实战:蓝桥杯巧克力问题解析与逆向思维应用

贪心算法实战:蓝桥杯巧克力问题解析与逆向思维应用 1. 项目概述从“巧克力”到“贪心策略”的实战拆解看到“蓝桥杯 2021国赛 巧克力”这个标题很多初次接触算法竞赛的同学可能会一愣以为是什么趣味数学题或者生活场景模拟。实际上这是一道非常经典的、考察贪心算法与排序策略的编程题。它披着“分巧克力”的日常外衣内核却是一个关于如何在资源天数和约束保质期、单价下做出最优决策的算法问题。我参加过也辅导过不少算法竞赛这类题目往往是区分“能否拿奖”和“能拿几等奖”的关键。它不要求你掌握多么高深的数据结构但对你的问题抽象能力和贪心策略证明能力提出了不低的要求。如果你正在备战蓝桥杯尤其是冲击国赛那么吃透这道题对你理解贪心算法的本质和适用场景会有极大的帮助。接下来我就带你完整拆解这道题不仅告诉你“怎么做”更重点分析“为什么这么做”以及在实际编码和竞赛中会遇到哪些坑。简单来说题目背景是你需要购买巧克力来满足接下来N天的需求每天吃一块。市场上有多种巧克力每种有各自的单价和保质期即生产日期后的有效天数。巧克力可以在保质期内的任意一天食用但不能在保质期后食用。目标是找出满足N天需求的最低总花费。这立刻将我们从一个生活问题拉入了一个典型的带约束的优化问题领域。核心挑战在于你不仅要考虑单价便宜还要确保巧克力在需要吃的那天没有过期这中间存在一个时间线上的匹配问题。2. 核心思路解析为什么贪心是可行的面对这个问题我们的第一反应可能是动态规划DP或者搜索。毕竟这看起来像一个“选择”问题每天我们都要从一堆尚未过期且未被食用的巧克力中选一块来吃。但仔细分析DP的状态设计会非常复杂因为状态需要包含“当前是第几天”以及“每种巧克力还剩多少块、分别在什么时候过期”这几乎是指数级的对于竞赛时限来说不可行。那么贪心算法凭什么能解决这个问题关键在于题目中“保质期”这个约束的性质。2.1 贪心策略的直觉与逆向思考一个最直接的贪心想法是每天都选当前能吃的、最便宜的那块巧克力。这个策略听起来合理但存在一个致命问题一块单价便宜但保质期很短的巧克力如果过早被吃掉可能会导致后面某一天没有合适的巧克力可吃被迫选择一块非常昂贵的从而总体成本上升。例如第1天有巧克力A单价1保质期1天和巧克力B单价100保质期100天。如果第1天就贪便宜吃了A那么第2到第100天你只能吃B总花费极高。如果第1天忍住吃了B那么A就浪费了但总花费反而可能更低如果N很大。这说明“每日最便宜”的策略是错的。正确的突破口是逆向思考即“从最后一天往前安排”。为什么因为对于最后一天第N天我们能选择的巧克力范围是严格受限的只有那些保质期大于等于N的巧克力才能在第N天被吃掉。我们自然希望在第N天在所有“有资格”被这天吃的巧克力中挑一块最便宜的。选定之后这块巧克力就从备选池中移除。接着考虑第N-1天此时备选池是所有未被选走的、且保质期大于等于N-1的巧克力我们再从中挑最便宜的以此类推直到第一天。这个策略为什么是全局最优的我们可以这样理解非严格证明对于第N天任何最优解中在第N天被吃掉的巧克力必然属于“保质期N”的集合。在这个集合中选择最便宜的那块肯定不会比选择其他更贵的块使总花费更高。因为第N天的选择不会影响前面日期的可选集合我们是从后往前选所以这个局部最优选择是安全的。依次类推每一天都做在当前状态下考虑已为后面日子做出的选择后的局部最优选择最终就能得到全局最优解。这本质上是贪心算法中“拟阵”或“带期限的调度问题”的一个经典模型。2.2 数据结构的选择堆优先队列的妙用思路有了如何高效实现从后往前每一天我们都需要从“当天可用的巧克力”中快速找到最便宜的那块。如果每次都遍历所有巧克力时间复杂度是O(N * M)M是巧克力种类数题目中可能很大这很可能超时。这里就需要引入一个高效的数据结构最小堆优先队列。具体操作如下预处理将所有巧克力按照保质期从大到小排序。这样当我们从第N天开始往前遍历时可以按顺序将“保质期能满足当前天数”的巧克力加入到我们的备选池堆中。遍历每一天从N到1 a. 将当前所有保质期 当前天数i的巧克力的单价加入到最小堆中。 b. 从堆顶取出最小单价即最便宜的巧克力累加到总花费中并将它从堆中移除表示这块巧克力被分配到了第i天食用。异常处理如果在某一天堆为空说明没有保质期能满足这一天的巧克力了问题无解。这个算法的时间复杂度是O(M log M N log M)其中O(M log M)是排序的复杂度O(N log M)是遍历N天、每天进行堆操作的复杂度。这在蓝桥杯的数据规模下是完全可行的。注意这里有一个非常关键的细节也是很多初学者会出错的地方。巧克力是按“种类”给出的每种有价格和保质期但题目并没有说每种只有一块通常每种巧克力的数量是无限的或者至少是充足供应的除非特别说明。在我们的贪心策略中同一种巧克力只要其保质期满足要求就可以被多次加入堆中代表我们可以购买多块同一种巧克力。在实现时我们排序和遍历的对象是“巧克力种类”但在加入堆时加入的是其“单价”。这意味着如果一种巧克力保质期很长它可能会在连续多天里成为堆中的候选并被多次选中。3. 算法实现与代码详解理论清晰后我们来看具体的代码实现。这里以Python为例因为它语法简洁非常适合算法竞赛的快速原型开发。我会逐行解释并指出关键点。3.1 数据输入与预处理首先我们需要读取数据。题目输入通常格式是第一行两个整数N天数和M巧克力种类数接下来M行每行两个整数分别表示一种巧克力的单价和保质期。import sys import heapq def main(): data sys.stdin.read().strip().split() if not data: return it iter(data) N int(next(it)) # 总天数 M int(next(it)) # 巧克力种类数 chocolates [] for _ in range(M): price int(next(it)) shelf_life int(next(it)) # 保质期 chocolates.append((shelf_life, price)) # 关键步骤1按保质期从大到小排序 chocolates.sort(reverseTrue)我们使用sys.stdin.read()一次性读取所有输入这在竞赛中比多次input()更快。将巧克力存储为(保质期, 单价)的元组列表然后按照保质期降序排序。这样当我们从后往前处理天数时可以按顺序将巧克力加入堆。3.2 核心贪心过程接下来是算法的核心循环。我们需要一个指针idx来遍历排序后的巧克力列表一个最小堆heap来维护当前可用的巧克力单价。# 初始化最小堆和巧克力列表指针 heap [] idx 0 total_cost 0 # 关键步骤2从第N天到第1天逆向遍历 for day in range(N, 0, -1): # 将所有保质期满足当前天数的巧克力单价加入堆中 while idx M and chocolates[idx][0] day: # 注意这里加入的是单价price heapq.heappush(heap, chocolates[idx][1]) idx 1 # 如果堆为空说明没有巧克力可供这一天食用问题无解 if not heap: print(-1) # 根据题目要求无解时输出-1 return # 取出当前最便宜的巧克力用于第day天 cheapest_price heapq.heappop(heap) total_cost cheapest_price # 输出总花费 print(total_cost)逐行解析for day in range(N, 0, -1):逆向遍历从最后一天N开始到第一天结束。while idx M and chocolates[idx][0] day:这个循环是高效的关键。因为巧克力已按保质期降序排好所以所有保质期 day的巧克力都集中在列表前端。我们不断将它们的单价加入最小堆直到遇到第一个保质期小于day的巧克力。这样每个巧克力只会被加入堆中一次。heapq.heappush(heap, chocolates[idx][1])heapq是Python内置的堆模块默认是最小堆。我们只将单价入堆。if not heap:这是无解判断。如果某一天没有任何巧克力的保质期能满足要求即堆为空则整个计划不可能实现按题目要求输出-1。cheapest_price heapq.heappop(heap)从堆中取出并移除当前最小的单价堆顶元素这就是我们分配给第day天的巧克力。total_cost cheapest_price累加花费。3.3 完整代码与测试将以上两部分组合并加上必要的入口判断就得到了完整代码import sys import heapq def solve(): data sys.stdin.read().strip().split() if not data: return it iter(data) N int(next(it)) M int(next(it)) chocolates [] for _ in range(M): price int(next(it)) shelf_life int(next(it)) chocolates.append((shelf_life, price)) # 按保质期降序排序 chocolates.sort(reverseTrue) heap [] idx 0 total_cost 0 # 从最后一天开始往前安排 for current_day in range(N, 0, -1): # 将保质期满足当前日期的巧克力加入候选堆 while idx M and chocolates[idx][0] current_day: heapq.heappush(heap, chocolates[idx][1]) idx 1 # 如果堆为空说明无法满足当天需求 if not heap: print(-1) return # 选择最便宜的巧克力 total_cost heapq.heappop(heap) print(total_cost) if __name__ __main__: solve()测试用例假设输入为5 3 2 3 3 1 5 2N5需要吃5天M33种巧克力种类1单价2保质期3天种类2单价3保质期1天种类3单价5保质期2天按照我们的算法排序后巧克力列表[(3,2), (2,5), (1,3)]保质期降序。处理第5天没有保质期5的巧克力堆为空等等这里会发现第一天就无解了。因为保质期最长的也只有3天无法满足第5天的需求。所以这个输入应该输出-1。让我们看一个可解的案例 输入3 3 1 3 2 2 3 1种类1单价1保质期3种类2单价2保质期2种类3单价3保质期1算法过程排序[(3,1), (2,2), (1,3)]Day3: 加入保质期3的巧克力(3,1)堆为[1]。选出1花费1。Day2: 加入保质期2的巧克力(2,2)堆为[2]。选出2花费123。Day1: 加入保质期1的巧克力(1,3)堆为[3]。选出3花费336。 输出结果为6。我们可以验证这确实是最优方案第3天吃最便宜的(1)第2天吃(2)第1天吃(3)。4. 常见错误与深度辨析在实际解题和竞赛中这道题有几个高频错误点和容易混淆的概念我结合自己的踩坑经验给大家捋一捋。4.1 错误一正向贪心每天选最便宜这是最直观的错误。如前所述正向贪心可能为了短期便宜而牺牲长期利益。我们可以构造反例天数N2。巧克力A单价1保质期1天。巧克力B单价100保质期2天。 正向贪心第1天选最便宜的A单价1第2天只能选B单价100总花费101。 最优解第1天选B单价100第2天选A单价1总花费101等等这里有问题。第2天时巧克力A已经过期保质期只有1天所以第2天只能选B。因此最优解其实是第1天选B第2天还选B假设供应无限总花费200这似乎比正向贪心还差。 让我们修正例子巧克力A单价1保质期1巧克力B单价10保质期2巧克力C单价100保质期2。 正向贪心Day1选A(1)Day2选B(10)总花费11。 最优解Day1选B(10)Day2选A(1)不行A在Day2过期了。所以只能Day1选C(100)? 这不对。 看来构造一个简洁的反例需要点技巧。更典型的反例是存在一块“物美价廉”但保质期短的巧克力如果被过早消耗会导致后期被迫选择高价商品。这个直觉是正确的但需要配合具体的保质期分布来构造数据。在竞赛中只要理解逆向贪心的正确性证明思路就足以避免掉入这个陷阱。4.2 错误二对“种类”和“块数”的理解偏差题目描述有时会说“第i种巧克力的单价是x保质期是y天”。很多同学会默认每种巧克力只有一块。但仔细看题目往往不会明确说明每种只有一块。在标准的“采购”问题中通常默认每种商品供应充足除非明确说明“数量有限”。在我们的算法中将同一种巧克力的单价多次加入堆只要其保质期覆盖了多天在逻辑上就等同于购买了多块同一种巧克力。这是完全合理的。如果题目真的规定每种只有一块那么数据结构需要更复杂例如需要记录每种巧克力的剩余数量但这类赛题非常少见。务必仔细阅读题目的输入输出说明。4.3 错误三排序关键字选择我们选择按保质期降序排序是为了配合从后向前遍历的天数可以线性地将巧克力加入堆。如果按单价排序呢那就完全错了因为这样破坏了时间维度上的连续性你无法快速知道哪些巧克力对“当前天”是可用的。如果按保质期升序排序然后从第一天开始向后遍历同样会遇到麻烦你需要维护一个堆里面存放所有“已经过期”的巧克力逻辑会变得混乱。所以“保质期降序 天数逆序”这个组合是匹配得天衣无缝的。4.4 错误四忽略无解情况这是致命错误。如果巧克力的最大保质期小于总天数N那么肯定无解。但即使最大保质期大于N也可能因为保质期分布不均而导致中间某天无解。我们的算法中的if not heap:判断就涵盖了所有无解情况。千万不要忘记处理否则可能会在某个测试点上得到错误结果或者运行时错误。5. 算法扩展与相关题型吃透这道“巧克力”题你其实掌握了一类问题的通解。这类问题在算法竞赛中被称为“带时间限制的贪心选择问题”或“基于截止时间的调度问题”。它的核心模型是有一系列任务每天吃一块巧克力每个任务有一个截止时间当天和一系列资源巧克力每个资源有成本单价和可用时间窗口保质期。目标是给每个任务分配一个资源使得总成本最小且资源在其时间窗口内被使用。相关的经典题型有课程安排问题有N个课程每个课程有开始时间和结束时间以及一间教室的使用费用。如何安排课程使用教室使得总费用最小可以转化为区间覆盖问题但贪心思路类似。机器调度问题有M台机器每个任务有处理时间和截止时间使用不同机器成本不同。如何安排任务使得总成本最低且不超截止时间蓝桥杯另一道真题“修理牛棚”虽然表面不同但其贪心思想逆向思考或间隔处理有异曲同工之妙。解决这类问题的通用步骤是确定贪心策略通常是按截止时间逆向处理或者按某种权重排序。选择合适的数据结构为了快速获取当前最优选择堆优先队列是最常用的工具。证明贪心选择性竞赛中至少心里有数通常使用“交换论证”或“归纳法”证明每一步的局部最优选择可以导向全局最优解。6. 竞赛实战技巧与心得最后分享一些在蓝桥杯等竞赛中解决此类题目的实战心得。技巧一快速识别模型看到“时间”、“期限”、“成本”、“最优”这些关键词就要联想到贪心或动态规划。如果任务之间独立性较强如每天吃巧克力互不影响且选择具有“如果现在不用以后可能就用不了”的特性如保质期那么逆向贪心堆的模型就概率极大。在国赛级别的比赛中题目往往不会直接套用模板但核心模型是相通的。技巧二善用Python的heapqPython没有内置的大顶堆但可以通过存入负值来实现heapq.heappush(heap, -value)取出时再取负-heapq.heappop(heap)。对于本题的最小堆直接使用正值即可。记住heapq模块的函数heappush,heappop,heapify默认操作的是列表并将其视为堆结构。技巧三注意数据范围和类型蓝桥杯的题目整数范围可能很大总花费可能会超过32位int的范围约21亿。在Python中这不是问题但在C/Java中要使用long long或long。虽然本题单价和天数通常不会太大但养成检查数据范围的习惯是好的。技巧四测试用例设计自己设计测试用例时要覆盖以下几种情况常规有解情况。无解情况最大保质期小于N或中间某天断供。所有巧克力保质期都相同的情况。所有巧克力单价都相同的情况。边界情况N1 M1。大数据压力测试在本地用脚本生成N和M都很大的数据检查程序是否超时或内存溢出。技巧五调试与验证对于贪心算法如果你不确定策略是否正确可以写一个暴力搜索DFS程序对小规模数据例如N10, M10进行验证对比贪心算法的结果和暴力枚举所有可能方案得到的最优解是否一致。这是验证贪心策略正确性的有效手段尤其在比赛时如果对思路存疑花一点时间写个暴力对拍程序是值得的。这道“巧克力”题就像它的名字一样初尝可能觉得有点甜简单但细细品味深入思考却能感受到其中算法设计的精巧和思维转换的乐趣。它完美地展示了如何将一个生活问题抽象为数学模型并通过巧妙的贪心策略和高效的数据结构予以解决。掌握它不仅能帮你拿下这道题的分数更能提升你解决一大类优化问题的能力。在算法学习的路上这种“透过现象看本质”的能力远比记忆十个八个模板更重要。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进