【寻迹#2】二分与三分
二分与三分
二分是一种常用且非常精妙的算法。(英才计划甚至还水了一篇文章)三分法则可以用来解决单峰函数的极值以及相关问题
一、二分
二分法,在一个单调有序的集合或函数中查找一个解,每次均分为左右两部分,判断解在哪一个部分后调整上下界。每次二分都会舍弃一半区间,因此效率比较高。
假设我们有一个非降序数组 \(a\) ,现要查找 \(a\) 中一个元素 \(x\) , 搜索数组的下标范围为 \([L,R]\) ,我们每次取一个中间值 \(mid=\frac{(L+R)}{2}\) ,接下来判断 \(a_{mid}\) 与 \(x\) 的大小关系,如果 \(a_{mid}<x\) 我们要在 \([mid+1,R]\) 中进行进一步的搜索,反之,我们要在 \([L,mid]\) 中进行进一步的搜索。直到我们在某一次的搜索中搜索的范围 \([L,R]\) 中只含有一个元素。
若求解的问题的定义域为整数域,对于长度为 \(N\) 的求解区间,算法需要 \(\log_2{N}\) 次确定出分界点。
对于定义域在实数域上的问题,可以用类似的方法,判断 \(R-L\) 的精度是否达到要求,即 \(R-L\geq eps\) ,但由于实数运算的精度问题,若 \(eps\) 取得太小就会导致程序死循环,因此指定二分次数更好。如果指定二分次数 \(t\) ,对于初始区间 \(L\) ,算法结束后 \(R-L\) 的值应为 \(\dfrac{L}{2^t}\) ,根据这个值来判断是否达到精度要求。
二分算法的复杂度为 \(二分次数单次判定复杂度O(二分次数\times 单次判定复杂度)\)
二、二分法常见模型
1.二分答案
最小值最大(或最大值最小)问题被称为双最值问题。双最值问题在确定答案区间后,可以用二分法二分答案,配合其他算法验证答案是否合理。根据复杂度理论,检验一个答案是否合理比直接求解一个答案的复杂度要低。因此可以将最优化问题转化为判定问题。例如,长度为 \(n\) 的序列 \(a_i\) 最多分成 \(m\) 个连续段,求所有分法中每段和的最大值的最小值是多少。
2.二分查找
最为基础最为简单的应用,例如查找 \(x\) 的排名。
3.代替三分
对于一些单峰函数,我们可以用二分导函数的方法求解函数极值,这时通常将函数的定义域定义为整数域求解比较方便,此时 \(dx\) 可以直接取整数 \(1\) 。
三、三分
三分法适用于求解凸性函数的极值问题,二次函数就是一个典型的单峰函数。
三分法与二分法一样,它会不断缩小答案所在的求解区间。二分法缩小区间利用的原理是函数的单调性,而三分法利用的则是函数的单峰性。
设当前求解区间为 \([l,r]\) ,令 \(m_1=l+\dfrac{r-l}{3}\) , \(m_2=r-\dfrac{r-l}{3}\) ,接着我们计算这两个点的函数值 \(f(m_1)\) , \(f(m_2)\) 之后我们将两点中函数值更优的那个点称为好点,而函数值更差的称为坏点。 如果 \(m_1\) 是坏点,则下一个区间为 \([m_1,r]\) ;反之( \(m_2\) 是坏点)下一个区间为 \([l,m_2]\)
下面以求上凸单峰函数最大值为例,给出代码:
double l=0,r=1e9;
while(r-l<=1e-3)
{
double m1=l+(r-l)/3,m2=r-(r-l)/3;
if(f(m1)<f(m2)) l=m1;//m1是坏点
else r=m2;
}
四、题单
T1.愤怒的牛
思路:直接二分两头牛之间的最小距离,区间为 \([1,maxx]\) ,其中 \(maxx=\max (a_i)\) ,对于每一个距离我们只需写一个 \(\operatorname{check}\) 函数判断 \(mid\) 处满足条件的牛舍是否达到 \(m\) 个,如果是则 \(l=mid\) ,否则 \(r=mid-1\)
代码:
#include<bits/stdc++.h>
using namespace std;
#define N 100050
int n,m;
int l,r,mid;
int p,cnt;
int a[N];
int check(int x)
{
cnt=1;p=a[1];
for(int i=2;i<=n;i++)
if(p+x<=a[i]) { cnt++;p=a[i]; }
if(cnt>=m) return 1;
return 0;
}
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>a[i];
sort(a+1,a+1+n);
l=1,r=a[n];
while(l<r)
{
mid=(l+r+1)/2;
if(check(mid)) l=mid;
else r=mid-1;
}
cout<<l<<endl;
return 0;
}
T2.Best Cow Fences
思路:首先要看到数据范围 \(n\leq 1\times10^5\) ,大概率是用二分了。进而发现可以浮点二分平均数,然后验证是否存在这样一个子段。原因是:枚举长度答案不具有单调性,但是枚举平均数的话答案就具有单调性。接下来需要考虑的是怎么写 \(\operatorname{check}\) 函数。思路是,每次 \(\operatorname{check}\) 的时候求前缀和,并将每个元素减去 \(mid\) (当前平均数为 \(mid\) ),然后只需求出最大子段和判断是否大于 \(0\) 即可。
还有一个难点就是最大子段和的求解。对于终点 \(r\) 的一个子段,其最大子段和为 \(s_r-minn\) ,其中 \(minn=\min\limits_{1\leq i\leq r-L}(s_i)\)
代码:
#include<bits/stdc++.h>
using namespace std;
#define N 100050
const double eps=1e-6;
int n,L;
double l,r,mid;
double m,s[N],a[N];
int check(double x)
{
m=0.0;
for(int i=1;i<=n;i++) s[i]=s[i-1]*1.0+a[i]*1.0-x*1.0;
for(int i=L;i<=n;i++)
{
m=min(m,s[i-L]);
if(s[i]-m>=0) return 1;
}
return 0;
}
int main()
{
cin>>n>>L;
for(int i=1;i<=n;i++) cin>>a[i];
l=0.0,r=2000.0;
while(r-l>eps)
{
mid=(l+r)/2.0;
if(check(mid)) l=mid;
else r=mid;
}
cout<<(int)(r*1000)<<endl;
return 0;
}
还有就是这道题是浮点二分,细节真的巨多!
T3.曲线
思路:题目中说二次函数可能退化为一次函数,又因为 \(a>0\) 所以函数 \(S_i(x)\) 只可能先减后增或者单增,所以 \(F(x)=\max\limits_{1\leq i\leq n}(S_i(x))\) 也会有相似的性质。因此直接在定义域内浮点三分即可。
代码:
#include<bits/stdc++.h>
using namespace std;
#define N 100050
const double eps=1e-8;
int T,n;
double l,r,lmid,rmid;
struct Curves{ double a,b,c; };
Curves s[N];
double Cal(double x)
{
double maxx=-0x7f7f7f7f;
for(int i=1;i<=n;i++) maxx=max(maxx,s[i].a*x*x+s[i].b*x+s[i].c);
return maxx;
}
int main()
{
cin>>T;
while(T--)
{
cin>>n;
for(int i=1;i<=n;i++) cin>>s[i].a>>s[i].b>>s[i].c;
l=0.0,r=1000.0;
while(r-l>eps)
{
lmid=l+(r-l)/3.0;
rmid=r-(r-l)/3.0;
if(Cal(lmid)>=Cal(rmid)) l=lmid;
else r=rmid;
}
printf("%.4lf\n",Cal(r));
}
return 0;
}
T4.数列分段Ⅱ
思路:二分答案。需要注意的细节:如果当前答案 \(mid\) 处分段次数大于 \(m\) ,说明指定的和太小,需要在 \([mid,r]\) 中继续查找;如果当前答案 \(mid\) 处分段次数恰好等于 \(m\) ,还需要再看看有没有更小的答案,所以要在 \([l,mid]\) 中查找;如果当前答案 \(mid\) 处分段次数小于 \(m\) ,则说明指定的和太大,也需要在 \([l,mid]\) 中查找。
代码:
#include<bits/stdc++.h>
using namespace std;
#define N 100050
int n,m,a[N];
int l,r,mid,sum,cnt,maxx;
int check(int x)
{
sum=0;cnt=1;
for(int i=1;i<=n;i++)
{
if(sum+a[i]<=x) sum+=a[i];
else { sum=a[i];cnt++; }
}
if(cnt>m) return 1;
return 0;
}
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++) { cin>>a[i];sum+=a[i];maxx=max(maxx,a[i]); }
l=maxx;r=sum;
while(l<r)
{
mid=(l+r)/2;
if(check(mid)) l=mid+1;
else r=mid;
}
cout<<l<<endl;
return 0;
}
T5.扩散
思路:时间可以看作是单调的。所以想到二分时间。接下来要考虑怎么判断答案是否合理。设任意两点间曼哈顿距离为 \(x\) ,可以发现,两个点形成连通块至少需要 \(\dfrac{x}{2}\) 的时间(两个点都在扩增)。对于每一次 \(\operatorname{check}\) 用并查集维护点之间的联通关系,最后只需验证是否只有一个父节点即可。记得每次二分都要初始化并查集。
代码:
#include<bits/stdc++.h>
using namespace std;
#define N 100
struct Dots{ int x,y; };
Dots a[N];
int fa[N];
int n,l,r,mid;
inline void init() { for(int i=1;i<=n;i++) fa[i]=i; }
inline int Find(int x)
{
if(fa[x]==x) return x;
return fa[x]=Find(fa[x]);
}
int check(int m)
{
for(int i=1;i<=n;i++)
{
for(int j=i+1;j<=n;j++)
{
int p=Find(i),q=Find(j);
int dist=abs(a[i].x-a[j].x)+abs(a[i].y-a[j].y);
if(dist<=m*2) { if(p!=q) fa[p]=q; }
}
}
int cnt=0;
for(int i=1;i<=n;i++) { if(fa[i]==i) cnt++; }
if(cnt==1) return 1;
return 0;
}
int main()
{
cin>>n;
for(int i=1;i<=n;i++) { cin>>a[i].x;cin>>a[i].y; }
l=0;r=1e9;
while(r>l)
{
init();
mid=(l+r)>>1;
if(check(mid)) r=mid;
else l=mid+1;
}
cout<<r<<endl;
return 0;
}
T6.灯泡
思路:更像是一道数学题。先推式子。假设人到墙的距离为 \(x\) ,影长为 \(L\) 。当 \(x\in(\dfrac{hD}{H},D]\) 时,此时影子一定在地上。可以推知 \(L=-\dfrac{h}{H-h}x+\dfrac{hD}{H-h}\) 是单减的,所以最终答案肯定不会在这个区间里。进而考虑 \(x\in[0,\dfrac{hD}{H}]\) ,此时影长等于地上的影子长度(即为 \(x\) )加上墙壁上的影子长度,设为 \(n\) 。根据初中平面几何知识可以求处 \(n=\dfrac{hD-Hx}{D-x}\) ,所以最终影子长度为 \(L=n+x=\dfrac{-x^2+(D-H)x+hD}{D-x}\) ,定义域内函数可能是单调也可能是单峰。所以选择了三分写法。
代码:
#include<bits/stdc++.h>
using namespace std;
const double eps=1e-12;
int T;
double H,h,D;
double l,r,lmid,rmid;
double check(double x){ return ((-x*x+(D-H)*x+h*D)/(D-x)); }
int main()
{
cin>>T;
while(T--)
{
cin>>H>>h>>D;
l=0.0,r=h*D/H;
while(r-l>eps)
{
lmid=l+(r-l)/3;rmid=r-(r-l)/3;
if(check(lmid)>=check(rmid)) r=rmid;
else l=lmid;
}
printf("%.12lf\n",check(r));
}
return 0;
}
T7.传送带
思路:比较自然地想到答案一定是由线段 \(AE,EF,FD\) 构成,其中 \(E\) 在 \(AB\) 上, \(F\) 在 \(CD\) 上。所以只需确定 \(E,F\) 两点即可。假设已经确定了 \(E\) ,考虑 \(F\) 的位置。我们会发现 \(EF+FD\) 是一个单峰或者单调的函数,所以三分即可。那我们怎么确定 \(E\) ?我们发现确定 \(E\) 也可以使用三分,对每一个 \(E\) 去找一个 \(F\) 最后找到能够使时间最短的 \(E,F\) 即可。
代码:
#include<bits/stdc++.h>
using namespace std;
const double eps=1e-8;
double ax,ay,bx,by,cx,cy,dx,dy,p,q,r;//题目输入
double l1x,l1y,r1x,r1y,p1x,p1y,l1midx,l1midy,r1midx,r1midy,ans1l,ans1r;//外层三分 1均表示外层
double l2x,l2y,r2x,r2y,p2x,p2y,l2midx,l2midy,r2midx,r2midy,ans2l,ans2r;//内层三分 2均表示内层
double ans;
inline double dist (double x1,double y1,double x2,double y2) { return sqrt((x1-x2)*(x1-x2)+(y1-y2)*(y1-y2)); }
inline double cal(double fx,double fy) { return dist(fx,fy,dx,dy)/q; }
inline double check(double ex,double ey)
{
l2x=cx;l2y=cy;r2x=dx;r2y=dy;
while(dist(l2x,l2y,r2x,r2y)>eps)
{
p2x=(r2x-l2x)/3;p2y=(r2y-l2y)/3;
l2midx=l2x+p2x;l2midy=l2y+p2y;
r2midx=r2x-p2x;r2midy=r2y-p2y;
ans2l=cal(l2midx,l2midy)+dist(ex,ey,l2midx,l2midy)/r;
ans2r=cal(r2midx,r2midy)+dist(ex,ey,r2midx,r2midy)/r;
if(ans2l-ans2r>eps) { l2x=l2midx;l2y=l2midy; }
else { r2x=r2midx;r2y=r2midy; }
}
return (cal(l2x,l2y)+dist(ex,ey,l2x,l2y)/r);
}
int main()
{
cin>>ax>>ay>>bx>>by;
cin>>cx>>cy>>dx>>dy;
cin>>p>>q>>r;
l1x=ax;l1y=ay;r1x=bx;r1y=by;
while(dist(l1x,l1y,r1x,r1y)>eps)
{
p1x=(r1x-l1x)/3;p1y=(r1y-l1y)/3;
l1midx=l1x+p1x;l1midy=l1y+p1y;
r1midx=r1x-p1x;r1midy=r1y-p1y;
ans1l=check(l1midx,l1midy)+dist(ax,ay,l1midx,l1midy)/p;
ans1r=check(r1midx,r1midy)+dist(ax,ay,r1midx,r1midy)/p;//计算左右两个分点的答案值
if(ans1l-ans1r>eps) { l1x=l1midx;l1y=l1midy; }
else { r1x=r1midx;r1y=r1midy; }
}
ans=check(l1x,l1y)+dist(ax,ay,l1x,l1y)/p;
printf("%.2lf\n",ans);
return 0;
}
但是这个三分嵌套是真的难写,变量最多的一集……