一、概述
给定一棵节点为N的树,我们可以任选一个节点为根节点,从而定义出每个节点的深度和每棵子树的根。在树上设计动态规划算法时,一般以节点从深到浅(子树从小到大)的顺序作为dp的阶段,在dp状态表示中,第一维状态通常是节点编号(代表以该节点为根的子树)。大多数时候使用的递归实现动态规划。对于每个节点x,先递归在他的每个子节点上进行dp,在回溯时,从子节点向节点x进行状态转移。
二、例题
树的最长路径问题
(1)模板
最直接写法就是暴力枚举起点和终点找出最长路径,但是这样肯定超时了。
应用树形dp的思想,对于一个节点,我们来分析经过这个节点的路径的集合:
对于图中标出的红色节点,经过它的路径有三种:
- 以其子树中的某个点为起点,终点是他自己
- 以其子树中某个点为起点,终点是它其他子树中的某个点
- 以其子树中某个点为起点,终点不在它自己以及其子树中
要在三种情况中找最大值:
对第一种情况,可以递归子树找到当前子树根节点的最长路径
对第二种情况,利用第一种情况时找出的子树根节点最长路径,再顺便找出次长路径,把最长和次长接起来,就是第二种路径的最大值
对第三种情况,我们在处理该节点的父节点时会处理到。
因此状态表示集合为:以节点i为根的子树中,从子树某个节点到i的最长路径为,次长路径
属性为最大值。状态计算为:,求次长路径同理。
求出通过该节点的最长路径后,用d1+d2更新全局变量ans。
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
//任取一个点u,找离这个点最远的点v,则v是直径一个端点 找离v最远的点k 则vk是直径
//本题提出v作为根,
const int N =10010,M=2*N;
int n;
int h[N],e[M],ne[M],w[M],idx;
int ans;
void add(int a,int b,int c)
{
e[idx]=b,w[idx]=c,ne[idx]=h[a],h[a]=idx++;
}
//dfs返回子树 根到节点的最大路径
int dfs(int u,int father)
{
int dist=0;
int d1=0,d2=0;//最大值和次最大值
for(int i=h[u];i!=-1;i=ne[i])
{
int j=e[i];
if(j==father) continue;
int d=dfs(j,u)+w[i];
dist=max(dist,d);
if(d>=d1) d2=d1,d1=d;
else if(d>d2) d2=d;
}
ans=max(ans,d1+d2);
return dist;
}
int main()
{
cin>>n;
memset(h,-1,sizeof h);
for(int i=0;i<n-1;i++)
{
int a,b,c;
cin>>a>>b>>c;
add(a,b,c),add(b,a,c);
}
dfs(1,-1);
cout<<ans<<endl;
return 0;
}
作者:yankai
链接:https://www.acwing.com/activity/content/code/content/4163894/
来源:AcWing
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
(2)树的中心
同样是这个图,分析一下某个节点到其它节点的路径的集合:
- 终点在其某个子树中
- 终点是其某个祖宗节点的子树某个节点中
- 如果该节点是叶节点,不存在第一种情况
为计算第一种情况最大值,我们想上一题一样从下至上递归处理出下行最大路径即可
对于第二种情况,我们需要从上至下处理每一个节点的上行最大路径。对图中红色节点,假定我们已经知道其父节点的最大上行路径,则红色节点的最大上行路径终点可能是该父节点的最大上行路径的终点,也可能是父节点的另一棵子树的某个节点。因此状态转移方程为:
如果该节点在父节点的最大下行路径的子树中:如果不在最大下行路径所在的子树中:
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N =10010,M=2* N,INF=0x3f3f3f3f;
int n;
int d1[N],d2[N],up[N],p[N];//最大值子树值 次大值子树值 上行最大值 最大值子节点编号
int h[N],e[M],ne[M],w[M],idx;
bool isleaf[N];
void add(int a,int b,int c)
{
e[idx]=b,w[idx]=c,ne[idx]=h[a],h[a]=idx++;
}
int dfs_d(int u,int father)
{
d1[u]=d2[u]=-INF; //如果边权存在负值
for(int i=h[u];i!=-1;i=ne[i])
{
int j=e[i];
if(j==father) continue;
int d=dfs_d(j,u)+w[i];
if(d>=d1[u])
{
d2[u]=d1[u],d1[u]=d;
p[u]=j;
}
else if(d>d2[u]) d2[u]=d;
}
if(d1[u]==-INF)
{
d1[u]=d2[u]=0;
isleaf[u]=true;
}
return d1[u];
}
void dfs_u(int u,int father)
{
for(int i=h[u];i!=-1;i=ne[i])
{
int j=e[i];
if(j==father) continue;
if(p[u]==j) up[j]=max(up[u],d2[u])+w[i];
else up[j]=max(up[u],d1[u]) +w[i];
dfs_u(j,u);
}
}
int main()
{
cin>>n;
memset(h,-1,sizeof h);
for(int i=0;i<n-1;i++)
{
int a,b,c;
cin>>a>>b>>c;
add(a,b,c),add(b,a,c);
}
//任取一点作为根 现取1
dfs_d(1,-1);
dfs_u(1,-1);
int res=d1[1];
for(int i=1;i<=n;i++)
if(isleaf[i]) res=min(res,up[i]);
else res=min(res,max(d1[i],up[i]));
cout<<res;
return 0;
}
作者:yankai
链接:https://www.acwing.com/activity/content/code/content/4165743/
来源:AcWing
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
(3)数字转换
对于一个数x,它可以转换成的数有:1.如果x的约数之和x'小于x,则可转换成x'。2.可以转换为约数之和是x,且本身大于x的x''。
容易发现:由于任意正整数 x,的约数之和是唯一的,且本题要求只有约数之和小于自身才能转换。故对于所有的 x来说,他向小于自己的数转换的边至多只有一条,那就是x的约数之和 x′(x′<x)x′(x′<x)。这样一个图论模型我们就很熟悉了,我们把每一个 数 看作一个 点,把上述这个转换看作该点的 入边每一个 点,至多只有一条入边,这就是森林。
于是本题转换成建图和求树的直径问题。
把每个数作为它约数之和的子节点,再dfs处理每个根节点,找出最大直径。
细节:本题确定了根节点,因此建了有向图,就不需要在dfs时传入其父节点的编号了。
//转化为树的最长路径问题
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N =50010;
int h[N],e[N],ne[N],idx;
int sum[N];
int n;
int ans;
int st[N];
void add(int a,int b)
{
e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}
int dfs(int u)
{
int d1=0,d2=0;
for(int i=h[u];i!=-1;i=ne[i])
{
int j=e[i];
int d=dfs(j)+1;
if(d>=d1) d2=d1,d1=d;
else if(d>d2) d2=d;
}
ans=max(ans,d1+d2);
return d1;
}
int main()
{
memset(h,-1,sizeof h);
cin>>n;
for(int i=1;i<=n;i++)
for(int j=2;j<=n/i;j++)
sum[i*j]+=i;
for(int i=2;i<=n;i++)//i从2开始枚举 因为不能再0和1之间连边 不能出现0
if(i>sum[i])
{
add(sum[i],i);
st[i]=true;
}
//for(int i=1;i<=n;i++)
// if(!st[i])
// dfs(i);
dfs(1);
cout<<ans<<endl;
}
作者:yankai
链接:https://www.acwing.com/activity/content/code/content/4166689/
来源:AcWing
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
背包类、状态机类树形dp
(1)二叉苹果树
与有依赖的背包问题十分相似。
emm题目限制保留的树枝数量,则容易想到状态表示f[i,j],代表在根节点编号是i的子树中选择,保留了j条边的所有方案的苹果最大值。
状态转移时即枚举所有子节点,判断是否选择这条边,选择子树中保留多少树枝。
因此转换成一个分组背包问题,组别是所有子节点的子树,组内物品是在这课子树中保留1~k个树枝的最优方案。
状态转移方程:
(01背包问题性质,因此注意j从大到小枚举)
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N =110,M=2*N;
int n,m;
int f[N][M];
int h[N],e[M],ne[M],w[M],idx;
void add(int a,int b,int c)
{
e[idx]=b,w[idx]=c,ne[idx]=h[a],h[a]=idx++;
}
void dfs(int u,int father)
{
for(int i=h[u];i!=-1;i=ne[i])
{
if(e[i]==father) continue;
dfs(e[i],u);
for(int j=m;j>=0;j--)
for(int k=0;k<j;k++)
f[u][j]=max(f[u][j],f[u][j-k-1]+f[e[i]][k]+w[i]);//边作为代价 而不是节点作为代价
//因此要选择有k条边的子树 还需要再-1 给u去连接这条子树的根
}
}
int main()
{
cin>>n>>m;
memset(h,-1,sizeof h);
for(int i=0;i<n-1;i++)
{
int a,b,c;
cin>>a>>b>>c;
add(a,b,c),add(b,a,c);
}
dfs(1,-1);
cout<<f[1][m]<<endl;
return 0;
}
作者:yankai
链接:https://www.acwing.com/activity/content/code/content/4166888/
来源:AcWing
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
(3)战略游戏
题目要求,一条边的两个点,必须有一个点放置守卫。因此在某个点放于不放,会影响状态转移方程。因此我们联想状态机模型,本题状态表示为:在以j为根的子树中选择,在j上放置与不放置的合法方案,属性为最小值。
状态转移方程为:
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N =1510,M=15100;
int f[N][2];
int n;
int h[N],e[M],ne[M],idx;
bool st[N];
void add(int a,int b)
{
e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}
void dfs(int u)
{
f[u][1]=1;f[u][0]=0;
for(int i=h[u];i!=-1;i=ne[i])
{
int j=e[i];
dfs(j);
f[u][1]+=min(f[j][0],f[j][1]);
f[u][0]+=f[j][1];
}
}
int main()
{
while(cin>>n)
{
memset(h,-1,sizeof h);
idx=0;
memset(st,false,sizeof st);
for(int i=0;i<n;i++)
{
int id,cnt;
scanf("%d:(%d)",&id,&cnt);
while(cnt--)
{
int ver;
cin>>ver;
add(id,ver);
st[ver]=true;
}
}
int root=0;
while(st[root]) root++;
dfs(root);
cout<<min(f[root][0],f[root][1])<<endl;
}
return 0;
}
作者:yankai
链接:https://www.acwing.com/activity/content/code/content/4167266/
来源:AcWing
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
(4)皇宫看守
上一题要求一条边两个端点至少有一个被选择 上一题看边 本题是看节点
本题可以出现1-2-3 只需要选择1和3其中一个即可2节点就被覆盖 而不需要1 3共选
因此本题需要分为3个状态 被父节点看到 被子节点看到 被自己看到。
0,1,2分别表示被父节点看到,被子节点看到,被自己看到。
接下来分析状态转移方程:
- 如果节点u被父节点看到,则u的子节点j需要被j的子节点看到,或是被j自己看到。
- 如果节点u被自己看到,则u的子节点j可以被父节点看到,也可以被自己看到,也可以被j的子节点看到。
- 如果节点u被子节点看到,则需要枚举是被哪个子节点看到。假设该子节点是j,则j需要被自己看到,且需要保证其它子节点被看到。该最小值就是原先求得的所有子节点被看到的总和sum,减去原先该子节点被看到点的最小代价,加上该子节点被自己看到的代价。
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N =1510;
int n;
int h[N],e[N],ne[N],idx;
int w[N];
int f[N][3];//上一题要求一条边两个端点至少有一个被选择 上一题看边 本题是看节点
//本题可以出现1-2-3 只需要选择1和3其中一个即可2节点就被覆盖 而不需要1 3共选
//因此本题需要分为3个状态 被父节点看到 被子节点看到 被自己看到
bool st[N];
void add(int a,int b)
{
e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}
void dfs(int u)
{
f[u][2]=w[u];
int sum=0;
for(int i=h[u];i!=-1;i=ne[i])
{
int j=e[i];
dfs(j);
f[u][0] += min(f[j][1], f[j][2]);
f[u][2] += min(min(f[j][0], f[j][1]), f[j][2]);
sum += min(f[j][1], f[j][2]);
}
f[u][1]=0x3f3f3f3f;
for(int i=h[u];i!=-1;i=ne[i])
{
int j=e[i];
f[u][1]=min(f[u][1],sum-min(f[j][1],f[j][2])+f[j][2]);
//某个子节点被自己看到加上 其他子节点被看到(除了被父节点看到)
}
}
int main()
{
cin>>n;
memset(h,-1,sizeof h);
for(int i=1;i<=n;i++)
{
int id;
int cnt;
cin>>id>>w[id];
cin>>cnt;
while(cnt--)
{
int ver;
cin>>ver;
add(id,ver);
st[ver]=true;
}
}
int root=1;
while(st[root]) root++;
dfs(root);
cout<<min(f[root][1],f[root][2]);
return 0;
}
作者:yankai
链接:https://www.acwing.com/activity/content/code/content/4167486/
来源:AcWing
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。