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

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

一、树形DP核心思想

树形DP是动态规划在树结构上的应用,通过后序遍历(自底向上)的方式处理子树信息,利用子节点状态推导父节点状态。常见问题类型:

  • 路径问题(最长路径、直径)
  • 子树选择问题(最大独立集、最小覆盖集)
  • 资源分配问题(带权值的选择)
二、基本实现步骤
  1. 建树:通常用邻接表存储树结构
  2. 确定状态 :定义dp[u][state]表示以u为根的子树在特定状态下的最优解
  3. 状态转移:根据子节点状态推导父节点状态
  4. 递归处理: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].froma[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,表示 uv 的上司。
  • 实现 :通过头插法维护邻接表,同时更新 fa[v] = u 以记录父子关系。
dfs(int x) 函数
  • 递归遍历子树
    1. 初始化dp[x][0] = 0(不选 x),dp[x][1] = h[x](选 x)。
    2. 遍历子节点 :对每个子节点 chd,递归计算其DP值。
    3. 状态转移
      • 若不选 x,子节点可选可不选:dp[x][0] += max(dp[chd][0], dp[chd][1])
      • 若选 x,子节点不可选:dp[x][1] += dp[chd][0]

3.4、主函数流程
  1. 输入处理
    • 读取节点数 n 和每个节点的快乐值。
    • 读取 n-1 条边,建立邻接表并记录父节点关系。
  2. 寻找根节点 :通过 fa 数组找到没有父节点的根(即公司最高领导)。
  3. 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;
} 
代码功能分析:
  1. 数据结构与初始化

    • 使用邻接矩阵a存储树的边及其权值(苹果数)。
    • LR数组分别记录每个节点的左、右子节点。
    • w数组存储每个节点到其父节点的边权值。
    • dp[i][j]为动态规划数组,表示以节点i为根的子树保留j个节点时的最大苹果数。
  2. 建树过程(DFS)

    • 通过深度优先搜索遍历树,将每个节点的子节点转换为二叉树结构,记录左、右子节点,并存储对应边的权值到w数组中。
    • 根节点(1号节点)没有父节点,其子节点的边权值被正确记录,而根节点本身的w值未被使用(保持为0)。
  3. 动态规划(记忆化搜索)

    • 函数f(i, j)递归计算以i为根的子树保留j个节点的最大苹果数。
    • 状态转移:枚举左子树保留k个节点,右子树保留j-1-k个节点(当前节点占1个),取左右子树最大值之和加上当前节点的父边权值(w[i])。
    • 边界条件:叶子节点直接返回其父边权值,保留0个节点返回0。
  4. 输入输出处理

    • 输入边信息构建邻接矩阵,并转换为二叉树结构。
    • 最终输出f(1, q+1),因为保留q条边对应保留q+1个节点,确保根节点必须保留。
核心逻辑:
  • 问题转化:将边权值转化为子节点的权值,保留子节点即意味着保留其父边。
  • 树形DP:通过递归分治,将问题分解为左右子树的子问题,利用记忆化避免重复计算。
  • 正确性保证:二叉树结构确保每个节点最多处理两个子节点,动态规划状态转移正确累加边权值。
五、树形DP常见模型
  1. 二叉苹果树(P2015) :保留q条树枝的最大苹果数
    • 状态定义:dp[u][k]表示以u为根的子树保留k条边的最大值
  2. 树形依赖背包(P2014) :选课依赖父节点的背包问题
    • 状态定义:dp[u][k]表示以u为根的子树选择k门课的最大学分
  3. 树的直径(两次DFS/BFS法) :树的最长路径
    • 状态记录:每个节点返回子树最大深度和次大深度
六、洛谷OJ练习清单
  1. 基础练习:

    • P1352 没有上司的舞会(最大独立集)
    • P2015 二叉苹果树(树形背包)
    • P2014 选课(树形依赖背包)
  2. 进阶练习:

    • P1273 有线电视网(树上分组背包)
    • P2585 三色二叉树(多状态转移)
    • P3047 [USACO12FEB] Nearby Cows G(二次扫描换根法)
    • P3177 [HAOI2015] 树上染色(组合计数+树形DP)
  3. 综合提高:

    • P4516 [JSOI2018] 潜入行动(复杂状态设计)
    • P2607 [ZJOI2008] 骑士(基环树处理)
    • P4381 [IOI2008] Island(基环树直径)
七、解题技巧
  1. 使用链式前向星建树提高效率
  2. 多叉树转二叉树可简化问题(左孩子右兄弟表示法)
  3. 处理大规模数据时考虑记忆化搜索优化
  4. 对于带权路径问题,注意同时维护最大值和次大值

更多系列知识,请查看专栏:《信奥赛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;
}
相关推荐
m0_748248652 小时前
C/C++ 项目与 Rust 项目区别
c语言·c++·rust
bugu___2 小时前
仿muduo库实现并发服务器(3)
开发语言·c++
m0_686041612 小时前
C++中的装饰器模式变体
开发语言·c++·算法
星火开发设计2 小时前
动态内存分配:new 与 delete 的基本用法
开发语言·c++·算法·内存·delete·知识·new
m0_748244962 小时前
Linux C++项目推荐:文件服务器+如何快速上手
linux·服务器·c++
闻缺陷则喜何志丹2 小时前
【前缀树(字典树)】P12124 [蓝桥杯 2024 省 B 第二场] 前缀总分|普及+
c++·算法·蓝桥杯·字典树·前缀树·洛谷
轩情吖2 小时前
Qt多元素控件之QListWidget
开发语言·前端·c++·qt·控件·qlistwidget·桌面级
浔川python社2 小时前
《C++ 小程序编写系列》(第七部):C++11 + 新特性实战 —— 性能与稳定性双提升
java·c++·小程序
tudficdew2 小时前
C++中的策略模式实战
开发语言·c++·算法