ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

贪心算法实战:从蓝桥杯国赛《巧克力》题解看优先队列应用

贪心算法实战:从蓝桥杯国赛《巧克力》题解看优先队列应用 1. 项目概述从一道国赛真题看贪心算法的实战应用最近在复盘蓝桥杯的历年真题第十二届国赛的《巧克力》这道题给我留下了挺深的印象。它不像一些纯数学题那样抽象也不像某些复杂模拟题那样繁琐而是将一个非常生活化的问题——如何在预算和保质期限制下买到最多的巧克力——转化为了一个经典的算法问题。这道题的核心是贪心算法思想的一次典型应用同时考察了对数据结构的理解和Java编程中处理自定义排序、优先队列等细节的熟练度。很多初次接触的同学可能会被“国赛”二字吓到或者被题目描述中关于保质期和价格的讨论绕晕但其实它的解题思路非常清晰一旦掌握了背后的“贪心”逻辑代码实现起来并不复杂。这篇文章我就结合自己的解题和教学经验把这道题的核心思路、关键实现细节以及常见的“坑点”彻底讲透无论你是正在备赛的选手还是对算法感兴趣的Java开发者都能从中获得可以直接复现的解题方案和深入的理解。2. 问题核心与解题思路拆解2.1 题目重述与需求分析我们先抛开编程语言把问题本身理清楚。题目大意是小明有N元钱他想用这些钱买巧克力。市场上有多种巧克力每种巧克力有它的单价和保质期还剩多少天过期。小明每天吃一块巧克力他希望买到的巧克力在过期之前被吃完。我们的目标是用有限的N元钱帮助小明买到最多数量的巧克力。这里有几个关键的约束条件需要明确预算限制总花费不能超过N元。时间限制一块巧克力必须在其保质期假设从购买当天算起结束之前被吃掉。例如一块保质期还剩3天的巧克力必须在第1、2、3天中的某一天被消耗。消耗规则每天只能吃一块。目标函数不是总价值最高也不是总花费最低而是巧克力数量最多。这立刻让我们联想到经典的“安排会议”或“任务调度”问题有一系列任务巧克力每个任务有一个截止时间保质期和执行成本价格我们需要在总成本有限的条件下安排尽可能多的任务并保证每个任务在其截止时间前完成。2.2 算法策略选择为什么是贪心面对这类“在限制条件下求最大数量”的问题动态规划DP和贪心算法是常见的候选。DP通常用于解决具有最优子结构的问题但在这里巧克力的“保质期”和“购买日期”构成了一个时间线如果定义状态dp[i][j]表示前i种巧克力、花费j元能买到的最大数量状态转移会非常复杂因为还需要记录哪些天已经被占用。时间复杂度很可能难以承受。贪心算法的核心思想是“每步都采取当前看来最优的选择”从而希望导致全局最优。对于本题一个直观的贪心策略是优先购买便宜的巧克力因为我们的目标是数量最大化。但这够吗显然不够。如果一块巧克力非常便宜但明天就过期而另一块稍贵但保质期很长我们可能需要为后者支付更多但它为我们未来的“档期”提供了更多灵活性。正确的贪心策略需要结合价格和保质期。经过分析也是这类问题的经典解法有效的策略是按保质期从大到小排序优先考虑保质期长的巧克力。这保证了我们在处理任何一块巧克力时当前可用的“消费日期”集合从第1天到该巧克力保质期当天的所有日子是尽可能大的。对于同一天保质期到期的巧克力我们只关心最便宜的那块不更精确的做法是从保质期最长的那天开始逆向安排即从后往前安排购买/消费。对于每一个保质期d我们将所有保质期d的巧克力放入一个候选集合然后从这个集合里选出价格最低的巧克力安排在第d天消费购买。这确保了在每一天我们都是在所有“还能存活到那一天”的巧克力中挑选最便宜的来占用那一天的“名额”。这个“从后往前每天选最便宜”的策略就是著名的“过期时间优先队列”贪心法。它为什么有效因为从最后一天往前安排可以保证当我们决定第d天吃什么时所有保质期大于等于d的巧克力都还没有被安排我们拥有当前最大的选择权。而在拥有选择权时选择最便宜的自然能为后续的日子节省出更多的预算来购买其他巧克力从而最大化总数量。3. 核心数据结构与Java实现解析理解了算法思想接下来就是用Java代码将其实现。这里的关键在于如何高效地实现“对于每个保质期d从所有保质期d的巧克力中选出价格最低的”。3.1 数据模型定义首先我们需要一个类来封装巧克力的信息。class Chocolate { int price; // 单价 int shelfLife; // 保质期剩余天数 // 构造函数、Getter/Setter省略... }输入会提供一系列这样的巧克力。我们需要根据shelfLife进行一定的处理。3.2 算法流程与数据结构选型整个算法的步骤可以分解如下数据预处理读取所有巧克力信息。按保质期排序将巧克力按照保质期从大到小进行排序。这样当我们从后往前遍历天数时可以方便地将保质期符合条件的巧克力加入候选集。逆向天数遍历与优先队列维护假设最长的保质期是maxDay。我们从day maxDay开始一直遍历到day 1。维护一个最小堆优先队列pq用于存放所有保质期 day的巧克力的价格。在每一天day将所有保质期 day的巧克力的价格加入优先队列pq。注意因为我们是按保质期从大到小排序的所以在遍历到第day天时所有保质期大于等于day的巧克力都已经在队列里了。我们只需要加入保质期恰好等于day的即可。这是一种常见的遍历技巧可以避免重复判断。如果优先队列不为空即有巧克力可以在第day天消费我们就取出队首元素即当前最便宜的价格cost。如果当前剩余预算N cost那么我们就“购买”这块巧克力预算减少cost购买计数count加一。如果预算不足则这块巧克力无法购买由于队列是最小堆这意味着当前以及后续所有更贵的巧克力在当前预算下都买不起可以直接退出循环。输出结果最终得到的count就是能买到的最大巧克力数量。为什么使用最小堆PriorityQueue因为我们需要动态地从候选集合中获取最小值。在遍历每一天时候选集合保质期当前天数的所有巧克力是不断变化的每天会加入新的保质期到期的巧克力我们需要一种数据结构能高效地支持插入和获取最小值的操作。Java中的PriorityQueue正是为此而生其插入和取出队首元素的时间复杂度都是O(log n)非常高效。3.3 完整代码实现与逐行解读下面结合代码详细讲解每个部分import java.util.*; public class Main { static class Chocolate { int price; int shelfLife; public Chocolate(int price, int shelfLife) { this.price price; this.shelfLife shelfLife; } } public static void main(String[] args) { Scanner sc new Scanner(System.in); int N sc.nextInt(); // 总预算 int m sc.nextInt(); // 巧克力种类数 ListChocolate list new ArrayList(); int maxDay 0; for (int i 0; i m; i) { int price sc.nextInt(); int shelfLife sc.nextInt(); maxDay Math.max(maxDay, shelfLife); // 记录最大保质期作为遍历终点 list.add(new Chocolate(price, shelfLife)); } sc.close(); // 1. 按保质期从大到小排序 list.sort((a, b) - b.shelfLife - a.shelfLife); // 2. 初始化最小堆优先队列存放巧克力价格 PriorityQueueInteger pq new PriorityQueue(); int index 0; // 用于遍历已排序的巧克力列表 int count 0; // 能买到的巧克力数量 // 3. 从最后一天maxDay向前遍历到第一天 for (int day maxDay; day 1; day--) { // 将保质期恰好等于当前day的巧克力价格加入优先队列 while (index list.size() list.get(index).shelfLife day) { // 注意这里是 day因为排序是降序当遍历到第day天时 // 所有保质期大于等于day的巧克力其下标index都小于当前遍历到的位置。 // 这个循环会把所有保质期day且还未入队的巧克力价格加入队列。 // 更精确的实现是判断 day但由于排序是降序day的判断可以简化逻辑。 // 为了绝对准确我们可以用两个循环但下面这种写法是等价的且高效的。 pq.offer(list.get(index).price); index; } // 如果队列不为空说明有巧克力可以在今天消费 if (!pq.isEmpty()) { int cost pq.poll(); // 取出最便宜的一块 if (N cost) { N - cost; // 购买 count; } else { // 预算不足连最便宜的都买不起后续天数更买不起可以提前结束 // 注意这里break的前提是队列是最小堆且之后天数候选集是当前子集。 // 实际上更严谨的做法是不break因为后续天数可能有更便宜的巧克力加入。 // 但基于贪心策略如果当前最便宜的都买不起那么用后续天数更早到期的巧克力 // 来替换当前选择并不会让总花费更少。所以这里break是安全的优化。 break; } } } System.out.println(count); } }关键点解读list.sort((a, b) - b.shelfLife - a.shelfLife);这是按保质期降序排序。Lambda表达式(a, b) - b.shelfLife - a.shelfLife表示如果结果为正则b排在a前面即shelfLife大的在前。while (index list.size() list.get(index).shelfLife day)这个循环是高效入队的关键。由于列表已按保质期降序排列当我们遍历到第day天时所有shelfLife day的巧克力都集中在列表尚未处理的部分index之后。这个循环将它们一次性加入优先队列。注意这里用而不是是因为降序排列下保质期更长的巧克力会先被遍历到并加入队列这是正确的。if (!pq.isEmpty()) { int cost pq.poll(); ... }每天我们从所有“还能存活到今天”的巧克力即在队列中的巧克力中选出最便宜的pq.poll()进行购买尝试。if (N cost)预算是全局约束每次购买前检查。break;这是一个重要的优化。当某一天我们连最便宜的巧克力都买不起时由于我们的队列是最小堆且后续天数的候选巧克力集合是当前集合的子集保质期更短所以后续也不可能买得起任何巧克力了可以直接结束循环。4. 算法正确性证明与复杂度分析4.1 贪心选择性质证明为什么“从后往前每天选择当前可用的最便宜巧克力”能得到最优解我们可以用“替换法”来思考。 假设存在一个最优解OPT它购买了一系列巧克力并在特定的日子消费。现在我们用贪心算法得到的解GREEDY来对比。 考虑最后一天保质期最大那天在OPT中这一天消费的巧克力价格记为P_opt。在GREEDY中这一天消费的是所有能存活到最后一天的巧克力中最便宜的价格记为P_greedy。显然P_greedy P_opt。如果P_greedy P_opt我们可以用GREEDY的选择替换OPT中的选择这样OPT的总花费不会增加但可能减少因此替换后仍然是一个可行解甚至可能更优。如果P_greedy P_opt则无需替换。 然后我们考虑倒数第二天。此时GREEDY已经为最后一天做了选择剩下的预算和可选的巧克力集合排除掉已被GREEDY安排掉的与OPT在做了相应替换后的状态是一致的。我们可以重复上述论证。通过从后往前归纳我们可以证明贪心解GREEDY在每一天的选择都不比某个最优解OPT差因此GREEDY本身就是一个最优解。4.2 时间与空间复杂度分析时间复杂度排序O(m log m)其中m是巧克力种类数。遍历天数与优先队列操作最坏情况下我们需要遍历maxDay天最大保质期每天进行一次优先队列的插入和弹出操作每次操作O(log m)。总复杂度为O(maxDay * log m)。 但是请注意每块巧克力只会被加入优先队列一次。因此优先队列的总操作次数是O(m)次插入和最多O(maxDay)次弹出。故这部分复杂度可视为O((m maxDay) log m)。 综合来看主要开销在排序和优先队列操作整体复杂度在O(m log m maxDay log m)级别对于蓝桥杯的数据规模通常是足够的。空间复杂度O(m)用于存储巧克力列表和优先队列。5. 常见问题与实战调试技巧在实际编码和调试过程中以下几个点是容易出错或需要特别注意的5.1 输入处理与边界条件数据范围务必注意题目中N预算、price单价、shelfLife保质期的数据范围。使用int是否足够在蓝桥杯系统中通常int32位有符号整数最大值约21亿是足够的但养成检查数据范围的习惯很重要。如果预算或单价可能很大需考虑使用long。零值处理预算N为0怎么办没有巧克力m0怎么办最大保质期maxDay可能为0吗在代码中循环for (int day maxDay; day 1; day--)在maxDay为0时不会执行count保持为0这是正确的。但为了代码健壮性可以在开头增加判断。重复保质期与价格题目中可能包含多块保质期和价格都相同的巧克力。我们的算法能正确处理吗可以。优先队列允许重复元素多块相同价格的巧克力会被视为不同的选择。5.2 贪心策略的陷阱排序顺序一定要按保质期从大到小降序排序配合从后往前遍历天数。如果按保质期从小到大排序然后从前往后遍历算法就失效了。你可以自己构造一个简单例子试试比如两块巧克力(价格1 保质期2)和(价格100 保质期1)预算为2。正确的策略应该先选贵的但快过期的还是便宜的但保质期长的从后往前贪心会做出正确选择。优先队列的使用时机在循环每一天时是先poll再判断预算还是先判断队列是否为空代码中必须先判断!pq.isEmpty()。否则在队列为空时调用poll()会抛出异常。提前退出条件代码中使用了if (N cost) break;。这是一个有效的优化但需要理解其正确性基础当前队列是所有保质期 day的巧克力中价格最小的集合如果当前最便宜的都买不起那么后面天数day-1, day-2,...可选的巧克力集合是当前集合的子集因为保质期要求更短所以更不可能买得起。因此break是安全的。5.3 调试与测试用例设计自己设计测试用例是验证算法正确性的好方法基础用例输入N10, m3巧克力(5,3), (3,2), (4,1)。预期输出3可以全部买下。输入N5, m3巧克力(5,3), (3,2), (4,1)。预期输出2买(3,2)和(4,1)或(5,3)和(4,1)? 实际贪心过程会买(3,2)和(4,1)。边界用例N0任何巧克力都买不了输出0。m0没有巧克力可买输出0。所有巧克力价格都超过N输出0。最大保质期maxDay很大比如100000但巧克力种类m很小比如10。测试算法在遍历天数时的效率。复杂用例多块巧克力保质期相同价格不同。验证优先队列能选出最便宜的。巧克力保质期分布很散验证从后往前遍历和入队逻辑是否正确。可以在本地IDE中运行这些用例或者使用蓝桥杯练习系统的在线评测功能。5.4 性能优化考量虽然上述算法对于竞赛通常已足够但在极端情况下maxDay非常大比如10^9但m只有10^5遍历每一天(maxDay ~ 10^9)是不可行的。这时我们需要优化天数遍历过程。因为很多天可能根本没有巧克力到期。我们可以只关心那些有巧克力到期的日子。具体做法是收集所有唯一的保质期天数进行排序。用一个指针指向当前处理的保质期另一个指针指向巧克力列表。用一个变量currentDay表示当前正在安排的日子从最大的保质期开始。在每一轮将保质期等于currentDay的巧克力加入优先队列然后进行消费选择。然后currentDay减一但如果下一天没有巧克力到期即不在唯一保质期集合里我们可以直接跳到下一个有巧克力到期的日子而不需要一天天模拟。这需要更复杂的逻辑来控制currentDay的递减和巧克力的入队时机。对于蓝桥杯国赛真题通常给出的数据范围使得简单的逐天遍历方法可以通过但了解这种优化思路对于解决更复杂的问题是有益的。6. 举一反三贪心算法的其他应用场景《巧克力》这道题是贪心算法中“带截止时间的任务调度”问题的变种。掌握其精髓后你可以解决许多类似问题会议安排问题有若干个会议每个会议有开始时间、结束时间和价值如何安排使总价值最大如果每个会议价值相同就是求最多能参加多少个会议按结束时间贪心。如果价值不同则可能需结合DP。哈夫曼编码构造最优前缀码每次合并频率最小的两棵树是贪心思想的典型体现。最小生成树Prim/Kruskal算法每次选择连接两个集合的最小边最终构成最小权重的生成树。找零钱问题硬币无限供应且面值设计合理时用面值最大的硬币尽可能多地找零。跳跃游戏给定数组每个位置代表能跳的最大步数判断能否到达终点。贪心策略是维护当前能到达的最远位置。识别贪心算法可解问题的特征问题具有最优子结构并且贪心选择性质成立即局部最优选择能导致全局最优解。证明贪心选择性质是应用贪心算法的关键有时可以通过反证法或替换法来证明。回到《巧克力》这道题它很好地融合了生活场景和算法思想。通过这道题我们不仅学会了一种高效的解题方法更重要的是理解了如何将现实约束转化为计算模型并运用合适的数据结构排序优先队列来支撑贪心策略的执行。在Java实现中熟练使用Collections.sort()、PriorityQueue以及自定义比较器是解决此类问题的基本功。希望这篇详细的解析能帮助你彻底掌握这个知识点在未来的比赛或开发中遇到类似问题时能够游刃有余。
返回列表