【尋跡】二分與三分

A&K.SKULL發表於2024-07-07

二分與三分

二分是一種常用且非常精妙的演算法。(英才計劃甚至還水了一篇文章)三分法則可以用來解決單峰函式的極值以及相關問題

一、二分

二分法,在一個單調有序的集合或函式中查詢一個解,每次均分為左右兩部分,判斷解在哪一個部分後調整上下界。每次二分都會捨棄一半區間,因此效率比較高。

假設我們有一個非降序陣列 \(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;
}

但是這個三分巢狀是真的難寫,變數最多的一集……

相關文章