樹形揹包例題及實現技巧
一、選課(模板題)
在大學裡每個學生,為了達到一定的學分,必須從很多課程裡選擇一些課程來學習,在課程裡有些課程必須在某些課程之前學習,如高等數學總是在其它課程之前學習。現在有門功課,每門課有個學分,每門課只有一門或沒有直接先修課(若課程是課程的先修課即只有學完了課程,才能學習課程)。一個學生要從這些課程裡選擇門課程學習,問他能獲得的最大學分是多少?
第一行有兩個整數,(,)。接下來的行,第行包含兩個整數和,表示第門課的直接先修課,表示第門課的學分。若表示沒有直接先修課(, )。
樹形揹包其實比較類似揹包問題中的分組揹包,想要取到一個物品,必須先取它的父親。設為以為根的子樹中取個物品的最大價值,非常類似普通揹包中前
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]);//更新答案 } }
如果仔細思考,我們會發現狀態轉移方程出了點問題。當(此時也有)時,狀態轉移方程就會變成,這樣,如果,就會被錯誤地更新,即會出現的情況。於是,我們可以對方程做一些小小的改動:。這樣,當時,,就不會出現錯誤的轉移了。除此之外,我們會發現我們列舉的很大一部分和是多餘的,有時以為根的子樹中根本就不足個點,或者以為根的子樹中不足個點,所以,我們可以記表示以為根的子樹中的點數,用於減少多餘的列舉。程式碼如下。
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]); } }
令人十分不愉快的問題又出現了:會被錯誤地更新。根據剛才的經驗,我們可以對程式碼稍作改動。
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]);
}
}
這樣我們就可以通過這道題了。但我們還有其它的優化方法。另一份程式碼如下。
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];
}
這段程式碼看起來只是之前的程式碼去掉賦初值,加上最後一句奇奇怪怪的話,為什麼就能呢?既然要取到以為根的子樹中的物品,必須先取本身,那我們就在前面的迴圈中不考慮取本身,最後用暴力更新答案,強迫被取一次,就保證了正確性。但優化還沒有結束,我們還可以在轉移方式上做一點改動。程式碼如下。
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]];
}
}
我們之前一直使用以前算出的值去更新當前的值,這種轉移方式稱為被動轉移。上面的程式碼中,我們使用當前的值去更新未來要算的值,這種轉移方式稱為主動轉移。我們變被動為主動,既減小了和的列舉範圍,又確保了不會有狀態從其他不合法的狀態轉移而來(因為我們使用當前的合法狀態去主動更新其他的狀態),保證了正確性,一舉兩得。這樣,我們就通過上面的逐步優化,較為透徹地理解了樹形揹包的實現方式。
二、小精靈
樹上有個位置,小精靈製造了個能量石,其中有個能量石和個能量石。如果兩個位置放置了同種能量石,那麼它們之間就會產生能量,產生的能量等於這兩個位置在樹上的距離,即它們之間唯一路徑的長度;否則這兩個位置不產生能量。請幫它們設計一種能量石的放置方案,使得在這種方案中,所有的位置對產生的能量之和最大。
這道題是樹形揹包的簡單應用。但不同的是,普通的樹形揹包如果中,一個物品取了就有價值,不取就沒有價值,而這題中不管取能量石還是能量石都有價值。我們依舊考慮子樹合併的思想,合併兩棵子樹時,有哪些邊產生了價值呢?首先是每棵子樹中同種點對會兩兩產生價值,這些價值已經在陣列中存下了。其次是兩棵子樹的樹根間的連邊會被經過多次,可以通過乘法原理算出它被經過的次數。最後是兩棵子樹中各取一個點會產生價值,可以發現,這部分產生的價值與每顆子樹中放置每種能量石的位置與根節點的距離之和有關。我最初的想法是記表示以為根的子樹中取個能量石且價值最大時,這個能量石到的距離之和,但這樣會給狀態轉移造成極大的麻煩。既然我們已經把每個結點和它所有子結點的連邊(其實就是樹上的所有邊)都統計了一次,那我們能不能在遇到一條邊時把它能做出的所有貢獻一勞永逸地統計進答案中呢?主動轉移可以輕鬆實現這樣的操作。程式碼如下(因為轉移方程太長了,程式碼有點醜)。
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]];
}
}
看看我們如何通過主動轉移更新答案。兩棵子樹原有的答案顯然需要貢獻,除此之外,我們還需要統計兩棵子樹的樹根間的連邊貢獻的答案。總共有個能量石,以為根的子樹中取了個,還剩個,因此這條邊被能量石經過了次。同理,總共有個能量石,以為根的子樹中取了個,還剩個,因此這條邊被能量石經過了次。我們把這條邊被能量石和能量石經過的次數相加,再乘以邊權,就是這條邊能貢獻的所有答案,既然這條邊以後再也不能對答案做出貢獻,陣列也失去了它存在的意義。於是,我們就通過了這道題。