[解题报告][算法总结] 2023/8/24 树形dp报告
简介
树形 dp,一般常使用 记忆化搜索 解决。其关键是找到儿子和父亲之间的转移关系,设计状态。和普通 dp 不同的是,一般先递归处理儿子,再回溯处理父亲。
树形 dp 的几个特殊例子有树上背包,换根 dp(二次扫描)。
处理树的通用做法:直接建无向图,因为无向图的任意一个点都可以作为 root,为了放置重复搜索每次 dfs 记录一下该节点的 father,如果搜到的 \(v= father\) 则不走。
这样的处理使得我们不必要取找 root。
在递归中统计答案 一定要使用局部变量!,全局变量每次递归会更改,出现一些奇怪的错误。
Basic:树的遍历
给定一颗树,树中包含 \(n\) 个结点(编号\(1\)∼\(n\))和 \(n−1\) 条无向边。
请你找到树的重心,并输出将重心删除后,剩余各个连通块中点数的最大值。
重心定义:重心是指树中的一个结点,如果将这个点删除后,剩余各个连通块中点数的最大值最小,那么这个节点被称为树的重心。
Solution
对于每个点 \(i\) ,都将其作为重心,去找到它的连通块点数最大值。
它的每个儿子都可以作为一个连通块,很好处理。但是它的父亲呢?
如下图所示:
在上图中,若将 \(2\) 号节点作为“重心”,有三个连通块。若记它下面的每一个连通块共有 \(k\) 个点,整张图共有 \(n\) 个点,则它上面连通块的点数量为 \(n-k-1\)。这样我们就间接地求出上面“不好求”的部分。
因此,我们只需要对于每个点,递归的求出它下面每个连通块的点数量,再计算出 \(n-k-1\)。取 \(max\) 即为当前点作为“重心”时的答案。
具体实现递归即可。
具体实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
#define int long long
using namespace std;
const int N = 1000010;
const int INF = 0x3f3f3f3f;
vector <int> Edge[N];
int n;
int vis[N];
int ans = INF;
int minn_num = 0;
int dfs(int now)
{
vis[now] = 1;
int sum = 1,res = 0;
for(int i=0;i<Edge[now].size();i++)
{
int v = Edge[now][i];
if(!vis[v])
{
int t = dfs(v);
sum += t;
res = max(res,t);
}
}
res = max(res,n-sum);
if(res <= ans)
{
ans = res;
minn_num = now;
}
// ans = min(ans,res);
return sum;
}
signed main()
{
// ios::sync_with_stdio(false);
// cin.tie(0);
// cout.tie(0);
cin>>n;
for(int i=1;i<n;i++)
{
int u,v;
cin>>u>>v;
Edge[u].push_back(v);
Edge[v].push_back(u);
}
dfs(1);
cout<<minn_num<<endl<<ans<<endl;
return 0;
}
树形 dp 初步:计数类
一棵树共有 \(n\) 个节点,\(n-1\) 条边。你可以删去任意条边。使得最后保留在树上的部分点权值最大
原题链接:Luogu P1122 最大子树和
Solution
设 \(f_{i,j}\) 表示当父节点为 \(i\) 时,保留 \(j\) 条边时点权值最大值。
对于边 \(u-v\) 。先处理 \(v\) 。我们发现当且仅当 \(f{v,k}>0(\forall k < j)\) 时,我们才使 \(f{u,j}+f{v,k}\) 。最坏情况就是把所有枝条全部剪掉,这也就是所有点都是负边权的情况下。
以上即为本题状态转移方程。具体地,\(f{u,j}+f{v,k}(\forall k < j,f{v,k}>0)\)。
实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
#define N 100010
#define INF 0x3f3f3f3f
using namespace std;
vector <int> Edge[N];
int n;
int maxn = -INF;
int a[N];
int f[N];
void dfs(int p,int fa)
{
f[p] = a[p];
for(int i=0;i<Edge[p].size();i++)
{
int t = Edge[p][i];
if(t!=fa)
{
dfs(t,p);
if(f[t] > 0) f[p] += f[t];
}
}
}
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
for(int i=1;i<n;i++)
{
int x,y;
scanf("%d%d",&x,&y);
Edge[x].push_back(y);
Edge[y].push_back(x);
}
dfs(1,-1);
for(int i=1;i<=n;i++) maxn = max(maxn,f[i]);
cout<<maxn<<endl;
return 0;
}
树上背包:有依赖的背包问题
从某种意义上讲,树上背包 本质是枚举子树的选择。由于树的结构特性,我们显然无法像线性背包一样 “优美地”线性转移。
显然,枚举每个子树选择多少个儿子是不可避免的。树上背包就是递归计算出子树选择多少个儿子的最大贡献,然后进行枚举选择方案取最大值。非常“暴力”。理解树形 dp 的本质非常重要。
树上背包最常见的是树上 01 背包,和朴素 01 背包同理也可以使用滚动数组优化掉一层状态。具体操作详见下文。
典例1:基础的树上 01 背包
有一棵有 \(n\) 个点,\(n-1\) 条边的完全二叉树。你需要保留任意 \(q\) 条边,前提它们必须还是一棵树。每条边上都有权值,求权值最大。
原题链接Luogu P2015 二叉苹果树
Solution
树上的 01 背包问题。
首先,题目要求 一系列操作后剩下的 \(m\) 条边必须还组成一颗树。因此想要保留一个儿子上的边就必须保留它通往它父亲的边。
借鉴以往的经验,设 \(f_{i,j}\) 表示以 \(i\) 为父节点,保留 \(j\) 条边时最大边权和。
和普通树上 dp 同理,先处理儿子。对于每条边 \(u-v\) ,枚举 \(u\) 保留几条边,\(v\) 保留几条边。(显然 \(v\) 保留边数小于等于 \(u\) 保留边数)。设 \(u\) 保留 \(k\) 条边,\(v\) 保留 \(m\) 条边,边 \(u-v\) 的边权为 \(w\),则有:
\(f_{u,k}=max(f_{u,k},f_{v,m}+f_{u,k-m-1}+w)\)
我们在考虑转移的时候,只考虑两个节点即可,无需考虑很多。这样会更加简单。
最后答案即为 \(f_{1,q}\) 。也就是以祖先为根节点保留 \(q\) 条边时的最大权值。特别地,并不一定选 root。
实现
/*
树上背包
不难发现本题修建完后最后留在树上的苹果才算作答案。
所以,若一个子节点的枝条想要保留,则它的父节点的枝条一定保留!
所以就变成了一个树上做 01 背包问题
不妨设 $f_{i,j}$ 为 以 i 为父节点下面 j 个枝条最大苹果数量。
*/
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;
const int N = 10010;
typedef pair<int,int> PAIR;
int n,q;
vector <PAIR> Edge[N];
int f[N][N];
int maxn = -1;
int sz[N];
void dp(int noww,int fa)
{
for(int i=0;i<Edge[noww].size();i++)
{
int v = Edge[noww][i].first;
if(v == fa) continue;
dp(v,noww);
int u = noww;
for(int j=q;j>=0;j--)
{
for(int k=j-1;k>=0;k--) //最多保留 j-1条边,父节点不可能一条边也不保留!
{
f[noww][j] = max(f[noww][j],Edge[noww][i].second+f[noww][j-k-1]+f[v][k]);
maxn = max(maxn,f[noww][j]);
}
}
}
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>q;
for(int i=1;i<n;i++)
{
int u,v,w;
cin>>u>>v>>w;
Edge[u].push_back(PAIR(v,w));
Edge[v].push_back(PAIR(u,w));
}
dp(1,-1);
cout<<f[1][q]<<endl;
return 0;
}
典例2:有依赖的01背包
有 \(n\) 门课程,每个课程都有一个学分和一个先修课程(只有学完先修课程才能学该课程),你需要选择 \(M\) 门课程学习,求最大学分。
Solution
和上题同理,设 \(f_{i,j}\) 表示以 \(i\) 为父节点学 \(j\) 门课程时的最大学分。先处理儿子,然后枚举 \(i\) 学几门课程,\(k\) 学几门课程(有一条边 \(i-k\) ),状态转移:
\(f_{i,j}=max(f_{i,j},f_{i,j-l}+f_{k,l}\)
初始化使 \(f_{i,1}=a_i\)(\(a_i\) 为每个课程 \(i\) 的学分)
本题还有一个小 Trick,其实用了很多次了。就是题目给出森林难以处理的时候,我们可以将 \(0\) 号节点参与建树,把森林变成一颗树。
最后输出 \(f_{0,m}\) 即可。\(0\) 即为我们的虚拟 root 。
实现
void dp(int now)
{
for(int i=0;i<Edge[now].size();i++)
{
dp(Edge[now][i]);
for(int j=m;j>=1;j--)
{
for(int k=0;k<j;k++)
{
f[now][j] = max(f[now][j],f[Edge[now][i]][k]+f[now][j-k]);
}
}
}
}
典例3:设计状态需要转化的树上背包
题目链接:Luogu P1273 有线电视网
Solution
欸这题看似有点难解决,如果我们给定满足用户数量求最多能赚多少钱是不是就好做了?
这也就是个朴素树上背包问题。
设 \(f_{i,j}\) 表示以 \(i\) 为父节点,满足 \(j\) 个用户时最多能赚多少钱,对于一条边 \(u-v\),先处理 \(v\)。枚举 \(u\) 和 \(v\) 的满足用户数量。设 \(u\) 满足 \(k\) 个用户,\(v\) 满足 \(l\) 个用户:
\(f_{u,k} = max(f_{u,k},f_{v,l}+f_{u,k-l}+w)\)(\(w\) 表示 \(u-v\) 边的权值)
一个小 Trick:我们首先预处理每个节点下面有多少个用户,这样就能减少很多不必要的枚举。
最后答案倒着枚举满足多少个用户,如果满足当前用户数量且赚钱大于0,则输出即可。
实现
//预处理部分
void pre(int now,int fa) //预处理,可以减少大量无效计算qwq
{
for(int i=0;i<Edge[now].size();i++)
{
int v = Edge[now][i].first;
if(v == fa) continue;
pre(v,now);
sz[now] += sz[v]; //递归预处理,回溯时更新
}
}
//dp部分
void dp(int noww,int fa)
{
for(int i=0;i<Edge[noww].size();i++)
{
int v = Edge[noww][i].first;
int w = Edge[noww][i].second;
if(v == fa) continue;
dp(v,noww);
for(int j=sz[noww];j>=1;j--) //避免多跑,预处理一个节点下面的儿子数量
{
for(int k=1;k<=sz[v];k++)
{
f[noww][j] = max(f[noww][j],f[v][k]+f[noww][j-k]-w); // 显然保证 sz[v] \leq sz[u]
}
}
}
}
决策dp
Trick:需要记录决策状态的dp,一个节点不同的决策对下一个节点有影响,并不一定代表有后效性,我们可以记录决策状态,分类讨论进行转移!
典例1:没有上司的舞会
某大学有 \(n\) 个职员,编号为 \(1\ldots n\)。他们之间有从属关系,也就是说他们的关系就像一棵以校长为根的树,父结点就是子结点的直接上司。
现在有个周年庆宴会,宴会每邀请来一个职员都会增加一定的快乐指数 \(r_i\),但是呢,如果某个职员的直接上司来参加舞会了,那么这个职员就无论如何也不肯来参加舞会了。
所以,请你编程计算,邀请哪些职员可以使快乐指数最大,求最大的快乐指数。
原题链接:Luogu P1352 没有上司的舞会
Solution
我们发现一个人来或者不来参加影响她的儿子。这并不一定代表有后效性,我们发现题目明确告知了如何转移,直接按照题意模拟即可。
设 \(f_{i,g}(g\in\{0,1\})\) 表示以 \(i\) 为父节点,她来 or 不来时的最大快乐值。
首先考虑初始状态,显然初始使得 \(f_{i,1}=a_i,f_{i,0}=0\)。这是一定的,我们所有操作都是在这个基础上进行的。
考虑转移:
\(f_{u,1}+=f_{v,0},f_{u,0}+=max(f_{v,1},f_{v,0})\)
Explanation:如果父节点去,则子节点只能不去。如果父节点不去,则子节点可去可不去,取 max 即可。
这个题应该是树形dp很典的题了吧qwq
实现
void dfs(int pos)
{
if(!Edge[pos].size())//叶子节点,直接赋值即可
{
f[0][pos] = 0;
f[1][pos] = a[pos];
return;
}
for(int i=0;i<Edge[pos].size();i++)
{
dfs(Edge[pos][i]);
f[0][pos] += max(f[0][Edge[pos][i]],f[1][Edge[pos][i]]);
f[1][pos] += f[0][Edge[pos][i]];
}
f[1][pos] += a[pos];
}
典例2:战略游戏
题目链接:Luogu P2016
Solution
容易发现一个节点放或者不放士兵影响后面的决策。所以需要设计这个状态。
定义 \(f_{i,k(k \in \{0,1\})}\) 表示以 \(i\) 为父节点,且 \(i\) 放或者不放时,其儿子放置士兵最小值。
因为 \(i\) 放 or 不放会影响到儿子的转移。所以,我们在写状态转移方程的时候需要分情况讨论:
-
当 \(i\) 放时,子节点可放可不放,因为所有子节点通向 \(i\) 的路已经被瞭望到。设边 \(u-v\),\(u\) 为 father,则 \(f_{u,1}+=min(f_{v,1},f_{v,0})\)
-
当 \(i\) 不放时,子节点必须放。因为如果子节点不放则子节点通向 \(i\) 的路无法瞭望。\(f_{u,0}+=f_{v,1}\)
每次转移前我们都需要初始化使 \(f_{u,1}=1,f{u,0}=0\) 。
最后答案输出 \(min(f_{1,1},f_{1,0})\) 即可。显然 root 可放可不放。
实现
void dp(int noww,int fa)
{
f[noww][1] = 1;
f[noww][0] = 0;
for(int i=0;i<Edge[noww].size();i++)
{
int v = Edge[noww][i];
if(v == fa) continue;
dp(v,noww);
int u = noww;
f[u][0] += f[v][1];
f[u][1] += min(f[v][0],f[v][1]);
}
}
典例3:Gem气垫车
给出一棵树,要求你为树上的结点标上权值,权值可以是任意的正整数
唯一的限制条件是相临的两个结点不能标上相同的权值,要求一种方案,使得整棵树的总价值最小
原题链接:Luogu P4395
Solution
好有意思的题目!
我们发现一个点标什么权值影响后面的决策,显然需要设计这一层状态。
定义 \(f_{i,j}\) 表示以 \(i\) 为父节点,它染 \(j\) 编号时最小总权值。
初始化令 \(f_{i,j}=1\)。接下来考虑转移,枚举父亲的染色 \(i\) 和儿子的染色 \(j\),对于每条边 \(u-v\),有:
\(f_{u,i}+=min(f_{v,j}(i \ne j))\)
最后枚举 root 也就是 \(1\) 染什么色的最小值即可。
实现
void dp(int noww,int fa)
{
for(int i=1;i<=20;i++) f[noww][i] = i;
for(int i=0;i<Edge[noww].size();i++)
{
int v = Edge[noww][i];
if(v == fa) continue;
dp(v,noww);
for(int j=1;j<=20;j++)
{
int minn = INF;
for(int k=1;k<=20;k++)
{
if(j == k) continue;
minn = min(minn,f[v][k]);
}
f[noww][j] += minn;
}
}
}
换根 dp
换根 dp 一般是没有给定 root,而且 root 之间的转移会影响一些值,并且影响的这些值随着 root 之间转移有规律。
换根 dp 非常套路,一般是两次 dfs,第一次 dfs 预处理一些值,以备转移。第二次 dfs 就是运行状态转移方程。
换根 dp 的很多处理方式都非常有用,很多题目都运用了换根 dp 的思想。
具体内容见 [算法学习笔记]换根 dp
本文作者:SXqwq,转载请注明原文链接:https://www.cnblogs.com/SXqwq/p/17655239.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!