反悔贪心:先选,再撤销最差的选择

1 做出的选择,能不能改?

在 贪心说明 中,我们通常寻找一种可以直接确定下来的选择:一旦选了,就不再回头。但有些问题里,新来的候选会让以前的选择显得不划算。

例如,你先答应了一个耗时很长的任务,后来又来了几个短任务。若以前的任务不能撤销,可能只能完成一个;若允许重新制定计划,就可能完成更多。

这里的“反悔”发生在计算方案时,不是执行到一半再倒流时间。我们维护的是一份可以修改的候选方案,最后才按题目的规则执行它。

定义反悔贪心

扫描候选时先尝试加入;若加入后破坏约束,就从当前候选方案中撤销一个最不划算的选择,恢复可行性。

核心不是“用了一个堆”,而是:为什么撤销这个选择,不会损失最优答案? 堆只负责快速找到它。

本文先讲任务调度,再迁移到喝药水。需要会使用堆;不熟悉 priority_queue 时,可以先读 堆。

2 主模型:最多完成多少个任务

有 nn 个任务。任务 ii 耗时 ti>0t_i>0,必须在截止时间 di≥0d_i\ge0 之前或恰好在该时刻完成。

  • 从时刻 00 开始,一次只能做一个任务;
  • 所有任务一开始就已知,可以选择其中一部分并安排顺序;
  • 一个任务必须完整完成,不能拆开;
  • 每完成一个任务,收益都为 11,目标是完成的数量最多。

注意:任务只有“耗时和截止时间”,没有固定的开始时刻。这和 最大不相交区间数量:优先选择最早结束的 中已经确定了起止时刻的比赛,是两种不同的模型。

2.1 策略一:先做耗时最短的?

考虑两个任务:A=(t=2,d=100)A=(t=2,d=100),B=(t=3,d=3)B=(t=3,d=3)。

先做较短的 AA,再做 BB,完成时刻是 55,BB 已经过期,只能完成一个。若先做 BB 再做 AA,两者都能完成。

耗时少很重要,但不能忽略谁更早到期。

2.2 先解决顺序:按截止时间排序

如果一个可行方案中,相邻的两个任务 x,yx,y 满足 dx>dyd_x>d_y,原顺序却是先 xx 后 yy,可以交换它们。

设这两个任务开始前,已经花了 TT 时间。原方案可行,说明

T+tx+ty≤dy. T+t_x+t_y\le d_y.

换成先 yy 后 xx 后,yy 更早完成;xx 的完成时刻仍是 T+tx+ty≤dy<dxT+t_x+t_y\le d_y<d_x。后面的任务也不受影响。

不断消除这样的逆序,就得到截止时间非降序的方案。因此只需在按截止时间排序的序列里,决定每个任务选不选。 截止时间相同的任务,先后顺序任意。

2.3 策略二:按截止时间扫描,能放下就选,放不下就跳过?

只看三个任务:

任务 耗时 tit_i 截止时间 did_i
A 6 7
B 3 8
C 4 9

按截止时间扫描,先选 AA,总耗时变成 66。加入 BB 要花 9>89>8,跳过;加入 CC 要花 10>910>9,也跳过。最后只选了一个。

但撤销 AA、改选 B,CB,C,总耗时只有 77,能完成两个。

问题出在:“已经选了 A”被当成了不能修改的事实。 实际上题目只要求输出最终方案,完全允许在计算时把 A 换掉。

3 超时了,撤销谁?

设加入新任务之前,已选任务的总耗时是 TT。把当前任务 ii 也加入后:

  • 若 T+ti≤diT+t_i\le d_i,暂时全部保留;
  • 若 T+ti>diT+t_i>d_i,当前候选方案无法全部完成,必须舍弃一些任务。

所有任务对答案的贡献都是一个。若都只删一个,删掉耗时最长的任务,释放的时间最多,也就给后面的任务留下最多的时间。

这里必须把新任务也放进候选集合:最长的可能是旧任务,也可能恰好是新任务自己。

算法先加入,再删除最长任务

按截止时间非降序扫描任务,维护总耗时 total 和已选任务耗时组成的大根堆。

  1. 将当前任务加入堆,total += duration。
  2. 如果 total > deadline,减去堆顶耗时并弹出堆顶。
  3. 扫描结束后,堆内任务的数量就是答案。

“释放的时间最多”解释了该考虑谁,但还没有证明算法正确。先把它实际运行一遍,再补上证明。

4 动手观察:新任务加入后,谁离开了?

在前面三个任务后,再加上 D=(2,10)D=(2,10)、E=(9,11)E=(9,11):

当前任务 尝试加入后的总耗时 如何处理 本轮结束时的已选任务 恢复后的总耗时
A 6 没有超时,保留 A 6
B 9 9>89>8,撤销耗时 6 的 A B 3
C 7 没有超时,保留 B、C 7
D 9 没有超时,保留 B、C、D 9
E 18 18>1118>11,撤销耗时 9 的 E B、C、D 9

交互动画加载中...

演示中,“尝试加入”与“保留或撤销”分成两帧。出现超时的那一帧只是临时候选,不能当作合法方案。 注意 B 到来时撤销的是旧任务 A,而 E 到来时撤销的是 E 自己。

最后执行顺序是 B、C、D,完成时刻依次为 3,7,93,7,9,都没有超过各自的截止时间。

5 为什么只撤销一次就够?

加入任务 ii 之前,旧方案可行。旧任务的截止时间都不晚于 did_i,因此它们的总耗时满足 T≤diT\le d_i。

设这次弹出的最长耗时为 LL。新任务也在堆中,所以 L≥tiL\ge t_i。弹出后:

T′=T+ti−L≤T≤di. T'=T+t_i-L\le T\le d_i.

更早的约束也不会被破坏:若删的是旧任务,所有受影响的旧任务只会更早完成;若删的是新任务,就回到了旧方案。

因此每轮最多删除一个,if 就够。这里能写 if 是由这个不等式保证的,不是所有反悔贪心都只需撤销一次。

6 为什么撤销最长的,不会错过最优解?

仅仅说明“删掉后时间更少”,还不能排除一种疑问:最长任务的截止时间可能很早,换掉它,会不会影响后面某种更好的组合?

我们证明更直接的一件事:第一次出现超时的时候,删掉这个最长任务,至少还留得住一个最优方案。

6.1 第一个冲突前缀

暂时把没有被删除的任务都看作“仍可供最终答案选择”的候选,包括尚未扫描到的任务,并按截止时间排列。

设它的第一个超时前缀结束在任务 ii。这意味着:

  • 对每个更早的前缀 h<ih<i,这个前缀里的全部候选任务都能按时完成;
  • 到 ii 为止的候选任务全部做完,总耗时超过了 did_i。

记这个冲突前缀为 PP,其中耗时最长的任务为 mm。

6.2 把一个最优方案变成“不选 m”的最优方案

证明安全删除的交换论证

在当前尚未被删除的候选任务中,取一个完成数量最多的可行方案 OO。

如果它本来就不选 mm,直接删除 mm 不会影响这个最优方案。

否则,OO 选了 mm。由于 PP 里的任务全部做完会超时,OO 必须漏选 PP 中的某个任务 qq。用 qq 替换 mm,得到同样多任务的方案 O′O'。

需要检查的不是“总耗时减少”这一件事,而是每个截止时间约束:

  • 对 ii 之前的前缀:O′O' 只选了这个前缀全部候选任务的一部分。任务耗时为正,而第一个冲突还没发生,所以这部分任务的总耗时一定不超过该前缀的截止时间。
  • 对 ii 以及之后的前缀:m,qm,q 都位于 PP 中,因此替换让这些前缀的耗时统一改变了 tq−tm≤0t_q-t_m\le0。原来 OO 能按时完成,替换后仍能完成。

所以 O′O' 可行、任务数量不变,而且不含 mm。这说明:删除 mm 后,最优答案不会减少。

6.3 为什么这个证明对应一趟扫描?

算法扫描到超时任务时,堆里正好是当前第一个冲突前缀的全部候选。删除最长任务后,前面证明过:更早前缀仍然可行,当前前缀也恢复可行。

于是继续向后扫描,就是去寻找下一个冲突前缀。每一次删除都不降低尚存候选中的最优答案;扫描结束后,所有剩余任务已经能全部按时完成。因此剩余任务数就是原问题的最优答案。

说明“只留最好的 k 个”不是完整证明

不能只说“堆里一直是最好的几个任务”。“最好”指什么?截止时间如何保证?删掉的任务为什么不用重新拿回来?

安全删除的证明回答了最后一个问题:每次删除之后,仍有一个最优方案完全由剩下的任务组成,所以没有必要把已经删除的任务重新加入。

7 对应真题:洛谷 P4053 建筑抢修

题目:luogu P4053: [JSOI2007] 建筑抢修。

只有一位工人,修复建筑 ii 需要 T1T_1 秒,必须在 T2T_2 秒之内完全修好。求最多能抢修多少座建筑。它与前面的任务模型直接对应:耗时是 T1T_1,截止时间是 T2T_2。

输入第一行是 NN,随后 NN 行每行两个整数 T1,T2T_1,T_2;输出最多能抢修的建筑数。数据范围为 1≤N<1500001\le N<150000、1≤T1<T2<2311\le T_1<T_2<2^{31}。

输入:
4
100 200
200 1300
1000 1250
2000 3200

输出:
3

按截止时间排序后,依次为 (100,200),(1000,1250),(200,1300),(2000,3200)(100,200),(1000,1250),(200,1300),(2000,3200)。最后尝试加入耗时 2000 的建筑,总耗时为 3300,超过 3200;删除这座建筑,留下三座。

7.1 完整 C++17 实现

#include <algorithm>
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

struct Task {
    long long duration, deadline;
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    if (!(cin >> n)) return 0;
    vector<Task> tasks(n);
    for (auto &task : tasks) cin >> task.duration >> task.deadline;
    sort(tasks.begin(), tasks.end(), [](const Task &a, const Task &b) {
        return a.deadline < b.deadline;
    });

    priority_queue<long long> chosen; // 大根堆:耗时最长的已选任务
    long long total = 0;
    for (const auto &task : tasks) {
        chosen.push(task.duration);
        total += task.duration;
        if (total > task.deadline) {
            total -= chosen.top();
            chosen.pop(); // 可能撤销旧任务,也可能撤销刚加入的任务
        }
    }
    cout << chosen.size() << '\n';
}

每个任务入堆一次,最多出堆一次,加上排序,总时间为 O(nlog⁡n)O(n\log n),空间为 O(n)O(n)。

total 使用 long long:尝试加入的临时总耗时也要正确计算,不能只考虑最终可行方案的数值范围。

注意堆的顺序不是任务的执行顺序

大根堆只保证堆顶是耗时最大的任务,不保证其它元素有序。若题目要求输出方案,要在堆中同时保存任务编号,再把最终保留的任务按截止时间排列,不能直接按弹堆顺序执行。

8 迁移:按顺序喝药水

题目:codeforces 1526C1: Potions (Easy Version)。

有一列药水,第 ii 瓶会使血量改变 aia_i,可能加血,也可能扣血。初始血量为 00,必须从左向右考虑每一瓶,可以喝,也可以跳过,但任意时刻血量都不能小于零。求最多能喝多少瓶。

输入第一行是 nn,第二行是 nn 个整数 aia_i;输出最多能喝的瓶数。本题 1≤n≤20001\le n\le2000,−109≤ai≤109-10^9\le a_i\le10^9。

这里不能按药水数值排序。先遇到的负药水,不能使用后面才出现的正药水来“提前补血”。例如 [−1,1][-1,1] 按原顺序最多喝一瓶;若排成 [1,−1][1,-1],算出的两瓶不符合原题顺序。

8.1 同一个反悔动作,换一个撤销标准

从左到右扫描,先尝试喝下当前药水,并把它加入已选集合。

若血量变成负数,就撤销已选集合中数值最小的药水,也就是扣血最多的那一瓶。应该使用小根堆。

当前药水 尝试后的血量 处理 本轮结束时已选下标 恢复后的血量
a1=4a_1=4 4 保留 1 4
a2=−4a_2=-4 0 保留 1、2 0
a3=1a_3=1 1 保留 1、2、3 1
a4=−3a_4=-3 -2 撤销第 2 瓶的 −4-4 1、3、4 2
a5=1a_5=1 3 保留 1、3、4、5 3
a6=−3a_6=-3 0 保留 1、3、4、5、6 0

这也是原题样例,答案为 55。最终按原顺序喝第 1,3,4,5,61,3,4,5,6 瓶,血量依次为 4,5,2,3,04,5,2,3,0,始终非负。

8.2 为什么只需撤销一次,并且不会破坏以前的血量?

设加入前的血量为 H≥0H\ge0,当前药水为 aia_i,堆中最小值为 xx。

若 H+ai<0H+a_i<0,则当前药水一定为负数,而 x≤ai<0x\le a_i<0。撤销它之后:

H′=H+ai−x≥H≥0. H'=H+a_i-x\ge H\ge0.

若撤销的是以前喝过的一瓶负药水,从那一瓶开始的历史血量都会增加 −x-x,所以以前的非负约束不会被破坏;若撤销的是当前药水,则回到原状态。

8.3 为什么最大瓶数也能保证?把它转回任务模型

所有非负药水都可以喝:它们不会让任何位置的血量变差,而且每喝一瓶就增加一个计数。因此至少存在一个最优方案,喝下全部非负药水。

现在只需决定哪些负药水值得喝。对原序列中的每个位置 ii,定义已经出现的非负药水提供的总血量:

Bi=∑j≤imax⁡(aj,0). B_i=\sum_{j\le i}\max(a_j,0).

一瓶负药水的“消耗”是 ci=−ai>0c_i=-a_i>0。在每个位置都不掉到负血量,等价于:

∑j≤ij 是已选负药水cj≤Bi. \sum_{\substack{j\le i\\j\text{ 是已选负药水}}}c_j\le B_i.

这就是前面的任务模型:消耗 cic_i 对应耗时,预算 BiB_i 对应截止时间。而 BiB_i 随扫描只增不减,原顺序已经是预算的非降序,完全不必给药水重新排序。

目标是选择尽可能多的负药水;加上固定会喝的全部非负药水,就是最多的总瓶数。预算不够时删除消耗最大的负药水,对应的正是删除最小的 aia_i。前面“删除最长任务不降低最优答案”的证明,可以直接应用。

预算 BiB_i 可能为零,这只表示前面还没有可用的血量,此时负药水必定不能保留。

8.4 完整 C++17 实现

#include <functional>
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    if (!(cin >> n)) return 0;
    priority_queue<long long, vector<long long>, greater<long long>> chosen;
    long long health = 0;
    for (int i = 0; i < n; ++i) {
        long long effect;
        cin >> effect;
        chosen.push(effect);
        health += effect;
        if (health < 0) {
            health -= chosen.top(); // 堆顶为最小的负数,减去它就是恢复血量
            chosen.pop();
        }
    }
    cout << chosen.size() << '\n';
}

代码把所有已选药水都放进堆,写法更统一。触发反悔时堆顶一定为负数,所以不会误删正药水或零药水。

时间复杂度为 O(nlog⁡n)O(n\log n),空间为 O(n)O(n);血量累计使用 long long。

9 两道题放在一起看

问题 任务调度 喝药水
扫描顺序 截止时间非降序 原序列顺序,不能排序
希望增加什么 完成的任务数 喝下的药水数
可行性约束 累计耗时不超过截止时间 当前血量不为负
反悔时撤销谁 耗时最大的任务 数值最小、扣血最多的药水
使用什么堆 大根堆 小根堆
为什么一次就够 被删耗时不小于新任务耗时 被删数值不大于新药水数值

它们的共同结构是:当前方案装不下所有候选时,删掉一个,保住尽可能多的选择,并把资源留给后面。

10 什么时候可以考虑反悔贪心?

看到下面的信号时,可以尝试这条思路:

  1. 候选是逐个处理的,而处理顺序能让约束逐步检查,例如按截止时间或按原下标。
  2. 先加入一个候选,可能让当前方案出现冲突。
  3. 以前的选择仍可在计算方案时替换,题目没有要求立即且不可撤回地执行。
  4. 可以比较“撤销谁”对后续资源的影响,并证明一次交换不会损失最优答案。

但这些只是找解法的线索,不是保证正确的判定条件。

注意三个不能照搬的改动

任务有不同收益:每次删一个都会损失不同收益,“删最长”可能错误。例如三个截止时间都为 3 的任务:一个耗时 3、收益 100,另外两个耗时各为 1、收益各为 1。按本文规则会删掉长任务,只得到收益 2,而最优收益为 100。

任务有不同的可开始时刻:按截止时间排序的交换证明不一定成立,不能继续套用本文模型。

药水可以重排:这改变了原题;把正药水全部搬到前面,会借用尚未出现的资源,得到不合法的答案。

11 自查:能否脱离“堆模板”解释清楚?

先独立完成 luogu P4053: [JSOI2007] 建筑抢修,再用同样的分析步骤完成 codeforces 1526C1: Potions。写代码前,试着回答:

  • 为什么任务可以按截止时间排序,药水却不能按数值排序?
  • “恢复当前可行”与“保证最终最优”,分别用了哪一段证明?
  • 为什么每次先入堆再判断,堆顶有时是旧选择,有时是新选择?
  • 为什么删掉的选择不必再放回来?
  • 如果任务的收益不再相等,证明在哪一步失效?

能说清这些问题,才算掌握了反悔贪心,而不仅是记住了 priority_queue。

12 相关题解

暂无相关题解