【寻迹#1】贪心算法
贪心算法
信息学竞赛试题中,经常出现求一个问题的可行解或最优解的题目。这类问题就是我们所说的最优化问题。而贪心算法是求解这类问题的常用算法。最近重回OI,回归基础,有很多新的收获新的思考。
一、贪心算法
贪心算法是从问题的初始状态出发,通过若干次的贪心选择的而得到的最优值。也就是说,每一次选择都是当前意义下的最优策略的算法,做出的选择只是局部最优,并不一定是全局最优。所以如果想使用贪心算法需要确保全局最优包括局部最优。来看两个直观的例子。
【例1】 在一个 \(N\) 行 \(M\) 列的正整数矩阵中,要求从每行中选出一个数,使得选出 \(N\) 个数的和最大
【例2】 在一个 \(N \times M\) 的方格阵中,每一个格子赋予一个权值,规定每次移动只能向上或向右。试找出一条从左下到右上的最长路径。
上述两个例子中,第一个是可以使用贪心算法的。原因是和最大的构成是每一个数都尽可能大,所以可以贪心地选择每一行最大的数。而第二个例子就不可以使用贪心算法,原因是全局最优并不包括局部最优,我们都知道需要用动态规划来求解。
二、贪心算法的特点
1.将原问题变为一个相似的但是规模更小的子问题,而后的每一步都是当前看似最佳的选择,且这种选择只依赖于已做出的选择,不依赖于未做出的选择。
2.只有满足全局最优解包含局部最优解时,才能保证最终结果是最优解。
三、几个简单的贪心实例
1.最优装载问题
给 \(n\) 个物体,第 \(i\) 个物体的重量为 \(w_i\) ,选择尽量多的物体,使得总物体质量不超过 \(C\)
在这个问题中我们只关心物体的数量,所以只需要将物体按重量从小到大排序,从前往后尽可能装即可。
2.部分背包问题
有 \(n\) 个物体,第 \(i\) 个物体的重量为 \(w_i\) ,价值为 \(v_i\) ,在总重量不超过 \(C\) 的情况下让总价值尽量高,没一个物体可以只取走一部分。
第一眼觉得是背包问题,后来一想不对。因为每个物体可以只取走一部分,所以一定可以恰好装满背包。所以我们只需要优先拿性价比比较高的。换句话说就是将物体按照 \(\dfrac{v_i}{w_i}\) 从大到小排序,然后一直装满背包即可。
3.乘船问题
有 \(n\) 个人,第 \(i\) 个人的重量为 \(w_i\) 。每艘船的载重量均为 \(C\) ,最多可乘两人。求用最少的船装载所有人的方案。
假设一艘船上先固定一个人。因为最多装两人,所以选择一个满足限重并且重量尽可能大的一定是最优的。因此只需要按照最轻的与最重的配对即可。
四、贪心算法的经典应用
1.选择区间不相交问题
给定 \(n\) 个开区间 \((a_i,b_i)\) ,选择尽量多个区间,使得这些区间两两没有公共点。
思路:将区间按照 \(b_i\) 从小到大排序。第一个区间必选,从第二个开始依次考虑每一个区间,没有冲突就选,有冲突就跳过。
2.区间选点问题
给定 \(n\) 个闭区间 \([a_i,b_i]\) ,在数轴上选尽量少的点,使得每个区间内都至少有一个点(不同区间内含的点可以是同一个)
思路:将区间按照 \(b_i\) 从小到大排序。然后从区间 \(1\) 到区间 \(n\) 选择:对于当前区间,若集合中的数均不位于该区间,则将区间末尾的数加入集合。
3.区间覆盖问题
给定 \(n\) 个区间 \([a_i.b_i]\) ,选择尽量少的区间覆盖一条指定的线段区间 \([s,t]\)
思路: 将区间按照 \(a_i\) 从小到大排序,每次选择覆盖点 \(s\) 区间中右端点最大的一个,然后将 \(s\) 更新为该区间的右端点,直到区间包含 \(t\)
4.流水作业调度问题
有 \(n\) 个作业要在两台机器 \(M_1\) 和 \(M_2\) 组成的流水线上完成加工。每个作业 \(i\) 都必须先花 \(a_i\) 在 \(M_1\) 上加工,然后花时间 \(b_i\) 在 \(M_2\) 上加工 。确定一个加工顺序,使加工总时间最短。
思路: 最优调度一定是让 \(M_1\) 、\(M_2\) 的空闲时间尽量短。
这里给出 \(Johnson\) 算法:设 \(N_1\) 为满足 \(a_i<b_i\) 所有 \(i\) 的集合;设 \(N_2\) 为满足 \(a_i\geq b_i\) 所有 \(i\) 的集合,将 \(N_1\) 的作业按 \(a_i\) 非减序排序, \(N_2\) 的作业按 \(b_i\) 非增序排序,则 \(N_1\) 作业接 \(N_2\) 作业构成最优顺序。
直观理解:前期让 \(M_2\) 等 \(M_1\) 先加工的时间短一些,后期让 \(M_1\) 等 \(M_2\) 完工的时间短一些。
5.带限期和罚款的单位时间任务调度
有 \(n\) 个任务,每个任务都需要 \(1\) 个时间单位执行,任务 \(i\) 的截止时间 \(d_i\) 表示要求任务 \(i\) 在时间 \(d_i\) 结束时必须完成,误时惩罚 \(w_i\) 。确定一个 任务执行顺序,使得罚款最小。
思路: 要使罚款最少,我们应尽量完成 \(w_i\) 较大的任务。所以,我们先将任务按照 \(w_i\) 从大到小排序。然后按照顺序处理,若能安排则找一个最晚时间;否则放弃处理。
五、题单
下列题目均涉及到贪心算法。整体难度递增。
T1.活动安排
思路:经典的区间不相交问题,先按照 \(f_i\) 排序,然后按顺序讨论选取即可。
代码:
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
using namespace std;
#define N 5050
struct E{ int s,f; };
int n,cnt=1;
E a[N];
inline int read()
{
int x=0,f=1;char ch=getchar();
while(ch<'0'||ch>'9') { f=-1;ch=getchar(); }
while(ch>='0'&&ch<='9') { x=x*10+ch-48;ch=getchar(); }
return x*f;
}
bool cmp(E a,E b){ return a.f<b.f; }
int main()
{
n=read();
for(int i=1;i<=n;i++) { a[i].s=read();a[i].f=read(); }
sort(a+1,a+1+n,cmp);
int x=a[1].f;
for(int i=2;i<=n;i++)
{
int p=a[i].s;
if(p>=x)
{
x=a[i].f;
cnt++;
}
}
cout<<cnt<<endl;
return 0;
}
T2.种树
思路:经典的区间选点问题。先按 \(e_i\) 排序,然后对于每一个区间如果已种树的数目满足要求可以直接跳过,否则从区间终点开始向前种树。可以使用 \(vis\) 数组标记某一点的种植情况。
代码:
#include<bits/stdc++.h>
using namespace std;
#define N 5050
#define M 30050
struct Tree{ int b,e,t; };
Tree a[N];
int n,h,sum;
int vis[M];
inline int read()
{
int x=0,f=1;char ch=getchar();
while(ch<'0'||ch>'9') { if(ch=='-')f=-1;ch=getchar(); }
while(ch>='0'&&ch<='9') { x=x*10+ch-48;ch=getchar(); }
return x*f;
}
bool cmp(Tree a,Tree b) { return a.e<b.e; }
int main()
{
memset(vis,0,sizeof(vis));
n=read();h=read();
for(int i=1;i<=h;i++) { a[i].b=read();a[i].e=read();a[i].t=read(); }
sort(a+1,a+1+h,cmp);
sum=a[1].t;
int cnt=0;
for(int i=a[1].e;i>=a[1].b;i--)
{
vis[i]=1;cnt++;
if(cnt==a[1].t) break;
}
for(int i=2;i<=h;i++)
{
cnt=0;
for(int j=a[i].b;j<=a[i-1].e;j++)
if(vis[j]) cnt++;
if(cnt>=a[i].t) continue;
for(int j=a[i].e;j>=a[i].b;j--)
if(!vis[j]) { vis[j]=1;cnt++; sum++;if(cnt==a[i].t) break; }
}
cout<<sum<<endl;
return 0;
}
T3.喷水装置
思路:区间覆盖问题。先通过输入求出每一个喷水装置的覆盖区间,然后按照左端点从小到大排序,如果第一个区间的左端点 \(>1\) 则无解,否则,将右端点更新为最大的那个,直到右端点大于 \(L\)
T4.数列分段
思路:注意这道题说的是连续的若干段。所以从第一个数开始,尽可能地将后面的数加入当前这一段中即可。
代码:
#include<bits/stdc++.h>
using namespace std;
#define N 100050
int n,m,pos=1;
int sum,cnt;
int a[N];
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>a[i];
for(int i=1;i<=n;i++)
{
if(sum+a[i]>m) { sum=a[i];cnt++; }
else sum+=a[i];
}
cout<<cnt+1<<endl;
return 0;
}
T5.数列极差
思路:可以感知到如果将数列从小到大排序,从第一位开始重复此操作得到的为 \(\max\) ;从大到小排序,从第一位开始重复操作得到的为 \(\min\) 。可以通过数学严谨证明,此处省略。
代码:
#include<bits/stdc++.h>
using namespace std;
#define N 50050
int n;
int a[N],b[N];
bool cmp(int x,int y) { return x>y; }
int main()
{
cin>>n;
for(int i=1;i<=n;i++) { cin>>a[i];b[i]=a[i]; }
int x;cin>>x;
for(int i=n;i>=1;i--)
{
sort(a+1,a+1+i);
sort(b+1,b+1+i,cmp);
a[i-1]=a[i]*a[i-1]+1;
b[i-1]=b[i]*b[i-1]+1;
}
cout<<b[1]-a[1]<<endl;
return 0;
}
另外笔者此处提供一种使用优先队列维护的做法:即贪心的处理过程中,使用优先队列维护一个大根堆,一个小根堆,可以更方便模拟操作的过程。
代码:
#include<bits/stdc++.h>
#include<queue>
using namespace std;
#define N 50050
priority_queue<int>Q;
priority_queue<int>H;
int n,x,y,p;
int a[N];
int main()
{
cin>>n;
for(int i=1;i<=n;i++) { cin>>a[i];Q.push(a[i]);H.push(-a[i]); }
cin>>p;
for(int i=1;i<=n-1;i++)
{
x=Q.top();Q.pop();y=Q.top();Q.pop();
Q.push(x*y+1);
x=-H.top();H.pop();y=-H.top();H.pop();
H.push(-(x*y+1));
}
x=Q.top();y=-H.top();
cout<<y-x<<endl;
return 0;
}
T6.线段
思路:经典的区间不重合问题
代码:
#include<bits/stdc++.h>
using namespace std;
#define N 1000050
struct S { int x,y; };
S a[N];
int n,p,q,cnt;
bool cmp(S a,S b) { return a.y<b.y; }
int main()
{
cin>>n;
for(int i=1;i<=n;i++) { cin>>p>>q;a[i].x=p+1;a[i].y=q+1; }
sort(a+1,a+1+n,cmp);
cnt=1;p=a[1].y;
for(int i=2;i<=n;i++)
if(a[i].x>=p) { cnt++;p=a[i].y; }
cout<<cnt<<endl;
return 0;
}
T7.家庭作业
思路:还是带罚款期限的任务调度问题。我们首先将所有的课程按照学分从大到小排序,然后枚举每一个课程,尽可能地安排在靠近期限的位置。
不过需要注意的是本题 \(n\) 范围较大,直接做可能时间超限。可以加一个判断来剪枝,就是如果一个课程无法被安排说明这个课程的期限之前的所有时间都无法被安排,将该期限记为 \(last\) ,以后期限早于 \(last\) 的都可以被跳过,同时不断更新 \(last\) 即可。
代码:
#include<bits/stdc++.h>
using namespace std;
#define N 1000050
struct course{ int ddl,v; };
int n,ans,last;
course a[N];
int vis[N];
bool cmp(course a,course b) { return a.v>b.v; }
int main()
{
cin>>n;
for(int i=1;i<=n;i++) { cin>>a[i].ddl;cin>>a[i].v; }
sort(a+1,a+1+n,cmp);
for(int i=1;i<=n;i++)
{
int book=0;
if(a[i].ddl<last) continue;
for(int j=a[i].ddl;j>=1;j--)
if(!vis[j]) { vis[j]=1;ans+=a[i].v;book=1;break; }
if(!book) last=a[i].ddl;
}
cout<<ans<<endl;
return 0;
}
T8.智力大冲浪
思路:带限期和罚款的任务调度问题。先将 \(n\) 个游戏按照罚款从大到小排序,顺序考虑每个游戏,从该任务的限期往前枚举找空闲的时间完成该游戏;若没有空闲时间,则放弃该游戏,并累加罚款。
代码:
#include<bits/stdc++.h>
using namespace std;
#define N 550
struct T { int t,w; };
int n,m,fine;
T a[N];
int vis[N],book;
inline int read()
{
int x=0,f=1;char ch=getchar();
while(ch<'0'||ch>'9') { if(ch=='-') f=-1;ch=getchar(); }
while(ch>='0'&&ch<='9') { x=x*10+ch-48;ch=getchar(); }
return x*f;
}
bool cmp(T a,T b) { return a.w>b.w; }
int main()
{
m=read();n=read();
for(int i=1;i<=n;i++) a[i].t=read();
for(int i=1;i<=n;i++) a[i].w=read();
sort(a+1,a+1+n,cmp);
for(int i=1;i<=n;i++)
{
book=0;
for(int j=a[i].t;j>=1;j--)
if(!vis[j]) { vis[j]=1;book=1;break; }
if(book==0) fine+=a[i].w;
}
cout<<m-fine<<endl;
return 0;
}
T9.钓鱼
思路:本题难在转化。首先我们要明确一件事,就是钓鱼的顺序一定是从左到右,不会出现折返。其次要考虑的是什么时候前往下一个湖,发现也很难考虑。观察数据范围,发现 \(n\) 很小。可以尝试枚举。
于是有下面的做法:我们枚举终点停在哪个湖,由于每两个湖之间只可能走一次,所以我们现在总时间中减去路上的时间,这样剩下的时间用来钓鱼即可。于是我们就可以每一次贪心的选择有最大鱼数的湖,每钓一次就对所有湖泊按鱼数从大到小排序,不断地选取最大的鱼即可。最后答案就是每一个终点所能调到的鱼中的最大值。
代码:
#include<bits/stdc++.h>
using namespace std;
#define N 150
struct fish{ int f,d; };
int n,h,ans,sum;
fish a[N],b[N];
int s[N],t[N];
bool cmp(fish x,fish y) { return x.f>y.f; }
inline void init()
{
sum=0;
for(int i=1;i<=n;i++) { b[i]=a[i]; }
}
int main()
{
cin>>n>>h;h*=12;
for(int i=1;i<=n;i++) { cin>>a[i].f;b[i].f=a[i].f; }
for(int i=1;i<=n;i++) { cin>>a[i].d;b[i].d=a[i].d; }
for(int i=2;i<=n;i++) { cin>>t[i];s[i]=s[i-1]+t[i]; }
for(int i=1;i<=n;i++)
{
init();
int x=h-s[i];
for(int j=1;j<=x;j++)
{
sort(b+1,b+1+i,cmp);
if(b[1].f<=0) break;
sum+=b[1].f;
b[1].f-=b[1].d;
}
if(sum>ans) ans=sum;
}
cout<<ans<<endl;
return 0;
}
T10.糖果传递
(说实话感觉这个题最难)
思路:先推式子。
假设一共有 \(n\) 个小朋友并且第 \(i\) 个小朋友手中的糖果初始为 \(A_i\) 个,设 \(ave\) 表示小朋友最后每个人手中的糖果数。其中 \(ave=\dfrac{\sum_{i=1}^{n} A_i}{n}\)
假设第 \(i\) 个小朋友会接受第 \(i+1\) 个小朋友传来的 \(X_{i+1}\) 个糖果,并且会给第 \(i-1\) 个小朋友传递 \(X_{i-1}\) 个糖果。特殊的,第 \(1\) 个小朋友会把 \(X_1\) 个糖果传递给第 \(n\) 个小朋友。但是我们可以增加一个第 \(n+1\) 个小朋友使得他与第 \(1\) 个小朋友完全一样(将环形拆为直线型)
那么有,
\(A_1+X_2-X_1=ave\)
\(A_2+X_3-X_2=ave\)
\(A_3+X_4-X_3=ave\)
\(...\)
\(A_n+X_1-X_n=ave\) 即 \(A_n+X_{n+1}-X_n=ave\)
对上述式子进行变形可得,
\(X_2=X_1+ave-A_1\)
\(X_3=X_2+ave-A_2=X_1+ave-A_1+ave-A_2=X_1+2\times ave-(A_1+A_2)\)
\(X_4=X_3+ave-A_3=X_1+3\times ave-(A_1+A_2+A_3)\)
\(...\)
\(X_n=X_1+n\times ave-(\sum_{i=1}^{n}A_i)\)
而最终传递的糖果数(也就是题目中的“代价”)即是 \(\sum_{i=1}^{n}|X_i|\)
如果我们令 \(C_n=(\sum_{i=1}^{n}A_i)-n\times ave\) 则 \(X_n=X_1-C_n\)
所以最终传递的糖果数即为 \(\sum_{i=1}^{n}|X_1-C_i|\)
又注意到 \(C_i\) 可以通过前缀和来处理,所以只需要考虑 \(X_1\) 取何值时最终代价最小。
根据初中知识,只需要将 \(C_i\) 排序后令 \(X_1\) 为中位数即可。至此本题结束。
代码:
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
#define N 1000050
int n,ave;
int a[N];
ll sum,ans,mid,c[N];
int main()
{
cin>>n;
for(int i=1;i<=n;i++) { cin>>a[i];sum+=a[i]; }
ave=sum/n;
for(int i=1;i<=n-1;i++) c[i]=c[i-1]+ave-a[i];
sort(c+1,c+n);
mid=c[n/2];
for(int i=0;i<=n-1;i++) ans+=abs(mid-c[i]);
cout<<ans<<endl;
return 0;
}
T11.加工生产调度
这题洛谷给评紫了。不提前学的话真的很难想到正确做法。
思路:其实就是任务调度问题,我们只需要让两个车间的空闲时间尽可能短即可。我们先将所有的任务划分为两组,\(A_i<B_i\) 的存为一组, \(A_i\geq B_i\) 的存为第二组。第一组按 \(A_i\) 不降序排序,第二组按 \(B_i\) 不增序排序。具体的实现方法是:设置一个结构体数组,输入 \(A_i\) 与 \(B_i\) 然后求出其中的最小值 \(\min\) ,将数组按照 \(\min\) 从小到大排序,符合第一组的放在新数组开头,符合第二组的放在新数组末尾,一直重复此操作即可完成分组。然后模拟计算时间。
代码:
#include<bits/stdc++.h>
using namespace std;
#define N 1050
int n;
int p,q,ans[N],b[N];
int k,t;
struct T{ int x,y,minn,num; };
T a[N];
bool cmp(T a,T b) { return a.minn<b.minn; }
bool cmp2(T a,T b) { return a.num<b.num; }
int main()
{
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i].x;
for(int i=1;i<=n;i++)
{
cin>>a[i].y;
a[i].num=i;
a[i].minn=min(a[i].x,a[i].y);
}
sort(a+1,a+1+n,cmp);
p=0;q=n+1;
for(int i=1;i<=n;i++)
{
if(a[i].minn==a[i].x) { p++;ans[p]=a[i].num;a[i].num=p; }
else { q--;ans[q]=a[i].num;a[i].num=q; } //完成任务分组
}
sort(a+1,a+1+n,cmp2);
for(int i=1;i<=n;i++)//模拟计算时间
{
k+=a[i].x;
if(t<k) t=k;
t+=a[i].y;
}
cout<<t<<endl;
for(int i=1;i<=n;i++) cout<<ans[i]<<" ";
return 0;
}
这道题模拟计算时间的过程也值得学习,可以用很简单的代码直线此类流程时间的计算。