反悔贪心 系列
2025 CSPS1 的选择题 T15,就是这样一道题。
其实本来应当是 Slope Trick 来做的题目,但是聪明的人类找到了用优先队列维护当前最优解的 O(nlogn)O(n\log n)O(nlogn) 做法。使得一些看起来很唬人的题目得以以惊人的小码量被解决,并且优于朴素 dp。
我的题单。大部分题目可以用相似的思路来做:
具体地,这类问题求可以选定元素的最大价值。每个元素都有价值和限制,价值总是和限制相关,即价值的叠加会影响限制。
先按照限制从小到大排序,然后依次插入每个元素,用优先队列维护已经插入的元素。当遇到违反限制、无法插入元素时,就判断当前元素能否替换队头。能就替换,且替换操作相对于全局而言要尽量是优的。
1.CF1526C Potions
题意
有 nnn 个药水排成一行,药水 111 在最左边,药水 nnn 在最右边。每个药水喝下后会使你的生命值增加 aia_iai。aia_iai 可能为负数,表示该药水会减少你的生命值。
你初始时生命值为 000,并且你会从左到右依次经过每个药水。每到一个药水处,你可以选择喝下它或者忽略它。你必须保证你的生命值始终不为负数。
你最多能喝下多少瓶药水?
CF1526C1 - Easy Version:1≤n≤20001\le n\le 20001≤n≤2000,ai∈[−109,109]a_i\in[-10^9,10^9]ai∈[−109,109]。
CF1526C2 - Hard Version:1≤n≤2×1051\le n\le 2\times 10^51≤n≤2×105,ai∈[−109,109]a_i\in[-10^9,10^9]ai∈[−109,109]。
思路
Easy Version 可以用 01 背包实现。设 fi,jf_{i,j}fi,j 表示前 iii 瓶药水喝了 jjj 瓶,获得的最大生命值。显然有转移:
fi,j=max(fi−1,j,fi−1,j−1+ai)f_{i,j}=\max(f_{i-1,j},f_{i-1,j-1}+a_i)fi,j=max(fi−1,j,fi−1,j−1+ai)
然后从 nnn 倒推,第一个 fn,j≥0f_{n,j}\ge 0fn,j≥0 的 jjj 就是能喝下的最多药水瓶数。时间复杂度 O(n2)O(n^2)O(n2),代码略。但是完全过不了 Hard Vesion 啊!
考虑 nnn 瓶药水扫过去,贪心地想要每瓶药水都喝。当喝到一瓶让生命值 <0<0<0 的药水时,我们考虑用这瓶药水替换前面选过的某瓶药水,以实现当前药水瓶数尽可能多且生命值尽量大。
用小根堆维护选过的药水,队头是所选药水最小值,curcurcur 是当前生命值且保证 cur≥0cur\ge 0cur≥0 总是成立。若 cur+a<0cur+a<0cur+a<0,但是 a>q.topa>q.topa>q.top,用 cur←cur−q.top+acur\leftarrow cur-q.top+acur←cur−q.top+a 依然能使得 cur≥0cur\ge0cur≥0,虽然瓶数不变但是 curcurcur 更大了——这就是“反悔操作”。然后将 aaa 入队变为已选。
发现整个操作下来“非常人性化”,似乎无法被 hack。其实这个“反悔操作”,和 dp 的转移是等价的。
代码
#include<bits/stdc++.h>
using namespace std;
#define ll long long
const ll N=5005;
ll n,a;
priority_queue<ll,vector<ll>,greater<ll> >q;
int main()
{scanf("%lld",&n);ll cur=0,ans=0;for(int i=1;i<=n;i++){scanf("%lld",&a);if(cur+a>=0){cur+=a;ans++;q.push(a);}else {if(q.empty())continue;ll tem=q.top();if(a>tem){cur=cur-tem+a;q.pop();q.push(a);}}}printf("%lld",ans);return 0;
}
2.洛谷 P4053 JSOI2007 建筑抢修
题意
有 nnn 个事件,做第 iii 件事要 tit_iti 的事件,只能在 edied_iedi 之前做完。问最多能做多少件事。
1≤n≤1.5×1051\le n\le 1.5\times 10^51≤n≤1.5×105,1≤ti<edi<2311\le t_i<ed_i<2^{31}1≤ti<edi<231。
思路
很经典的题目。我们先对限制 ededed 排序,尽可能做每件事,设 curcurcur 表示做完所有所选事件的结束时刻。
若 cur+ti>edicur+t_i>ed_icur+ti>edi,考虑用当前事件替换已选事件,并且想要 curcurcur 变小以实现全局更优。那就用小根堆维护已选事件的 ttt,若 ti<q.topt_i<q.topti<q.top 且 cur−ti+q.top≤edicur-t_i+q.top\le ed_icur−ti+q.top≤edi,说明 tit_iti 可以替换 q.topq.topq.top 对应的事件,并且耗时减少。
满足这个条件的替换总是可行的!因为 q.topq.topq.top 之间的事件总是在限制之前,q.topq.topq.top 之后的事件全部往前推,必然在限制之内。
如果加 iii 事件,导致后面某个事件 jjj 无法加入怎么办?这应该是大多数人一开始会有的问题。实则针对这一次加入 iii,后面 jjj 同样会进行这样一次“反悔操作”。
代码
#include<bits/stdc++.h>
using namespace std;
#define ll long long
const ll N=2e5+9;
ll n;
struct node
{ll t,ed;
}a[N];
bool cmp(node x,node y)
{return x.ed<y.ed;
}
priority_queue<ll>q;
int main()
{scanf("%lld",&n);for(int i=1;i<=n;i++){ll t,ed;scanf("%lld%lld",&t,&ed);a[i]=(node){t,ed};}sort(a+1,a+n+1,cmp);ll cur=0,ans=0;for(int i=1;i<=n;i++){cur+=a[i].t;if(cur<=a[i].ed){ans++;q.push(a[i].t);}else {cur-=a[i].t;if(q.empty())continue;ll tem=q.top();if(a[i].t<tem&&cur-tem+a[i].t<=a[i].ed){cur=cur-tem+a[i].t;q.pop();q.push(a[i].t);}}}printf("%lld",ans);return 0;
}
类似的题目还有洛谷 P2949、P14097:前者基本相同,后者把结束时间的限制改成了开始时间的限制而已。
3.CF725D Contest Balloons
题意
ACM比赛,AC一题会有一个气球。现在有nnn 支队伍,每支队伍的重量是 wiw_iwi ,拥有 tit_iti 个气球 ,当一支队伍的气球个数比它的重量都要大时,这个队伍就会飘起来,从而被取消比赛资格。
现在你带领的是 111 号队,你希望你队伍的名次尽可能靠前,你是个有原则的人,不会偷气球,但你可以把气球送给别的队伍,让他们飞起来。
求最终你的队伍所获得的最好名次。
2≤n≤3×1052\le n\le 3\times 10^52≤n≤3×105,0≤ti≤wi≤10180\le t_i\le w_i\le 10^{18}0≤ti≤wi≤1018。
思路
决定排名先后的是气球数量的多少。
把 111 对单独拎出来,对 2∼n2\sim n2∼n 按照气球个数 cntcntcnt 从大到小排序,因为 cnt1cnt_1cnt1 会不断送出去气球,所以会不断有队伍跑到 111 队所在名次之前。用一个指针 pospospos 维护当前哪个队伍在 111 队排名之上。
将排名在 111 队之上的队伍的 wi−ti+1w_i-t_i+1wi−ti+1 加入优先队列,作为“作案名单”。
挑所需气球更少的队伍送给它气球即可。因为 111 队气球减少,会导致后面的队伍跑到前面去,于是不保证每一步都比上一步更优。每一次给其他队伍气球的决策,都要更新一遍答案。
代码
#include<bits/stdc++.h>
using namespace std;
#define ll long long
const ll N=3e5+9;
ll n;
struct node
{ll cnt,w;
}a[N];
bool cmp(node x,node y)
{if(x.cnt!=y.cnt)return x.cnt>y.cnt;return x.w<y.w;
}
priority_queue<ll,vector<ll>,greater<ll> >q;
int main()
{scanf("%lld",&n);for(int i=1;i<=n;i++){ll cnt,w;scanf("%lld%lld",&cnt,&w);a[i]=(node){cnt,w};}sort(a+2,a+n+1,cmp);ll pos=2,ans=n;while(1){while(pos<=n&&a[1].cnt<a[pos].cnt){q.push(a[pos].w-a[pos].cnt+1);pos++;}if(q.empty()){puts("1");exit(0);}ans=min(ans,(ll)q.size()+1ll);ll tem=q.top();if(a[1].cnt<tem)break;a[1].cnt-=tem;q.pop();}printf("%lld",ans);return 0;
}
其它要动用一些小技巧的题目:CF865D(“反悔”时加入两次,方便后面的反悔,提交记录)。
4.P4597 Sequence 加强版
题意
给定一个长度为 nnn 的序列 aaa,每次操作可以把某个数 +1 或 −1。要求把序列变成非降数列。
n≤5×105n\le 5\times 10^5n≤5×105。
还有洛谷 P2893、P4331、CF13C、CF713C,不同在于数据范围或题意。
思路
很大争议的一道题。费尽心血写 Slope Trick 的看不起码量很小的反悔贪心的,写反悔贪心的又不好好证明……不过确实难证明捏。
设当前 aia_iai 小于之前的最大数 mxmxmx(在之前已经实现单调不降,1∼i−11\sim i-11∼i−1)。为了使得序列不降,我们要选一个 t∈[mx,ai]t\in[mx,a_i]t∈[mx,ai],让 mx,ai→xmx,a_i\to xmx,ai→x。我们发现 xxx 无论取多少,操作代价都是 mx−aimx-a_imx−ai。
贪心地,为了使得后面的数更容易变为单调不降,我们让 mxmxmx “变为”尽量小的 aia_iai。
这样做对吗?为什么能改变 mxmxmx?序列不会就不满足不降了吗?
为了使序列不降,mxmxmx 不能小于其之前的最大值 mx′mx'mx′(1∼i−21\sim i-21∼i−2)。若 mx′≤aimx'\le a_imx′≤ai,那么直接更新没有问题。否则 mx′∈[ai,mx]mx'\in[a_i,mx]mx′∈[ai,mx],那就让 ai→mx′a_i\to mx'ai→mx′,mx→mx′mx\to mx'mx→mx′,代价依然为 mx−aimx-a_imx−ai。
总结:“反悔”是相当于让 mxmxmx 减得尽量小,使得后面的数更容易变为不降。
代码
#include<bits/stdc++.h>
using namespace std;
#define ll long long
ll n,a;
priority_queue<ll>q;
ll ans;
int main()
{scanf("%lld",&n);for(int i=1;i<=n;i++){scanf("%lld",&a);q.push(a);if(a==q.top())continue;ans+=q.top()-a;q.pop();q.push(a);}printf("%lld",ans);return 0;
}
部分思路参考这篇博客。更加严谨的 Slope Trick 证明。