7️⃣

树形背包

树形背包算是这个背包九讲的最后几篇之一了,它的定义是:
💡
有N个物品和一个容量为C的背包,物品编号为0...N-1。物品之间有依赖关系,该依赖关系为树形结构,当你选择某个物品时,必须也该物品的父节点。第i件物品体积为vi,价值为wi,其父节点的编号为pi,其中根节点的编号为-1。

树形背包的组织形式

先来看看这道题给的数据,他给的是每个子节点的父结点编号,是由下而上的结构,而如果我们需要使用DP来解决问题的话,需要由上而下构建一颗多叉树。
这时候我们可以使用之前在字典树中使用过的构建方法,链式向前星存图:
*// 链式向前星存图*
void add(int a, int b) {
	e[idx] = b;
	ne[idx] = he[a];
	he[a] = idx++;
}
这种链式向前星的数组写法其实并不是特别的直观,最好还是用结构体来实现。

经典的树形背包问题

分组背包思维

这题以来我第一时间想的是,既然是一颗树,那是不是可以用map把每个节点到根节点的价值和体积都储存起来,最后再用01背包在map里面挑物品。这一段还没想明白。
这题属于有依赖的背包问题,通常我们可以将这类树形背包问题看作是有依赖的分组背包。如果一个根结点有N个子节点,那么我们可以从任意一个子结点中选取占用k容量的的最大价值。对比分组背包的解决步骤,N个子节点对应N个分组,K容量对应决策,这样我们可以得到下面这个递推式:
对于这里dp的定义让我觉得有点不明白:
💡
dp[u][j]为考虑以u为根的子树,背包容量不超过j的最大价值。
问题在于,对于dpu(j-k)来说,这样它的涵义就是以u为根的子树,背包容量不超过j-k的最大价值。但是在实际代码中,你需要遍历j和k时,你无法直接获得dp[u][j-k]的最终值。而每一次迭代,其实都应该类比于分组背包中的前i个组中以u为根,容量不超过j的最大价值。
而上面的定义我觉得无法消除这个问题的后效性。
不过如果将i那一维优化掉的话,这个递推式应该是正确的。换而言之,对于这个问题而言上述的表达式中dpu(j-k)应该表达的是前i-1个u的子节点中容量不超过j-k的最大价值。
先贴一段三叶的代码,树形背包这个问题,肯定还有需要更多的讨论才能深刻理解。
code
class Solution {
    int N = 110, M = N * 2;
    int[] he = new int[N], e = new int[M], ne = new int[M];
    int[] vi, wi;
    int n, c, idx;
    // 定义 f[u][j] 为考虑以 u 为根的子树,背包容量不超过 j 的最大价值
    int[][] f = new int[N][N];

    // 链式向前星存图
    void add(int a, int b) {
        e[idx] = b;
        ne[idx] = he[a];
        he[a] = idx;
        idx++;
    }

    void dfs(int u) {
        // 节点 u 的价值和体积
        int cw = wi[u], cv = vi[u];
        // 要选任一节点,必须先选 u,同时也限制了至少需要 cv 的容量
        for (int i = cv; i <= c; i++) f[u][i] += cw;

        // 遍历节点 u 的所有子节点 x(分组背包遍历物品组)
        for (int i = he[u]; i != -1; i = ne[i]) {
            int x = e[i];
            // 递归处理节点 x
            dfs(x); 
            // 从大到小遍历背包容量(分组背包遍历容量)
            for (int j = c; j >= 0; j--) {
                // 遍历给节点 x 分配多少背包容量(分组背包遍历决策)
                for (int k = 0; k <= j - cv; k++) {
                    f[u][j] = Math.max(f[u][j], f[u][j - k] + f[x][k]);        
                }
            }
        }
    }

    public int maxValue(int N, int C, int[] p, int[] v, int[] w) {
        n = N; c = C;
        vi = v; wi = w;
        Arrays.fill(he, -1);
        int root = -1;
        for (int i = 0; i < n; i++) {
            if (p[i] == -1) {
                root = i;
            } else {
                add(p[i], i);
            }
        }
        dfs(root);
        return f[root][c];
    }
}

下面来考虑一下这一道题:
这道题算是上面那题的简化版,所以我试着自己写了一下,发现果然还是有很多细节处理不好。例如链式向前星的写法我就没理解清楚,对循环条件不确定。事实上这种写法确实略带了一点trick,当我使用纯数组存图的时候he[a]中的值第一次为初始值,也是这一条链的末端点,而最后一次对该根节点add的时候he[a]将会保存这条链的起点。所以当我们遍历的时候起点应该为he[a],终止条件则为he[a]中的初始值。
其次关于dp数组的初始化,在这个题中我们只需要针对只取一门课时的数组初始化为这门课的权重即可。
code
#include<bits/stdc++.h>
using namespace std;
const int n1 = 310;
vector<int>he(n1,-1),e(n1),ne(n1);
int idx = 0;
int dp[n1][n1];
int n , m;
vector<int>ki;
vector<int>si;
//struct edge
//{
//	int to,pre; 
//}e[n1];
void add(int a, int b){
	e[idx] = b;
	ne[idx] = he[a];
	he[a] = idx++;
}
//void add(int a, int b){
//	e[++idx].pre = he[a];
//	e[idx].to = b;
//	he[a] = idx;
//}

void dfs(int u){
	for(int i = he[u]; i!= -1 ; i = ne[i]){
		int x = e[i];
		dfs(x);
//		for(int i = he[u] ; i ; i = e[i].pre){
//		dfs(e[i].to);
		for(int j = m+1 ; j > 0 ; j --){
			for(int k = j-1 ; k >= 0 ; k --){
				dp[u][j] = max(dp[u][j],dp[u][j-k]+dp[x][k]);
			}
		}
	}
}

int main(void){
	cin>>n>>m;
	for(int i = 1 ; i <= n ; i ++){
		int k , s;
		cin>>k>>s;
		dp[i][1] = s;
		add(k,i);
	}
	dfs(0);
	cout<<dp[0][m+1];
	return 0;
}

接下来是这道题:
[NOIP2006 提高组] 金明的预算方案
金明今天很开心,家里购置的新房就要领钥匙了,新房里有一间金明自己专用的很宽敞的房间。更让他高兴的是,妈妈昨天对他说:"你的房间需要购买哪些物品,怎么布置,你说了算,只要不超过 $n$ 元钱就行"。今天一早,金明就开始做预算了,他把想买的物品分为两类:主件与附件,附件是从属于某个主件的,下表就是一些主件与附件的例子: | 主件 | 附件 | | :----------: | :----------: | | 电脑 | 打印机,扫描仪 | | 书柜 | 图书 | | 书桌 | 台灯,文具 | | 工作椅 | 无 | 如果要买归类为附件的物品,必须先买该附件所属的主件。每个主件可以有 $0$ 个、$1$ 个或 $2$ 个附件。每个附件对应一个主件,附件不再有从属于自己的附件。金明想买的东西很多,肯定会超过妈妈限定的 $n$ 元。于是,他把每件物品规定了一个重要度,分为 $5$ 等:用整数 $1 \sim 5$ 表示,第 $5$ 等最重要。他还从因特网上查到了每件物品的价格(都是 $10$ 元的整数倍)。他希望在不超过 $n$ 元的前提下,使每件物品的价格与重要度的乘积的总和最大。 设第 $j$ 件物品的价格为 $v_j$,重要度为$w_j$,共选中了 $k$ 件物品,编号依次为 $j_1,j_2,\dots,j_k$,则所求的总和为: $v_{j_1} \times w_{j_1}+v_{j_2} \times w_{j_2}+ \dots +v_{j_k} \times w_{j_k}$。 请你帮助金明设计一个满足要求的购物单。
首先这道题,显然是可以用上面的方式去做的,几乎就是一摸一样的套模板就可以了:
code
#include<bits/stdc++.h>
using namespace std;
int n , c;
const int MAXN = 33000;
int wi[61],vi[61];
int he[61],e[61],ne[61];
int dp[61][MAXN];
int cnt = 0;
void add(int from,int to){
	e[cnt] = to;
	ne[cnt] = he[from];
	he[from] = cnt++;
}

void dfs(int u){
	for(int i = wi[u] ; i <= c ; i ++){
		dp[u][i] += vi[u]*wi[u];
	}
	
	for(int i = he[u]; i!=-1 ; i = ne[i]){
		int x = e[i];
		dfs(x);
		for(int j = c ; j >= 0 ; j --){
			for(int k = 0 ; k <= j-wi[u];k++){
				dp[u][j] = max(dp[u][j],dp[u][j-k]+dp[x][k]);
			}
		}
	}
}

int main(void){
	cin>>c>>n;
	memset(he,-1,sizeof(he));
	for(int i = 1 ; i <= n ; i ++){
		int k;
		cin>>wi[i]>>vi[i]>>k;
		add(k,i);
	}
	dfs(0);
	cout<<dp[0][c];
	return 0;
}
但是这道题的c为3.2*10^4,而上面解法的时间复杂度是,这样的做法会TLE。
notion image
而我们可以注意到,题目描述中还有给出几个关键数据并没有被使用,显然在符合某些条件的时候树形DP是可以做优化的。
注意本题中的容量维度很大,但是相对物品数量只有60,同时每件物品最多只有两个附件。
看到这里,我想起了我开头那一段——想法 ,是否可以直接对每个物品及附件进行拆分,然后使用分组背包求解呢?由于每个物品最多只有两个附件,所以我们最多只需要枚举三个物品状态。
这样问题就彻底转化成了分组背包问题。但是这种优化是有条件的,首先问题中明确说明了附件不会再有附件,所以这颗树只有三层(包括根节点),如果附件还能有附件的话,我不清楚是否能用其他方法进行优化。
实现上我们首先需要对主附结点进行预处理,存放在一个map<int,list>里,然后对这个map进行物品的划分,最后进行分组背包DP就可以了。
code
#include<bits/stdc++.h>
using namespace std;
int wi[61],vi[61],ki[61];
int n,c;
int dp[32000];
int solution(){
	unordered_map<int,vector<int>> map;
	for(int i = 1 ; i <= n ; i ++){
		if(map.count(ki[i]) == 0){
			map[i] = vector<int>();
		}else{
			if(map.count(ki[i]) == 0)
				map[ki[i]] = vector<int>();
			map[ki[i]].emplace_back(i);
		}
		
	}
	
	vector<vector<pair<int,int>>> list;
	for(auto it:map){
		vector<pair<int,int>> temp;
		int rootv = wi[it.first];int rootw = wi[it.first]*vi[it.first];
		temp.emplace_back(make_pair(rootv,rootw));
		vector<int>sub = it.second;
		for(int i = 0 ; i < sub.size(); i  ++){
			int x = sub[i];
			int xv = wi[x];int xw = vi[x]*wi[x];
			temp.emplace_back(make_pair(rootv+xv,rootw+xw));
			for(int j = i+1 ; j <sub.size() ; j ++){
				int y = sub[j];
				int yv = wi[y];int yw = wi[y]*vi[y];
				temp.emplace_back(make_pair(rootv+yv+xv,rootw+yw+xw));
			}
		}
		list.emplace_back(temp);
	}
	for(int i = 0 ; i < list.size() ; i ++){
		
		vector<pair<int,int>> temp = list[i];
		for(int j = c ; j >= 0 ; j --){
			for(int k = 0 ; k <temp.size() ; k++){
				if(temp[k].first<=j)dp[j] = max(dp[j],dp[j-temp[k].first]+temp[k].second);
			}
		}
	}
	return dp[c];
}
			
int main(void){
	cin>>c>>n;
	for(int i = 1 ; i <= n ; i ++){
		cin>>wi[i]>>vi[i]>>ki[i];
	}
	cout<<solution();
	return 0;
}
准确来说,这种解法和我最上面的思路不一样,按那种解法来说应当是将n个点全部划分为状态,并且直接对这些状态进行01背包,好像也不能直接01背包,需要在dp的时候还需要用一个map存已经拿取了的状态,针对每个物品存入前都进行查询如果已经存在就跳过,未存入就递归存入。
这样的话可能复杂度能变成一个带常数的N*C?好像算上递归的话应该是N*C*N反正我也懒得写了,感觉很麻烦。不过这个思路给我带来的一点点想法可能就是,树形背包问题应该最少能优化到N*C*N级别,可能能到N*C。这里有个简单的说明——说明
最后总结一下这种类分组背包思路的树形背包问题把,通过上面几道题,可以体会到。树形背包的问题其实并不是一个特别复杂或者全新类型的背包问题,解法大致能从前面的背包类型找到思路。难点在于对于初始状态的划分,这种森林图,如果没有选择好的状态进行划分,很容易造成TLE。而选择一个好的状态划分,往往能大幅度提升运行速度。
对于上面的这一类朴素的树形背包问题来说,一般直接从已有维度上进行划分,例如我们知道根节点是由所有子树的状态组合更新而来的,这时候我们可以对所有子树状态组合,进行状态划分。从而找到一个更优的更新方式。
而最后一题的优化解法,则是利用了树的层级只有3层这一点,直接将二维树形背包(三层还要除去根节点相当于两层)转化为了一个一维的分组背包问题。从而避免了使用通用的树形背包解法的复杂度。

泛型背包的并优化

首先泛化背包的定义是这样的:
考虑这样一种物品,它并没有固定的费用(体积)和价值,而是它的价值随着你分配给它的费用(体积)变化而变化。
其实就是说将DP过程中的一些状态当成一个物品来看待,这种方式来看树形背包问题的话,其实就是将合并的两颗子树当成两个泛型背包,而将两颗子树的合并看作两个泛化物品的合并。每一次泛化物品合并的复杂度是,总共n个结点,这样复杂度就是
但是引入泛型背包这个概念的原因是能以一个更加理想的方式去划分状态。在前面的朴素写法中,对于我们都是通过合并所有的子节点然后更新根节点,但是那个写法中更新所有的子节点需要C^2的复杂度,那么有没有一种划分方法可以将这个复杂度变成C呢?
考虑将dpij定义为所有已经选择过的结点中选取j个结点,并且一定包含i结点的答案。这时候两棵子树可以被定义为dp[u][j]、dp[v][j],合并时当前根节点dp[u][j]的状态就从这两个泛化物品中选择max更新即可。
但是在此之前我们还需要将dpvj求得,因为根据定义dpuj的含义是在已经选取了的结点中,选取j个结点且一定包含u结点的最大值,所以这个状态在更新当前结点之前就已经确定了。我们需要在dfs时将dpvj的状态更新出来,并且在dfs之前将dpvj进行初始化,初始化方式为在已经统计过的所有结点中选取j-1个并且一定包含当前结点的父节点u的状态,即dp[u][j-1]的状态上添加上当前子节点v的价值。
值得注意的是,在循环j的时候,通常会引入一个层数,因为当选取dpvj时,就代表着我们一定会将v的所有父节点选取,所以我们选取的j一定会大于当前的层数。
说到底,泛化物品是为了找到一个更好的状态划分方式,在这种写法中,我们在最终更新dpuj时,dpvj这个状态其实代表了一整个状态,包括了在dfs之前对于其父节点u的状态统计以及dfs时对其子结点的统计。dpuj其实是在已经处理的节点中选取s的所有方案和不选取s的所有方案中进行选择。关于泛化背包问题还需要在最后一章做一个更好的说明。
code
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;

void dfs(int u,int dep);
void add(int u,int v);
const int N=310;
int head[N],nxt[N],to[N],cnt;
int n,m,a[N],f[N][N];
int main(){
    int i,k;
    scanf("%d%d",&n,&m);
    for (i=1;i<=n;++i){
        scanf("%d%d",&k,a+i);
        add(k,i);
    }
    dfs(0,0); //按理来说应该将f[0][j]全部赋为a[0],然后dfs(0,1),但由于0是虚的根节点,就不需要这样做了。
    printf("%d",f[0][m]); //答案是f[0][m]而非f[0][m+1]的原因同上
    return 0;
}
void add(int u,int v){
    nxt[++cnt]=head[u];
    head[u]=cnt;
    to[cnt]=v;
}
void dfs(int u,int dep){
    int i,j,v;
    for (i=head[u];i;i=nxt[i]){
        v=to[i];
        for (j=dep+1;j<=m;++j){
            f[v][j]=f[u][j-1]+a[v];
        }
        dfs(v,dep+1);
        for (j=dep+1;j<=m;++j){
            f[u][j]=max(f[u][j],f[v][j]);
        }
    }
}

上下界优化

前面提到过,树形背包问题的复杂度上界应该是 N*C,详情可以参考那篇博客,每个点对都只会在lca处合并一次,所以总的复杂度应该是的。
树形背包的优化方式有很多,除了前面提到的特殊情况的优化,还有先序遍历优化、上下界优化、求泛型物品的并,这里我也只讨论一下另外一种上下界优化,因为这种优化的通用性强而且理解简单,最适合我这种懒狗。
首先,观察朴素树形背包问题的循环:
for(int j = m+1 ; j > 0 ; j --){
		for(int k = j-1 ; k >= 0 ; k --){
			dp[u][j] = max(dp[u][j],dp[u][j-k]+dp[x][k]);
		}
	}
注意看这里的循环条件,因为DP数组的定义是i个物品,根为u,背包容量为j的最大价值,这里我们考虑每个结点包括其子节点都会有一个总的容量值,例如u这个结点本身的容量就应当是其子节点容量的和加上当前u结点的容量,这样我们在dfs的时候也可以求得x结点的容量。因此我们可以利用这个结点的容量值缩减这两个循环的大小。
void dfs(int u)
{
    siz[u]=1;
    f[u][1]=a[u];
    int i,j,k,v;
    for (i=head[u];i;i=nxt[i])
    {
        v=to[i];
        dfs(v);
        for (j=min(m+1,siz[u]+siz[v]);j>=1;--j)
        {
            for (k=max(1,j-siz[u]);k<=siz[v]&&k<j;++k)
            {
                f[u][j]=max(f[u][j],f[u][j-k]+f[v][k]);
            }
        }
        siz[u]+=siz[v];
    }
}
可以看到siz数组用来记录结点的大小,每次遍历一个子结点的时候就对根节点的大小进行更新,因为虽然数组减少了一维,但实际上还是表达的前i个物品中根为u的价值。所以我们在对背包容量进行循环时,j这层循环应该将最大值控制为min(m+1,siz[u]+siz[v]) 而里面的k层,则是控制在v结点的重量以下,并且已经完成的部分一定会包含u结点所以k大小一定小于j-w[u],并且已经完成的部分一定小于siz[u]所以有siz[u]≥j-k即k一定大于等于j-siz[u],可以使用这个条件作为初始值。

总结

对于树形背包这个问题而言,最重要的肯定是要掌握朴素的树形背包解法,理解这种解法是理解其他多种优化的基础。而针对优化而言,这个问题有多种优化实现时间复杂度都在,其中最通用的也最简单的肯定是限定上下界的方法,这种方法很好用,也容易实现和理解,唯一比其他几种困难的是时间复杂度的分析。而泛型背包的优化则是利用泛化物品的概念将状态重新划分,从而将合并子树的步骤从C^2缩减至C,但是这种优化相对前面的那种更难理解。最后关于泛型背包的问题,还需要在最后一篇泛型背包里讨论。
 
树形背包O(n * v^2)入门_mrclr_51CTO博客
看了好多博客以及论文之后,对树形背包确实有了一个全新的认识,尤其是这篇博客以及徐持恒的论文 《浅谈积累背包问题》,对我有很大的帮助。两者都提到了泛化物品(当然这个名词最初是在背包九讲里面提到的)这个概念,我觉得这是对树形背包O(n * v 2)做法的一种不同理解,不过我认为引入这个名词的主要目的还是对O(nv)的做法做出了解释。遗憾的是,我虽然用O(nv)的做法成功写出了一道题,然而却仍旧不是很懂。所以这篇博文主要是讲解O(n * v 2)的做法,也算是整理自己的学习笔记吧。 如果哪一天我把O(nv)的做法看懂了的话,可能还会来更这篇博客。 上文已经提到,对于O(n * v 2)的做法有两种不同的理解,那么我在这里就分别阐述一下。 一、用分组背包来理解 首先题中给的依赖关系是一个森林,那么可以建立一个虚拟节点0,作为森林的根,形成一棵树。 令dp[u][j]表示以 u 为根的子树中,选 j 门课(体积)能得到的最大学分。那么 u 一定要选(初始化dp[u][1] = val[u]),而对于子树内其他点的选取情况,可以把每一种 选取方案 看成一个物品,又因为每一种方案都是互斥的,每一组只能选一个,那么就是一个分组背包了。这里的组数,是 u 的儿子个数 p = |son(u)|,对于一个vi ∈son(u),他其实代表了j - 1个物品(因为还要选u),拿其中一个为例,dp[vi][k](0 <= k < j)这种选取方案才代表一个物品。 现在考虑转移方程。按照分组背包的写法,我们应该先加一维,dp[u][k][j]表示以x为根的子树,选到第k组,选了 j 门课得到的最大学分。于是有dp[u][k][j] = max(dp[u][k - 1][j], dp[u][k - 1][j - h] + dp[v][sz][h])。注意,dp[v][sz][h]代表一个物品,sz是v的所有组数,因为要保证最优,所以一定从v的所有组数选完的状态转移到u。 然后再模仿分组背包省去第二维,把 j 倒着枚举。 对于每一个节点只会进行一次O(v 2)的分组背包,所以复杂度O(n * v 2)。 二、用泛化物品来理解 首先得解释一下啥叫泛化物品:一个价值随体积改变而改变的物品,而且对于一个体积 i,有对应的v[i]。 这个其实人人都见过,只不过没有听说这个名词而已。比如求解01背包就是 泛化一个物品 的过程,得到的dp[i]就是一个泛化物品。 还有这么回事, 泛化物品的和 :有两个泛化物品G1[i], G2[i],要将这两个物品合并。做法就是对于每一个体积 i ,枚举分配给这两个物品的体积 j ,G[i] = max{G1[j], G2[i - j]}。复杂度O(v 2)。 现在用泛化物品的概念看看树形背包。dp[u][j]表示的是u所在的泛化物品,则从子树向上递归的时候,其实就是 不断地将u所在的泛化物品和他的子树vi的泛化物品合并。合并一次的复杂度O(v 2),一共n各节点,每合并一次减少一个,所以总复杂度还是O(n * v 2)。 代码和上面完全相同,因为这本来就是对树形背包的两种理解,而不是两种写法。 树形背包O(n * v 2)的做法到此也基本讲完了,但这其实都是基础,深入的话还是得靠自己刷题去"悟"。还有一点就是如果哪位大佬会O(nv)的做法,能不能给我讲讲......
树形背包O(n * v^2)入门_mrclr_51CTO博客