树形背包例题及实现技巧

一、选课(模板题)

在大学里每个学生,为了达到一定的学分,必须从很多课程里选择一些课程来学习,在课程里有些课程必须在某些课程之前学习,如高等数学总是在其它课程之前学习。现在有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数组也失去了它存在的意义。于是,我们就通过了这道题。

你可能感兴趣的:(树形背包例题及实现技巧)