信奥赛C++提高组csp-s之树形DP详解及编程实例

一、树形DP核心思想
树形DP是动态规划在树结构上的应用,通过后序遍历(自底向上)的方式处理子树信息,利用子节点状态推导父节点状态。常见问题类型:
- 路径问题(最长路径、直径)
- 子树选择问题(最大独立集、最小覆盖集)
- 资源分配问题(带权值的选择)
二、基本实现步骤
- 建树:通常用邻接表存储树结构
- 确定状态 :定义
dp[u][state]表示以u为根的子树在特定状态下的最优解 - 状态转移:根据子节点状态推导父节点状态
- 递归处理:DFS遍历树,回溯时更新状态
三、经典例题1:没有上司的舞会(洛谷P1352)
题目描述:选择若干员工参加舞会,不能同时选择直属上下级,求最大快乐值总和。
状态定义:
dp[u][0]:不选u时的最大快乐值dp[u][1]:选u时的最大快乐值
状态转移方程:
dp[u][0] += Σ max(dp[v][0], dp[v][1]) // 不选u时,子节点可选可不选
dp[u][1] += Σ dp[v][0] // 选u时,子节点必须不选
完整代码:
cpp
/*
洛谷P1352 "没有上司的舞会" 树形DP解法
题意:选择若干树节点,使得直接相连的节点不同时被选,求最大快乐值和
*/
#include<bits/stdc++.h>
using namespace std;
const int N = 6e3 + 10; // 最大节点数
// 全局变量定义
int n; // 节点总数
int h[N]; // h[i]表示第i个节点的快乐值
int fa[N]; // fa[i]记录节点i的父节点,用于找根
int dp[N][2]; // dp[x][0]:不选x时的最大值,dp[x][1]:选x时的最大值
// 邻接表相关定义
struct Edge { // 邻接表边结构体
int from, to; // 边的起点(from其实可以省略,仅用于调试)
int next; // 指向下一条邻接边的索引
} a[N]; // 边池(静态链表)
int pre[N]; // pre[u]表示u的第一条邻接边在a中的索引
int k; // 当前已使用的边数(全局计数器)
// 添加一条u->v的边(u是v的直接上司)
void add(int u, int v) {
k++; // 使用新边
a[k].from = u; // 记录起点(可省略)
a[k].to = v; // 边的终点是v
a[k].next = pre[u]; // 头插法:新边的next指向u原来的第一条边
pre[u] = k; // 更新u的第一条边为当前边
fa[v] = u; // 同时维护父节点关系
}
// DFS遍历树进行DP计算
void dfs(int x) { // x是当前处理的节点
// 初始化:不选x时值为0,选x时初始值为x的快乐值
dp[x][0] = 0;
dp[x][1] = h[x];
// 遍历x的所有子节点(邻接表实现)
for(int i = pre[x]; i != 0; i = a[i].next) {
int child = a[i].to; // 获取子节点
dfs(child); // 递归处理子树
// 状态转移:
// 如果不选x,子节点可选可不选,取最大值累加
dp[x][0] += max(dp[child][0], dp[child][1]);
// 如果选x,子节点必须不选,直接累加不选子节点的值
dp[x][1] += dp[child][0];
}
}
int main() {
// ---------- 输入处理 ----------
cin >> n;
// 读入每个节点的快乐值
for(int i = 1; i <= n; i++) {
cin >> h[i];
}
// 建立树结构(注意题目输入是"下属 上司")
int l, k; // l是下属,k是上司(此处k是局部变量,与全局k无关)
for(int i = 1; i <= n-1; i++) {
cin >> l >> k;
add(k, l); // 添加k->l的边(上司->下属)
}
// ---------- 寻找根节点 ----------
// 根节点是没有父节点的节点
int root = 1; // 初始假设根是1
while(fa[root] != 0) { // 沿着父节点指针上溯
root = fa[root];
}
// ---------- DP计算 ----------
dfs(root); // 从根开始深度优先遍历
// 最终结果是根节点选或不选的最大值
cout << max(dp[root][0], dp[root][1]);
return 0;
}
/*
关键点说明:
1. 邻接表构建:使用头插法,pre[u]始终指向u的最新边
2. 变量名注意:主函数中的局部变量k与全局k不冲突
3. 状态转移逻辑:
- 不选当前节点时,子节点可以自由选择(取最大值)
- 选当前节点时,子节点必须不选
4. 根节点查找:通过fa数组逆向查找,直到找到没有父节点的节点
*/
3.1、问题概述
- 目标 :给定一棵树结构的员工关系,每个节点(员工)有快乐值。要求选择一些节点,使得相邻节点(直接上下级)不同时被选,且快乐值总和最大。
- 解决方法:树形DP,通过动态规划遍历树的每个节点,记录选与不选当前节点的最大快乐值。
3.2、数据结构与初始化
- 邻接表存储树 :使用结构体数组
a[N]和pre[N]实现邻接表,存储每个节点的子节点。a[k].from和a[k].to表示边的起点和终点。pre[u]存储节点u的邻接表头指针。
- 全局变量 :
h[N]:存储每个节点的快乐值。fa[N]:记录每个节点的父节点。dp[N][2]:DP数组,dp[x][0]表示不选节点x的最大值,dp[x][1]表示选中的最大值。
3.3、核心函数分析
add(int u, int v) 函数
- 功能 :向邻接表中添加边
u → v,表示u是v的上司。 - 实现 :通过头插法维护邻接表,同时更新
fa[v] = u以记录父子关系。
dfs(int x) 函数
- 递归遍历子树 :
- 初始化 :
dp[x][0] = 0(不选x),dp[x][1] = h[x](选x)。 - 遍历子节点 :对每个子节点
chd,递归计算其DP值。 - 状态转移 :
- 若不选
x,子节点可选可不选:dp[x][0] += max(dp[chd][0], dp[chd][1])。 - 若选
x,子节点不可选:dp[x][1] += dp[chd][0]。
- 若不选
- 初始化 :
3.4、主函数流程
- 输入处理 :
- 读取节点数
n和每个节点的快乐值。 - 读取
n-1条边,建立邻接表并记录父节点关系。
- 读取节点数
- 寻找根节点 :通过
fa数组找到没有父节点的根(即公司最高领导)。 - DP计算:从根节点开始进行DFS,最终结果为根节点选与不选的最大值。
3.5、关键点分析
- 变量作用域 :主函数中局部变量
k与全局变量k分离,避免冲突,确保邻接表正确构建。 - 树形DP的正确性:通过后序遍历(DFS)保证子节点的DP值先于父节点计算,状态转移方程符合题目约束。
复杂度
- 时间:每个节点遍历一次,时间复杂度为 O(n)。
- 空间:邻接表和DP数组的空间复杂度均为 O(n)。
四、经典例题2:二叉苹果树(洛谷P2015)
题目描述 :给定一棵二叉树,每条树枝上有若干苹果。要求保留 m 条树枝(即边),使得剩下的苹果总数最大。剪枝规则是:若剪掉某条树枝,则其子树上的所有树枝都会被剪掉。
题目分析:
- 留q个树枝,转换为留q+1个结点
- 把树枝上的苹果树,转换到结点上
- 对于树中的每棵子树,要保留j个结点 (因为根必须保留,所以需要保留j-1个子结点),如果左子树保留k个结点,则右子树需保留j-1-k个结点
状态定义:
dp[i][j]表示以节点i为根的子树保留j个结点时的最大苹果数。
状态转移方程:
dp[i][j]=max(dp[i][j],dp[L[i]][k]+dp[R[i]][j-1-k]+a[i])
其中:0≤k≤j-1 , L[i]表示i结点的左儿子, R[i]表示i结点的右儿子
边界条件:
j==0时,dp[i][0]=0 //保留0个结点
j!=0 && L[i]==0 && R[i]==0时,dp[i][j]=a[i] //叶子结点
最终求解的答案为:
dp[1][q+1]
完整代码:
cpp
#include<bits/stdc++.h>
using namespace std;
int n,q,x,y,z;
int L[110],R[110];//存结点的左右儿子
int a[110][110];//邻接矩阵
int dp[110][110];//dp[i][j]:表示以节点 i为根的子树保留 j个结点时的最大苹果数
int w[110];//结点的苹果数
//建树
void dfs(int x,int fa){//x是结点编号,fa是x的父结点编号
for(int i=1;i<=n;i++){
if(a[x][i]!=-1 && i!=fa){
if(L[x]==0){
L[x]=i;//i是x的左孩子
w[i]=a[x][i];//i结点的苹果数
}else if(R[x]==0){
R[x]=i;//i是x的右孩子
w[i]=a[x][i];//i结点的苹果数
}
dfs(i,x);
}
}
}
//i结点为根的子树,保留j个结点的最大苹果树
int f(int i,int j){
if(j==0) return 0;//保留0个结点
if(L[i]==0 && R[i]==0) return w[i];//叶子结点
if(dp[i][j]!=-1) return dp[i][j];//记忆化递归
for(int k=0;k<=j-1;k++){
dp[i][j]=max(dp[i][j],f(L[i],k)+f(R[i],j-1-k)+w[i]);
}
return dp[i][j];
}
int main(){
cin>>n>>q;
memset(a,-1,sizeof(a));//a数组初始化
memset(dp,-1,sizeof(dp)); //dp数组初始化
for(int i=1;i<=n-1;i++){
cin>>x>>y>>z;
a[x][y]=z;//无向图
a[y][x]=z;
}
//建树
dfs(1,0);//1是根结点,0表示根结点没有父亲
//输出答案
cout<<f(1,q+1);
return 0;
}
代码功能分析:
-
数据结构与初始化:
- 使用邻接矩阵
a存储树的边及其权值(苹果数)。 L和R数组分别记录每个节点的左、右子节点。w数组存储每个节点到其父节点的边权值。dp[i][j]为动态规划数组,表示以节点i为根的子树保留j个节点时的最大苹果数。
- 使用邻接矩阵
-
建树过程(DFS):
- 通过深度优先搜索遍历树,将每个节点的子节点转换为二叉树结构,记录左、右子节点,并存储对应边的权值到
w数组中。 - 根节点(1号节点)没有父节点,其子节点的边权值被正确记录,而根节点本身的
w值未被使用(保持为0)。
- 通过深度优先搜索遍历树,将每个节点的子节点转换为二叉树结构,记录左、右子节点,并存储对应边的权值到
-
动态规划(记忆化搜索):
- 函数
f(i, j)递归计算以i为根的子树保留j个节点的最大苹果数。 - 状态转移:枚举左子树保留
k个节点,右子树保留j-1-k个节点(当前节点占1个),取左右子树最大值之和加上当前节点的父边权值(w[i])。 - 边界条件:叶子节点直接返回其父边权值,保留0个节点返回0。
- 函数
-
输入输出处理:
- 输入边信息构建邻接矩阵,并转换为二叉树结构。
- 最终输出
f(1, q+1),因为保留q条边对应保留q+1个节点,确保根节点必须保留。
核心逻辑:
- 问题转化:将边权值转化为子节点的权值,保留子节点即意味着保留其父边。
- 树形DP:通过递归分治,将问题分解为左右子树的子问题,利用记忆化避免重复计算。
- 正确性保证:二叉树结构确保每个节点最多处理两个子节点,动态规划状态转移正确累加边权值。
五、树形DP常见模型
- 二叉苹果树(P2015) :保留q条树枝的最大苹果数
- 状态定义:
dp[u][k]表示以u为根的子树保留k条边的最大值
- 状态定义:
- 树形依赖背包(P2014) :选课依赖父节点的背包问题
- 状态定义:
dp[u][k]表示以u为根的子树选择k门课的最大学分
- 状态定义:
- 树的直径(两次DFS/BFS法) :树的最长路径
- 状态记录:每个节点返回子树最大深度和次大深度
六、洛谷OJ练习清单
-
基础练习:
- P1352 没有上司的舞会(最大独立集)
- P2015 二叉苹果树(树形背包)
- P2014 选课(树形依赖背包)
-
进阶练习:
- P1273 有线电视网(树上分组背包)
- P2585 三色二叉树(多状态转移)
- P3047 [USACO12FEB] Nearby Cows G(二次扫描换根法)
- P3177 [HAOI2015] 树上染色(组合计数+树形DP)
-
综合提高:
- P4516 [JSOI2018] 潜入行动(复杂状态设计)
- P2607 [ZJOI2008] 骑士(基环树处理)
- P4381 [IOI2008] Island(基环树直径)
七、解题技巧
- 使用链式前向星建树提高效率
- 多叉树转二叉树可简化问题(左孩子右兄弟表示法)
- 处理大规模数据时考虑记忆化搜索优化
- 对于带权路径问题,注意同时维护最大值和次大值
更多系列知识,请查看专栏:《信奥赛C++提高组csp-s知识详解及案例实践》:
https://blog.csdn.net/weixin_66461496/category_13113932.html
各种学习资料,助力大家一站式学习和提升!!!
cpp
#include<bits/stdc++.h>
using namespace std;
int main(){
cout<<"########## 一站式掌握信奥赛知识! ##########";
cout<<"############# 冲刺信奥赛拿奖! #############";
cout<<"###### 课程购买后永久学习,不受限制! ######";
return 0;
}
1、csp信奥赛高频考点知识详解及案例实践:
CSP信奥赛C++动态规划:
https://blog.csdn.net/weixin_66461496/category_13096895.html点击跳转
CSP信奥赛C++标准模板库STL:
https://blog.csdn.net/weixin_66461496/category_13108077.html 点击跳转
信奥赛C++提高组csp-s知识详解及案例实践:
https://blog.csdn.net/weixin_66461496/category_13113932.html
2、csp信奥赛冲刺一等奖有效刷题题解:
CSP信奥赛C++初赛及复赛高频考点真题解析(持续更新):https://blog.csdn.net/weixin_66461496/category_12808781.html 点击跳转
CSP信奥赛C++一等奖通关刷题题单及题解(持续更新):https://blog.csdn.net/weixin_66461496/category_12673810.html 点击跳转
3、GESP C++考级真题题解:

GESP(C++ 一级+二级+三级)真题题解(持续更新):https://blog.csdn.net/weixin_66461496/category_12858102.html 点击跳转

GESP(C++ 四级+五级+六级)真题题解(持续更新):https://blog.csdn.net/weixin_66461496/category_12869848.html 点击跳转

GESP(C++ 七级+八级)真题题解(持续更新):
https://blog.csdn.net/weixin_66461496/category_13117178.html
4、CSP信奥赛C++竞赛拿奖视频课:
https://edu.csdn.net/course/detail/40437 点击跳转

· 文末祝福 ·
cpp
#include<bits/stdc++.h>
using namespace std;
int main(){
cout<<"跟着王老师一起学习信奥赛C++";
cout<<" 成就更好的自己! ";
cout<<" csp信奥赛一等奖属于你! ";
return 0;
}