1. 程式人生 > >樹形揹包例題及實現技巧

樹形揹包例題及實現技巧

一、選課(模板題)

在大學裡每個學生,為了達到一定的學分,必須從很多課程裡選擇一些課程來學習,在課程裡有些課程必須在某些課程之前學習,如高等數學總是在其它課程之前學習。現在有n門功課,每門課有個學分,每門課只有一門或沒有直接先修課(若課程a是課程b的先修課即只有學完了課程a,才能學習課程b)。一個學生要從這些課程裡選擇m門課程學習,問他能獲得的最大學分是多少?

第一行有兩個整數nm(1\leqslant n\leqslant 2001\leqslant m\leqslant 150)。接下來的n行,第i+1行包含兩個整數k[i]s[i]k[i]表示第i門課的直接先修課,s[i]表示第i門課的學分。若k[i]=0表示沒有直接先修課(1\leqslant k[i]\leqslant n1\leqslant s[i]\leqslant 20)。

樹形揹包其實比較類似揹包問題中的分組揹包,想要取到一個物品,必須先取它的父親。設dp[i][j]為以i為根的子樹中取j個物品的最大價值,非常類似普通揹包中前i

種物品中取j個的最大價值的狀態定義,但不同的是,樹形揹包通過子樹合併的方式更新答案。核心程式碼如下。

void dfs(int x)
{
	dp[x][1]=s[i];
//因為要取到以x為根的子樹中的物品,必須先取x本身,所以以x為根的子樹中取1個物品的最大價值一定等於s[i]
	for(int i=first[x];i;i=next[i])
	{
		dfs(to[i]);
		for(int j=m;j;j--)
//因為樹形揹包屬於01揹包,一個物品只能取一次,所以使用倒序迴圈,防止一個物品被重複取
			for(int k=1;k<=j;k++)
				dp[x][j]=max(dp[x][j],dp[x][j-k]+dp[to[i]][k]);//更新答案
	}
}

如果仔細思考,我們會發現狀態轉移方程出了點問題。當j=1(此時也有k=1)時,狀態轉移方程就會變成dp[x][1]=max(dp[x][1],dp[x][0]+dp[to[i]][1]),這樣,如果s[to[i]]>s[x]dp[x][1]就會被錯誤地更新,即會出現dp[x][1]\neq s[i]的情況。於是,我們可以對方程做一些小小的改動:dp[x][j]=max(dp[x][j],dp[x][k]+dp[to[i]][j-k])。這樣,當j=k=1時,dp[x][1]=max(dp[x][1],dp[x][1]+dp[to[i]][0]),就不會出現錯誤的轉移了。除此之外,我們會發現我們列舉的很大一部分jk是多餘的,有時以x為根的子樹中根本就不足m個點,或者以to[i]為根的子樹中不足j個點,所以,我們可以記size[i]表示以i為根的子樹中的點數,用於減少多餘的列舉。程式碼如下。

void dfs(int x)
{
	dp[x][1]=s[i],size[x]=1;
	for(r int i=first[x];i;i=next[i])
	{
		dfs(to[i]),size[x]+=size[to[i]];
		for(r int j=min(m,size[x]);j;j--)
			for(r int k=1;k<=min(j,size[to[i]]);k++)
				dp[x][j]=max(dp[x][j],dp[x][j-k]+dp[to[i]][k]);
	}
}

令人十分不愉快的問題又出現了:dp[x][1]會被錯誤地更新。根據剛才的經驗,我們可以對程式碼稍作改動。

void dfs(int x)
{
	dp[x][1]=s[i],size[x]=1;
	for(int i=first[x];i;i=next[i])
	{
		dfs(to[i]),size[x]+=size[to[i]];
		for(int j=min(m,size[x]);j;j--)
			for(int k=max(1,j-size[to[i]]);k<=j;k++)
//因為0<=j-k<=size[to[i]]且k>=1,所以max(1,j-size[to[i]])<=k<=j
				dp[x][j]=max(dp[x][j],dp[x][k]+dp[to[i]][j-k]);
	}
}

這樣我們就可以通過這道題了。但我們還有其它的優化方法。另一份AC程式碼如下。

void dfs(int x)
{
	size[x]=1;
	for(r int i=first[x];i;i=next[i])
	{
		dfs(to[i]),size[x]+=size[to[i]];
		for(r int j=min(m,size[x]);j;j--)
			for(r int k=1;k<=min(j,size[to[i]]);k++)
				dp[x][j]=max(dp[x][j],dp[x][j-k]+dp[to[i]][k]);
	}
	for(int i=min(m,size[x]);i;i--) dp[x][i]=dp[x][i-1]+s[x];
}

這段程式碼看起來只是之前的WA程式碼去掉賦初值,加上最後一句奇奇怪怪的話,為什麼就能AC呢?既然要取到以x為根的子樹中的物品,必須先取x本身,那我們就在前面的迴圈中不考慮取x本身,最後用s[x]暴力更新答案,強迫s[x]被取一次,就保證了正確性。但優化還沒有結束,我們還可以在轉移方式上做一點改動。程式碼如下。

void dfs(int x)
{
	dp[x][1]=s[i],size[x]=1;
	for(int i=first[x];i;i=next[i])
	{
		dfs(to[i]);
		for(int j=cmin(m,size[x]);j;j--)
			for(int k=1;k<=cmin(m-j,size[to[i]]);k++)
				dp[x][j+k]=cmax(dp[x][j+k],dp[x][j]+dp[to[i]][k]);
		size[x]+=size[to[i]];
	}
}

我們之前一直使用以前算出的dp值去更新當前的dp值,這種轉移方式稱為被動轉移。上面的程式碼中,我們使用當前的dp值去更新未來要算的dp值,這種轉移方式稱為主動轉移。我們變被動為主動,既減小了jk的列舉範圍,又確保了不會有狀態從其他不合法的狀態轉移而來(因為我們使用當前的合法狀態去主動更新其他的狀態),保證了正確性,一舉兩得。這樣,我們就通過上面的逐步優化,較為透徹地理解了樹形揹包的實現方式。

二、小精靈

樹上有n個位置,小精靈製造了n個能量石,其中有m個能量石An-m個能量石B。如果兩個位置放置了同種能量石,那麼它們之間就會產生能量,產生的能量等於這兩個位置在樹上的距離,即它們之間唯一路徑的長度;否則這兩個位置不產生能量。請幫它們設計一種能量石的放置方案,使得在這種方案中,所有的位置對產生的能量之和最大。

這道題是樹形揹包的簡單應用。但不同的是,普通的樹形揹包如果中,一個物品取了就有價值,不取就沒有價值,而這題中不管取能量石A還是能量石B都有價值。我們依舊考慮子樹合併的思想,合併兩棵子樹時,有哪些邊產生了價值呢?首先是每棵子樹中同種點對會兩兩產生價值,這些價值已經在dp陣列中存下了。其次是兩棵子樹的樹根間的連邊會被經過多次,可以通過乘法原理算出它被經過的次數。最後是兩棵子樹中各取一個點會產生價值,可以發現,這部分產生的價值與每顆子樹中放置每種能量石的位置與根節點的距離之和有關。我最初的想法是記dis[i][j]表示以i為根的子樹中取j個能量石A且價值最大時,這j個能量石Ai的距離之和,但這樣會給狀態轉移造成極大的麻煩。既然我們已經把每個結點和它所有子結點的連邊(其實就是樹上的所有邊)都統計了一次,那我們能不能在遇到一條邊時把它能做出的所有貢獻一勞永逸地統計進答案中呢?主動轉移可以輕鬆實現這樣的操作。程式碼如下(因為轉移方程太長了,程式碼有點醜)。

void dfs(r int x)
{
	size[x]=1;
	for(int i=first[x];i;i=next[i])
		if(!size[to[i]])
		{
			dfs(to[i]);
			for(int j=cmin(m,size[x]);j>=0;j--)
				for(int k=cmin(m-j,size[to[i]]);k>=0;k--)
				{
				dp[x][j+k]=max(dp[x][j+k],
				dp[x][j]+dp[to[i]][k]+w[i]*((m-k)*k+(size[to[i]]-k)*(n-m-size[to[i]]+k)));
				}
			size[x]+=size[to[i]];
		}
}

看看我們如何通過主動轉移更新答案。兩棵子樹原有的答案顯然需要貢獻,除此之外,我們還需要統計兩棵子樹的樹根間的連邊貢獻的答案。總共有n個能量石A,以to[i]為根的子樹中取了k個,還剩m-k個,因此這條邊被能量石A經過了k*(m-k)次。同理,總共有n-m個能量石B,以to[i]為根的子樹中取了size[to[i]]-k個,還剩n-m-size[to[i]]+k個,因此這條邊被能量石B經過了(size[to[i]]-k)*(n-m-size[to[i]]+k)次。我們把這條邊被能量石A和能量石B經過的次數相加,再乘以邊權,就是這條邊能貢獻的所有答案,既然這條邊以後再也不能對答案做出貢獻,dis陣列也失去了它存在的意義。於是,我們就通過了這道題。