贪心算法实战:从蓝桥杯国赛《巧克力》题解看优先队列应用
2026/9/21 11:00:47 网站建设 项目流程

1. 项目概述:从一道国赛真题看贪心算法的实战应用

最近在复盘蓝桥杯的历年真题,第十二届国赛的《巧克力》这道题给我留下了挺深的印象。它不像一些纯数学题那样抽象,也不像某些复杂模拟题那样繁琐,而是将一个非常生活化的问题——如何在预算和保质期限制下买到最多的巧克力——转化为了一个经典的算法问题。这道题的核心,是贪心算法思想的一次典型应用,同时考察了对数据结构的理解和Java编程中处理自定义排序、优先队列等细节的熟练度。很多初次接触的同学可能会被“国赛”二字吓到,或者被题目描述中关于保质期和价格的讨论绕晕,但其实它的解题思路非常清晰,一旦掌握了背后的“贪心”逻辑,代码实现起来并不复杂。这篇文章,我就结合自己的解题和教学经验,把这道题的核心思路、关键实现细节以及常见的“坑点”彻底讲透,无论你是正在备赛的选手,还是对算法感兴趣的Java开发者,都能从中获得可以直接复现的解题方案和深入的理解。

2. 问题核心与解题思路拆解

2.1 题目重述与需求分析

我们先抛开编程语言,把问题本身理清楚。题目大意是:小明有N元钱,他想用这些钱买巧克力。市场上有多种巧克力,每种巧克力有它的单价保质期(还剩多少天过期)。小明每天吃一块巧克力,他希望买到的巧克力在过期之前被吃完。我们的目标是,用有限的N元钱,帮助小明买到最多数量的巧克力。

这里有几个关键的约束条件需要明确:

  1. 预算限制:总花费不能超过N元。
  2. 时间限制:一块巧克力必须在其保质期(假设从购买当天算起)结束之前被吃掉。例如,一块保质期还剩3天的巧克力,必须在第1、2、3天中的某一天被消耗。
  3. 消耗规则:每天只能吃一块。
  4. 目标函数:不是总价值最高,也不是总花费最低,而是巧克力数量最多

这立刻让我们联想到经典的“安排会议”或“任务调度”问题:有一系列任务(巧克力),每个任务有一个截止时间(保质期)和执行成本(价格),我们需要在总成本有限的条件下,安排尽可能多的任务,并保证每个任务在其截止时间前完成。

2.2 算法策略选择:为什么是贪心?

面对这类“在限制条件下求最大数量”的问题,动态规划(DP)和贪心算法是常见的候选。DP通常用于解决具有最优子结构的问题,但在这里,巧克力的“保质期”和“购买日期”构成了一个时间线,如果定义状态dp[i][j]表示前i种巧克力、花费j元能买到的最大数量,状态转移会非常复杂,因为还需要记录哪些天已经被占用。时间复杂度很可能难以承受。

贪心算法的核心思想是“每步都采取当前看来最优的选择”,从而希望导致全局最优。对于本题,一个直观的贪心策略是:优先购买便宜的巧克力,因为我们的目标是数量最大化。但这够吗?显然不够。如果一块巧克力非常便宜但明天就过期,而另一块稍贵但保质期很长,我们可能需要为后者支付更多,但它为我们未来的“档期”提供了更多灵活性。

正确的贪心策略需要结合价格保质期。经过分析(也是这类问题的经典解法),有效的策略是:

  1. 按保质期从大到小排序:优先考虑保质期长的巧克力。这保证了我们在处理任何一块巧克力时,当前可用的“消费日期”集合(从第1天到该巧克力保质期当天的所有日子)是尽可能大的。
  2. 对于同一天(保质期)到期的巧克力,我们只关心最便宜的那块?不,更精确的做法是:从保质期最长的那天开始,逆向安排(即从后往前安排购买/消费)。对于每一个保质期d,我们将所有保质期>=d的巧克力放入一个候选集合,然后从这个集合里选出价格最低的巧克力,安排在第d天消费(购买)。这确保了在每一天,我们都是在所有“还能存活到那一天”的巧克力中,挑选最便宜的来占用那一天的“名额”。

这个“从后往前,每天选最便宜”的策略,就是著名的“过期时间+优先队列”贪心法。它为什么有效?因为从最后一天往前安排,可以保证当我们决定第d天吃什么时,所有保质期大于等于d的巧克力都还没有被安排,我们拥有当前最大的选择权。而在拥有选择权时,选择最便宜的,自然能为后续的日子节省出更多的预算来购买其他巧克力,从而最大化总数量。

3. 核心数据结构与Java实现解析

理解了算法思想,接下来就是用Java代码将其实现。这里的关键在于如何高效地实现“对于每个保质期d,从所有保质期>=d的巧克力中选出价格最低的”。

3.1 数据模型定义

首先,我们需要一个类来封装巧克力的信息。

class Chocolate { int price; // 单价 int shelfLife; // 保质期(剩余天数) // 构造函数、Getter/Setter省略... }

输入会提供一系列这样的巧克力。我们需要根据shelfLife进行一定的处理。

3.2 算法流程与数据结构选型

整个算法的步骤可以分解如下:

  1. 数据预处理:读取所有巧克力信息。
  2. 按保质期排序:将巧克力按照保质期从大到小进行排序。这样,当我们从后往前遍历天数时,可以方便地将保质期符合条件的巧克力加入候选集。
  3. 逆向天数遍历与优先队列维护
    • 假设最长的保质期是maxDay。我们从day = maxDay开始,一直遍历到day = 1
    • 维护一个最小堆(优先队列)pq,用于存放所有保质期>= day的巧克力的价格。
    • 在每一天day,将所有保质期== day的巧克力的价格加入优先队列pq。(注意:因为我们是按保质期从大到小排序的,所以在遍历到第day天时,所有保质期大于等于day的巧克力都已经在队列里了。我们只需要加入保质期恰好等于day的即可。这是一种常见的遍历技巧,可以避免重复判断。)
    • 如果优先队列不为空(即有巧克力可以在第day天消费),我们就取出队首元素,即当前最便宜的价格cost
    • 如果当前剩余预算N >= cost,那么我们就“购买”这块巧克力,预算减少cost,购买计数count加一。如果预算不足,则这块巧克力无法购买,由于队列是最小堆,这意味着当前以及后续所有更贵的巧克力在当前预算下都买不起,可以直接退出循环。
  4. 输出结果:最终得到的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(); // 巧克力种类数 List<Chocolate> 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. 初始化最小堆(优先队列),存放巧克力价格 PriorityQueue<Integer> 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 时间与空间复杂度分析

  • 时间复杂度
    1. 排序:O(m log m),其中m是巧克力种类数。
    2. 遍历天数与优先队列操作:最坏情况下,我们需要遍历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是否足够?在蓝桥杯系统中,通常int(32位有符号整数,最大值约21亿)是足够的,但养成检查数据范围的习惯很重要。如果预算或单价可能很大,需考虑使用long
  • 零值处理:预算N为0怎么办?没有巧克力(m=0)怎么办?最大保质期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 调试与测试用例设计

自己设计测试用例是验证算法正确性的好方法:

  1. 基础用例
    • 输入:N=10, m=3,巧克力:(5,3), (3,2), (4,1)。预期输出:3(可以全部买下)。
    • 输入:N=5, m=3,巧克力:(5,3), (3,2), (4,1)。预期输出:2(买(3,2)和(4,1)或(5,3)和(4,1)? 实际贪心过程会买(3,2)和(4,1))。
  2. 边界用例
    • N=0,任何巧克力都买不了,输出0。
    • m=0,没有巧克力可买,输出0。
    • 所有巧克力价格都超过N,输出0。
    • 最大保质期maxDay很大(比如100000),但巧克力种类m很小(比如10)。测试算法在遍历天数时的效率。
  3. 复杂用例
    • 多块巧克力保质期相同,价格不同。验证优先队列能选出最便宜的。
    • 巧克力保质期分布很散,验证从后往前遍历和入队逻辑是否正确。

可以在本地IDE中运行这些用例,或者使用蓝桥杯练习系统的在线评测功能。

5.4 性能优化考量

虽然上述算法对于竞赛通常已足够,但在极端情况下(maxDay非常大,比如10^9,但m只有10^5),遍历每一天(maxDay ~ 10^9)是不可行的。这时,我们需要优化天数遍历过程。因为很多天可能根本没有巧克力到期。我们可以只关心那些有巧克力到期的日子。具体做法是:

  1. 收集所有唯一的保质期天数,进行排序。
  2. 用一个指针指向当前处理的保质期,另一个指针指向巧克力列表。
  3. 用一个变量currentDay表示当前正在安排的日子,从最大的保质期开始。
  4. 在每一轮,将保质期等于currentDay的巧克力加入优先队列,然后进行消费选择。然后currentDay减一,但如果下一天没有巧克力到期(即不在唯一保质期集合里),我们可以直接跳到下一个有巧克力到期的日子,而不需要一天天模拟。这需要更复杂的逻辑来控制currentDay的递减和巧克力的入队时机。

对于蓝桥杯国赛真题,通常给出的数据范围使得简单的逐天遍历方法可以通过,但了解这种优化思路对于解决更复杂的问题是有益的。

6. 举一反三:贪心算法的其他应用场景

《巧克力》这道题是贪心算法中“带截止时间的任务调度”问题的变种。掌握其精髓后,你可以解决许多类似问题:

  1. 会议安排问题:有若干个会议,每个会议有开始时间、结束时间和价值,如何安排使总价值最大?如果每个会议价值相同,就是求最多能参加多少个会议(按结束时间贪心)。如果价值不同,则可能需结合DP。
  2. 哈夫曼编码:构造最优前缀码,每次合并频率最小的两棵树,是贪心思想的典型体现。
  3. 最小生成树(Prim/Kruskal算法):每次选择连接两个集合的最小边,最终构成最小权重的生成树。
  4. 找零钱问题(硬币无限供应且面值设计合理时):用面值最大的硬币尽可能多地找零。
  5. 跳跃游戏:给定数组,每个位置代表能跳的最大步数,判断能否到达终点。贪心策略是维护当前能到达的最远位置。

识别贪心算法可解问题的特征:问题具有最优子结构,并且贪心选择性质成立(即局部最优选择能导致全局最优解)。证明贪心选择性质是应用贪心算法的关键,有时可以通过反证法或替换法来证明。

回到《巧克力》这道题,它很好地融合了生活场景和算法思想。通过这道题,我们不仅学会了一种高效的解题方法,更重要的是理解了如何将现实约束转化为计算模型,并运用合适的数据结构(排序+优先队列)来支撑贪心策略的执行。在Java实现中,熟练使用Collections.sort()PriorityQueue以及自定义比较器,是解决此类问题的基本功。希望这篇详细的解析能帮助你彻底掌握这个知识点,在未来的比赛或开发中遇到类似问题时,能够游刃有余。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询