二分與三分
二分是一種常用且非常精妙的演算法。(英才計劃甚至還水了一篇文章)三分法則可以用來解決單峰函式的極值以及相關問題
一、二分
二分法,在一個單調有序的集合或函式中查詢一個解,每次均分為左右兩部分,判斷解在哪一個部分後調整上下界。每次二分都會捨棄一半區間,因此效率比較高。
假設我們有一個非降序陣列 \(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;
}
但是這個三分巢狀是真的難寫,變數最多的一集……