[笔记]树形dp - 2/4(树上背包类)

树上背包是树形dp的常见模型,通常是分组背包的变形,而分组背包的本质就是多个泛化物品不断相加。因此掌握泛化物品的合并的方法有助于理解转移的过程(具体见1.4)。
此类问题一般也可以用DFS序、多叉转二叉等方法解决。

引入:二叉苹果树

P2015 二叉苹果树
题意简述:在一个二叉树中,每个边有一个权值。我们要保留\(Q\)条边组成的连通分量(必须包含根节点),求边权和最大值。

我们思考,从节点\(1\)(根节点)开始保留\(Q\)条边,最大答案是什么?

我们分出\(3\)种情况,根节点的答案就是这\(3\)种情况的最大值:

  • 保留连接左子结点的边。这种情况下我们消耗\(1\)条边连接左子结点,那么从根节点开始保留\(Q\)条边,就相当于从左子结点开始保留\(Q-1\)条边的答案。
  • 保留连接右子节点的边。同上,相当于从右子结点开始保留\(Q-1\)条边的答案。
  • 左右都保留。连接左右消耗\(2\)条边,剩下\(Q-2\)条边,我们需要循环枚举分给左边多少,右边多少。

\(f[i][j]\)表示以\(i\)为根的子树,保留\(j\)条边的最大答案。
实现细节:因为题目输入边的方向不定,所以我们双向存图,并且使用\(vis\)数组。

引入 点击查看代码
#include<bits/stdc++.h>
using namespace std;
struct edge{
	int to,w;
};
vector<edge> G[110];
int f[110][110],n,q;
bool vis[110];
void dfs(int pos){
	if(G[pos].size()==1) return;
	int ch[2],w[2],len=0;
	for(auto i:G[pos]){
		if(!vis[i.to]){
			w[len]=i.w;
			ch[len++]=i.to;
		}
	}
	vis[pos]=1;
	dfs(ch[0]);
	dfs(ch[1]);
	for(int i=1;i<=q;i++){
		f[pos][i]=max(f[ch[1]][i-1]+w[1],f[ch[0]][i-1]+w[0]);
		for(int j=0;j<=i-2;j++){
			f[pos][i]=max(f[pos][i],f[ch[0]][j]+w[0]+f[ch[1]][i-j-2]+w[1]);
		}
	}
	vis[pos]=0;
}
int main(){
	cin>>n>>q;
	for(int i=1;i<n;i++){
		int u,v;
		edge e;
		cin>>u>>v>>e.w;
		e.to=v,G[u].emplace_back(e);
		e.to=u,G[v].emplace_back(e);
	}
	dfs(1);
	cout<<f[1][q];
	return 0;
}

例1:选课问题

P2014 [CTSC1997] 选课
题意简述:在大学,有\(N\)门课来学习,每个课程有一个先修课,或者没有(要学习某门课,必须先学习它的先修课)。学一门课可以得到相应的学分。请问学\(M\)门课,最多能得多少学分。

因为给定的图是一片森林,不好操作,我们把所有无先修课的课程,都设置上一个公共的先修课——节点\(0\)。这样变成树后就好操作了。注意相应地,\(M\)需要\(+1\)

我们再回到这个题,与上一道题最大的不同点就在于——不是二叉树了。

回想上一题,如果是二叉树,我们可以枚举从这一节点开始保留的\(M\)门课,分给左右子结点各多少门。但现在这不是二叉树了,我们该怎么考虑呢?

如上图,\(a,b,c\)的前驱都是\(x\)。我们设正在求从\(x\)开始学习\(q\)门课的最大学分。

我们受上一道题的影响,考虑枚举每个子节点分配多少。
(虽然上一题是边,这一题是点,但考虑方式相似)
首先根节点必须选,自己用去\(1\)后,子节点还能用\(q-1\)个。
但我们仔细思考,会发现这是一个对\(q-1\)分拆成\(3\)个自然数之和的问题。我们需要枚举每个子节点分配多少,效率上就已经输了。所以我们考虑使用背包。

1.1.1 - 分组背包

受上一题的启发,我们用\(f[x][q]\)表示以\(x\)为根的子树,学\(q\)门课的最大学分。
对于\(x\)的每个子节点,我们可以对其分配\(0,1,2,…,siz\)门课(\(siz\)是该子树的大小)。

我们发现这是一个泛化物品的背包问题(每个物品随着你给它分配的空间而改变为不同的价值。这里每个子节点相当于物品,给其中一个物品\(v\)分配\(0,1,…\)个物品,各自的价值就是\(f[v][0],f[v][1],…\))。

这类问题,我们常常可以将其转化为分组背包问题(物品分组,每组内物品最多选\(1\)个的背包问题,具体见此文)。因为我们发现,其实一个泛化物品就是一个“组”

背包容量为\(q\),每个子节点\(i\)都是一组,每组内有\(siz[i]\)个元素,组内第\(k\)个元素重量为\(k\),价值为\(f[x][k]\)

注意:根节点\(x\)必须要选,所以搜索之前我们需要赋初值\(f[i][1]=w[i]\)

虽然我们的状态表示是\(f[i][j]\),但是\(i\)表示的是根节点编号,和背包没有任何关系,不要被迷惑了。真正起作用的只有\(j\)而已。所以用于背包的其实只有一维,这是一个空间压缩为一维的分组背包。

附上一维分组背包Code,注意第\(2\)层的倒序枚举:

for(int i=1;i<=s;i++){
	for(int k=m;k>=1;k--){
		for(int j=1;j<=n[i];j++){
			if(k>=w[i][j]) f[k]=max(f[k],f[k-w[i][j]]+v[i][j]);
		}
	}
}

最终求出的答案是\(f[0][M]\),注意这里的\(M\)已经加过\(1\)了。

#include<bits/stdc++.h>
using namespace std;
int n,m;
vector<int> G[1010];
int f[1010][1010];
void dfs(int pos){
	for(auto i:G[pos]){
		dfs(i);
		for(int j=m;j>1;j--){
			for(int k=1;k<j;k++){
				f[pos][j]=max(f[pos][j],f[pos][j-k]+f[i][k]);
			}
		}
	}
}
int main(){
	cin>>n>>m;
	m++;
	for(int i=1;i<=n;i++){
		int u;
		cin>>u>>f[i][1];
		G[u].emplace_back(i);
	}
	dfs(0);
	cout<<f[0][m];
	return 0;
}

1.1.2 - 上下界优化

我们发现这份代码只是一个雏形,其实还有许多地方可供优化。上文我们提到了边界\(siz\)的问题。故我们用\(siz[i]\)表示“当前遍历过的子树大小(包括\(pos\))”,在搜索的过程中实时累加,便可以得到以下优化(建议结合代码理解):

  • 枚举\(k\)(给子节点\(i\)分配的个数)只需从\(k=\max(1,j-siz[pos])\)开始,枚举到\(k\leq\min(j-1,siz[i])\)即可。
    其原因就是:以\(pos\)的子节点\(i\)为根的子树最多只有\(siz[i]\)个节点,超出就没有遍历的必要性了,同样地,如果\(k<j-siz[pos]\),则分给已合并部分的个数就\(>siz[pos]\),同样没必要考虑。
  • 相应地,既然超过子树大小就调用不到了,我们还额外求它有什么用呢?
    所以,枚举\(j\)(背包大小)只需要从\(j=\min(m,siz[pos]+siz[i])\)开始。
#include<bits/stdc++.h>
using namespace std;
int n,m;
vector<int> G[1010];
int f[1010][1010],siz[1010];
void dfs(int pos){
	siz[pos]=1;
	for(auto i:G[pos]){
		dfs(i);
		for(int j=min(m,siz[pos]+siz[i]);j>1;j--){
			//j>siz[pos]+siz[i]就没有计算的必要了,
			//因为根本调用不到这个值
			int lim=min(j-1,siz[i]);
			//k之所以要<=siz[i]是因为下面调用了f[i][k],
			//如果k>siz[i]是没有必要遍历的
			for(int k=max(1,j-siz[pos]);k<=lim;k++){
				//k之所以从j-siz[pos]开始是因为下面调用了f[pos][j-k],
				//而如果j-k>siz[pos]是没有必要遍历的
				f[pos][j]=max(f[pos][j],f[pos][j-k]+f[i][k]);
			}
		}
		siz[pos]+=siz[i];
		//注意这里siz[pos]是实时累加的
	}
}
int main(){
	cin>>n>>m;
	m++;
	for(int i=1;i<=n;i++){
		int u;
		cin>>u>>f[i][1];
		G[u].emplace_back(i);
	}
	dfs(0);
	cout<<f[0][m];
	return 0;
}

这个优化十分重要,\(m\leq n\leq 1000\)情况下,可以把时间从100ms左右降到10ms以内。

理解起来可能有一定难度,建议自己造一个样例模拟填表的过程,多填几遍。下图可能会给你帮助:

附上图中样例:

Sample
14 8
0 1
0 3
1 1
1 5
1 6
2 1
3 1
3 9
7 100
4 2
4 3
6 7
6 8
6 9

U53204 【数据加强版】选课
这道加强版数据范围是\(1\le m\le n\le 10^5\)\(n*m\le 10^8\)
因此用这种方法做需要注意用vector,根据\(m\)的大小动态开数组,才不会MLE。

1.1 分组背包上下界优化
#include<bits/stdc++.h>
using namespace std;
int n,m;
vector<int> G[1010],f[1010];
int siz[1010],v[1010];
void dfs(int pos){
	f[pos].resize(m+1);
    //其实根据上下界优化,有些情况不用开到m
    //但是此时该节点的大小还没计算出来
    //如果提前计算代码就得翻新了(懒)
	f[pos][1]=v[pos];
	siz[pos]=1;
	for(auto i:G[pos]){
		dfs(i);
		for(int j=min(m,siz[pos]+siz[i]);j>1;j--){
			int lim=min(j-1,siz[i]);
			for(int k=max(1,j-siz[pos]);k<=lim;k++){
				f[pos][j]=max(f[pos][j],f[pos][j-k]+f[i][k]);
			}
		}
		siz[pos]+=siz[i];
	}
}
int main(){
	cin>>n>>m;
	m++;
	for(int i=1;i<=n;i++){
		int u;
		cin>>u>>v[i];
		G[u].emplace_back(i);
	}
	dfs(0);
	cout<<f[0][m];
	return 0;
}

1.1.3 - 复杂度分析

我们简单分析一下算法的复杂度。

空间的话,就是\(O(nm)\)

而时间,我们先考虑朴素算法,每个节点都遍历自己的子节点\(1\)遍,共\(n\);对于每个子节点,枚举背包大小,共\(m\);对于每个背包大小我们枚举\(k\),共\(m\)。所以时间复杂度上界是\(O(nm^2)\)

上下界优化后的时间复杂度是\(O(nm)\),下面我们进行证明。

内容转述自ouuan「树上背包的上下界优化」,万分感谢原作者!!

1.1.3 A - 证明

我们先思考,如果没有\(m\)的限制,合并以\(u\)为根节点的子树,时间复杂度是多少?

\(T_x\)处理以\(x\)为根的子树的时间,那么

\[\begin{aligned}T_u&=\left(\sum\limits_{\forall fa[v_i]=u}T_{v_i}\right)+t_u\\t_u&=1+(1+siz[v_1])\times siz[v_1]+(1+siz[v_1]+siz[v_2])\times siz[v_2]+\cdots+siz[u]\times siz[v_k]\\&=1+\sum\limits_{\forall fa[v_i]=u}siz[v_i]\times(siz[u]+1)\\&=siz[u]^2\end{aligned} \]

解释:计算\(t\)时,对于第\(1\)步得到的式子,我们显然可以把\(1\)都去掉。接下来我们利用放缩,把原式扩大成\(siz[u]\times siz[v_1]+siz[u]\times siz[v_2]+\cdots+siz[u]\times siz[v_k]\),接下来运用乘法分配律,就得到\(t\)的上界\(siz[u]^2\)了。

对于所有叶子节点\(x\)\(T_x=1=siz[x]^2\)

那么对于计算\(T\)中的\(\sum\limits_{\forall fa[v_i]=u}T_{v_i}\),每个\(T_{v_i}\)的上界都是\(\le siz[v_i]^2\),而平方的和一定\(<\)和的平方,所以\(\sum\limits_{\forall fa[v_i]=u}T_{v_i}\)上界是\(siz[u]^2\),又因为\(t\)也是\(siz[u]^2\),所以\(T_u=siz[u]^2\)。故初步时间复杂度是\(O(n^2)\)


接下来,我们思考有\(m\)的限制时,时间复杂度将会是多少。

此时,因为要和\(m\)取最小值,所以

\[\begin{aligned}t_u&=1+\min(m,1+siz[v_1])\times \min(m,siz[v_1])+\min(m,1+siz[v_1]+siz[v_2])\times \min(m,siz[v_2])+\cdots+\min(m,siz[u])\times \min(m,siz[v_k])\\&\le m\times siz[u]\end{aligned} \]

与上面相似地,叶节点\(x\)\(T_x\)\(m\times siz[u]\)

每个\(T_{v_i}\)的上界都是\(m\times siz[v_i]\),加起来,\(\sum\limits_{\forall fa[v_i]=u}T_{v_i}\)上界是\(m*siz[u]\),又因为\(t=m\times siz[u]\),所以更精确的时间复杂度是\(O(nm)\)


呜哇 树形dp水好深 (・・)

1.2 - DFS序解法

我们把树上的节点按DFS序编号:

那么,我们用\(f[i][j]\)表示编号为\(1\sim i\)的节点中,选\(j\)个节点,所能获得的最大学分。
\(awa[i]\)表示DFS序中第\(i\)个是什么。

那么对于\(1\)个点,我们分两种情况讨论:

  • 选当前点。那么,该点的子树中,所有点都可以参与考虑。\(f[i][j]=f[i-1][j-1]+v[awa[i]]\)
  • 不选当前点。那么,该点的子树都不能参与考虑。此时需要退回到该节点为根的子树以外。因为是后序遍历,所以节点\(i\)为根的子树前,最晚遍历的点就是\(i-siz[awa[i]]\)。故\(f[i][j]=f[i-siz[awa[i]]][j]\)

两种情况取最大即可。

时空复杂度显然都是\(O(nm)\)

实现细节的话,还是请注意vector需要动态开大小。否则会MLE。

1.2 - DFS序
#include<bits/stdc++.h>
using namespace std;
int n,m,v[100010];
vector<int> G[100010],f[100010];
int siz[100010],awa[100010],len;
void dfs(int pos){
	siz[pos]=1;
	for(auto i:G[pos]){
		dfs(i);
		siz[pos]+=siz[i];
	}
	awa[++len]=pos;
}
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		int u;
		cin>>u>>v[i];
		G[u].emplace_back(i);
	}
	dfs(0);
	f[0].resize(m+1);
	for(int i=1;i<=n;i++){
		f[i].resize(m+1);
		for(int j=1;j<=m;j++){
			f[i][j]=max(f[i-1][j-1]+v[awa[i]],f[i-siz[awa[i]]][j]);
		}
	}
	cout<<f[n][m];
	return 0;
}

1.3 - 多叉转二叉解法

收到“二叉苹果树”的启发,我们发现如果能把多叉树转化成二叉树,那么转移时仅需枚举左子结点分配个数即可,十分方便。但是我们怎么将多叉转为二叉呢?

具体方法就是不断递归,找到根节点,把它与最左边的子节点之间的边保留,其他全部断掉。然后从这个保留的孩子开始,连一条链到所有断开的点。具体见此文

用上面的例子,转完二叉数的图如下。

注意:此二叉树区分左右孩子,我们把保留的最左边的节点作为根的左子节点,链上的点都是其父节点的右子节点

根据上面的规则,我们对上图进行整理,使左右子节点关系更清晰。

注意到对于节点\(pos\),其左子节点就是原图中的\(pos\)最左边的子节点,而右子节点就是原图中的\(pos\)的兄弟。于是转移时,我们先一层大循环枚举\(i\),即节点\(pos\)有多少门课可以学。接下来,我们分类讨论:

  • 根节点自己用去\(1\)个。那么还剩下\(i-1\)门课可以学,我们枚举这\(i-1\)门课在左右子节点之间的分配即可,和引入的思路相同。
  • 根节点自己不用。这里和引入有些不一样了,根节点可以不选。这是因为\(pos\)的右子节点在原图中是自己的兄弟,也就是说,如果我们不选根节点\(pos\),不影响我们选它的右子节点(也就是原图中的兄弟节点)。我们需要让“根节点不选,其他\(i\)门课全给右子节点”的状态参与考虑。

两种情况取最大即可。

但是我们注意到,引入部分代码的的时间复杂度是\(O(nm^2)\),会超时。

于是我们受1.1.2中树上背包优化的启发,对上下界进行优化。

把枚举和计算范围都限制在\(siz[pos]\)以内即可。

易证时间复杂度是\(O(nm)\),因为每个节点花费上限\(m\)去平衡左右子树,一共\(n\)个节点。

详见代码注释。

1.3 - 多叉转二叉
#include<bits/stdc++.h>
#define NO 100009
using namespace std;
int n,m,v[100010],lc[100010],rc[100010];
int siz[100010];
vector<int> G[100010],f[100010];
void dfs(int pos){
	if(pos==NO) return;
	dfs(lc[pos]);
	dfs(rc[pos]);
	for(int i=1;i<=min(m,siz[pos]);i++){
		f[pos][i]=f[rc[pos]][i];
		//不需要  ...][i]  =>  ...][min(i,siz[rc[pos]])]
		//原因是如果i>siz[rc[pos]],就说明把右节点分配满,
		//也有剩余的课程可以加到pos和pos的左子节点
		//这个语句就相当于没用了
		int lj,rj;
		rj=min(i-1,siz[lc[pos]]);
		//左节点最多分配siz[lc[pos]]个
		lj=max(0,i-1-siz[rc[pos]]);
		//右节点最多分配siz[rc[pos]]个
		//而右节点个数是i-1-j,
		//所以j最大枚举到i-1-siz[rc[pos]]
		for(int j=lj;j<=rj;j++){
			//左 j个    右 i-1-j个
			int l=f[lc[pos]][j];
			int r=f[rc[pos]][i-1-j];
			f[pos][i]=max(f[pos][i],l+r+v[pos]);
		}
	}
}
void conv(int pos){
	siz[pos]=1;
	int prei=-1;
	for(auto i:G[pos]){
		if(prei==-1) lc[pos]=i;
		else rc[prei]=i;
		prei=i;
		conv(i);
	}
}
void cntsiz(int pos){
	if(pos==NO) return;
	cntsiz(lc[pos]);
	cntsiz(rc[pos]);
	siz[pos]=1+siz[lc[pos]]+siz[rc[pos]];
}
int main(){
	cin>>n>>m;
	m++;
	for(int i=1;i<=n;i++){
		int u;
		cin>>u>>v[i];
		G[u].emplace_back(i);
	}
	for(int i=0;i<=n;i++) f[i].resize(m+1),lc[i]=rc[i]=NO;
	f[NO].resize(m+1);
	conv(0);//转二叉
	cntsiz(0);//计算大小
	dfs(0);
	cout<<f[0][m];
	return 0;
}

1.4 - 泛化物品求并解法

来源:徐持衡 - 国家集训队2009论文集 浅谈几类背包题

\(f[i][j]\)为遍历到节点\(i\),选择\(j\)个节点而且必须选择\(i\)的答案。

每一个\(f[x]\)都是一个泛化的物品。给\(f[x]\)分配\(y\)的空间,获得的价值就是\(f[x][y]\)


我们假设当前遍历到\(i\),现在需要处理\(i\)的一个子节点\(s\)

因为\(s\)的前驱是\(i\),所以\(f[s][j]\)初值为\(f[i][j-1]+v[s]\)

假设现在完成了对\(s\)的处理,那么现在对于两个物品\(f[i]\)\(f[s]\)\(f[i]\)表示选\(i\)不选\(s\)的答案,\(f[s]\)表示选\(i\)也选\(s\)的答案。两者一同覆盖了“选\(i\)”这一条件,所以在合并的时候我们需要对每个大小下的价值取\(\max\),即对子节点和合并到现在的根节点进行一个“泛化物品的并”运算。


在这里需要明确\(3\)个概念、求法、复杂度和它们的适用情况:

  • 两个泛化物品的和:两个泛化物品\(a,b\),分配大小为\(j\)时价值分别为\(a[j],b[j]\)。它们的和为泛化物品\(c\),那么\(c[j]\)表示\(j\)的大小,可以随意分配给\(a,b\)能获得的最大价值。

    • 求法:\(c[j]=\max(a[k]+b[j-k])(0\le k\le j\le m)\)\(m\)表示可取大小的最大值)。
    • 复杂度:\(O(m^2)\),每个大小遍历一次,共\(m\);每次枚举\(k\)又花费\(m\)
    • 适用情况:可以在两个物品之间进行分配。绝大多数树上的背包都是这种做法。比如1.1.1的分组背包就是一个不断求两个泛化物品之和的过程,每次将子节点和之前遍历过的子树进行合并。每个点都要完成一个\(O(m^2)\)的合并,时间复杂度就是\(O(nm^2)\),优化上下界后才变成\(O(nm)\)
  • 泛化物品与普通物品的和:一个泛化物品\(a\)和一个普通物品\(b\)\(b\)的大小为\(w\),价值为\(v\),泛化物品\(c\)

    • 求法:\(c[k]=\begin{cases} a[k]&0\le k<w\\ \max(a[k],a[k-w]+v)&w\le k\le m \end{cases}\)
    • 复杂度:\(O(m)\)
    • 适用情况:比如01背包、完全背包这些问题,它们本质上就是不断把普通物品加到泛化物品中,一共加\(n\)次(所以时间复杂度\(O(nm)\))。
  • 两个泛化物品的并: 两个泛化物品\(a,b\),它们的并为\(c\)

    • 求法:\(c[k]=\max(a[k],b[k])(0\le k\le m)\)
    • 复杂度\(O(m)\)
    • 适用情况:两个物品只能取一个。比如当前这种解法,\(f[i]\)\(f[s]\)一同覆盖了“选\(i\)”这一条件,因此合并时不能求和,而是求并。
1.4 - 泛化物品求并
#include<bits/stdc++.h>
using namespace std;
int n,m,v[100010];
vector<int> G[100010],f[100010];
void dfs(int pos,int dep){
	for(auto i:G[pos]){
		for(int j=dep+1;j<=m;j++){
			f[i][j]=f[pos][j-1]+v[i];
		}
		dfs(i,dep+1);
		for(int j=dep+1;j<=m;j++){
			f[pos][j]=max(f[pos][j],f[i][j]);
		}
	}
}
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		int u;
		cin>>u>>v[i];
		G[u].emplace_back(i);
	}
	for(int i=0;i<=n;i++) f[i].resize(m+1);
	dfs(0,0);
	cout<<f[0][m];
	return 0;
}

1.5 - 总结

\(4\)种方法时间复杂度都为\(O(nm)\),跑加强版都能保持在所有测试点\(3s\)左右。主要是还没有进行常数优化,比如I/O等等。

其中我们最常用的是第\(1\)种,不过多了解一些算法总归没有坏处嘛。

最后总结的这三个概念,个人感觉是背包\(dp\)中很本质的东西。结合一下适用情况就可以发现,背包转移几乎都可以用这三个求法来概括。如果从这一角度来解释转移,就会变得很好理解。

如果有任何疑问或建议,请在评论区指出,我会认真阅读的!

例2:有线电视网

P1273 有线电视网

给定一个树形结构。其中根节点是足球比赛的现场,叶子节点是用户终端,其他节点就是转播站。每条边都有一个边权,表示信号传递的消费。每个叶子都有一个点权,表示每个用户准备的钱数。

请问在不亏本的情况下,最多能让多少用户看到比赛?
(注意不是让赚的钱最多)


此题我们用分组背包来实现。用\(f[i][j]\)表示以\(i\)为根,让\(j\)个观众看到比赛的最大收益。用\(siz[i]\)表示以\(i\)为根的子树中有多少个叶子。

与上面1.1.2的代码十分相像了,同样是两个泛化物品求和,具体见代码吧。只不过有以下细节需要注意:

  • 由于各子树选择叶子节点,不存在对父节点的依赖关系,所以\(j\)需要枚举到\(1\),相应地\(k\)也需要枚举到\(j\)
  • \(f\)的初始值应为极小值,因为收益可能为负。特别地,\(f[i][0]=0\)
  • 因为此题中遍历每一条边也有相应的花费,所以泛化物品的价值需要减去边权。
  • 题目不是让赚的钱最多,所以我们是要从大到小遍历\(m\ge i\ge 0\)\(f[1][i]\)非负则输出\(i\),结束程序。

时间复杂度与树上背包相同,是\(O(nm)\)

2 点击查看代码
#include<bits/stdc++.h>
using namespace std;
struct edge{int to,w;};
vector<edge> G[3010];
int n,m,v[3010];
int f[3010][3010],siz[3010];
//siz[i]表示i为根的子树,叶子的个数
//f[i][j]表示i为根,让j个观众看到的最大收益
void dfs(int pos){
	if(pos>n-m){
		f[pos][1]=v[pos];
		siz[pos]=1;
	}
	for(auto i:G[pos]){
		dfs(i.to);
		siz[pos]+=siz[i.to];
		for(int j=siz[pos];j>=1;j--){
			for(int k=1,lim=min(siz[i.to],j);k<=lim;k++){
				f[pos][j]=max(f[pos][j],f[pos][j-k]+f[i.to][k]-i.w);
			}
		}
	}
}
int main(){
	cin>>n>>m;
	memset(f,128,sizeof f);//int极小值
	for(int i=1;i<=n;i++) f[i][0]=0;
	for(int i=1;i<=n-m;i++){
		int k;
		edge tmp;
		cin>>k;
		for(int j=1;j<=k;j++){
			cin>>tmp.to>>tmp.w;
			G[i].emplace_back(tmp);
		}
	}
	for(int i=n-m+1;i<=n;i++) cin>>v[i];
	dfs(1);
	for(int i=m;i>=0;i--){
		if(f[1][i]>=0){
			cout<<i;
			break;
		}
	}
	return 0;
}

例3:重建道路

P1272 重建道路

有一个\(N\)个节点的树形结构,现在要剪断若干条边,使得产生一个大小恰好为\(P\)的连通块。请问最少需要剪断多少条边?


我们简单地设\(f[i][j]\)表示以\(i\)为根节点的子树中,保留大小为\(j\)的子树连接父节点的最小删边次数。

设当前在处理以\(pos\)为根的子树,需要保留\(p\)个节点。

由于保留的子树需要与父节点联通,所以显然根节点\(pos\)必须要选,子孙结点中一共要保留\(p-1\)个。

我们就发现这是一个泛化物品的背包问题了。每个\(f[i]\)都是一个泛化的物品,如果给这个物品分配\(j\)的空间,所获得的价值是\(f[i][j]\)。现在对于总空间\(p\),要让价值和最小(即让删边次数最小)。

我们对于每一个子节点,将其合并到根节点上来。相当于两个泛化物品求和(上面有提到)的过程。

转移:\(f[pos][j]=\min(f[pos][j]+1,f[pos][j-k]+f[i][k])(1\le k<j)\)
\(i\)枚举的是\(pos\)的所有子节点
\(j\)枚举的是\(p\),即保留的节点个数
\(k\)枚举的是分给子节点多少个(\(k\)不能\(=j\)的原因就是根节点必须选,子节点最多选\(j-1\),上面说了)

很好理解,要么前面合并的子树已经保留\(j\)个节点了,当前这个子节点\(i\)根本不需要,于是把\(pos\)和当前子节点\(i\)之间的边断掉,额外\(+1\);要么考虑给当前节点分配多少个,\(1\le k<j\)

枚举的上下界优化和1.1.2类似,不再赘述。

最终答案是\(\min\{f[1][P],f[2][P]+1,f[3][P]+1,…,f[n][P]+1\}\)
除了\(f[1]\)其他都需要额外\(+1\)的原因是需要额外断掉根节点和它父节点之间的连边。

关于初始化:因为取最小值所以全部设为正无穷。但要注意不要置为最大值,因为代码中有\(+1\)的过程,如果置为最大可能会溢出。特别地,\(f[pos][1]=0\),因为一开始一个子节点都没遍历,所以一开始就满足“保留\(1\)个点”。

时间复杂度\(O(NP)\),证明同分组背包。

3 点击查看代码
#include<bits/stdc++.h>
using namespace std;
int n,P,siz[160];
int f[160][160],ans;
vector<int> G[160];
void dfs(int pos){
	siz[pos]=1,f[pos][1]=0;
	for(auto i:G[pos]){
		dfs(i);
		for(int j=min(P,siz[pos]+siz[i]);j>=1;j--){
			f[pos][j]++;//注意需要放在外面
			for(int k=max(1,j-siz[pos]);k<=min(siz[i],j-1);k++){
				f[pos][j]=min(f[pos][j],f[pos][j-k]+f[i][k]);
			}
		}
		siz[pos]+=siz[i];
	}
}
int main(){
	cin>>n>>P;
	memset(f,127,sizeof f);//int极大值
	for(int i=1;i<n;i++){
		int u,v;
		cin>>u>>v;
		G[u].emplace_back(v);
	}
	dfs(1);
	ans=f[1][P];
	for(int i=2;i<=n;i++) ans=min(ans,f[i][P]+1);
	cout<<ans;
	return 0;
}

例4:软件安装

P2515 [HAOI2010] 软件安装

\(N\)个软件,每个软件有一个依赖软件,或没有。如果有依赖软件,则必须先安装依赖软件,才能运行此软件。软件\(i\)占空间\(W_i\),如果该软件能运行,则会产生\(V_i\)的价值。给定磁盘空间\(M\),请问价值最大是多少?


首先我们需要意识到这道题可能是有环的,给定的图是一个基环树森林。遇到这种情况我们可以将环缩为一个点,这个点的\(V\)是环上所有点的\(V\)求和,\(W\)也是环上所有点的\(W\)之和。因为对于一个环,我们要么都安装,要么都不安装。

这里使用Tarjan求强连通分量来缩点,其实直接拓扑排序/dfs找环也可以。
如果没学过Tarjan可以阅读我的文章,或者观看[算法]轻松掌握tarjan强连通分量(P4开始看就可以)。

缩点后就是一片森林了。和选课相同,我们把所有入度为\(0\)的节点添上\(1\)个公共父节点——节点\(0\)

接下来就和选课很相像了。但选课限制的是选择节点的个数,这道题限制的是选择节点占的空间。

我们回顾一下分组背包的代码:

  • 初始化:\(f[pos][1]=v[pos]\)\(siz[pos]=1\)
    修改后:对于\(w[pos]\le j\le \min(m,siz[pos])\)\(f[pos][j]=v[pos]\)\(siz[pos]=w[pos]\)

  • \(i\)\(\leftarrow G[pos]\)
    不修改

  • \(j\)\(\min(m,siz[pos]+siz[i])\sim 2\)(倒序)
    修改后:\(\min(m,siz[pos]+siz[i])\sim w[pos]+1\)(倒序)

  • \(k\)\(\max(0,j-siz[pos])\sim \min(siz[i],j-1)\)
    修改后:\(\max(0,j-siz[pos])\sim \min(siz[i],j-w[pos])\)

其实就是把\(1\)都变成了\(w[pos]\),然后初始化相应地改一下就行。如果理解了上下界优化,这个应该比较好懂。

void dfs(int pos){
	int tsiz=w[pos];
	for(auto i:G[pos])
		//提前计算siz是为了减少初始化的循环次数
		//不加几乎没有影响
		//姑且算卡常吧(汗)
		dfs(i),tsiz+=siz[i];
	for(int i=w[pos];i<=min(m,tsiz);i++) f[pos][i]=v[pos];
	siz[pos]=w[pos];
	for(auto i:G[pos]){
		for(auto j=min(m,siz[pos]+siz[i]);j>w[pos];j--){
			for(int k=max(0,j-siz[pos]),lim=min(siz[i],j-w[pos]);k<=lim;k++){
				f[pos][j]=max(f[pos][j],f[i][k]+f[pos][j-k]);
			}
		}
		siz[pos]+=siz[i];
	}
}
4 点击查看代码
#include<bits/stdc++.h>
using namespace std;
int n,m,w[110],v[110],f[110][510];
int low[110],dfn[110],ori[110],tim;
//ori表示缩点后的位置
int n2,w2[110],v2[110];
bool vis[110],in[110];
//vis在tarjan过程中标记是否在栈中
//in记录是否有入度,用于添加0节点
int siz[110];
vector<int> G[110],G2[110];
stack<int> st;
void dfs(int pos){
	int tsiz=w2[pos];
	for(auto i:G2[pos])
		//提前计算siz是为了减少初始化的循环次数
		//不加影响不大
		//姑且算卡常吧(汗)
		dfs(i),tsiz+=siz[i];
	for(int i=w2[pos];i<=min(m,tsiz);i++) f[pos][i]=v2[pos];
	siz[pos]=w2[pos];
	for(auto i:G2[pos]){
		for(auto j=min(m,siz[pos]+siz[i]);j>w2[pos];j--){
			for(int k=max(0,j-siz[pos]),lim=min(siz[i],j-w2[pos]);k<=lim;k++){
				f[pos][j]=max(f[pos][j],f[i][k]+f[pos][j-k]);
			}
		}
		siz[pos]+=siz[i];
	}
}
void tarjan(int x){
	low[x]=dfn[x]=++tim;
	st.push(x);
	vis[x]=1;
	for(auto i:G[x]){
		if(!dfn[i]){
			tarjan(i);
			low[x]=min(low[x],low[i]);
		}else if(vis[i]){
			low[x]=min(low[x],low[i]);
		}
	}
	if(dfn[x]==low[x]){
		++n2;
		while(1){
			int t=st.top();
			ori[t]=n2;//t缩到x
			st.pop();
			vis[t]=0;
			v2[n2]+=v[t];//累加权值
			w2[n2]+=w[t];//累加大小
			if(t==x) break;
		}
	}
}
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++) cin>>w[i];
	for(int i=1;i<=n;i++) cin>>v[i];
	for(int i=1;i<=n;i++){
		int d;
		cin>>d;
		if(d) G[d].emplace_back(i);
	}
	for(int i=1;i<=n;i++) if(!dfn[i]) tarjan(i);
	for(int i=1;i<=n;i++){
		int x=ori[i];
		for(auto j:G[i]){
			int y=ori[j];
			if(x==y) continue;
			G2[x].emplace_back(y);
			in[y]=1;
		}
	}
	for(int i=1;i<=n2;i++) if(!in[i]) G2[0].emplace_back(i);
	dfs(0);
	cout<<f[0][m];
	return 0;
}

时间复杂度\(O(nm)\)


\(\textbf{[- The End -]}\)

完结撒花~
如果有问题或建议请务必在评论区提出!谢谢!

\(\colorbox{black}{\texttt{\color{Cyan}{.\_.\ \ -\_-}}}\)
\(\colorbox{black}{\texttt{\color{Fuchsia}{Next Phantasm...}}}\)

posted @ 2024-05-12 17:36  Sinktank  阅读(449)  评论(1编辑  收藏  举报
★CLICK FOR MORE INFO★ TOP-BOTTOM-THEME
Enable/Disable Transition
Copyright © 2023 ~ 2024 Sinktank - 1328312655@qq.com
Illustration from 稲葉曇『リレイアウター/Relayouter/中继输出者』,by ぬくぬくにぎりめし.