C++ 树形 DP解析

目录
- 树形DP:从原理到实战的深度解析(对话版)
树形DP:从原理到实战的深度解析(对话版)
引言
树形DP(树形动态规划)是动态规划中专门处理树形结构问题的分支,其核心是“以树的递归结构为依托,将子树的状态信息向上合并,最终得到整棵树的最优解”。树的天然递归特性(每个子树都是独立的树)与DP的“子问题最优解推导父问题最优解”思想高度契合,使得树形DP成为算法竞赛、面试中高频出现的考点。本文以“新手提问+导师解答”的对话形式,从树的基础遍历到树形DP的核心框架,再到经典例题的深度拆解,用超万字篇幅帮你彻底吃透树形DP。
一、初识树形DP:它解决什么问题?
新手:导师您好!我学完了线性DP、区间DP,现在接触树形DP完全摸不着头脑——树形DP的“树”体现在哪里?它和普通DP的核心区别是什么?
导师:别急,咱们先从树形DP的核心特征入手:
树形DP的“树”,指的是问题的数据结构是树形结构(比如家族关系树、公司组织树、图的生成树等),且问题的最优解需要依赖子树的最优解来推导。
树形DP的典型特征:
- 数据结构是树:问题的载体是树(或森林,可通过加虚拟根转化为树),节点之间存在父子/兄弟关系,无环且有明确的层级;
- 递归求解子树:对于任意节点u,其最优解必须先求解所有子节点v的最优解,再合并子节点的结果;
- 无后效性:子树的解仅依赖自身的结构,与父节点的处理无关,符合DP的无后效性要求。
举个最直观的例子:“树的最大独立集问题”——给定一棵有n个节点的树,选择若干节点,使得任意两个选中的节点不相邻,求最多能选多少个节点。这个问题中,节点u的选择状态(选/不选)直接影响子节点v的选择,且必须先知道所有子节点的最优解,才能计算u的最优解,完全符合树形DP的特征。
和普通DP的核心区别:
- 普通DP(如01背包、区间DP)的状态是线性/区间的,枚举顺序是“从左到右”“从小到大”;
- 树形DP的状态是基于节点的,枚举顺序是“自底向上”(先子树后父节点),依赖树的遍历(深度优先遍历DFS)实现。
树形DP的前置知识
在学习树形DP前,你需要掌握:
- 树的存储:邻接表(最常用,适合无向树转化为有向父-子树);
- 树的遍历:深度优先遍历(DFS,核心)、广度优先遍历(BFS,辅助);
- 递归思想:理解“递归处理子树,回溯合并结果”的逻辑。
二、树形DP核心框架:四步走
新手:树形DP有没有通用的实现框架?我看不同例题的代码好像都有相似的递归结构。
导师:当然有!树形DP的实现框架高度固定,核心是“建图→状态定义→递归遍历→状态合并”,具体分四步:
步骤1:树的存储(邻接表)
树通常以无向图的形式给出(比如输入u和v表示边),需要用邻接表存储,并在遍历时标记父节点,避免重复访问(比如从父节点u到子节点v后,不再从v回到u)。
C++邻接表存储模板:
#include<iostream>#include<vector>usingnamespace std;constint MAXN =1e5+5; vector<int> adj[MAXN];// 邻接表bool vis[MAXN];// 标记是否访问过(或用父节点标记)// 添加边voidaddEdge(int u,int v){ adj[u].push_back(v); adj[v].push_back(u);}步骤2:状态定义
树形DP的状态通常定义为dp[u][k],其中:
u:当前处理的节点;k:节点u的状态(比如“选/不选u”“u的子树中选m个节点”等,k的维度根据问题定)。
状态定义是树形DP的核心难点——必须结合问题要求,让状态能覆盖“子树的所有必要信息”,且能通过子节点的状态推导父节点的状态。
步骤3:递归遍历(DFS)
以根节点为起点(通常选1号节点,或根据问题指定),递归遍历每个节点的所有子节点:
- 对于当前节点u,遍历其所有邻接节点v;
- 若v是u的父节点,跳过(避免回退);
- 递归处理子节点v(即调用
dfs(v, u),u是v的父节点); - 回溯时,将子节点v的状态合并到父节点u的状态中。
步骤4:状态合并(核心)
根据问题的状态转移逻辑,将子节点v的dp[v][...]合并到父节点u的dp[u][...]中。比如:
- 若u选,则v不能选:
dp[u][1] += dp[v][0]; - 若u不选,则v可选可不选:
dp[u][0] += max(dp[v][0], dp[v][1])。
树形DP通用模板(伪代码)
// 递归处理节点u,fa是u的父节点voiddfs(int u,int fa){// 初始化当前节点的状态(比如dp[u][0] = 0, dp[u][1] = w[u])init_dp(u);// 遍历所有邻接节点for(auto v : adj[u]){if(v == fa)continue;// 跳过父节点dfs(v, u);// 递归处理子节点v// 状态合并:将v的状态合并到u中merge_dp(u, v);}}intmain(){int n; cin >> n;// 输入n-1条边,构建邻接表for(int i =1; i < n;++i){int u, v; cin >> u >> v;addEdge(u, v);}dfs(1,-1);// 以1为根,父节点为-1(无)// 根据问题输出结果(比如max(dp[1][0], dp[1][1])) cout <<get_ans()<< endl;return0;}三、入门例题1:树的最大独立集
新手:先从最简单的树形DP例题入手吧!树的最大独立集问题具体该怎么实现?
导师:咱们先明确问题:
- 给定一棵n个节点的树,每个节点有一个权值(也可默认权值为1);
- 选择若干节点组成独立集,要求任意两个选中的节点不相邻(无直接边相连);
- 求独立集的最大权值和(若权值为1,则是最大节点数)。
解题思路
- 状态定义:
dp[u][0]:不选节点u时,u的子树能得到的最大权值和;dp[u][1]:选节点u时,u的子树能得到的最大权值和。
- 初始化:
dp[u][1] = w[u](选u,初始权值为自身权值);dp[u][0] = 0(不选u,初始权值为0)。
- 状态转移:
- 选u:则所有子节点v都不能选,
dp[u][1] += dp[v][0]; - 不选u:则子节点v可选可不选,取最大值,
dp[u][0] += max(dp[v][0], dp[v][1])。
- 选u:则所有子节点v都不能选,
- 最终答案:整棵树的最大独立集是
max(dp[root][0], dp[root][1])(root为根节点,通常选1)。
完整代码实现(C++)
#include<iostream>#include<vector>#include<algorithm>usingnamespace std;constint MAXN =1e5+5; vector<int> adj[MAXN];// 邻接表int w[MAXN];// 节点权值longlong dp[MAXN][2];// dp[u][0]不选u,dp[u][1]选u// 递归处理节点u,fa是父节点voiddfs(int u,int fa){// 初始化状态 dp[u][1]= w[u]; dp[u][0]=0;// 遍历所有子节点for(auto v : adj[u]){if(v == fa)continue;dfs(v, u);// 先处理子节点// 状态转移:合并子节点的结果 dp[u][1]+= dp[v][0];// 选u,子节点必须不选 dp[u][0]+=max(dp[v][0], dp[v][1]);// 不选u,子节点选或不选取最大}}intmain(){ ios::sync_with_stdio(false); cin.tie(0);int n; cin >> n;// 输入节点权值for(int i =1; i <= n;++i){ cin >> w[i];}// 输入n-1条边,构建邻接表for(int i =1; i < n;++i){int u, v; cin >> u >> v; adj[u].push_back(v); adj[v].push_back(u);}// 以1为根节点,父节点为-1dfs(1,-1);// 输出最大独立集的权值和 cout <<max(dp[1][0], dp[1][1])<< endl;return0;}/* 测试案例: 输入: 5 1 2 3 4 5 1 2 1 3 2 4 2 5 树结构: 1(1) / \ 2(2) 3(3) / \ 4(4) 5(5) 计算过程: - 处理4:dp[4][1]=4, dp[4][0]=0 - 处理5:dp[5][1]=5, dp[5][0]=0 - 处理2: dp[2][1] = 2 + dp[4][0] + dp[5][0] = 2+0+0=2 dp[2][0] = max(4,0) + max(5,0) = 4+5=9 - 处理3:dp[3][1]=3, dp[3][0]=0 - 处理1: dp[1][1] = 1 + dp[2][0] + dp[3][0] = 1+9+0=10 dp[1][0] = max(2,9) + max(3,0) = 9+3=12 最终答案:max(10,12)=12 解释:选3、4、5,权值和3+4+5=12,符合独立集要求(无相邻节点) */代码核心解析
- 邻接表存储:无向边双向存储,遍历子节点时通过父节点过滤回退路径;
- 递归逻辑:先递归处理所有子节点,再回溯合并状态——这是树形DP的“自底向上”核心;
- 状态转移:严格遵循“选父则不选子,不选父则子可选可不选”的规则;
- 数据类型:用
long long避免权值和过大导致的溢出(比如n=1e5,每个节点权值1e3,总和会超过int范围)。
新手:为什么必须先处理子节点再合并?反过来不行吗?
导师:树形DP的核心是“子树的解推导父节点的解”,如果先处理父节点再处理子节点,子节点的状态还未计算,父节点的状态就无法正确合并。比如处理节点2时,必须先知道4和5的dp值,才能计算2的dp值——这和区间DP“从小到大枚举长度”的逻辑本质一致,都是保证“依赖的子问题先求解”。
四、入门例题2:树的直径(最长路径)
新手:树的直径问题也是树形DP的经典应用吧?它的实现和最大独立集有什么不同?
导师:树的直径(也叫树的最长路径)是指树上任意两个节点之间的最长简单路径,树形DP是求解树的直径的两种核心方法之一(另一种是两次DFS/BFS),且更适合需要同时处理权值的场景。
问题定义
给定一棵带权树(边权或点权,此处以边权为例),求树上任意两点之间的最长路径长度(直径)。
解题思路
- 状态定义:
dp[u]:从节点u出发,向下走到其子树中的最长路径长度(即u到子树中最远节点的距离)。
- 状态转移:
- 对于节点u的每个子节点v,边权为
w,则dp[u] = max(dp[u], dp[v] + w); - 同时,遍历子节点时,记录u的“最长路径”和“次长路径”,两者之和就是以u为拐点的最长路径(可能是整棵树的直径)。
- 对于节点u的每个子节点v,边权为
- 最终答案:遍历所有节点的“最长路径+次长路径”,取最大值即为树的直径。
完整代码实现(C++)
#include<iostream>#include<vector>#include<algorithm>#include<climits>usingnamespace std;constint MAXN =1e5+5;// 邻接表:pair<子节点v,边权w> vector<pair<int,int>> adj[MAXN];longlong dp[MAXN];// dp[u]:u到子树的最长路径longlong ans;// 存储树的直径// 递归处理节点u,fa是父节点voiddfs(int u,int fa){ dp[u]=0;// 初始化:u到自身的路径长度为0longlong max1 =0, max2 =0;// 最长、次长路径for(auto& edge : adj[u]){int v = edge.first;int w = edge.second;if(v == fa)continue;dfs(v, u);// 递归处理子节点// 计算u到v子树的路径长度longlong cur = dp[v]+ w;// 更新最长、次长路径if(cur > max1){ max2 = max1; max1 = cur;}elseif(cur > max2){ max2 = cur;}// 更新dp[u](u到子树的最长路径) dp[u]=max(dp[u], cur);}// 以u为拐点的最长路径 = 最长+次长 ans =max(ans, max1 + max2);}intmain(){ ios::sync_with_stdio(false); cin.tie(0);int n; cin >> n;// 输入n-1条边(u, v, w)for(int i =1; i < n;++i){int u, v, w; cin >> u >> v >> w; adj[u].emplace_back(v, w); adj[v].emplace_back(u, w);} ans =0;dfs(1,-1);// 以1为根 cout <<"树的直径:"<< ans << endl;return0;}/* 测试案例: 输入: 5 1 2 2 1 3 3 2 4 4 2 5 5 树结构: 1 / \ (3) (2) / 3 2 / \ (4)/ \(5) 4 5 计算过程: - 处理4:dp[4]=0,max1=0, max2=0 → ans=0 - 处理5:dp[5]=0,max1=0, max2=0 → ans=0 - 处理2: 子节点4:cur=0+4=4 → max1=4, max2=0 子节点5:cur=0+5=5 → max1=5, max2=4 dp[2] = max(4,5)=5 以2为拐点的路径:5+4=9 → ans=9 - 处理3:dp[3]=0,max1=0, max2=0 → ans=9 - 处理1: 子节点2:cur=5+2=7 → max1=7, max2=0 子节点3:cur=0+3=3 → max1=7, max2=3 dp[1] = max(7,3)=7 以1为拐点的路径:7+3=10 → ans=10 最终答案:10(路径4-2-1-3,长度4+2+3=9;或5-2-1-3,长度5+2+3=10,后者是直径) */核心区别与解析
- 状态定义简化:树的直径的状态是一维的(仅记录最长路径),但需要额外维护“最长”和“次长”路径;
- 答案更新时机:答案不是根节点的状态,而是遍历所有节点时的“最长+次长”路径之和——因为直径可能出现在任意子树的拐点;
- 边权处理:邻接表存储
pair<v, w>,状态转移时加上边权,符合“路径长度”的计算逻辑。
新手:为什么两次DFS也能求直径,而树形DP更优?
导师:两次DFS的思路是:
- 任选一个节点u,找到离u最远的节点v;
- 从v出发,找到离v最远的节点w,v-w的路径就是直径。
两次DFS的优点是代码更简洁,但仅适用于边权非负的情况;而树形DP不仅能处理边权非负的情况,还能灵活处理点权、带限制的直径(比如路径上节点数不超过k),适用场景更广。
五、进阶例题1:树上背包(分组背包的树形版)
新手:树上背包是树形DP的难点吧?它和普通的分组背包有什么关联?
导师:树上背包是“树形DP + 分组背包”的结合体,核心是“每个节点的子树对应一组物品,选择子树中的节点相当于选择该组的物品,且组内物品互斥(或有依赖)”。最经典的树上背包问题是“树上选m个节点,要求选的节点构成连通块,求最大权值和”。
问题定义
给定一棵n个节点的树,每个节点有一个权值w[u],选择恰好m个节点,且选中的节点构成连通块(任意两个选中节点之间的路径上的所有节点都被选中),求最大权值和。
解题思路
- 状态定义:
dp[u][k]:在节点u的子树中,选择恰好k个节点(包含u),且构成连通块的最大权值和。
- 初始化:
dp[u][1] = w[u](选u自己,1个节点,权值为自身);- 其余
dp[u][k] = -INF(初始为负无穷,表示不可行)。
- 状态转移(分组背包):
- 对于节点u的每个子节点v,将“u的子树选k个节点”拆分为“u的原有选x个节点 + v的子树选y个节点”,其中
x + y = k(x≥1,y≥0); - 转移方程:
dp[u][k] = max(dp[u][k], dp[u][x] + dp[v][y])(1≤x<k,y=k-x)。 - 这本质是分组背包:每个子节点v对应一组物品,每组物品有“选1个、2个、…、size[v]个”的选项,且每组最多选一个选项。
- 对于节点u的每个子节点v,将“u的子树选k个节点”拆分为“u的原有选x个节点 + v的子树选y个节点”,其中
- 枚举顺序:
- 对每个子节点v,k从大到小枚举(类似01背包的一维优化,避免重复选择)。
完整代码实现(C++)
#include<iostream>#include<vector>#include<algorithm>#include<climits>usingnamespace std;constint MAXN =105;// 节点数通常较小(n≤100),因为O(n²m)复杂度constint INF = INT_MAX /2; vector<int> adj[MAXN];int w[MAXN];int dp[MAXN][MAXN];// dp[u][k]:u子树选k个节点(含u)的最大权值和int size_[MAXN];// size_[u]:u子树的节点数// 递归处理节点u,fa是父节点voiddfs(int u,int fa){ size_[u]=1;// 初始化:选u自己,1个节点 dp[u][1]= w[u];for(auto v : adj[u]){if(v == fa)continue;dfs(v, u); size_[u]+= size_[v];// 分组背包:k从大到小枚举,避免重复选for(int k = size_[u]; k >=1;--k){// x:u原有选x个节点,y = k - x:v子树选y个节点for(int x =min(k-1, size_[u]- size_[v]); x >=1;--x){int y = k - x;if(y <0|| y > size_[v])continue;if(dp[u][x]==-INF || dp[v][y]==-INF)continue; dp[u][k]=max(dp[u][k], dp[u][x]+ dp[v][y]);}}}}intmain(){ ios::sync_with_stdio(false); cin.tie(0);int n, m; cin >> n >> m;// 初始化dp为负无穷for(int i =1; i <= n;++i){for(int j =1; j <= n;++j){ dp[i][j]=-INF;}}// 输入节点权值for(int i =1; i <= n;++i){ cin >> w[i];}// 输入边for(int i =1; i < n;++i){int u, v; cin >> u >> v; adj[u].push_back(v); adj[v].push_back(u);}dfs(1,-1);// 输出答案:根节点1的子树选m个节点的最大权值和 cout <<(dp[1][m]==-INF ?-1: dp[1][m])<< endl;return0;}/* 测试案例: 输入: 5 3 1 2 3 4 5 1 2 1 3 2 4 2 5 树结构同最大独立集案例 初始化: dp[4][1]=4, dp[5][1]=5, dp[2][1]=2, dp[3][1]=3, dp[1][1]=1 处理节点2的子节点4: size_[2] = 2 k从2到1: k=2:x=1, y=1 → dp[2][2] = dp[2][1] + dp[4][1] = 2+4=6 处理节点2的子节点5: size_[2] = 3 k从3到1: k=3:x=2, y=1 → dp[2][3] = 6+5=11 k=2:x=1, y=1 → dp[2][2] = max(6, 2+5=7)=7 处理节点1的子节点2: size_[1] = 4 k从4到1: k=3:x=1, y=2 → dp[1][3] = 1 + 7=8 k=2:x=1, y=1 → dp[1][2] = 1+2=3 处理节点1的子节点3: size_[1] = 5 k从5到1: k=3:x=2, y=1 → dp[1][3] = max(8, 3+3=6)=8;x=1, y=2 → 1+(-INF)无效 最终dp[1][3]=8(选1、2、3,权值1+2+3=6;或1、2、4,1+2+4=7;或1、2、5,1+2+5=8;或2、4、5,2+4+5=11?注意要求包含根节点1,所以最大是8) 输出:8 */核心难点解析
- 分组背包逻辑:每个子节点v对应一组“选y个节点”的选项,父节点u的k从大到小枚举,避免同一子节点被多次选择;
- 状态初始化:用
-INF表示“不可行”,只有合法的状态(选的节点数连通)才会被更新; - 子树大小限制:
size_[u]记录子树节点数,枚举时限制k的范围,减少无效计算; - 复杂度分析:时间复杂度为O(n²)(n≤100时可过),这是树上背包的典型复杂度,无法优化到更低(因为分组背包的本质是O(nm))。
六、进阶例题2:树形DP的换根法(二次扫描)
新手:有些树形DP问题需要求“每个节点作为根时的最优解”,比如“求每个节点到其他所有节点的距离和”,这该怎么处理?
导师:这就需要用到“换根DP(也叫二次扫描法)”——核心是“先以任意节点为根做一次DFS,计算子树内的状态;再做一次DFS,将根的状态转移到子节点,得到所有节点作为根的解”。
问题定义
给定一棵带权树(边权为1,即路径长度为节点数),求每个节点u到其他所有节点的距离之和ans[u]。
解题思路
第一步:第一次DFS(自底向上)
- 状态定义:
dp[u]:以u为根的子树中,所有子节点到u的距离之和;size_[u]:以u为根的子树的节点数(包含u)。
- 状态转移:
size_[u] = 1 + sum(size_[v])(v是u的子节点);dp[u] = sum(dp[v] + size_[v])(v是u的子节点,每个子节点v的距离和需要加上“所有子节点到v的距离+1”,即dp[v] + size_[v])。
第二步:第二次DFS(自顶向下,换根)
- 状态转移:
- 假设当前根是u,要将根换成子节点v,则:
ans[v] = ans[u] - size_[v] + (n - size_[v]);- 解释:
- 原来v的子树到v的距离比到u少1,所以减去
size_[v]; - 非v的子树到v的距离比到u多1,所以加上
n - size_[v]。
- 原来v的子树到v的距离比到u少1,所以减去
- 假设当前根是u,要将根换成子节点v,则:
- 初始值:
ans[root] = dp[root](root为第一次DFS的根)。
完整代码实现(C++)
#include<iostream>#include<vector>usingnamespace std;constint MAXN =1e5+5; vector<int> adj[MAXN];longlong dp[MAXN];// 子树内距离和longlong ans[MAXN];// 每个节点的总距离和int size_[MAXN];// 子树节点数int n;// 第一次DFS:自底向上计算dp和size_voiddfs1(int u,int fa){ size_[u]=1; dp[u]=0;for(auto v : adj[u]){if(v == fa)continue;dfs1(v, u); size_[u]+= size_[v]; dp[u]+= dp[v]+ size_[v];}}// 第二次DFS:自顶向下换根计算ansvoiddfs2(int u,int fa){for(auto v : adj[u]){if(v == fa)continue;// 换根:u→v ans[v]= ans[u]- size_[v]+(n - size_[v]);dfs2(v, u);}}intmain(){ ios::sync_with_stdio(false); cin.tie(0); cin >> n;for(int i =1; i < n;++i){int u, v; cin >> u >> v; adj[u].push_back(v); adj[v].push_back(u);}// 第一次DFS:以1为根dfs1(1,-1); ans[1]= dp[1];// 根节点的总距离和就是dp[1]// 第二次DFS:换根dfs2(1,-1);// 输出每个节点的距离和for(int i =1; i <= n;++i){ cout <<"节点"<< i <<"的距离和:"<< ans[i]<< endl;}return0;}/* 测试案例: 输入: 5 1 2 1 3 2 4 2 5 树结构同前 第一次DFS(根1): - dfs1(4,-1): size_[4]=1, dp[4]=0 - dfs1(5,-1): size_[5]=1, dp[5]=0 - dfs1(2,1): size_[2]=3, dp[2] = (0+1)+(0+1)=2 - dfs1(3,1): size_[3]=1, dp[3]=0 - dfs1(1,-1): size_[1]=5, dp[1] = (2+3)+(0+1)=6 → ans[1]=6 第二次DFS: - 处理1的子节点2: ans[2] = 6 - 3 + (5-3) = 6-3+2=5 - 处理1的子节点3: ans[3] = 6 - 1 + (5-1) = 6-1+4=9 - 处理2的子节点4: ans[4] = 5 - 1 + (5-1) = 5-1+4=8 - 处理2的子节点5: ans[5] = 5 - 1 + (5-1) = 8 输出: 节点1的距离和:6 节点2的距离和:5 节点3的距离和:9 节点4的距离和:8 节点5的距离和:8 验证: 节点1的距离:1→2(1),1→3(1),1→4(2),1→5(2) → 1+1+2+2=6 ✔️ 节点2的距离:2→1(1),2→3(2),2→4(1),2→5(1) →1+2+1+1=5 ✔️ */核心解析
- 两次DFS的意义:
- 第一次DFS:计算“子树内”的状态,仅能得到根节点的答案;
- 第二次DFS:利用父节点的答案推导子节点的答案,实现“换根”;
- 换根转移方程:核心是“距离的增减”——换根后,子树内的节点距离减1,子树外的节点距离加1;
- 复杂度:两次DFS都是O(n),总复杂度O(n),适合处理大数节点(n=1e5)。
七、树形DP的常见坑点与优化
新手:学习树形DP容易踩哪些坑?有没有通用的优化技巧?
导师:新手最容易踩的5个坑:
- 父节点过滤错误:遍历邻接节点时未跳过父节点,导致递归死循环(比如u→v→u→v…);
- 状态初始化错误:比如树上背包未初始化
dp[u][1] = w[u],或用0初始化负权值节点; - 枚举顺序错误:树上背包的k未从大到小枚举,导致重复选择大到小枚举,导致重复选择子节点;
- 数据溢出:未用
long long存储距离和、权值和,导致int溢出; - 换根DP的转移逻辑错误:未正确计算“子树大小”和“距离增减”,导致答案错误。
通用优化技巧
- 邻接表优化:
- 用
vector的reserve预分配空间,避免动态扩容; - 对于无向树,可在输入时直接构建有向父-子树(减少遍历次数);
- 用
- 状态压缩:
- 对于二维状态
dp[u][k],若k的范围小(如k≤2),可拆分为多个一维数组(如dp0[u]、dp1[u]),减少缓存开销;
- 对于二维状态
- 剪枝优化:
- 树上背包中,跳过
dp[u][x] = -INF的无效状态; - 换根DP中,提前计算子树大小,避免重复计算;
- 树上背包中,跳过
- 记忆化优化:
- 对于多组查询的树形DP,可预处理所有节点的状态,避免重复DFS。
八、树形DP的经典应用场景
新手:除了上述例题,树形DP还能解决哪些问题?
导师:树形DP的应用场景覆盖所有“树结构的最优解/计数问题”,常见场景:
- 最优解类:
- 树的最小支配集(选最少节点,使得每个节点要么被选,要么邻接节点被选);
- 树的最小点覆盖(选最少节点,使得每条边至少有一个端点被选);
- 树上最长链(带权)、树上路径计数(满足特定条件的路径数);
- 计数类:
- 树的独立集计数(统计所有独立集的数量);
- 树上合法路径计数(如路径上节点权值和为k的路径数);
- 依赖类:
- 有依赖的背包问题(物品之间存在父子依赖,必须选父物品才能选子物品);
- 树上分组问题(将树划分为k个连通块,求最小代价)。
九、总结
核心要点回顾
- 树形DP核心:以树的递归结构为依托,自底向上递归处理子树,回溯合并子节点状态得到父节点状态;
- 通用框架:
- 存储:邻接表存储树,遍历时分隔父/子节点;
- 状态:定义
dp[u][k]表示节点u的子树中,状态k的最优解/计数; - 递归:DFS遍历子树,先处理子节点再合并状态;
- 答案:根节点的状态(或换根后所有节点的状态);
- 关键技巧:
- 换根DP(二次扫描):解决“每个节点作为根的最优解”问题;
- 树上背包:结合分组背包,处理“子树内选k个节点”的依赖问题;
- 状态初始化:根据问题设置合理的初始值(如负无穷、0、自身权值);
- 避坑指南:
- 必须过滤父节点,避免递归死循环;
- 严格遵循“先子后父”的递归顺序;
- 用
long long避免数值溢出; - 树上背包的k需从大到小枚举。
学习建议
树形DP的核心是“理解树的递归结构+灵活设计状态转移”,建议你:
- 先吃透“最大独立集”“树的直径”两个入门例题,手动推导小案例的
dp数组,理解“自底向上”的合并逻辑; - 练习“树上背包”“换根DP”等进阶例题,掌握状态设计的技巧(比如如何将分组背包融入树形结构);
- 结合真题(如NOIP、蓝桥杯的树形DP题)巩固,重点关注“状态定义”和“转移逻辑”——这是树形DP的灵魂,而非背模板;
- 尝试将树形DP与其他算法结合(如树形DP+贪心、树形DP+二分),拓展解题思路。
记住:树形DP的框架是固定的,但状态转移需要根据问题灵活调整——只要想清楚“父节点的状态如何由子节点的状态推导”,就能设计出正确的转移方程。从简单例题入手,逐步攻克复杂场景,你会发现树形DP其实并不难。