[笔记] 贪心 - 3/3(反悔贪心)
我们知道,贪心从来都是只顾现在,不太考虑将来的算法,然而,这种鼠目寸光的策略,在很多题目中并不成立,因为贪心算法只会紧紧抱住眼前的利益不放,而忽视了将来可能有更大的利益在等着它。
于是反悔贪心这一种特殊的贪心就出现了,它同样是贪心,但比普通的贪心更灵活,它学会了在合适的时机放下曾经拿起的东西,从而获得更大的利益。
当然,反悔贪心的本质还是贪心,所以反悔策略不是万能的,也会有考虑不全的情况,这时就得放弃贪心转而考虑其他算法。
P14361 [CSP-S 2025] 社团招新
我们首先要尽可能选每个人满意度最大的部门,这样可能会导致爆满。
但观察题目可以发现,一共有三个部门,但每个部门的上限都是 \(\frac{n}{2}\),也就是说最多有一个部门会满。
所以我们考虑反悔,把满了的部门中的一些人换到他们的满意值次大的部门,为了使得答案的变化更小一点,我们肯定要找最大 \(-\) 次大最小的。
大体思路就是这样。实现上可以用排序,也可以用堆动态维护。
p14361
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<queue>
#define int long long
using namespace std;
const int N=1e5+10;
struct Node1{
int w,id;
int operator<(Node1 y)const{return w>y.w;}
};
struct Node2{
int w1,w2,id;
int operator<(Node2 y)const{return w1-w2>y.w1-y.w2;}
};
vector<Node1> a[N];
priority_queue<Node2> q[3];
int n, ans;
void fastio(){
ios::sync_with_stdio(0);
cin.tie(0); cout.tie(0);
}
inline void Init(){
ans=0;
for(int i=1; i<=n; i++) a[i].clear();
for(int i=0; i<3; i++)
while(!q[i].empty()) q[i].pop();
}
inline void solve(){
cin>>n;
Init();
int A,B,C;
for(int i=1; i<=n; i++){
cin>>A>>B>>C;
a[i].emplace_back(Node1{A,0});
a[i].emplace_back(Node1{B,1});
a[i].emplace_back(Node1{C,2});
}
for(int i=1; i<=n; i++) stable_sort(a[i].begin(), a[i].end());
int W1,W2,ID;
Node1 fst,scd,trd;
for(int i=1; i<=n; i++){
fst=a[i][0]; scd=a[i][1]; trd=a[i][2];
if(q[fst.id].size()<(n>>1)){
q[fst.id].push(Node2{fst.w,scd.w, scd.id});
continue;
}
W1=q[fst.id].top().w1, W2=q[fst.id].top().w2;
ID=q[fst.id].top().id;
if(W1-W2<fst.w-scd.w){
q[fst.id].pop();
q[fst.id].push({fst.w, scd.w, scd.id});
q[ID].push({W2, 0, 0});
}
else q[scd.id].push({scd.w, trd.w,trd.id});
}
for(int i=0; i<3; i++)
while(!q[i].empty()){
ans+=q[i].top().w1; q[i].pop();
}
cout<<ans<<"\n";
}
signed main(){
fastio();
int t;
cin>>t;
while(t--) solve();
return 0;
}
P2107 小 Z 的 AK 计划
题意很简单,我们先看一下绝大多数题解是怎么讲这个题的:
- 考虑反悔贪心。我们尽可能 AK 所有路过的机房,如果 AK 当前机房后时间不够用,则从所有 AK 过的机房中取耗时最长的删掉,直到时间够用或者没有机房为止。 ——引用自 Sinktank的题解
非常简洁明了的把思路和代码的大体框架讲明白了,代码大致如下(先别写):
常规写法
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+10;
struct Node{
int x,t;
}a[N];
int n,m,ans;
bool cmp(Node x, Node y){
return x.x<y.x;
}
signed main(){
cin.tie(0)->sync_with_stdio(0);
cin>>n>>m;
for(int i=1; i<=n; i++) cin>>a[i].x>>a[i].t;
stable_sort(a+1, a+n+1, cmp);
priority_queue<int> q;
int lst=0;
for(int i=1; i<=n; i++){
int tim=a[i].x-lst+a[i].t;
if(m<tim){
while(!q.empty() && m<tim){// 为什么用while?
int mx=q.top();
if(mx<=a[i].t) break;
m+=mx;
q.pop();
}
}
if(m>=tim){
q.push(a[i].t);
lst=a[i].x;
m-=tim;
}
ans=max(ans, (int)q.size());
}
cout<<ans;
return 0;
}
代码整体结构和前面的题区别不大,设当前考虑第 \(i\) 个机房,如果时间不够用,就反悔往外弹,直到时间够用了就把第 \(i\) 个加进堆里。
唯一的细节是,这个代码的反悔用了 while,而不是 if。
这就使人摸不着头脑了。用 while,意味着要一次性弹出多个元素,然而,如果弹出了多个元素,那么就算把第 \(i\) 个加回来,总数量还是净减少了。
虽然这样给后面的元素留下了更多机会,但从数量上看,似乎并不是最优解,为什么这样的贪心策略是正确的?
对于这一问题,大部分题解并没有给出明确解答。
比较容易想到的解释是,while 相当于求出了走到第 \(i\) 个元素的位置时,最优的数量,作为局部答案,这样全局答案取个 \(\max\) 就行了。
然而这个解释还是有点牵强,主要问题在于,为什么走到第 \(i\) 个位置时删掉的元素,在求解将来的答案时,不会被加回来?
所以在这里,会给出一个较为详细的解释。
首先,进行反悔的条件是,当前时间不够用,并且堆顶的时间比 \(t_i\) 大,如果第二条不满足,反悔还不如不反悔。
因此,如果我们走到了 \(x_i\) 的位置要反悔,因为堆顶一定比 \(t_i\) 大,反悔一次时间肯定就够了。但代码中的 while 又是必需的,如果改成 if 会 wa,也就是说必须反悔多次,这似乎形成了矛盾。
但矛盾是不可能出现的,这只能说明,多出来的那些反悔次数,用在了别的环节,也就是上句话中我们最容易忽视的地方:走到 \(x_i\) 的位置。
是的,我们忽略了剩下的时间可能甚至不够走到 \(x_i\) 的位置,如果真是这样,那 \(x_i\) 往后的位置就都走不到了,这个答案也就不会继续扩展了。
这时要是还想走到更远处,计算更远处的答案,只能先记录当前值,然后扔掉一些比较大的元素释放时间,直到足够走到为止。
现在能走到了,再考虑第 \(i\) 个要不要放进去,这时就最多删掉一个元素了,和前面的分析一致。
仔细分析代码不难发现,while 循环实现的就是上面的过程,只不过把为了走到 \(x_i\) 释放时间的过程和对第 \(i\) 个元素做反悔的过程合在一起了,所以会很难理解,完全按上面的思路去写是这样的:
好理解的写法
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+10;
struct Node{int x,t;}a[N];
int n,m,ans;
bool cmp(Node x, Node y){return x.x<y.x;}
signed main(){
cin.tie(0)->sync_with_stdio(0);
cin>>n>>m;
for(int i=1; i<=n; i++) cin>>a[i].x>>a[i].t;
priority_queue<int> q;
stable_sort(a+1, a+n+1, cmp);
for(int i=1; i<=n; i++){
int tim=a[i].x-a[i-1].x;
while(!q.empty() && m<tim){
m+=q.top(), q.pop();
}
m-=tim;
if(m<a[i].t && !q.empty() && q.top()>=a[i].t)
m+=q.top(), q.pop();
if(m>=a[i].t){
q.push(a[i].t);
m-=a[i].t;
}
ans=max(ans, (int)q.size());
}
cout<<ans;
return 0;
}
现在就很好理解,为什么这样能正确维护走到第 \(i\) 个位置时的答案了,这种思想在反悔贪心中应很常见,比如下一个题
P14635 [NOIP2025] 糖果店
首先有一个错误的贪心策略:维护一个所有糖的价格集合,初始为 \(\{x_i\}\),每次选集合中最小的,然后把对应的状态切换,即 \(x_k \to y_k,y_k \to x_k\),不断重复直到用完预算。
hack 掉上面的贪心也很简单,只需要构造几个 \(x\) 很大但 \(x+y\) 很小的糖,就会欺骗算法找到错误解。
重新分析题目可以发现,我们选的糖,可以分成这样两类:
- 单糖,指对花费的贡献为 \(x_i\) 的糖。
- 糖组,指贡献为 \(x_i+y_i\) 的两个同种糖组成的二元组。
选糖相当于选了一堆单糖和一堆糖组。可以发现,糖组肯定要选、并且只选 \(x+y\) 最小的,才能让数量最多。
于是考虑这样一个反悔贪心:最开始全选 \(x+y\) 最小的糖组,然后再按 \(x_i\) 升序考虑单糖的,如果钱不够了,就不断反悔掉原来选的两颗糖的组,直到够用,取所有答案的最大值。
这种思路和上个题差不多,也是用 while 一次性弹出多个,局部答案是“选出单糖的数量为 \(x\) 时的答案”。
p14635
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+10;
struct Node{
int st,nd;
}a[N];
int n,m,ans;
bool cmp1(Node x, Node y){
return x.st+x.nd<y.st+y.nd;
}
bool cmp2(Node x, Node y){
return x.st<y.st;
}
signed main(){
cin.tie(0)->sync_with_stdio(0);
cin>>n>>m;
for(int i=1; i<=n; i++) cin>>a[i].st>>a[i].nd;
stable_sort(a+1, a+n+1, cmp1);
int x=a[1].st+a[1].nd, cnt=0;
cnt+=m/x*2;
m%=x;
ans=cnt;
stable_sort(a+1, a+n+1, cmp2);
for(int i=1; i<=n; i++){
while(m<a[i].st){
cnt-=2;
m+=x;
}
if(m>=a[i].st){
cnt++;
m-=a[i].st;
}
ans=max(ans, cnt);
}
cout<<ans;
return 0;
}
P1792 [国家集训队] 种树
显而易见的贪心策略是,每次操作选最大的树,hack 也是显而易见的,这种思路过于鼠目寸光,不可取。
DP 倒是可以比较简单的解决此题,但复杂度太高了,优化需要用到凸性,超出了 CSP-S 和 NOIP 的大纲,我们还是得考虑贪心法。
对于任意三棵相邻的树,我们有选中间和选两边两种策略,遗憾的是,在知道最优解之前,我们并不知道那种策略是最优的。
如果直接做出选择,可能会把更优解漏掉,所以考虑反悔。
这里我们采用另一种特殊的反悔方式。我们还是选所有树里面最大的,设其编号为 \(x\),左边的树是 \(pre[x]\), 右边的是 \(nxt[x]\)。
把 \(a_x\) 计入答案后,把三棵树都删掉,然后在三棵树原来的位置上,插入一个新“树”,权值设为 \(a_{pre[x]}+a_{nxt[x]}-a_x\) 。
当前的决策是选择 \(x\),如果将来发现选 \(x\) 的两边更优,就选插入的新“树”,总贡献刚好变成了 \(a_{pre[x]}+a_{nxt[x]}-a_x+a_x=a_{pre[x]}+a_{nxt[x]}\),和反悔的效果一样。
于是,我们通过这种方式,给每个决策留下了“后悔权”,之后再不断选最大值就一定是对的了。
实现上,用双向链表维护插入和删除,用堆+懒惰删除法维护最大值。
p1792
#include<bits/stdc++.h>
using namespace std;
const int N=5e5+10;
struct Node{
int val, pre, Next;
}l[N];
struct Heap{
int w, id;
bool operator<(const Heap &y)const{
return w<y.w;
}
};
priority_queue<Heap> q;
int a[N];
bool vis[N];
int n, m, ans, h, t, cnt, tot;
int New(int v){
l[++tot]={v,0,0};
q.push({v,tot});
return tot;
}
void Insert(int x, int v){
int id=New(v);
cnt++;
if(h==0){
h=t=id;
return;
}
if(x==t) t=id;
l[id].Next=l[x].Next;
l[id].pre=x;
l[l[x].Next].pre=id;
l[x].Next=id;
}
void del(int x){
if(!cnt) return;
cnt--;
if(x==t) t=l[x].pre;
if(x==h) h=l[x].Next;
vis[x]=1;
l[l[x].pre].Next=l[x].Next;
l[l[x].Next].pre=l[x].pre;
l[x].pre=l[x].Next=0;
}
signed main(){
cin.tie(0)->sync_with_stdio(0);
cin>>n>>m;
if(m*2>n){
cout<<"Error!";
return 0;
}
for(int i=1; i<=n; i++)
cin>>a[i], Insert(t,a[i]);
l[t].Next=h; l[h].pre=t;
int num=1;
while(num<=m){
Heap x=q.top(); q.pop();
if(vis[x.id]) continue;
ans+=x.w;
int v=l[l[x.id].pre].val+l[l[x.id].Next].val-x.w;
int pr=l[l[x.id].pre].pre;
del(l[x.id].pre);
del(l[x.id].Next);
del(x.id);
Insert(pr,v);
q.push({v,l[pr].Next});
num++;
}
cout<<ans;
return 0;
}

浙公网安备 33010602011771号