☰
树形动态规划入门:节点选择问题与状态转移方程详解
2026/10/16 13:40:48 网站建设 项目流程

1. 从一道题看树形动态规划的核心思路

节点选择这道题,在蓝桥杯的算法训练题单里算是树形动态规划的入门经典。我第一次遇到它的时候,第一反应是贪心——每次选权重最大的节点不就行了?结果提交上去直接WA了一半的测试点。后来才想明白,树上的节点之间存在父子约束关系,选了父节点就不能选子节点,这种带约束的优化问题,贪心根本处理不了全局最优,必须用动态规划来做。

这道题的需求很明确:给定一棵有N个节点的树,每个节点有一个正整数的权值,要求从中选出若干个节点,使得选出的节点中任意两个都不相邻(即没有直接的父子关系),同时让选出的节点权值之和最大。说白了就是在树上做一个带约束的最大权独立集问题。

它解决的是什么问题?是资源调度中常见的"互斥选择"场景。比如公司团建安排,选了部门主管就不能选他直接下属,每个人对团建的贡献值不同,怎么选让总贡献最大。再比如课程安排,先修课和后续课不能同时选,每门课学分不同,怎么选学分最高。这类问题的本质都是树形结构上的互斥优化。

适合谁来参考?如果你已经掌握了基础的线性动态规划,知道什么是状态转移方程,但对树形结构上的DP还没有头绪,那这道题就是最好的切入点。它不需要复杂的图论知识,只要会写树的遍历,理解递归和回溯,就能把这道题吃透。我当初就是从这道题开始,才真正理解了树形DP的套路。

2. 树形DP的状态设计与转移方程推导

2.1 为什么用DP而不是贪心或暴力

先说说为什么贪心不行。假设一棵树,根节点权值是10,它有两个子节点,权值分别是9和9。贪心策略如果每次选当前最大的,会先选根节点10,然后两个子节点都不能选了,总和是10。但最优解是选两个子节点,总和18。贪心在这里直接翻车。

暴力枚举呢?每个节点选或不选,N个节点就是2的N次方种组合,N稍微大一点就超时。蓝桥杯的题目N通常能到10的5次方级别,暴力完全不可行。

树形DP的核心思想是利用树的结构特性,把问题分解到每棵子树上。对于任意一个节点,它只有两种状态:选它,或者不选它。而它的选择会直接影响它的子节点能不能选。这种父子之间的依赖关系,天然适合用递归的方式从叶子往上推导。

2.2 状态定义的关键考量

定义dp[u][0]表示以u为根的子树中,不选节点u时能获得的最大权值和。dp[u][1]表示以u为根的子树中,选节点u时能获得的最大权值和。

为什么是二维的?因为父节点的选择状态会约束子节点。如果父节点选了,子节点就绝对不能选;如果父节点没选,子节点可选可不选。这个"可选可不选"就需要在子节点层面做决策,所以每个节点需要记录两种状态下的最优值,供父节点参考。

这里有个容易踩的坑:dp数组的含义是"以u为根的子树",而不是"从u到某个节点的路径"。很多初学者会把树形DP和树上路径问题搞混。子树是一个集合概念,包含u以及u的所有后代节点,而路径是一条链。这道题是子树问题,不是路径问题。

2.3 转移方程的完整推导

对于叶子节点,它没有子节点。dp[leaf][0] = 0,因为不选它就没有权值贡献。dp[leaf][1] = weight[leaf],选它就获得它自己的权值。

对于非叶子节点u,假设它有若干个子节点v1, v2, ..., vk。

如果选u(dp[u][1]),那么所有子节点都不能选。每个子节点vi只能取dp[vi][0]。所以dp[u][1] = weight[u] + Σ dp[vi][0]。

如果不选u(dp[u][0]),那么每个子节点vi可以选也可以不选,取两者中的较大值。所以dp[u][0] = Σ max(dp[vi][0], dp[vi][1])。

最终答案是max(dp[root][0], dp[root][1]),因为根节点选不选都行,取最优。

这个转移方程看起来简单,但里面有一个隐含的前提:子节点之间的选择是相互独立的。在这道题里确实如此,因为约束只存在于父子之间,兄弟节点之间没有直接约束。如果题目改成"选了某个节点,它的兄弟节点也不能选",那转移方程就要重新设计了。所以做题时一定要看清楚约束条件的作用范围。

2.4 边界条件与初始化细节

初始化的时候,dp数组全部置为0。然后在DFS的过程中,先递归处理所有子节点,再根据子节点的返回值计算当前节点的dp值。这就是典型的后序遍历思路——先处理完所有孩子,再处理自己。

有一个细节需要注意:dp[u][1]的初始值应该设为weight[u],而不是0。因为选u的话,u本身的权值一定要算进去。我见过有人在递归里先把dp[u][1]置0,然后累加子节点的dp[vi][0],最后忘了加weight[u],结果答案偏小。

另外,如果权值可能是负数(虽然这道题说是正整数,但有些变种题会出现负数),那dp[u][0]的转移就要小心了。因为max(dp[vi][0], dp[vi][1])在负数情况下可能都是负的,这时候不选任何子节点反而更好。不过这道题权值为正,所以直接取max就行。

3. 完整代码实现与逐行解析

3.1 建树方式的选择

树形DP的第一步是把树建出来。常见的建树方式有两种:邻接表(vector数组)和链式前向星。对于蓝桥杯这种规模的题目,用vector邻接表就够了,写起来简单,不容易出错。

#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; vector<int> adj[MAXN]; int weight[MAXN]; long long dp[MAXN][2]; bool visited[MAXN];

这里用long long存dp值,因为N个节点的权值累加可能超过int范围。蓝桥杯的题目经常在数据范围上设坑,权值虽然说是正整数,但没说上限,累加之后爆int是很常见的事。我当初就是用了int,结果最后一个测试点WA,改成long long就过了。

visited数组用来标记节点是否被访问过,防止在DFS过程中往回走。因为树是无向的,建边的时候是双向的,如果不标记,递归会陷入死循环。

3.2 DFS后序遍历的实现

void dfs(int u) { visited[u] = true; dp[u][0] = 0; dp[u][1] = weight[u]; for (int v : adj[u]) { if (!visited[v]) { dfs(v); dp[u][0] += max(dp[v][0], dp[v][1]); dp[u][1] += dp[v][0]; } } }

这段代码的逻辑很清晰:先标记当前节点已访问,初始化dp值,然后遍历所有邻居。对于未访问的邻居(即子节点),先递归处理,再用子节点的结果更新当前节点的dp值。

注意dp[u][1] += dp[v][0]这一行。因为选了u就不能选v,所以v只能取不选的状态。而dp[u][0] += max(dp[v][0], dp[v][1]),因为不选u的话,v选不选都行,取大的那个。

这里有一个常见的错误:把dp[u][1]的累加写成了dp[u][1] += max(dp[v][0], dp[v][1])。这样写的话,相当于选了u之后还能选v,违反了题目约束。我当初检查了半天才发现是这个错误,测试点过了但答案偏大,就是因为这个。

3.3 主函数的处理流程

int main() { int n; cin >> n; for (int i = 1; i <= n; i++) { cin >> weight[i]; } for (int i = 0; i < n - 1; i++) { int u, v; cin >> u >> v; adj[u].push_back(v); adj[v].push_back(u); } memset(visited, false, sizeof(visited)); dfs(1); cout << max(dp[1][0], dp[1][1]) << endl; return 0; }

主函数先读入节点数和每个节点的权值,然后读入n-1条边建树。因为是一棵树,所以边数一定是n-1。建边的时候双向添加,因为题目给的边不一定是从父到子的方向,可能是任意方向。

从节点1开始DFS,因为节点1是根节点(题目通常默认1是根,或者任意节点都可以作为根,因为树形DP的结果与根的选择无关)。最后输出max(dp[1][0], dp[1][1])。

有一个细节:如果n=0或者n=1,需要特判吗?n=1的时候,只有一个节点,答案是weight[1]。代码跑下来,dfs(1)会设置dp[1][1]=weight[1],dp[1][0]=0,最后输出max(0, weight[1])=weight[1],没问题。n=0的情况题目一般不会出现,但保险起见可以加个判断。

3.4 时间与空间复杂度分析

时间复杂度是O(N),因为每个节点在DFS中只被访问一次,每条边也被遍历两次(双向边)。空间复杂度是O(N),主要是邻接表和dp数组的开销。对于N=10的5次方,这个复杂度完全没问题,跑起来很快。

对比一下其他做法:如果不用DP,用记忆化搜索也能做,本质上是一样的,只是写法不同。如果用树的最小支配集或者其他图论算法,反而会把问题复杂化。树形DP是这道题最自然、最高效的解法。

4. 常见错误与调试经验实录

4.1 递归爆栈问题

N=10的5次方的时候,如果树退化成一条链,递归深度会达到10的5次方层。在有些环境下,系统栈可能撑不住,导致段错误。我当初在本地跑的时候没问题,但提交到评测机上就RE了。

解决办法有两个:一是手动开大栈空间,比如在代码开头加#pragma comment(linker, "/STACK:102400000,102400000"),但这个只在某些编译器下有效。二是改成非递归的写法,用栈模拟DFS,或者用拓扑排序从叶子往上推。

非递归的写法稍微复杂一点,但更稳妥。思路是先用BFS或者栈求出拓扑序,然后按逆序处理每个节点。因为树形DP需要先处理子节点再处理父节点,所以逆拓扑序正好满足这个要求。

void dfs_iterative(int root) { stack<int> st; vector<int> order; st.push(root); visited[root] = true; while (!st.empty()) { int u = st.top(); st.pop(); order.push_back(u); for (int v : adj[u]) { if (!visited[v]) { visited[v] = true; st.push(v); } } } reverse(order.begin(), order.end()); for (int u : order) { dp[u][0] = 0; dp[u][1] = weight[u]; for (int v : adj[u]) { if (v != parent[u]) { dp[u][0] += max(dp[v][0], dp[v][1]); dp[u][1] += dp[v][0]; } } } }

这个写法需要额外记录每个节点的父节点,避免重复处理。虽然代码长了一点,但不用担心栈溢出。

4.2 输入输出超时问题

蓝桥杯的题目有时候输入量很大,用cin和cout可能会超时。我习惯在main函数开头加两行:

ios::sync_with_stdio(false); cin.tie(nullptr);

这两行关闭了C++流和C流的同步,解除了cin和cout的绑定,能显著提升输入输出速度。实测下来,对于10的5次方级别的输入,不加这两行可能要跑几百毫秒,加了之后降到几十毫秒。

如果还嫌慢,可以用scanf和printf,或者手写快速读入。不过对于这道题,加了sync_with_stdio(false)基本就够了。

4.3 答案偏大或偏小的排查思路

答案偏大,通常是转移方程写错了。最常见的是dp[u][1]累加的时候用了max(dp[v][0], dp[v][1]),相当于允许父子同时选。检查一下这一行是不是写成了dp[u][1] += dp[v][0]。

答案偏小,可能是漏加了weight[u],或者dp数组没有用long long导致溢出。还有一种可能是建树的时候只加了单向边,导致有些节点没被遍历到。检查一下建边的时候是不是双向都加了。

如果答案完全不对,可能是根节点选错了。虽然理论上任意节点作为根都能得到正确答案,但如果建树的时候边是单向的,从错误的根出发可能遍历不到所有节点。建议建双向边,然后从节点1开始DFS。

4.4 多组测试数据的处理

有些蓝桥杯的题目会有多组测试数据,每组数据之间需要清空全局数组。我当初就踩过这个坑,第一组数据跑完,第二组数据的时候dp数组还残留着上一组的值,导致答案错误。

解决办法是在每组数据开始前,把adj数组、weight数组、dp数组、visited数组全部清空。adj数组用vector的话,可以遍历一遍clear,或者直接重新声明。dp和visited用memset清空。

for (int i = 1; i <= n; i++) { adj[i].clear(); } memset(dp, 0, sizeof(dp)); memset(visited, false, sizeof(visited));

如果题目明确说只有一组数据,那就不用管。但保险起见,养成清空的好习惯总没错。

5. 从节点选择延伸出的树形DP通用套路

5.1 树形DP的识别特征

什么样的题目适合用树形DP?我总结了几条特征:第一,问题定义在一棵树上,节点之间有层级关系。第二,每个节点有选或不选两种状态,或者有多种状态。第三,父节点的选择会约束子节点的选择,存在依赖关系。第四,要求全局最优解,而不是局部最优。

节点选择这道题完美符合这四条。类似的题目还有"没有上司的舞会"(本质和节点选择一模一样)、"战略游戏"(最小点覆盖)、"皇宫看守"(最小支配集)等。这些题的状态设计和转移方程各有不同,但核心思路都是树形DP。

5.2 状态设计的通用方法

树形DP的状态设计,通常围绕"当前节点的状态"来展开。最简单的就是二状态:选或不选。复杂一点的会有三状态,比如"选了且被父节点覆盖"、"选了但没被父节点覆盖"、"没选但被子节点覆盖"等。

设计状态的时候,问自己几个问题:当前节点的状态会影响哪些子节点的决策?子节点的哪些信息需要传递给父节点?父节点需要知道子节点的哪些状态才能做出最优决策?

以节点选择为例,父节点需要知道子节点"选"和"不选"两种状态下的最优值,因为父节点的选择会决定子节点能不能选。所以子节点需要把这两种状态都传递给父节点。

5.3 转移方程的推导技巧

推导转移方程的时候,先固定当前节点的状态,然后考虑在这个状态下,子节点有哪些合法的状态可以选择。对于每种合法的子节点状态,取最优值累加。

比如节点选择中,如果当前节点选了,子节点只能不选,所以累加dp[v][0]。如果当前节点没选,子节点可以选也可以不选,所以累加max(dp[v][0], dp[v][1])。

这个思路可以推广到更复杂的情况。比如三状态的题目,当前节点有状态0、1、2,子节点有状态0、1、2,先列出哪些状态组合是合法的,然后对每种合法的组合取最优值。

5.4 树形DP与线性DP的对比

线性DP是在一条链上做决策,状态转移只依赖于前一个或前几个状态。树形DP是在一棵树上做决策,状态转移依赖于所有子节点的状态。

线性DP通常用迭代的方式,从左到右或者从右到左递推。树形DP通常用递归的方式,从根到叶子再回溯到根。线性DP的状态转移是局部的,树形DP的状态转移是全局的(需要汇总所有子节点的信息)。

理解了这个区别,就能明白为什么树形DP需要后序遍历。因为父节点的决策依赖于所有子节点的决策结果,所以必须先把所有子节点处理完,才能处理父节点。这就是后序遍历的本质。

5.5 实战中的优化技巧

如果题目数据量特别大,递归写法可能会爆栈,这时候可以用非递归的拓扑排序写法。如果题目要求输出方案而不只是最优值,可以在DP过程中记录每个节点的选择,最后回溯输出。

如果题目中的树不是二叉树而是多叉树,转移方程不变,只是子节点的数量多了。如果题目中的树是带权边的,状态设计可能需要考虑边的权值。如果题目中的约束不是父子互斥而是其他关系,需要重新分析约束的作用范围。

我个人的经验是,树形DP的题目,只要把状态定义清楚了,转移方程推导出来了,代码实现反而不是难点。难点在于识别题目是否适合树形DP,以及如何设计出正确的状态。这需要多做题、多总结,形成条件反射。

6. 蓝桥杯算法训练中的实战建议

6.1 刷题顺序的建议

蓝桥杯的算法训练题单里,树形DP的题目不算多,但每道都很经典。建议的刷题顺序是:先做节点选择(最大权独立集),再做战略游戏(最小点覆盖),最后做皇宫看守(最小支配集)。这三道题的状态设计从二状态到三状态,难度递增,循序渐进。

节点选择是入门,理解了二状态的设计和转移,后面的题目就好办了。战略游戏的状态设计和节点选择类似,只是目标从最大权值和变成了最小点数和。皇宫看守的状态设计更复杂,需要三状态,但核心思路还是一样的。

6.2 比赛中的时间分配

蓝桥杯比赛的时候,树形DP的题目通常出现在中后期。如果前面的基础题做得顺利,可以留出足够的时间来啃树形DP。但如果时间紧张,建议先拿部分分,比如暴力枚举小数据,然后再优化。

节点选择这道题,暴力枚举能过N<=20的测试点,大概能拿30%到40%的分。如果时间不够,先写暴力拿分,再改成树形DP拿满分。不要一上来就死磕满分,结果其他题没时间做。

6.3 调试技巧与测试用例设计

调试树形DP的时候,建议手动画一棵小树,比如5个节点的树,手动模拟DP过程,看看每一步的dp值是否符合预期。如果不符合,检查转移方程是不是写错了。

测试用例的设计,要覆盖几种情况:链状树(退化成一条链)、星状树(一个根多个叶子)、平衡二叉树、随机树。链状树测试递归深度,星状树测试子节点数量多的情况,平衡二叉树测试一般情况。

我习惯在本地写一个暴力程序,随机生成小数据,然后和树形DP的结果对拍。对拍能发现很多隐藏的错误,比如边界条件没处理好、数组越界等。这个方法虽然土,但非常有效。

6.4 心态调整与经验积累

树形DP的题目,第一次做的时候可能会觉得无从下手。这很正常,我当初也是看了别人的题解才明白的。关键是要理解状态设计的思路,而不是死记硬背代码。

做完一道题之后,试着总结一下:这道题的状态是怎么定义的?转移方程是怎么推导的?有没有类似的题目可以用同样的思路?这样积累下来,下次遇到新的树形DP题目,就能快速找到切入点。

蓝桥杯的算法训练,重在质量而不是数量。把一道题吃透,比刷十道题但每道都一知半解要强得多。节点选择这道题,值得反复做几遍,直到能闭着眼睛写出正确的转移方程为止。

最后分享一个小技巧:如果实在想不出状态怎么设计,可以先考虑暴力解法,然后观察暴力解法中有哪些重复计算,这些重复计算就是DP可以优化的地方。从暴力到DP的优化过程,往往能帮你找到状态设计的灵感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询