【算法學習】點分治


【算法梗概】

點分治,是一種針對可帶權樹上簡單路徑統計問題的算法。本質上是一種帶優化的暴力,帶上一點容斥的感覺。

注意對於樹上路徑,並不要求這棵樹有根,即我們只需要對無根樹進行統計。接下來請把無根樹這一關鍵點牢記於心。

【引入】

話不多說,先看一題:

給定一棵樹,樹上的邊有權值,給定一個閾值\(k\),請統計這棵樹上總長度小於等於\(k\)的路徑個數。

路徑長度為路徑路徑上所有邊的權值和。

這就是POJ 1741

題意描述很清楚,你是否已經有了想法?

考慮簡單的DFS過程,能否統計答案?

DFS把樹看作有根樹,那么對於一個子樹\(\mathfrak T\),根節點為\(t\),如何統計\(\mathfrak T\)中的路徑個數(答案)?

我們考慮\(\mathfrak T\)中的路徑。

把路徑分為兩種:

一、經過\(t\)的路徑。

二、不經過\(t\)的路徑。

這樣分類是顯然正確的,而且對於不經過\(t\)的路徑,它們一定在\(t\)的某個子節點所構成的子樹中。

這樣就對答案進行了划分:樹\(\mathfrak T\)的答案等於\(\mathfrak T\)中經過\(t\)的路徑的答案加上\(t\)的所有子節點構成的子樹的答案

對於第二部分的答案,我們遞歸處理;現在考慮計算第一部分的答案,即計算經過\(t\)的路徑的個數。

這里提供一種思路:

考慮路徑的合並,利用容斥去除非法路徑:

“路徑的合並”說的是\(t\)到\(\mathfrak T\)中的任意一個節點(包括自身)的路徑集合的合並。

比如看下面這張圖:

現在樹根是\(A\)點,那么路徑的集合是:\(\left\{\begin{matrix}A\\A\to B\\A\to B\to D\\A\to B\to E\\A\to C\\A\to C\to F\end{matrix}\right\}\)。

兩兩組合,共有\(C_{n+1}^{2}=C_{7}^{2}=21\)種不同的方式。但是顯然有不合法的路徑:比如\(A\to B\to D\;,\;A\to B\to E\)不能合並。

注意到一條路徑可以簡單地用路徑的終點表示,那么共有子樹節點個數條路徑,而能夠合並的路徑只有在不同子樹的路徑,而在同一子樹的路徑無法合並。

當然,可以合並的路徑也可能因為路徑長度大於\(k\)而不能計入答案。

先不考慮非法的路徑,看看如何統計長度小於等於\(k\)的路徑:

  • 通過一次DFS,把所有從根向子樹的路徑長度處理出來,在數組里排序,這一步時間為\(O(n\;log\;n)\),\(n\)為子樹節點數。
  • 通過雙指針掃描的技巧,在\(O(n)\)的時間內計算答案;或者直接在數組內二分,在\(O(n\;log\;n)\)的時間內計算。

那么接下來考慮容斥去除非法的路徑,對於根節點的每個子節點代表的子樹,按照同樣方式統計答案,並把得出的結果從原來的答案中減去即可。

真的就行了嗎?

其實還要考慮子樹的路徑長度,剛才對根的計算時,子樹的所有路徑長都加上了根到子樹的邊的權值。

那么在對這棵子樹計算時,注意子樹的路徑也都要加上這條邊的權值,這樣正好和原來的路徑長度吻合。

或者,同樣的道理,因為這條邊多計算了兩次,把\(k\)相應地減少\(2\)倍的這條邊的權值也可以。

那么對於一個節點,總的時間復雜度為\(O(n\;log\;n)\),\(n\)為子樹節點個數。

那么這樣,就能對於一個節點的子樹統計答案了,最終把所有答案加起來就行了。

但是,真的就行了嗎?請看下一個內容。

【算法核心】

可以看到,計算一個節點的復雜度為\(O(n\;log\;n)\),但要保證總復雜度不超過一個量級卻很難。

但是我們可以利用無根樹的性質!

可以看到,把一個點的答案算完時,它的子節點所代表的子樹就互不影響了!

就是說,這些子樹彼此獨立,可以完全當作一個新的子問題處理。

那么考慮如下算法:

  1. 對於這棵無根樹,找到一個點,使得它在樹的中心位置,滿足如果以它為根,它的最大子樹大小盡量小,這個點稱為重心
  2. 以這個點為根,計算它的答案。
  3. 把以這個點為根的樹的所有子樹單獨作為一個子問題,回到步驟\(1\)遞歸處理。

這個算法的復雜度是多少呢?

先介紹一個定理:以樹的重心為根的有根樹,最大子樹大小不超過\(\frac{n}{2}\)。

假設超過了,大小為\(k>\frac{n}{2}\),那么其他子樹大小之和等於\(n-k-1\)。

那么把重心往這個子樹方向移動,最大子樹大小一定減小,因為\(n-k<\frac{n}{2}<k\)。

那么進一步地,就證明了經過這個算法,遞歸的次數是\(O(log\;n)\)級別。

這樣,就進一步說明了算法總時間復雜度不超過\(O(n\;log^2\;n)\)。

【算法實現】

按照上述步驟實現代碼:

①計算重心位置:使用一次簡單的DFS來實現。

②計算答案:直接用另一個DFS計算。

③分治子問題:重新調用尋找重心的DFS函數,再遞歸求解即可。

那么可以根據此,寫出代碼:

void GetRoot(int u,int f){
	siz[u]=1; wt[u]=0;
	eF(i,u) if(to[i]!=f&&!vis[to[i]])
		GetRoot(to[i],u), siz[u]+=siz[to[i]], wt[u]=max(wt[u],siz[to[i]]);
	wt[u]=max(wt[u],Tsiz-siz[u]);
	if(wt[Root]>wt[u]) Root=u;
}

這是尋找重心的函數,需要傳入父節點,還要調用vis數組。調用前保證Root等於0,並且wt[0]等於無限大。

注意第5行,Tsiz是當前處理的樹的大小,這是因為把無根樹轉成有根樹后,父親所連的子樹也是自己的孩子了。

void Dfs(int u,int D,int f){
	arr[++cnt]=D;
	eF(i,u) if(to[i]!=f&&!vis[to[i]]) Dfs(to[i],D+w[i],u);
}

int calc(int u,int D){
	cnt=0; Dfs(u,D,0); int l=1,r=cnt,sum=0;
	sort(arr+1,arr+cnt+1);
	for(;;++l){
		while(r&&arr[l]+arr[r]>k) --r;
		if(r<l) break;
		sum+=r-l+1;
	}
	return sum;
}

這兩個是計算答案的函數,Dfs統計路徑長度,而calc計算答案,使用了雙指針的技巧。

void DFS(int u){
	Ans+=calc(u,0); vis[u]=1;
	eF(i,u) if(!vis[to[i]]){
		Ans-=calc(to[i],w[i]);
		Root=0, Tsiz=siz[to[i]], GetRoot(to[i],0);
		DFS(Root);
	}
}

這是點分治的核心函數,傳入的是當前樹的重心,在調用時計算重心的答案。

然后求每個子樹的重心,再遞歸求解。

對於剛剛的題目,有如下代碼實現:

#include<cstdio>
#include<algorithm>
#include<cstring>
#define F(i,a,b) for(int i=a;i<=(b);++i)
#define eF(i,u) for(int i=h[u];i;i=nxt[i])
using namespace std;
const int INF=0x3f3f3f3f;

int n,k,Ans;

int h[10001],nxt[20001],to[20001],w[20001],tot;
inline void ins(int x,int y,int z){nxt[++tot]=h[x];to[tot]=y;w[tot]=z;h[x]=tot;}

bool vis[10001];
int Root,Tsiz,siz[10001],wt[10001];
int arr[10001],cnt;

void GetRoot(int u,int f){
	siz[u]=1; wt[u]=0;
	eF(i,u) if(to[i]!=f&&!vis[to[i]])
		GetRoot(to[i],u), siz[u]+=siz[to[i]], wt[u]=max(wt[u],siz[to[i]]);
	wt[u]=max(wt[u],Tsiz-siz[u]);
	if(wt[Root]>wt[u]) Root=u;
}

void Dfs(int u,int D,int f){
	arr[++cnt]=D;
	eF(i,u) if(to[i]!=f&&!vis[to[i]]) Dfs(to[i],D+w[i],u);
}

int calc(int u,int D){
	cnt=0; Dfs(u,D,0); int l=1,r=cnt,sum=0;
	sort(arr+1,arr+cnt+1);
	for(;;++l){
		while(r&&arr[l]+arr[r]>k) --r;
		if(r<l) break;
		sum+=r-l+1;
	}
	return sum;
}

void DFS(int u){
	Ans+=calc(u,0); vis[u]=1;
	eF(i,u) if(!vis[to[i]]){
		Ans-=calc(to[i],w[i]);
		Root=0, Tsiz=siz[to[i]], GetRoot(to[i],0);
		DFS(Root);
	}
}

int main(){
	int x,y,z;
	while(~scanf("%d%d",&n,&k)&&n&&k){
		tot=Ans=0; memset(vis,0,sizeof vis); memset(h,0,sizeof h);
		F(i,2,n) scanf("%d%d%d",&x,&y,&z), ins(x,y,z), ins(y,x,z);
		wt[0]=INF; Tsiz=n; GetRoot(1,0);
		DFS(Root);
		printf("%d\n",Ans-n);
	}
	return 0;
}

【總結】

點分治是經典的分治思想在樹上的應用,是很重要的OI算法。

其精髓在於把無根樹平均地分割成若干互不影響的子問題求解,極大降低了時間復雜度,是一種巧妙的暴力。

【注】

細心的讀者可能已經發現,代碼中在分治過程中,對下一層分治塊的總結點數處理可能會出錯,但是不影響復雜度,具體證明見http://liu-cheng-ao.blog.uoj.ac/blog/2969


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM