蓝桥杯“巧克力”题解:逆向贪心与优先队列实现最优调度
2026/9/17 11:01:30 网站建设 项目流程

1. 问题引入:一块巧克力的最优“吃法”

最近在整理历年算法竞赛的真题时,我又翻到了蓝桥杯2021年国赛的这道“巧克力”题。说实话,第一次看到题目描述时,我下意识地以为这又是一道关于贪心或者动态规划的经典题型,无非是计算最优购买方案或者分配策略。但当我真正静下心来,把题目从头到尾读了几遍,并动手开始建模时,才发现它的内核远比我想象的要精巧和“狡猾”。这道题表面上在讨论如何吃巧克力,实际上是一个关于“时间窗口”与“资源最优匹配”的经典问题,它完美地将生活直觉抽象成了严谨的算法模型,非常考验选手的逆向思维和数据结构应用能力。今天,我就结合自己的解题过程,和大家深入聊聊这道题的“坑”与“美”,以及如何一步步推导出那个反直觉的“从后往前贪心”策略。

题目的大意是这样的:小明有n种巧克力,第i种巧克力单价是a_i元,保质期有b_i天(意味着在第b_i天及之前必须吃掉),每天最多吃一块。他需要在接下来的m天里,每天都有巧克力吃。问:在满足每天都有巧克力吃的前提下,小明最少需要花多少钱?这听起来就像一个精打细算的采购计划:既要保证库存(每天有得吃),又要考虑商品的保质期(过期就浪费了),还要控制总成本。我们作为“算法采购员”,目标就是找到那个总价最低的采购清单。

2. 核心模型抽象:为什么这不是简单的排序贪心?

拿到问题,我们首先要做的是把自然语言描述转化为清晰的数学模型。定义我们有n种商品(巧克力),每种商品有两个属性:价格cost[i]和过期时间deadline[i](即保质期b_i)。我们有一个长度为m的时间线(第1天到第m天)。目标:为每一天分配一块巧克力,且这块巧克力的过期时间必须大于等于它被分配的那一天(即当天及之前必须吃掉)。同时,希望所有被分配巧克力的总价格最小。

一个最直接、最符合直觉的想法是:贪心!既然要总价最小,那肯定优先买便宜的巧克力。所以,我们是不是可以按价格从小到大排序所有巧克力,然后依次尝试把每块巧克力安排到它过期之前最早的空闲天里?这个思路听起来非常合理,我最初也是这么想的。让我们写一下这个“正向贪心”的伪代码逻辑:

  1. 将巧克力按价格升序排序。
  2. 初始化一个布尔数组occupied[1..m] = false,表示每天是否已被分配巧克力。
  3. 遍历排序后的巧克力列表: a. 对于当前巧克力i,其过期时间为d = deadline[i]。 b. 从第1天开始,找到第一个小于等于d且未被占用的天数day。 c. 如果找到了这样的day,则将occupied[day]标记为true,并将cost[i]加入总花费。 d. 如果没找到(即从第1天到第d天都已被占用),则这块巧克力无法被安排,跳过。

这个算法对吗?让我们构造一个简单的反例。假设m=3天,有以下两种巧克力: 巧克力A:价格1元,保质期第1天。 巧克力B:价格2元,保质期第3天。 按价格排序,先处理A(1元)。它能被安排在第1天。然后处理B(2元),它能被安排在第2天或第3天,假设我们安排在第2天。总花费是3元。 但显然,更优的方案是:购买巧克力B(2元),安排在第3天;再购买巧克力A(1元),安排在第1天。总花费同样是3元?等等,这个例子总价一样。那我们调整一下: 巧克力A:价格1元,保质期第1天。 巧克力B:价格100元,保质期第3天。 巧克力C:价格2元,保质期第2天。 按价格排序:A(1元, 第1天) -> C(2元, 第2天) -> B(100元, 第3天)。 处理A:安排在第1天。 处理C:安排在第2天。 处理B:此时第1、2、3天中,只有第3天空闲且满足deadline=3,所以安排在第3天。总花费=1+2+100=103元。 但最优解显然是:购买A(1元)安排在第1天,购买C(2元)安排在第2天,不购买B。总花费=3元。因为第3天我们完全可以用一个更便宜的、保质期大于等于3天的巧克力来覆盖,但在这个顺序里,便宜的C被过早地消耗在了第2天,导致第3天被迫选择昂贵的B。

这个反例揭示了“正向价格贪心”的根本缺陷:它只考虑了当前巧克力的价格局部最优,但没有考虑其对未来日期选择空间的占用影响。一块便宜但保质期短的巧克力,如果过早地占用了一个相对“宽松”(未来还有很多选择)的日期,可能会迫使我们在后面一个“紧张”的日期(选择很少)去购买一块极其昂贵的巧克力。问题的关键在于,每一天的“紧张”程度是不同的。越靠近结束日期m,能覆盖这一天的巧克力种类就越少(因为巧克力的保质期必须>=当天)。因此,我们应该优先为那些“选择余地小”的日期(即靠后的日期)安排巧克力。

3. 逆向贪心策略:为什么“从后往前”安排是关键?

既然正向贪心有问题,我们就要换一个视角。让我们思考一下日期m,也就是最后一天。能在第m天吃的巧克力,其保质期deadline必须 >= m。换句话说,只有那些保质期至少为m的巧克力,才有资格被安排在第m天。在所有有资格候选巧克力中,我们当然应该选一个最便宜的来覆盖第m天,因为这一天没有任何其他选择可以替代它——如果我们不用最便宜的覆盖它,就必须用一个更贵的,这显然不划算。为最晚的日期选择最便宜的可行巧克力,这是一个毫无争议的局部最优决策

选定第m天的巧克力后,我们将其从候选池中移除。现在考虑第m-1天。能在第m-1天吃的巧克力,其保质期必须 >= m-1。但注意,之前选给第m天的巧克力,如果它的保质期也 >= m-1,它理论上也能在第m-1天吃,但它已经被“消耗”了。所以,对于第m-1天,我们的候选集是所有未被选择的、且保质期deadline >= m-1的巧克力。同样地,在这个候选集里,我们应该选择最便宜的一块来覆盖第m-1天。

如此反复,从第m天开始,倒着往前推进,每一天都在当前可用的(即未被选择且保质期满足要求的)巧克力中,选择最便宜的一块。这个策略就是逆向贪心,或者叫“截止时间贪心”,在调度问题中很常见。

为什么这个策略就是正确的呢?我们可以用“交换论证”的思路来理解。假设存在一个最优解S。我们从最后一天m开始,检查最优解S中第m天吃的是哪块巧克力,记为choco_m。在我们的贪心算法G中,第m天选择的是所有deadline>=m的巧克力中最便宜的,记为greedy_m。如果choco_m的价格等于greedy_m,那最好。如果choco_m的价格高于greedy_m,那么我们可以把S中的choco_m替换成greedy_m。因为greedy_m的保质期也>=m,所以替换后第m天依然合法。这个替换不会影响其他日期的安排(因为只是换了一块巧克力给第m天),并且总花费降低了,这与S是最优解矛盾。因此,在最优解中,第m天必然使用了所有可行巧克力中最便宜的那一块。同理,在确定了第m天必须用最便宜的后,我们可以“固定”这一天,然后用完全相同的逻辑去分析第m-1天,依此类推。这就证明了我们逆向贪心策略的每一步都是构成全局最优解的必要步骤。

4. 数据结构的选择与实现:如何高效“挑选最便宜的”?

策略清晰了,接下来就是工程实现。核心操作是:对于当前日期current_day(从m递减到1),我们需要从所有满足deadline >= current_day且未被选择的巧克力中,快速找到价格最低的那一块。

最朴素的方法是:每一天,都遍历所有巧克力,找出满足条件且价格最小的。时间复杂度是O(m * n),在m和n最大都为10^5时,这显然是不可接受的(10^10操作量级)。

我们需要更高效的数据结构来维护这个“候选集合”。观察发现,随着current_day逐渐减小,候选集合是在动态扩大的。因为条件deadline >= current_day会越来越宽松。当current_dayd+1变为d时,所有保质期恰好等于d的巧克力,现在都新加入了候选集合。

这给了我们一个绝妙的处理思路:

  1. 按保质期分组:首先,将所有巧克力按照保质期deadline进行分组。我们可以用一个向量数组chocolates_by_deadline[deadline]来存储所有保质期为deadline的巧克力的价格。
  2. 从后向前扫描日期:从day = m开始,递减到day = 1。 a.扩充候选池:将chocolates_by_deadline[day]中的所有巧克力价格,加入到我们的“候选池”数据结构中。因为对于当前day,这些巧克力的保质期正好满足要求。 b.选择最便宜的:从候选池中,取出价格最小的那个值(并移除它),它就是分配给第day天的巧克力。 c. 如果某一天,候选池为空,说明无法找到一块巧克力能在这一天吃,问题无解,直接返回失败或特定值。

现在,问题的核心就变成了:我们需要一个数据结构,支持两种操作:

  • insert(val): 向集合中插入一个数值(巧克力价格)。
  • extract_min(): 从集合中取出并移除最小的数值。 并且这两种操作要尽可能快。

这简直就是为优先队列(小顶堆)量身定做的场景!在C++中,我们可以使用std::priority_queue(默认是大顶堆,所以需要传入greater<T>比较器使其成为小顶堆),在Python中可以使用heapq模块。堆的插入和弹出最小值的操作时间复杂度都是O(log N),其中N是候选池的大小,在这里N最大是n(巧克力总数)。因此,整个算法的时间复杂度是O((n + m) log n),空间复杂度O(n),完全可以在题目限制内高效运行。

让我们用C++语言勾勒出核心代码框架:

#include <bits/stdc++.h> using namespace std; typedef long long ll; // 价格和总花费可能很大,用long long int main() { int n, m; cin >> n >> m; // 按保质期分组存储价格 vector<vector<int>> choco_by_deadline(m + 1); for (int i = 0; i < n; ++i) { int price, deadline; cin >> price >> deadline; // 注意:保质期可能大于m,但我们也只需要它能覆盖到m天。 // 所以对于deadline > m的巧克力,可以将其视为deadline = m,因为它对[1, m]天内任何一天都有效。 if (deadline > m) deadline = m; choco_by_deadline[deadline].push_back(price); } priority_queue<int, vector<int>, greater<int>> pq; // 小顶堆,存储候选巧克力价格 ll total_cost = 0; // 从第m天倒着向第1天处理 for (int day = m; day >= 1; --day) { // 1. 将保质期正好是day的巧克力加入候选堆 for (int price : choco_by_deadline[day]) { pq.push(price); } // 2. 如果堆为空,说明没有巧克力可以在第day天吃,问题无解 if (pq.empty()) { cout << -1 << endl; // 根据题目要求返回-1或其他标识 return 0; } // 3. 取出最便宜的巧克力分配给第day天 total_cost += pq.top(); pq.pop(); } cout << total_cost << endl; return 0; }

5. 边界条件与易错点剖析

代码看似简洁,但实际实现和思考时,有几个关键的边界条件和细节必须处理好,否则极易出错。

5.1 保质期大于总天数m的处理题目中只说了保质期b_i天,并没有保证b_i <= m。如果一块巧克力的保质期是100天,而我们的计划只有50天,那么这块巧克力在50天内的任何一天吃都是可以的。在分组时,如果我们简单地把保质期为100的巧克力放到choco_by_deadline[100],而我们的循环只到day=m=50,那么这块巧克力永远也不会被加入堆中,这显然是错误的。正确的处理方法是:将所有保质期b_i > m的巧克力,视作其保质期为m。因为对于问题域[1, m]天来说,它们和保质期等于m的巧克力是等价的。所以,在读取数据时,需要加一个判断:if (deadline > m) deadline = m;

5.2 “每天最多吃一块”与巧克力数量这个条件在我们的贪心算法中自然满足了,因为我们就是严格地为每一天分配一块巧克力。但是,我们需要确保有足够的巧克力来覆盖所有天数。算法的pq.empty()判断就是用于检测这一点。如果某一天候选池为空,意味着即使动用所有保质期满足的巧克力(包括未来的),也无法覆盖这一天,直接判定无解。另一种无解的情况是巧克力总数n < m,但我们的算法也能处理,因为最终会因某一天无法从堆中取出元素而检测到。

5.3 贪心正确性的再思考与严格证明虽然我们之前用交换论证做了说明,但这里可以更形式化地证明一下。定义我们的贪心算法为:对于t = m, m-1, ..., 1,令S_t为所有满足deadline >= t且尚未被分配的巧克力集合,选择S_t中价格最小的巧克力分配给第t天。命题:该贪心算法得到的解是最优解。证明思路(数学归纳法加强)

  • 基础:对于天数区间[k, m](即最后m-k+1天),贪心算法得到的安排是所有可能安排中总花费最小的。当k = m时,显然成立(只考虑最后一天)。
  • 归纳:假设对于天数区间[k+1, m],贪心解G_{k+1}是最优的。现在考虑区间[k, m]。设O[k, m]上的一个最优解。设dO中分配给第k天的巧克力。
    • 情况1:d的价格等于贪心算法在第k天选择的巧克力g的价格。那么用g替换d,得到一个新解O‘,花费不变,且O’[k+1, m]上的部分与O相同。根据归纳假设,G_{k+1}[k+1, m]上的最优解,所以O‘[k, m]上的总花费 >=G_{k}的总花费。
    • 情况2:d的价格大于g。那么我们可以构造一个新解:在第k天用g替换d。因为g的保质期>=k,所以替换可行。替换后,d被释放出来。由于d的保质期>=k,它可以在[k+1, m]中的某一天使用。我们可以调整[k+1, m]的安排(总是选择可用的最便宜巧克力),这个调整不会增加总花费(因为gd便宜,且后续调整是基于贪心最优性)。因此,我们得到了一个花费不高于O且在第k天使用了g的解。归结为情况1。
    • 情况3:d的价格小于g。这与gS_k中价格最小的巧克力矛盾(因为d也在S_k中)。 因此,贪心解G_k也是[k, m]上的最优解。由归纳法,当k=1时,贪心解是整个问题的最优解。

5.4 数据范围与类型选择题目没有明确给出价格和天数的范围,但在算法竞赛中,通常需要预防大数据。总花费可能很大,nm最大可能为10^5级别。因此:

  • 总花费total_cost应使用long long(C++)或int64(Python)来存储,避免溢出。
  • 优先队列(堆)中存储的是价格,价格本身用int通常足够,但弹出和累加时要注意类型提升。

5.5 输入格式与初始化蓝桥杯真题的输入通常是:第一行两个整数n, m,接下来n行,每行两个整数,分别表示巧克力的单价和保质期。我们的代码需要严格按照这个格式读取。另外,choco_by_deadline向量数组的大小应初始化为m+1(索引从1到m),方便直接映射。

6. 从解题到举一反三:这类问题的通用模式

解完这道题,我们不应该只停留在AC的喜悦上,更要提炼出这类问题的通用模式和解法框架。我把它称为“时间截止点上的资源最优匹配”问题。它的特征如下:

  1. 有一系列任务:每个任务有一个固定的执行时间点(或时间段),比如本题中的“第i天必须吃一块巧克力”。
  2. 有一系列资源:每个资源有两个关键属性:一个是“资格属性”(本题中的保质期deadline),表示该资源有资格被用于哪个时间点之前的任务;另一个是“成本属性”(本题中的价格cost)。
  3. 匹配规则:一个资源可以匹配给一个时间点不晚于其“资格属性”的任务。
  4. 目标:为每个任务分配一个资源,使得总成本最小(或总收益最大)。

这类问题的通用解法就是“按时间逆序贪心 + 优先队列维护当前可用资源”

  • 为什么逆序?因为越晚的时间点,可用的资源(满足资格)越少,选择约束越强,优先为约束强的任务分配资源,可以避免廉价资源被过早消耗。
  • 为什么用堆?因为我们需要在动态增加的资源集合中(随着时间逆推,资格条件放宽,可用资源变多),快速取出成本最小(或收益最大)的那一个。

这个模式可以应用到许多变种问题上:

  • 变种1:最大收益问题。比如,有n个工作,每个工作有截止时间d_i和收益p_i,完成一个工作需要1单位时间,问如何安排能在截止时间前完成工作,使得总收益最大。解法:按截止时间从晚到早扫描,用一个最小堆(存储收益)维护当前可选的工作,每天从堆里弹出收益最大的工作来完成。
  • 变种2:多资源问题。每天可以吃多块巧克力?那就变成了每天需要从堆里取出k个最小元素。只要堆的大小足够,逻辑是类似的。
  • 变种3:连续时间段问题。资源不是用在离散时间点,而是占用一个连续时间段。这时可能需要结合线段树等更复杂的数据结构来查询和更新时间区间的可用性。

掌握这个模式,再遇到类似“截止时间”、“最小成本”、“最大收益”的调度或匹配问题时,你就能快速识别并套用这个高效的贪心框架。

7. 测试与验证:构造数据确保代码稳健

写完代码,尤其是贪心算法,必须用各种边界数据测试。以下是我通常会构造的几组测试数据:

  1. 基础功能测试

    输入: 3 3 1 1 2 3 5 2 输出:4 (选择价格1和2的巧克力,分别放第1、3天,第2天用价格5的,总价1+5+2?等等,最优是1+2=3?我们算一下:逆向贪心,day3: 可用{2}, 选2;day2: 可用{5, 2(已用)} -> {5}, 选5;day1: 可用{1,5(已用)} -> {1}, 选1。总价8。这显然不对,因为还有更优解:day1用1,day2用5,day3用2,总价也是8。等等,这个例子所有巧克力都要买?因为m=3,必须每天一块,所以三块都得买。总价就是1+2+5=8。我之前的“输出:4”是错的。) 更正:这个例子最优就是8,因为必须买三块。
  2. 无解测试

    输入: 2 3 1 1 2 1 输出:-1 (只有保质期1天的巧克力,无法覆盖第2、3天)
  3. 保质期超限测试

    输入: 2 2 10 5 // 保质期5天,视为2天 20 1 输出:30 (第2天选10元的,第1天选20元的。注意:虽然10元巧克力保质期长,但第2天必须选一个,选它是最优的)
  4. 价格溢出测试(大数):

    输入: 100000 100000 // 生成10万个数据,价格和保质期都很大,测试long long和算法效率
  5. 贪心策略验证测试(构造一个正向贪心会错的例子):

    输入: 3 3 1 1 // 便宜但短命 100 3 // 昂贵但长命 2 2 // 中等 输出:5 (逆向贪心:day3选100?不对,应该是:day3,可用{100},选100;day2,可用{2, 100(已用)},选2;day1,可用{1, 2(已用), 100(已用)},选1。总价103。这显然贵了。最优解是:day1用1,day2用2,day3用100?总价103。等等,这个例子似乎逆向贪心结果就是最优?我们想要一个逆向贪心优于其他策略的例子。) 让我们设计一个: 输入: 3 3 5 3 // A 3 2 // B 1 1 // C 逆向贪心:day3,可用{A(5)},选5;day2,可用{B(3), A(已用)},选3;day1,可用{C(1), B(已用)},选1。总价9。 正向(价格)贪心:排序C(1), B(3), A(5)。day1用C,day2用B,day3用A。总价也是9。 看来这个例子不够有区分度。我们需要一个“便宜但保质期短”的巧克力,和一个“稍贵但保质期长”的巧克力,以及一个“非常贵但保质期很长”的巧克力。 输入: 4 4 1 1 // C1 2 2 // C2 100 4 // C3 3 3 // C4 逆向贪心:day4: {C3(100)} -> 100; day3: {C4(3), C3(used)} -> 3; day2: {C2(2), C4(used)} -> 2; day1: {C1(1), C2(used)} -> 1。总价106。 正向价格贪心:排序:C1(1), C2(2), C4(3), C3(100)。day1: C1; day2: C2; day3: C4; day4: C3。总价也是106。 似乎在这个模型下,两种贪心结果一样?不,我们回到最初的反例思想:让一个便宜短命的过早占用一个位置,导致后面一个紧张的位置被迫选天价。 输入: 3 3 1 1 // 便宜短命 100 3 // 天价长命 2 2 // 中等 最优解:第1天用1,第2天用2,第3天用100。总价103。 任何贪心都是103。因为第3天只能用100。 我们需要的是:第3天有多个选择,但最便宜的那个被前面的策略错误地提前用掉了。 输入: 4 3 // 注意m=3 1 1 // A 2 2 // B 5 3 // C 10 3 // D 逆向贪心:day3: 可用{C(5), D(10)},选5;day2: 可用{B(2), C(used), D(10)},选2;day1: 可用{A(1), B(used)},选1。总价8。 正向价格贪心:排序A(1), B(2), C(5), D(10)。day1: A; day2: B; day3: 从C和D中选(因为A,B已用,且保质期>=3),选C。总价也是8。 还是不行。关键在于,正向贪心在安排B(2)时,它看到第2天空着,就安排了。但也许把B留给第3天,让一个更便宜的(但保质期只到2)的来覆盖第2天,会更好?但保质期只到2的不能覆盖第3天。所以这个矛盾需要精心设计。 经典反例(来自调度问题): n=3, m=2 工作(价格, 截止时间): (100, 1), (2, 1), (1, 2) 逆向贪心:day2: 可用{(1,2)},选1;day1: 可用{(100,1), (2,1), (1,used)},选最便宜的2。总价3。 正向价格贪心:排序(1,2), (2,1), (100,1)。day1: 选择(1,2)? 不行,它的截止时间是2,不能在第1天做。所以跳过,选下一个(2,1)。day1安排(2,1)。day2: 剩下(1,2)和(100,1),能用于day2的只有(1,2)。总价也是3。 看来在“每天必须做一个”且“资源过期时间必须>=安排时间”的约束下,逆向贪心似乎总是最优?我需要查证一下。实际上,这就是经典的“单位时间任务调度”问题的一个变种(带权、每个任务可在其截止时间前的任意时间完成,每天完成一个),其最优解确实可以通过逆序贪心得到。我最初构造的反例可能不成立,因为那个反例是基于“每个任务需要时间,且可以安排在截止时间前任意连续时间段”的模型,和本题“每个任务瞬间完成,但必须安排在截止时间当天或之前”略有不同。对于本题模型,逆向贪心被证明是正确的。这提醒我们,贪心策略的证明必须严格基于模型本身。
尽管在严格的本题模型下,那个直观的“正向价格贪心”反例可能难以构造,但逆向贪心的正确性是已被证明的。测试时,我们更应关注算法实现本身的正确性:比如保质期大于m的处理、堆为空的无解判断、以及大数据下的性能。通过设计不同规模、不同特征的数据,并手动计算或编写暴力程序(对于小数据)进行对拍,是确保代码正确的唯一可靠方法。

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

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

立即咨询