二分+贪心check问题总结
二分+贪心题目总结
概述
二分答案,是将最优性问题转化为可行性问题的一种思路。有时候题目可能会让我们求一个最值,比如说最小值,这个值是由某些操作得到的。通过适当的操作,我们一定能够达到值域内的比最小值更大的值(即可行),但小于最小值的值是不可以到达的。这类问题可能会有一些单调性,比如可能小于最小值的答案会发生“质的变化”,比如违反规则等非法状况。这种单调性并非序列数值的单调性,我们可以把这种“质的变化”前后看做一个抽象的单调性,这样我们就可以使用二分的方法去枚举解,判断其可行性,然后调整枚举区间,直到找到我们想要的答案。
模板
二分答案题目的模板一般是这样的:外面是二分的程序,二分区间的调整取决于check函数,即判断当前答案是否是合法的。模板程序如下:
while(l<=r){
mid=(l+r)/2;
if(check(mid)){ //如果合法,则枚举右半部分区间
ans=mid;
l=mid+1;
} else {
r=mid-1;
}
} //这是一个求最大值的模板,如果合法的话就枚举更大的值,直到不可枚举
printf("%d\n",ans);
例题建模
二分答案的核心在于check()函数怎么写,这个因题而异,需要多做题去理解,下面通过一些例题来说事儿:
-
题目大意是:有一个序列,每过一个时间单位就会减去一个固定的数a,同时每过一个时间单位,你可以选择序列中的一个数,让它瞬间减去b。问最少需要多少个时间单位才能把序列的所有数都变得不大于0。
题目问最短的时间,我们考虑一下,如果时间更长,那必然是有办法使得所有数变成不大于0 的数的,如果时间更短,由于刚才那个已经是最短时间了,所以不可能有方案使得在更短的时间内让所有数满足题意,这是一个抽象的单调性,,并且显然最短时间的值域我们可以估计出来,比如这个时间肯定小于等于最大的数/a+1,所以可以考虑二分答案。
我们需要枚举可能的最短的时间,然后判断其合法性。对于合法性判断,我们贪心地进行一下考虑:假如在t时间内能够满足题意,那么我们可以对初始序列模拟一下,看看每个数-t*a能否变成非正数,如果不能,则消耗瞬间减b的操作,直到其<=0。对所有的数都操作完了之后,我们看看用了几次b操作,如果超过了t次,则非法,否则合法。其实这个地方还是有些不严谨的:这种贪心的使用b操作的策略(能不用就不用)如果没法在t时间内让所有数满足题意,那么会不会有其他的策略使得在t内能满足题意呢?这个是需要想一下的。事实上,在check()函数中,我们经常会用到贪心的思想,大胆猜想,无需证明(雾)。
代码如下:
#include <cstdio> #include <cstdlib> #include <cmath> #include <cstring> #include <cctype> #include <algorithm> #include <iostream> #include <queue> #define ll long long #define M 10008 using namespace std; int a[500005]; int n,A,B; int bsearch(int left,int right); int finished(int value); int main(){ scanf("%d %d %d",&n,&A,&B); for(int i=0;i<n;i++) scanf("%d",&a[i]); int left=1,right=500005; while(left<=right) { int mid=(left+right)/2; if(finished(mid)) right=mid-1; else left=mid+1; } printf("%d\n",left); return 0; } int finished(int value){ int ans=0; for(int i=0;i<n;i++){ if(a[i]-value*A<=0) continue; if((a[i]-value*A)%B==0) ans+=(a[i]-value*A)/B; else ans+=(a[i]-value*A)/B+1; } return ans<=value; } int bsearch(int left,int right){ int mid=(left+right)/2; if(left>right) return -1; if(left==right){ return left; } if(finished(mid)) return bsearch(left,mid); else return bsearch(mid+1,right); }
-
这道题目大意是有n个数组成序列,从中取走m个,使得剩下的相邻的数的最小的差最大,保证原序列非负且单调递增。
这也是一个常见的套路:题目相当于问的是最小值的最大值,并且观察可以发现,如果想用二分的话,可以采用和上面那个题差不多的check()方式,即每次\(O(n)\) 扫一遍序列,发现某两个数的差小于当前枚举值的时候,就不断移去后面的那个,直到满足题意,最后统计一下是否移走了不超过m个数判断合法性即可。
代码如下:
#include <cstdio> #include <cstdlib> #include <algorithm> #define ll long long using namespace std; const int N=5e4+9; ll len,n,m,d[N]; int isvalid(ll mid); int main(){ ll l,r,mid,ans; scanf("%lld %lld %lld",&len,&n,&m); for(int i=1;i<=n;i++){ scanf("%lld",&d[i]); } d[n+1]=len; l=1; r=len; while(l<=r){ //二分最小值的最大值 mid=(l+r)/2; if(isvalid(mid)){ ans=mid; l=mid+1; } else { r=mid-1; } } //? printf("%lld\n",ans); return 0; } int isvalid(ll mid){ int cnt=0; for(int i=0;i<=n+1;i++){ int j=i+1; while(d[j]-d[i]<mid && j<=n+1){ cnt++; j++; } //now i jump to j is valid i=j-1; } if(cnt<=m){ return 1; } return 0; }
-
校队选拔赛D题
题目大意:有一个原字符串和一个目标字符串,现在想通过删除原字符串的一些字符来得到目标字符串。现在给出一个删除序列,序列中的元素是原字符串中的下标,按顺序每次删除那个下标对应的元素。显然对于随意给的序列,有可能会删得得不到目标串,所以我们想知道,至多按照那个给出的删除序列删除几个元素,还能保证原字符串通过我们自己手动观察删除得到目标串。
比如acefd是原串,cfd是目标串,删除序列是3 2 1 5 4,那么显然按照删除序列,3(即原串中的第3个字符e)是可以删除的,删完之后是acfd,然后删除2(即c),不可以,所以至多按照删除序列的顺序删除1个元素。
做这道题最开始我受到P6373 IOI计数的启发,最开始想把删除序列当成一组操作,每删除一个字符,快速统计原字符串中还有几个能形成的目标字符序列(不一定连续),如果目标串固定并且比较简单的话,这个或许是可以的,线段树就可以维护,但是这里目标串和原字符串都是根据输入改变的,所以就不能用这个方法了。
这个题目的正解是二分。思考了一下,如果直接枚举至少按照序列删除多少个元素的话,我们可以通过扫一遍原串,check()一下剩下的是否有目标串即可,但是,在check的时候,哪些位置已经删除过,是需要通过查删除序列才能得到的,所以我们可能要预处理一下原串中每个字符是由删除序列中的哪个位置的元素控制的,比如如果删除序列是2 5 3 1 4,原串是abcde,那么e是由序列中的第二个元素5控制删除的,我们存储q[5]=2,其余元素同理,造一个hash,这样问题便迎刃而解了。
没有代码,理解思路很容易写出代码的。
-
题目大意见原题即可。
这个题我拿线段树水过去的,我按照教室数目建立了一个线段树,然后每次借教室,都是一个区间修改,修改完了之后立刻查询看看有没有非法申请就好了。用线段树必须读优+内联卡常才能过。
想一下这个题能否用二分做:显然,如果某个人申请非法,那么就不用考虑后面的人了,另外,答案肯定是有限的,所以初步判断可以二分。二分枚举最后一个申请者,每次枚举之后,用\(O(n)\) 的时间把那些申请操作进行了,看看是否合法即可。
没用二分写这道题的代码,不过思路已经有了,应该不难写出来。
-
题目大意见原题即可。
这个题目,看起来有种方程求数值解的味道,所以大胆猜测可以二分。为了加快枚举后的计算,我们需要处理一下前缀和。顺便说一句,注意到前缀和一般自带单调性,所以遇到前缀和时可以考虑二分。这里的前缀和并非无脑前缀和,而是判断一下:当价值>w的时候,我们才加这个价值,否则就不加。每个区间计算的时候,直接查前缀和就OK了。根据s-y的正负来判断应该如何调整w即可。
代码如下:
#include <cstdio> #include <cstdlib> #include <algorithm> #include <cmath> #define ll long long using namespace std; const int N=2e5+9; typedef struct { ll left,right; }Interval; Interval val[N]; typedef struct{ ll w,v; }NB; NB a[N]; ll s,n,m,sum[N],sumv[N]; ll calculate(ll now); int main(){ ll l,r,mid,ans,now; scanf("%lld %lld %lld",&n,&m,&s); for(int i=1;i<=n;i++){ scanf("%lld %lld",&a[i].w,&a[i].v); } for(int i=1;i<=m;i++){ scanf("%lld %lld",&val[i].left,&val[i].right); } l=0; r=1e6+1; ans=2e12+9; while(l<=r){ mid=(l+r)/2; now=calculate(mid); if(abs(s-now)<ans){ ans=abs(s-now); } if(s>=now){ r=mid-1; } else { l=mid+1; } } printf("%lld\n",ans); return 0; } ll calculate(ll now){ //给定w=now ll ret=0; for(int i=1;i<=n;i++){ if(a[i].w>=now){ sum[i]=sum[i-1]+1; sumv[i]=sumv[i-1]+a[i].v; } else { sum[i]=sum[i-1]; sumv[i]=sumv[i-1]; } } for(int i=1;i<=m;i++){ ret=ret+(sum[val[i].right]-sum[val[i].left-1])*(sumv[val[i].right]-sumv[val[i].left-1]); } return ret; }
-
题目大意:有一个图,想从s点走到t点,问所走路径的最长边的最小值是多少。
这个题最开始我是用最小生成树做的,但是总感觉这样难以理解为什么最小生成树一定保证了最长边最小。
又看了看题解区,有二分答案的做法,特来学习一下。最大值最小问题也是一种二分经典问题,最大值,意味着更大就非法,最小,意味着局部有单调性,可以调整答案。我们可以二分枚举最长边的长度,然后把图中小于等于这个长度的边用并查集放到一起(相当于重新建了一个图),最后判断s与t是否都在并查集中即可(巧妙在用并查集判断可达性),可见这种二分的方法是不存在MST做法中的难以说明严谨性的问题的。并且,这个问题也说明了,只要你会写check,不管是数列还是图,都可以二分。
这个题还没拿二分写过代码,但是感觉很有必要,所以先占坑。
-
题目大意:如题目名字所示
这个题原本是个快排思想就能搞定的问题,但是,看到题解中给出了二分答案的方法,感觉很新颖很有道理,并且复杂度还不错,所以就总结一下。注意到权值线段树的退化版本是桶,并且这个题是让求第几大的数,所以,我们可能会考虑用桶去做这道题。用桶处理完所有数据出现次数之后,预处理桶的前缀和,然后二分答案,这样复杂度主要在读入和前缀和处理上,二分的复杂度并不是主要矛盾了。
代码如下:
#include <cstdio> #include <cstdlib> #include <algorithm> #define ll long long using namespace std; const int N=1e4+9; const int M=3e4+9; int bucket[M],n,k,sum[M],ans; int main(){ int x,l,r,mid; scanf("%d%d",&n,&k); for(int i=1;i<=n;i++){ scanf("%d",&x); if(bucket[x]) continue; bucket[x]=1; } for(int i=1;i<M;i++){ sum[i]=sum[i-1]+bucket[i]; } l=1; r=M-1; ans=M; while(l<=r){ mid=(l+r)/2; if(sum[mid]<k){ l=mid+1; } else { if(sum[mid]==k){ ans=min(ans,mid); } r=mid-1; } } if(ans!=M){ printf("%d\n",ans); } else { printf("NO RESULT\n"); } return 0; }
-
题目大意:有一个图,现在要把图的结点集合分成两个集合,分处于不同集合的点之间的边忽略,处于同一个集合中的点之间的边保留,求这两个集合内部最大的边的最小值。
看到求最大值的最小值,所以马上想到这个题目可能可以二分答案。并且,看到题目中说的把整个点集分成两个集合,所以也想到了二分图。可以知道,如果原图是二分图的话,那么可以通过划分使得两个集合内部没有任何边,即最大边为0。
本题确实可以用二分和二分图来做:考虑二分答案,枚举最大值m,则边权更大的值不能出现在两个集合的内部,那它能出现在哪里呢?它应该只能出现在两个集合之间。所以一个思路就是:如果我忽略掉边权小于m等于的边,只考虑大于m的边,如果这些边和点能够组成一个二分图,那么这个m就是合理的,我们就可以找更小的最大值,否则我们就找更大的最大值。
这里涉及到一个问题就是如何判定一个图是不是二分图。这里我在题解区学到了一种方法:染色法。对图进行bfs,如果一个点没有被染色,我们可以标记其颜色为1,然后与其直接相连的结点的颜色为2,然后保证相邻结点的染色不同。如果某个结点之前被染色了,而后来又发生了冲突,则原图不是二分图。并且容易发现,被染成相同颜色的点,正好处于同一个集合。这样,这道题就做完了。
这道题目的一个启示是:二分有两种,一种是以数组下标为区间进行二分,另一种是以答案值域为区间进行二分。两种二分有时候均可以,但是对于本题来讲,由于图存了双向边,所以如果对边集数组下标二分的话,会出现问题,即可能某条边的正向边被pass了,但是反向边却被选进来了,导致错误。
代码如下:
#include <bits/stdc++.h> #define ll long long #define INF 9999999999999 using namespace std; const int N=2e4+9; const int M=2e5+9; typedef struct{ ll to,nxt,weight,id; }Edge; Edge edge[M],copyedge[M]; ll head[N],cnt,n,m,ans=INF,color[N],vis[N],maxweight; bool flag; queue<ll> q; bool check(ll mid); void add(ll x,ll y,ll z); int main(){ ll x,y,z,l,r,mid; scanf("%lld %lld",&n,&m); for(int i=0;i<=n;i++){ head[i]=-1; } for(int i=1;i<=m;i++){ scanf("%lld %lld %lld",&x,&y,&z); add(x,y,z); add(y,x,z); maxweight=max(maxweight,z); } // for(int i=0;i<cnt;i++){ // copyedge[i]=edge[i]; // copyedge[i].id=i; //记录排序之前的编号 // } // sort(copyedge,copyedge+cnt); // for(int i=0;i<cnt;i++){ // edge[copyedge[i].id].id=i; //把每条边的排名赋给原edge数组,便于后续遍历 // } l=0; r=maxweight; while(l<=r){ mid=(l+r)/2; if(check(mid)){ r=mid-1; ans=mid; } else { l=mid+1; } } printf("%lld\n",ans); return 0; } bool check(ll mid){ ll now; for(int i=0;i<=n;i++){ color[i]=0; vis[i]=false; } while(!q.empty()){ q.pop(); } for(int i=1;i<=n;i++){ //防止因为不连通导致没法遍历全图 if(!vis[i]){ color[i]=1; q.push(i); while(!q.empty()){ now=q.front(); q.pop(); vis[now]=true; for(ll j=head[now];j>=0;j=edge[j].nxt){ if(edge[j].weight<=mid) continue; //只看mid以及之后的边组成的图 if(color[edge[j].to]){ //如果已经染过色了 if(color[edge[j].to]==color[now]){ //如果冲突 return false; } } else { if(color[now]==1){ color[edge[j].to]=2; } else if(color[now]==2){ color[edge[j].to]=1; } q.push(edge[j].to); } } } } } return true; } //bool operator <(const Edge &a,const Edge &b){ // return a.weight<b.weight; //} inline void add(ll x,ll y,ll z){ edge[cnt].to=y; edge[cnt].nxt=head[x]; edge[cnt].weight=z; head[x]=cnt++; }
-
题目大意:有m本书,k个人,每本书有不同的页数,每个人抄写编号连续的几本书,假设每个人的抄写速度是一定的,现在要求分派完任务之后,能使得抄写时间最少。求分派任务的方案。
我们可以把题意转化成:让这k个人中任务量最大的人的任务尽可能少,所以看起来这道题可以二分。不过这道题并不是让求最短用多少时间,而是求分配方案,所以在二分的时候,虽然我们是对任务量进行的二分,但是我们也要在check的时候记录一下我们是怎样选的。
先想一下如果是让求最短时间的话,我们可以枚举任务量最大的人的任务量,显然枚举范围是\([0,\sum m_i]\) ,在check时,假设传入参数是key,一种思路是贪心地取书,只要不超过key就可以把这本书加入自己的抄写任务中,最后看最大值是否小于key。但是这个题有点坑,它说如果有多组方案,则希望前面的人尽可能少抄写(题目数据保证每个人都必然有活干),但是如果按照前面的人尽可能少抄写的策略分配任务则很容易pass掉正解。这时候改变一下想法:我们先给后面的人分派工作,让后面的人抄写尽可能多,这样相应地前面的人抄写就少了。
代码如下:
#include <bits/stdc++.h> #define ll long long #define INF 99999999999 using namespace std; const int M=505; typedef struct{ ll left,right; }NB; ll a[M],m,k; NB ans[M],tmpans[M]; bool check(ll key); int main(){ ll l=0,r=0,mid; scanf("%lld %lld",&m,&k); for(int i=1;i<=m;i++){ scanf("%lld",&a[i]); r+=a[i]; } while(l<=r){ mid=(l+r)/2; if(check(mid)){ r=mid-1; } else { l=mid+1; } } for(ll i=1;i<=k;i++){ printf("%lld %lld\n",ans[i].left,ans[i].right); } return 0; } bool check(ll key){ ll sum=0,i,r=m,j=k; for(i=m;i>0;i--){ if(sum+a[i]<=key){ sum+=a[i]; } else { tmpans[j].left=i+1; tmpans[j].right=r; if(tmpans[j].left>tmpans[j].right){ return false; //一本书都放不下 } j--; //下一个人 if(j==0){ break; //人用完了 } sum=0; r=i; i++; } } if(i==0){ tmpans[j].left=1; //最后一个被分配任务的人并没有记录下来任务,所以要记录一下 tmpans[j].right=r; for(ll l=1;l<=k;l++){ ans[l]=tmpans[l]; } return true; } return false; }