贪心

事实上,贪心并不属于一种特定的算法,而是一种极为重要的算法思想。

基础的大家都会,主要讲一下三种常见的贪心证明方式,即:

  1. 前面忘了

  2. 邻项交换法

  3. 数学归纳法

然后就是,贪心和 dp 都仅适用于具有最优子结构(局部最优解能推导出全局最优解)的问题。唯一不同的一点是,dp 是将所有子问题的最优解全部求出(不考虑优化的前提下),而贪心则状态量要少得多,这也是为什么贪心的时间复杂度往往优于 dp。

接下来进入例题。

线段覆盖问题

按右端点排序即可模拟贪心。

总结:「将更宝贵的地方腾出来」或者说「让前面的 / 后面的选择面更广」是贪心的常见策略。

P2672

经典题。

我们考虑按照疲劳值从大到小排序,进行贪心。

我们首先考虑选上前 \(X\) 个人,他们的贡献用前缀和统计一下即可。

这时,是否可以舍弃一点疲劳值,换取距离上的更优呢?答案显然是肯定的,我们可以考虑置换一个人出去,换取后面一个人加入队伍。考虑到置换不一定更优,所以需要和第一部分取个 \(\max\)。这部分处理一个后缀 \(\max\) 即可。

那么,是否还有可能置换出更多的人?不可能。以置换两个人为例,当置换完第一个人之后,新加进来的人一定是后面贡献最大的,之后置换的那个人,不仅贡献没他多,疲劳值也没有我自己大,那肯定不置换更优。更多的人同理。

最后一个问题,为什么不按照距离从大到小排序?感性理解一下,我舍弃疲劳值换取距离可以获得两倍(因为有来回),但舍弃距离换取疲劳值只有一倍,而且后者能得到的最优方案前者同样找得到,这表明前者一定不劣于后者,于是按照疲劳值从大到小排序。

实现
#include<bits/stdc++.h>
using namespace std;

const int N=1e5+5;
int n;
int sum[N],suf[N];
struct NODE{
	int dis,a;
}b[N];

bool cmp(NODE &x,NODE &y){
	return x.a>y.a;
}

signed main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cin>>n;
	for(int i=1;i<=n;i++)
		cin>>b[i].dis;
	for(int i=1;i<=n;i++)
		cin>>b[i].a;
	sort(b+1,b+n+1,cmp);
	for(int i=n;i>=1;i--)
		suf[i]=max(suf[i+1],b[i].a+2*b[i].dis);
	int dismax=-1e9;
	for(int i=1;i<=n;i++){
		sum[i]=sum[i-1]+b[i].a;
		dismax=max(dismax,2*b[i].dis);
		cout<<max(sum[i]+dismax,sum[i-1]+suf[i])<<'\n';
	}
	return 0;
}

总结:排序、置换技巧(通常最多一个)。

CF865D

反悔贪心入门。

每一天,能对我产生影响的操作只有两个:买和卖。这促使我们考虑反悔贪心。

对于第 \(i\) 天,我首先卖前面买价最低的那一只股票(维护一个大根堆即可)。但有可能这次操作并不是最优的,所以我考虑把它的卖价扔到堆中竞争最低价。

为什么这样是对的呢?考虑第 \(i\) 天的利润,即 \(p_i-\min\),如果我在第 \(j\) 天以 \(p_i\)\(\min\) 卖出了 \(p_j\) 的股票,则利润为 \(p_j-p_i\),两者相加,得到 \(p_j-\min\),这便相当于我在第 \(j\) 天作出了「反悔」的决策。

实现(十分 naive)
#include<bits/stdc++.h>
#define int long long
using namespace std;

const int N=3e5+5;
int n,p[N];
priority_queue<int,vector<int>,greater<int> > pq;

signed main(){
	cin>>n;
	int ans=0;
	for(int i=1;i<=n;i++){
		cin>>p[i],pq.push(p[i]);
		if(p[i]>pq.top()){
			ans+=p[i]-pq.top();
			pq.pop(),pq.push(p[i]);
		}
	} 
	cout<<ans;
	return 0;
}

总结:反悔贪心通常直接作出决策,然后维护一些堆来进行「反悔」。通常在决策数量较少时使用。

P1484

绝世秒题。

想象一下,现在我们拿出了最大收益的那个坑,以及它旁边两个坑。

现在,如果中间那个坑中了树,它旁边两个都不能种了。

但是,有可能种下去之后,发现种旁边两个坑的收益之和更大,这启发我们考虑反悔贪心。

具体的,我们仍然考虑维护一个大根堆,并维护一个标记数组,以便检查当前坑位是否能种树。

当我们取出最大收益的坑位时,因为我并不确定它是否种,所以我不标记它,转而标记它旁边两个坑位。这是因为,如果我种这个坑位,旁边两个就不能种了;如果我不种,那么必定会种旁边两个(我不会平白无故地放弃最大值),那它们也种不了。也就是说,无论这个最大坑位种或者不种,旁边两个必定种不了。

有了上面的分析,这题是否做完了?错误的。我们很有可能遇到多次反悔的情况,例如,我不种最大坑位,改种旁边两个后,发现还是不够优,再次改种旁边两个,这怎么办?我们考虑每次种树后,把三个坑打包成一个坑,这样就转化为先前的情况了。这部分可以使用双向链表轻松实现。

实现
#include<bits/stdc++.h>
#define int long long
using namespace std;

const int N=3e5+5;
int n,k;
int a[N];
bool vis[N];
priority_queue<pair<int,int> > pq;
struct NODE{
	int pre,nxt;
}lnk[N];

signed main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cin>>n>>k;
	for(int i=1;i<=n;i++)
		cin>>a[i];
	for(int i=1;i<=n;i++){
		pq.push({a[i],i});
		lnk[i].pre=i-1;
		lnk[lnk[i].pre].nxt=i;
		lnk[i].nxt=i+1;
		lnk[lnk[i].nxt].pre=i;
	}
	int ans=0,cnt=0;
	while(!pq.empty()&&cnt<k){
		auto now=pq.top(); pq.pop();
		int cur=now.second,val=now.first;
		if(val<0)
			break;
		if(vis[cur])
			continue;
		ans+=val,cnt++;
		int pre=lnk[cur].pre,nxt=lnk[cur].nxt;
		vis[pre]=vis[nxt]=1;
		a[cur]=a[pre]+a[nxt]-a[cur];
		lnk[cur].pre=lnk[pre].pre;
		lnk[lnk[cur].pre].nxt=cur;
		lnk[cur].nxt=lnk[nxt].nxt;
		lnk[lnk[cur].nxt].pre=cur;
		pq.push({a[cur],cur}); 
	}
	cout<<ans;
	return 0;
}

总结:从最小单位出发,研究贪心策略。

P2949

也是很 Edu 的一道题。

考虑普通贪心,按照收益从大到小排序。因为要让前面的任务有更多选择,我们考虑让每一个任务都在 deadline 的前一刻做完。这样,我需要对于每个任务,维护它最晚做的时间是多少,这个用并查集维护即可,不过我写了只有 \(92\) 分,可能要离散化一下以防 MLE。

另一种思路是考虑反悔贪心,按照截止时间从小到大排序。然后对于每个任务,如果时间够,我就先做,获取收益。时间不够了,我再考虑置换一个时间充裕的前提下收益最大的任务。但上述操作并不一定最优,所以还维护一个大根堆来反悔即可。

实现(反悔贪心)
#include<bits/stdc++.h>
#define int long long
using namespace std;

const int N=1e5+5;
int n;
pair<int,int> a[N];
priority_queue<int,vector<int>,greater<int> > pq;

signed main(){
	cin>>n;
	for(int i=1;i<=n;i++) 
		cin>>a[i].first>>a[i].second;
	sort(a+1,a+n+1);
	int cnt=0,ans=0;
	for(int i=1;i<=n;i++){
		if(a[i].first>cnt){
			cnt++;
			ans+=a[i].second;
			pq.push(a[i].second);
		}
		else if(!pq.empty()&&a[i].second>pq.top()){
			auto cur=pq.top();
			pq.pop();
			ans+=a[i].second-cur;
			pq.push(a[i].second);
		}
	}
	cout<<ans<<'\n';
	return 0;
}

CF730I

决策很少,考虑反悔贪心。

我们考虑先作出决策,就是将所有人的编程能力扔进大根堆,然后取出编程能力最强的 \(p\) 个人。

这样显然不一定最优,因为有些人可能运动能力更强。于是开始反悔,我们也将所有人的运动能力扔进另一个大根堆,每次取出上述两个堆的有效(没有用过的)顶部。这时,有两种选择:直接拿第二个堆的顶部加入体育团队,或者编程团队中选一个人进行反悔。第一种选择的代价显然是 \(b_i\),第二种则是那个人的运动能力与编程能力的差值加上另一个顶替的人的编程能力。显然我们希望差值和编程能力都尽可能大,所以我们应当还维护一个大根堆保存差值。每次拿第一个堆和第三个堆的堆顶进行上述反悔即可。

实现
#include <cstdio>
#include <iostream>
#include <algorithm>
#include <cstring>
#include <string>
#include <stdlib.h>
#include <vector>
#include <queue>
#include <cmath>
#include <stack>
#include <map>
#include <set>
#define int long long
using namespace std;

const int N=3e3+5;
int n,p,s,ans;
int a[N],b[N];
int tag[N];
priority_queue<pair<int,int> > pq1,pq2,pq3;

signed main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cin>>n>>p>>s;
	for(int i=1;i<=n;i++)
		cin>>a[i],pq1.push({a[i],i});
	for(int i=1;i<=n;i++)
		cin>>b[i],pq2.push({b[i],i});
	for(int i=1;i<=p;i++){
		int pos=pq1.top().second;
		tag[pos]=1;
		pq3.push({b[pos]-a[pos],pos});
		pq1.pop();
		ans+=a[pos];
	}
	for(int i=1;i<=s;i++){
		for(;!pq1.empty()&&tag[pq1.top().second];pq1.pop());
		for(;!pq2.empty()&&tag[pq2.top().second];pq2.pop());
		int val1=pq1.top().first,val2=pq2.top().first,val3=pq3.top().first;
		int pos1=pq1.top().second,pos2=pq2.top().second,pos3=pq3.top().second;
		if(val2>val1+val3){
			tag[pos2]=2;
			ans+=val2;
			pq2.pop();
		}
		else{
			ans+=val1+val3;
			pq1.pop(),pq3.pop();
			tag[pos3]=2,tag[pos1]=1;
			pq3.push({b[pos1]-a[pos1],pos1});
		}
	}
	cout<<ans<<'\n';
	for(int i=1;i<=n;i++)
		if(tag[i]==1)
			cout<<i<<' ';
	cout<<'\n';
	for(int i=1;i<=n;i++)
		if(tag[i]==2)
			cout<<i<<' ';
	return 0;
}

总结:反悔贪心的重要思想:分组;要设身处地地想贪心策略。

AGC018C

这题有三个组,直接贪心会有 6 个堆,不想写,怎么办?

考虑转化。容易发现我们可以直接先把 \(a_i\) 全贪了,然后令 \(b_i=b_i-a_i,c_i=c_i-a_i\),这样就只要考虑选两种价格的了。

其实这样就变成上一个题了,直接做就好。但这里我们必须讲一下邻项交换贪心的做法。

对于两个相邻的人 \(i,j\),我们钦定 \(i\) 选择 \(b\)\(j\) 选择 \(c\) 不劣于 \(i\) 选择 \(c\)\(j\) 选择 \(b\),即:

\[b_i+c_j \ge c_i+b_j \]

移项,得:

\[b_i-c_i \ge b_j-c_j \]

这表明,我们仅需将 \(i\) 按照 \(b_i-c_i\) 降序排列,就能保证上述条件。

换言之,只要这样排序之后,选择 \(b_i\) 的一定全在选择 \(c_i\) 的左边。

这启发我们预处理一下前、后缀 \(\max\),然后枚举分界点 \(k\) 即可得到答案。时间复杂度单 \(\log\)

实现
#include <cstdio>
#include <iostream>
#include <algorithm>
#include <cstring>
#include <string>
#include <stdlib.h>
#include <vector>
#include <queue>
#include <cmath>
#include <stack>
#include <map>
#include <set>
#define int long long
using namespace std;

const int N=1e5+5;
int n,x,y,z;
struct A{
	int u,v,w;
}a[N];
int pre[N],suf[N];
priority_queue<int,vector<int>,greater<int> > pq1,pq2;

bool cmp(A &x,A &y){
	return x.v-x.w>y.v-y.w;
}

signed main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cin>>x>>y>>z;
	n=x+y+z;
	int sum=0;
	for(int i=1;i<=n;i++){
		cin>>a[i].u>>a[i].v>>a[i].w;
		a[i].v-=a[i].u,a[i].w-=a[i].u;
		sum+=a[i].u;
	}
	sort(a+1,a+n+1,cmp);
	for(int i=1;i<=n;i++){
		pq1.push(a[i].v);
		pre[i]=pre[i-1]+a[i].v;
		if(pq1.size()>y)
			pre[i]-=pq1.top(),pq1.pop();
	}
	for(int i=n;i>=1;i--){
		pq2.push(a[i].w);
		suf[i]=suf[i+1]+a[i].w;
		if(pq2.size()>z)
			suf[i]-=pq2.top(),pq2.pop();
	}
	int ans=-1e18;
	for(int i=y+1;i<=n-z+1;i++)
		ans=max(ans,pre[i-1]+suf[i]);
	cout<<ans+sum;
	return 0;
}
  • 总结:消元思想。

邻项交换贪心的一般步骤:考虑相邻两个下标 \(i,j\),钦定一个最优性条件,然后通过推柿子找到满足这个条件的排序规则。

CF436E

如果你是玩家,你会怎么买?我的答案是,「占便宜」为王道。

何谓「占便宜」?如果获得两颗🌟的代价比获得两个一颗🌟还便宜,我就必须买。

于是,我们考虑把关卡分为两类:一类是能「占便宜」的,一类是不能「占便宜」的。

我们先考虑第一类。显然能选则选,从小到大选即可,这里可以维护一个小根堆处理。值得注意的是,可能会出现剩下恰好一颗🌟的情况,这个我们待会讨论。

对于第二类,我们将价格拆分为 \(a_i\)\(b_i-a_i\),然后和上面那一类一起竞争最小值即可,同样需要维护一个小根堆。

现在,最棘手的部分来了,如何处理还剩一颗🌟的情况?可以直接选一个,或者选两个再退掉一个,在这里边取一个 \(\min\) 即可。注意!这里不能选堆顶了,必须全局找最小 / 最大(选两个可以直接用堆顶,其他不行),因为我想退掉的那个可能已经被弹出了,选一个的话之前没选的也要考虑进去,所以必须全局找。

实现
#include <cstdio>
#include <iostream>
#include <algorithm>
#include <cstring>
#include <string>
#include <stdlib.h>
#include <vector>
#include <queue>
#include <cmath>
#include <stack>
#include <map>
#include <set>
#define int long long
using namespace std;

const int N=3e5+5,INF=1e18;
int n,w;
int a[N],b[N],vis[N];
priority_queue<pair<int,int>,vector<pair<int,int> >,greater<pair<int,int> > > pq1,pq2;

pair<int,int> outmax(){
	int res=0,id=-1;
	for(int i=1;i<=n;i++){
		if(vis[i]==1&&res<a[i])
			res=a[i],id=i;
		if(vis[i]==2&&res<b[i]-a[i])
			res=b[i]-a[i],id=i;
	}
	return {res,id};
}
pair<int,int> inmin(){
	int res=INF,id=-1;
	for(int i=1;i<=n;i++){
		if(vis[i]==0&&res>a[i])
			res=a[i],id=i;
		if(vis[i]==1&&res>b[i]-a[i])
			res=b[i]-a[i],id=i;
	}
	return {res,id};
}

signed main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cin>>n>>w;
	for(int i=1;i<=n;i++){
		cin>>a[i]>>b[i];
		if(2*a[i]>=b[i])
			pq1.push({b[i],i});
		else
			pq2.push({a[i],i}),pq2.push({b[i]-a[i],i});
	}
	int ans=0;
	while(w){
		int val1=pq1.size()?pq1.top().first:INF,val2=pq2.size()?pq2.top().first:INF;
		if(w==1){
			auto a=outmax(),b=inmin();
			int p=val1-a.first,q=b.first;
			if(p<=q){
				ans+=p;
				vis[pq1.top().second]=2,vis[a.second]--;
			}
			else{
				ans+=q;
				vis[b.second]++;
			}
			break;
		}
		if(val2==INF||val2*2>=val1){
			w-=2,ans+=val1;
			vis[pq1.top().second]=2;
			pq1.pop();
		}
		else{
			w--,ans+=val2;
			vis[pq2.top().second]++;
			pq2.pop();
		}
	}
	cout<<ans<<'\n';
	for(int i=1;i<=n;i++)
		cout<<vis[i];
	return 0;
}

总结:拆分思想。

结语

贪心策略的思考:

  • 设身处地

  • 从最小单位出发,研究贪心策略。

  • 「将更宝贵的地方腾出来」或者说「让前面的 / 后面的选择面更广」是贪心的常见策略。

常见贪心类型:

  • 反悔贪心通常直接作出决策,然后维护一些堆来进行「反悔」。通常在决策数量较少时使用。

  • 邻项交换贪心的一般步骤:考虑相邻两个下标 \(i,j\),钦定一个最优性条件,然后通过推柿子找到满足这个条件的排序规则。

技巧:

  • 排序、置换。

  • 拆分思想、消元思想、分组思想。

以上。

posted @ 2025-07-28 16:45  _KidA  阅读(12)  评论(0)    收藏  举报