反悔贪心
前置知识:堆。
反悔贪心,顾名思义,就是在朴素贪心的基础上加上【反悔】操作,做增量更新,以修正答案。
反悔贪心的模板操作可以看前三道例题。
例题
题目 | 备注 |
---|---|
P2949 [USACO09OPEN] Work Scheduling G | 存在非反悔贪心解法,本身也很板子,可以想一想 |
iai617 生存游戏 | 同 P2949 |
CF865D Buy Low Sell High | 反悔贪心模板题 |
CF730I Olympiad in Programming and Sports | 比较明显的反悔贪心,用三个堆实现 |
iai69 火车 | 稍微难想一点的反悔贪心,可以堆/ set / map 实现 |
P1792 [国家集训队] 种树 | 很难想的反悔贪心,需要用到双向链表 |
P3620 [APIO/CTSC2007] 数据备份 | P1792 的兄弟题 |
P2949 [USACO09OPEN] Work Scheduling G
可能第一眼看到要求最值会联想到 dp,但很快就会发现,dp 的状态很难表示,而且有后效性,维度太多复杂度又吃不住,考虑贪心。
这里有两种贪心的方法。
解法 1:反向扫描,纯粹的贪心
把问题变一下:从某个时刻 \(D_i\) 开始能够做一项任务,完成会有 \(P_i\) 的收益。
这样贪心思路就很明确了,每次都挑手上能做的、收益最高的那一个任务即可,不用考虑需不需要给后面任务留时间的问题,因为后面的任务还没有开始。
当前手上收益最高的任务,可以用堆维护。
#include <bits/stdc++.h>
#define ll long long
using namespace std;
const int MAXN=1e5+5;
int n;
ll ans;
struct node {
ll d, p;
} tsk[MAXN];
priority_queue <ll, vector<ll>, less<ll> > q;
int main()
{
// freopen("P2949_3.in", "r", stdin);
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> tsk[i].d >> tsk[i].p;
}
tsk[++n] = {0, 0};
sort(tsk+1, tsk+n+1, [](node a, node b){return (a.d>b.d)||(a.d==b.d&&a.p>b.p);});
q.push(tsk[1].p);
ll j = tsk[1].d;
for (int i = 2; i <= n; ++i) {
j = min(j, tsk[i-1].d);
while ( (!q.empty()) && (j > tsk[i].d) ) {
ans+=q.top(); q.pop(); --j;
}
q.push(tsk[i].p);
}
cout << ans << endl;
}
解法 2:正向扫描,反悔贪心
正向扫描需要解决的最大问题就是:需不需要给后面任务留时间。比如对于这组数据:
4
1 5
2 3
3 7
3 4
最大收益是 \(5+7+4=16\),在这里就是把 \(i=2\) 的那一个任务放弃,把时间留给 \(i=4\) 的那一个任务了。
我们可以利用反悔贪心的思想来考虑这个问题。
也就是说,讲任务按照时间从前往后排序之后,每次碰到一个任务,就先接手开干。然后考虑:
- 如果新遇到的任务和已有的任务时间不冲突,那么就接受。
- 如果冲突了,那么,和手上收益最低的那个任务比较收益谁更大,考虑用新任务替换是不是赚,并且对收益做增量更新。
这里需要维护已有任务的最低收益,可以用堆。
#include <bits/stdc++.h>
#define ll long long
using namespace std;
const int MAXN=1e5+5;
int n;
long long ans;
struct node {
ll d, p;
} tsk[MAXN];
priority_queue <ll, vector<ll>, greater<ll>> q;
int main()
{
// freopen("P2949_3.in", "r", stdin);
cin >> n;
for (int i = 1; i <= n; ++i) { cin >> tsk[i].d >> tsk[i].p; }
sort(tsk+1, tsk+n+1, [](node a, node b){return a.d<b.d;});
for (int i = 1; i <= n; ++i) {
if (tsk[i].d > q.size()) {
ans += tsk[i].p;
q.push(tsk[i].p);
} else if (tsk[i].p > q.top()) {
ans += tsk[i].p-q.top();
q.pop();
q.push(tsk[i].p);
}
}
cout << ans << endl; return 0;
}
iai617 生存游戏
同 P2949 [USACO09OPEN] Work Scheduling G
#include <bits/stdc++.h>
using namespace std;
const int MAXN=2e5+5;
int n, d, ans;
long long c, x[MAXN], a[MAXN];
priority_queue <long long> q;
int main()
{
cin >> n >> c >> d;
for (int i = 1; i <= n; ++i) { cin >> x[i] >> a[i]; }
x[++n] = d;
for (int i = 1; i <= n; ++i) {
c -= x[i]-x[i-1];
if (c < 0) {
while (!q.empty() && c < 0) { c += q.top(); q.pop(); ++ans; }
if (c < 0) { cout << "Impossible" << endl; return 0; }
}
q.push(a[i]);
}
cout << ans << endl;
return 0;
}
CF865D Buy Low Sell High
反悔贪心模板题。
假设现在是第 \(i\) 天,对于前 \(i-1\) 天【尚未购买】的股票,将其加入【代购清单】。
当前的最优策略是:从代购清单中选择价格最小的那一天买入,并在第 \(i\) 天卖出。
但局部的最优解不一定是全局最优解,比如下面这组数据就可以卡掉普通贪心:
4
1 2 100 100
但我们可以发现的是,虽然题目说一天只能进行一次操作,但在一天内买入和卖出的操作是可以完美抵消的,也就是【做了跟没做一样】。
所以,我们可以【先买着】,等发现更优的情况再【反悔】。
具体而言,如果现在是第 \(i\) 天,我在第 \(j\ (j<i)\) 天贪心地选择买入,而在第 \(i\) 天卖出。但我只将第 \(j\) 天的价格移出代购清单,而在代购清单中加入两遍第 \(i\) 天的价格。
这样一来,如果我选择反悔,那么只需要在第 \(i\) 天再卖出,一买一卖完美抵消,对答案没有影响。
如果我在后续第 \(k\ (i<k)\) 天卖出第 \(i\) 天的股票,由于代购清单中加入了两遍第 \(i\) 天的价格,同样不影响答案。
比如,对于上述的数据:
- 在第一天无法卖出,将
1
加入代购清单。- 【此时的代购清单:
1
,收益为0
元。】
- 【此时的代购清单:
- 第二天的价格大于第一天,所以先买着,将
1
移出代购清单,再往里面加入两个2
。- 【此时的代购清单为:
2, 2
,收益为1
元,即第一天买入,第二天卖出。】
- 【此时的代购清单为:
- 第三天的价格大于第二天,也大于第一天。此时可以反悔上一步的操作,将一个
2
移出清单,收益加上100-2
,将两个100
加入清单。- 【此时代购清单:
2, 100, 100
,收益为99
元,即第一天买入,第三天卖出。】
- 【此时代购清单:
- 第四天价格大于第二天,可以在第二天买入,第四天卖出。(如果选择反悔或在第三天买入,那么额外收益为 0)
- 【此时的代购清单:
100, 100, 100, 100
,收益为197
元,即第一、二天买入,第三、四天卖出。】
- 【此时的代购清单:
- 最终答案即为
197
元。
#include <bits/stdc++.h>
#define ll long long
using namespace std;
int n;
long long p, ans;
priority_queue <ll, vector<ll>, greater<ll> > q;
int main()
{
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> p;
if ( (!q.empty()) && (p > q.top())) { ans += p-q.top(); q.pop(); q.push(p); }
q.push(p);
}
cout << ans << endl;
return 0;
}
CF730I Olympiad in Programming and Sports
先把编程能力最大的 \(p\) 个人丢到编程团队中,然后通过反悔贪心修改选择。
有两种情况:
- 有一个新人,加入了体育团队;
- 有一个原来在编程团队中的人,加入了体育团队,然后从新人中挑了一个加入编程团队。
因此需要维护 3 个堆(下文用 \(q.top().x\) 表示堆顶表示的人的能力值):
- 用 \(q_1\)、\(q_2\) 分别维护 【新人】 的编程能力 \(a_i\) 和体育能力 \(b_i\);
- 用 \(q_3\) 维护 【已经加入编程团队的人】 通过反悔加入体育团队后对答案的修改量 \(b_i-a_i\)。
在每次选择开始前,先确定堆中的人员是否有变动,比如 \(q_1\)、\(q_2\) 中的人有没有加入任一团队、\(q_3\) 中的人有没有反悔加入体育团队。
比较上文所说两种选择对应对答案的贡献 【 \(q_2.top().x\) 】 和 【 \(q1.top().x+q3.top().x\) 】,取较大者计入答案后更改人员所属队伍即可。
#include <bits/stdc++.h>
using namespace std;
const int MAXN=3005;
int n, p, s, a[MAXN], b[MAXN], team[MAXN], ans;
struct node {
int x, id;
bool operator < (const node &a) const
{
return x < a.x;
}
};
priority_queue <node> q1, q2, q3;
int main()
{
// freopen("CF730I_39.in", "r", stdin);
cin >> n >> p >> s;
for (int i = 1; i <= n; ++i) { cin >> a[i]; q1.push( {a[i],i} ); }
// 将编程能力最强的人先加入队伍
for (int i = 1; i <= p; ++i) {
ans += q1.top().x;
team[q1.top().id] = 1;
q1.pop();
}
// 一开始被选上的人加入反悔堆q3,未被选上加入体育组待选堆q2
for (int i = 1; i <= n; ++i) {
cin >> b[i];
if (team[i]) { q3.push( {b[i]-a[i],i} ); }
else { q2.push( {b[i],i}); }
}
// 选择体育组
for (int i = 1; i <= s; ++i) {
while (team[q1.top().id]) { q1.pop(); }
while (team[q3.top().id] == 2) { q3.pop(); }
while (team[q2.top().id]) { q2.pop(); }
if (q2.top().x > q3.top().x+q1.top().x) {
// 选择新人加入体育组
ans += q2.top().x;
team[q2.top().id] = 2;
q2.pop();
} else {
// 反悔,把编程组的人拉到体育组来
ans += q3.top().x + q1.top().x;
team[q3.top().id] = 2; q3.pop();
team[q1.top().id] = 1;
q3.push( {b[q1.top().id]-a[q1.top().id],q1.top().id} ); q1.pop();
}
}
// 输出
cout << ans << endl;
for (int i = 1; i <= n; ++i) { if (team[i]==1) { cout << i << " "; } }
cout << endl;
for (int i = 1; i <= n; ++i) { if (team[i]==2) { cout << i << " "; } }
return 0;
}
iai69 火车
有一个比较显然的贪心思路,就是目的地较近的乘客,优于目的地较远的乘客。
如果 \(x\) 名乘客在 \(i\) 下车,又有 \(x\) 名乘客在 \(j\ (i<j)\) 下车,当这 \(x\) 名乘客在 \(i\) 下车了之后,就可以在 \(i\) 处留出 \(x\) 个空位以供选择。
- 如果这 \(x\) 个位置接着留空,结果其实选 \(i\) 和选 \(j\) 是一样的;
- 如果这 \(x\) 个位置上有人在到达 \(j\) 以前有人到达了目的地,那么选 \(i\) 就赚了。
所以说,空位必然优先留给目的地较近的乘客。
考虑反悔贪心。
每到一站,先让目的地在这里的乘客下车,计入答案。
接下来我们可以假想,把车上剩余的未到其目的地的乘客都【赶】下车,再重新选择。
对于尚未到达目的地的乘客,无论在哪一站被【赶】下车,其结果都是没有计入答案,等价于一开始没有选择。
- 如果此时乘客数量没有超过上限,就把所有乘客再拉上车;
- 如果此时乘客数量超过上限了,就把目的地最远的那一批乘客赶下车。
实现方面,可以开一个堆记录当前车上的乘客的目的地,做懒删除。
另外的写法是用 map
替代 priority_queue
,既可以求出最远的车站,又方便修改到达终点站下车的那些乘客。
以下给出一种基于堆的实现。
#include <bits/stdc++.h>
using namespace std;
const int MAXN=1e5+5;
int n, s, t, c;
long long cnt[MAXN], sum, ans;
bool pushed[MAXN];
priority_queue <int> q;
int main()
{
cin >> n >> s;
for (int i = 1; i < n; ++i) {
cin >> t >> c;
// 下车
sum -= cnt[i];
ans += cnt[i];
cnt[i] = 0;
// 上车
if (!pushed[t]) { q.push(t); pushed[t]=true; }
cnt[t] += c;
sum += c;
// 反悔,把最远的乘客赶下车
while (sum > s) {
while (!cnt[q.top()]) { pushed[q.top()]=false; q.pop(); }
long long d = min(sum-s, cnt[q.top()]);
sum -= d;
cnt[q.top()] -= d;
if (cnt[q.top()] == 0) { pushed[q.top()]=false; q.pop(); }
}
}
cout << ans+sum << endl;
}
P1792 [国家集训队] 种树
为了方便表述,这里用 \(pre[i]\) 表示 \(i\) 的上一棵树、\(nxt[i]\) 表示 \(i\) 的下一棵树。
看到这道题,第一眼想到的是 dp,和打家劫舍很像,但这道题额外限制了树的数量,需要 dp 方程额外增加一维,复杂度直接爆炸。
考虑贪心。
首先,每次都选择美观度最大的那一棵树,可想而知是错误的,比如下面这组数据:
5 2
-1 2 3 2 -1
最优选择是 2+2=4
。
在这个例子中,我们用 3
左右的两棵美观度为 2
的树将其替换掉了。
可想而知:由于每次都选择了美观度最大的那棵树 \(i\),那么【只用】它左边的树 \(pre[i]\)、或【只用】它右边的树 \(nxt[i]\) 进行替换,结果都不可能是最优。
换句话说,如果一棵树 \(i\) 的美观度比它左边的树 \(pre[i]\) 大、也比它右边的树 \(nxt[i]\) 大,在左右两棵树至少有一棵不能选的情况下,\(i\) 这棵树必然在待选清单中。
【因此,如果每次贪心地种下一棵美观度最高的树,结果只有两种:要么这棵树在最终的选择中,要么这棵树被同时替换为了左右两边两棵树。】
考虑反悔贪心,每次都贪心地选择美观度最大的那一棵树 \(i\),暂时将其加入待选清单。
接下来我们要做的事情就是,明确一种方式进行反悔,【将选择的树 \(i\) 从待选清单中移除,并将其左右两棵树 \(pre[i]\) 和 \(nxt[i]\) 加入待选清单】。
这里可以做增量更新。
【每当暂时选择了 \(i\) 时,将 \(i\)、\(pre[i]\)、\(nxt[i]\) 三棵树合在一起,放在 \(i\) 这棵树的位置,美观度为 \(A_{pre[i]}+A_{nxt[i]}-A_i\) 。】
这样一来,如果我再次选择了 \(i\) 位置上的树,两次选择的美观度之和为 \(A_i+A_{pre[i]}+A_{nxt[i]}-A_i=A_{pre[i]}+A_{nxt[i]}\)。这两次选择的结果也就等价于:【没有选择 \(i\),而是选择了 \(pre[i]\) 和 \(nxt[i]\) 】。
就结果而言,既然现在选择了 \(pre[i]\) 和 \(nxt[i]\),那么 \(pre[pre[i]]\)、\(nxt[nxt[i]]\)、\(i\) 这三棵树是不能选择的。
由于在上一步中,我们将 \(pre[i]\)、\(i\)、\(nxt[i]\) 三棵树合成了一棵新的树 \(j\),因此撇去上述三棵不能选择的树、并且保留反悔机会的操作,也就等价于:【对于这棵新的树 \(j\),将 \(pre[j]\)、\(j\)、\(nxt[j]\) 三棵树合在一起,美观度为 \(A_{pre[j]}+A_{nxt[j]}-A_j\) 】。
这样一来,反悔贪心的思路就理清了:
- 选择一棵美观度最大的树 \(i\),将 \(A_i\) 计入答案,计数器+1;
- 将 \(pre[i]\) 和 \(nxt[i]\) 标记为不可选择;
- 在 \(i\) 的位置上放一棵假想的树 \(j\),下列所有的更改,都在 \(i\) 的位置上进行:
- 将 \(j\) 的美观度 \(A_j\) 更新为 \(A_{pre[i]}+A_{nxt[i]}-A_i\);
- 更新双向链表中的 \(pre[j]\)、\(nxt[j]\)、\(pre[nxt[j]]\)、\(nxt[pre[j]]\) 四项;
- 重复上述三步,直到计数器达到了规定的数量 \(m\),输出答案。
#include <bits/stdc++.h>
using namespace std;
const int MAXN=2e5+5;
int n, m, a[MAXN], ans, pre[MAXN], nxt[MAXN];
bool fbd[MAXN];
struct node {
int id, a;
bool operator < (const node &x) const
{
return a < x.a;
}
};
priority_queue <node> q;
void del(int id)
{
fbd[pre[id]] = fbd[nxt[id]] = true;
pre[id] = pre[pre[id]];
nxt[id] = nxt[nxt[id]];
nxt[pre[id]] = id;
pre[nxt[id]] = id;
}
int main()
{
// freopen("P1792_1.in", "r", stdin);
cin >> n >> m;
if (n < 2*m) { cout << "Error!" << endl; return 0; }
for (int i = 1; i <= n; ++i) {
cin >> a[i];
q.push( (node){i,a[i]} );
pre[i] = (i==1? n: i-1);
nxt[i] = (i==n? 1: i+1);
}
for (int i = 1; i <= m; ++i) {
while (fbd[q.top().id]) { q.pop(); }
node c = q.top(); q.pop();
ans += c.a;
a[c.id] = a[pre[c.id]] + a[nxt[c.id]] - a[c.id];
q.push( (node){c.id, a[c.id]} );
del(c.id);
}
cout << ans << endl;
}
P3620 [APIO/CTSC2007] 数据备份
显然,相邻办公楼之间相连,必然优于比非相邻办公楼相连。
类似差分,记录下所有 \(d_i=x_i-x_{i-1}\) 的值,然后做法同 P1792。
需要注意的是,由于要求的是最小值,并且不在办公楼不在环上,所以需要设 \(d_1=d_{n+1}=+\infty\),保证不会选择使用 \(d_1+d_3\) 替换 \(d_2\),或者用 \(d_{n-1}+d_{n+1}\) 替换 \(d_n\)。
#include <bits/stdc++.h>
#define ll long long
using namespace std;
const int MAXN=1e5+5;
int n, k, pre[MAXN], nxt[MAXN];
ll x[MAXN], val[MAXN], ans;
bool vis[MAXN];
struct node {
int id;
long long val;
bool operator < (const node &x) const
{
return val > x.val;
}
};
priority_queue <node> q;
void del(int id)
{
vis[pre[id]] = vis[nxt[id]] = true;
pre[id] = pre[pre[id]];
nxt[id] = nxt[nxt[id]];
pre[nxt[id]] = id;
nxt[pre[id]] = id;
}
int main()
{
// freopen("P3620_2.in", "r", stdin);
cin >> n >> k;
for (int i = 1; i <= n; ++i) { cin >> x[i]; }
for (int i = 2; i <= n; ++i) {
pre[i] = i-1;
nxt[i] = i+1;
val[i] = x[i] - x[i-1];
q.push( (node){i, val[i]} );
}
val[1] = val[n+1] = 0x3f3f3f3f;
for (int i = 1; i <= k; ++i) {
while (vis[q.top().id]) { q.pop(); }
node c = q.top(); q.pop();
ans += c.val;
val[c.id] = val[pre[c.id]] + val[nxt[c.id]] - val[c.id];
q.push( (node){c.id,val[c.id]} );
del(c.id);
}
cout << ans << endl; return 0;
}