树形背包总结

概念

树形背包,就是说,在树上选一个包含根的连通块,或背包存在依赖关系(选父才能选子),或者需要知道每个点的子树中选了多少……
通常,我们有两种方法:

一、基于dfs合并:

我们设dp(i,j)表示在i的子节点中选j个的状态。
在转移时,先dfs子节点,然后依次把子节点合并,每次合并2个。
即枚举a,b,用dp(i,a)dp(son,b)组合为f(a+b),每次合并后,把f赋给dp。

下面分析时间复杂度:

1、物品大小为1,没有限制:

(伪)代码:

void Tree_Dp(int p)
{
    size[p]=1;
    each(x,son[p])
    {
        Tree_Dp(x);
        for(int i=0;i<=size[p];++i)
            for(int j=0;j<=size[x];++j)
                update dp[p][i+j];
        size[p]+=size[x];
    }
}

时间复杂度为O(n2)
考虑那个二重循环,可以看做分别枚举两棵子树的每个点。可以发现,点对(u,v),只会在TreeDp(lca(u,v))处被考虑到,所以复杂度是O(n2)

2、有物品大小:

这个复杂度可以卡到O(wn2),w是物品大小。

代码:

void dfs(int x)
{
	sum[x]=c[x];
	dp[x][c[x]]=v[x];
	if(!he[x])
		return;
	for(int i=he[x];i!=0;i=e[i].ne)
	{
		int t=e[i].to;
                dfs(t);sum[x]+=sum[t];
		for(int s1=min(sum[x],lim);s1>=c[x];s1--)//注意倒序
		{
			for(int s2=min(sum[t],min(s1-c[x],lim));s2>=c[t];s2--)//注意倒序
				dp[x][s1]=max(dp[x][s1],dp[x][s1-s2]+dp[t][s2]);
		}
	}
}

3、物品大小为1,有k的限制。

(伪)代码:

void dfs(int u, int fu) {
    int si = 0;
    for (int i = fr[u]; i != -1; i = ne[i]) {
        if (v[i] != fu) 
            dfs(v[i], u);
    }
    for (int i = fr[u]; i != -1; i = ne[i]) {
        if (v[i] == fu) continue;
        int rt = sz[v[i]];
        for (int a = 0; a <= min(si, k); a++) {
            for (int b = 0; b <= min(rt, k - a); b++) {
                   //转移dp
            }
        }
        si += rt;
    sz[u] = si + 1;
}

这个算法,最初觉得是O(nk2)的,实际上是O(nk)的。

复杂度证明:

  1. 根据正常树形背包的复杂度O(n2),小于等于k的最多产生n/kk2的复杂度。
  2. 大于k与大于k的合并一次,被合并的就增加k,最多n/k次,最多产生n/kk2的复杂度。
  3. 大于k的与小于等于k的合并时,每个小于等于k的最多被合并一次,所以是ns1+ns2+...+nsm,也是nk

还有一种理解:

把树按照dfs序变为序列。
然后,在子树中枚举取x个,可以理解为取dfs序的前(后)x个。
而合并时,认为一棵子树取后x个,另一棵取前y个。(x+yk)。这可以合并为长x+y的区间。
这其实就是长度不大于k的子串,最多有nk个。

但是,因为有取0个的情况,所以实际做题时,大约有2的常数。但那个常数就忽略了可以。

二、dfs序上dp:

按照dfs序考虑:
我们设dp(i,j)表示考虑到第i个,剩余容量为j的状态:
有两种转移:
1、不选i,那么i的子树都不能选,转移到dp(i+Sizei,j)
2、选i,那么按照dfs序考虑下一个,转移到dp(i+1,jw)+v
正确性显然。
但是,有些树上的信息无法知道。

代码

for(int i=1;i<=m;i++)
{
	for(int j=0;j<=n;j++)
	{
		if(j>=cost[i].v)
			dp[i][j]=max(dp[f[i]][j],dp[i-1][j-cost[i].v]+cost[i].w);
		else
			dp[i][j]=dp[i-size(i)][j];
	}
}
printf("%d\n",dp[m][n]);

时间复杂度分析

这个比较显然,n个点,m的容量限制(没有则m=n),状态有nm个,转移代价为O(1),复杂度为O(nm)
而且,这种方法更好写,且常数更小。
在物品有多个等特殊问题时,也方便优化。

例题1

题目描述

妈妈昨天对他说:“你的房间需要购买哪些物品,怎么布置,你说了算,只要不超过 N 元钱就行”。今天一早,金明就开始做预算了,他把想买的物品分为两类:主件不附件,附件是从属于某个件的,下表就是一些主件不附件的例子:
如果要买归类为附件的物品,必须先买该附件所属的件。每个主件可以有很多个附件。附件可能有从属于自己的附件。金明想买的东西很多,肯定会超过妈妈限定的 N 元。于是,他把每件物品规定了一个重要度,分为 5 等:用整数 15 表示,第 5 等最重要。他还从因特网上查到了每件物品的价格(都是在10 元以内)。他希望在不超过 N 元(可以等于 N 元)的前提下,使每件物品的价格不重要度的乘积的总和最大。 设第 j 件物品的价格为 v[j] ,重要度为 w[j] ,共选中了 k 件物品,编号依次为 j1,j2,…,jk,则所求的总和为: v[j1]×w[j1]+v[j2]×w[j2]+…+v[jk]×w[jk] 请你帮助金明设计一个满足要求的购物单。

输入格式
第 1 行,为两个正整数,用一个空格隔开:
N,m (其中 N(<8000) 表示总钱数, m(<8000) 为希望购买物品的个数。) 从第 2 行到第 m+1 行,第 j 行给出了编号为 j−1的物品的基本数据,每行有 3 个非负整数:v,p,q(其中 v 表示该物品的价格(v<10),p表示该物品的重要度(15),q 表示该物品是主件还是附件。如果 q=0,表示该物品为主件,如果 q>0 ,表示该物品为附件, q 是所属件的编号(q< j-1))
输出格式
一个正整数,为不超过总钱数的物品的价格与重要度乘积的总和的最大值。

这个,是显然的树型背包。
如果用方法1,则由于物品大小的影响,可以卡到O(nm2)
但是,方法二就是O(nm)的,很快,还好写。

(差距很大)。

例题2

  • P4516 [JSOI2018]潜入行动
    题解
    这题,由于我要知道父子关系的信息(例如:父节点是否选择等),所以方法二便不能使用。
    使用方法一,复杂度为O(nk)

例题3

  • P3780 [SDOI2017]苹果树
    题解
    这题有些像例1,只不过物品有多个,转成dfs序后,单调队列优化即可,复杂度O(nk)
    如果方法1至少要O(nk2)
    此时,dfs序还可以知道某条链两侧的状态。

例题4

  • P6668 [清华集训2016] 连通子树
    由于每次询问涉及到的点不多,可以把这些点拿出来建立虚树。
    通过倍增,可以求出每条边的选择方案数。
    由于平方算法太慢,同时还可以不包含根节点,因此在虚树上进行点分治+DFS序列上DP。
    注意在na=nb=nc=0时要特殊考虑,即维护一条链上选择一段区间的方案数,可以维护前后缀乘积的和。
    细节很多,代码量极大(400行+)。

总结下

方法一:
可以知道每个点的确切情况(比如子树中选了多少,也能记录父亲的信息)。可以与普通的树型dp结合使用(因为毕竟是在树上)。
也能直接处理任意连通块的情况。
但是,复杂度相对较高(因为合并背包很慢)。如果是计数可以考虑FFT。

方法二:
复杂度较低(因为不需要合并背包,相当于依次添加)。
但是,不能知道每个点的确切情况(因为是在序列上),有些题不能使用。
如果是连通块的DP,只能算出包含根的连通块。如果要求所有连通块的信息,需要使用点分治,复杂度多一个log。

posted @   lnzwz  阅读(4213)  评论(1编辑  收藏  举报
编辑推荐:
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示