树形动态规划精讲:从“父与子”问题掌握递归与状态转移

发布时间:2026/8/13 5:36:17
树形动态规划精讲:从“父与子”问题掌握递归与状态转移 1. 项目概述一场关于“父与子”的编程思维较量最近在整理历年信息素养大赛的真题时我又一次翻到了2022年Python国赛的第10题题目名字很有意思叫“父与子”。这可不是一道简单的亲情阅读理解题而是一道典型的、考察递归思想和动态规划思维的编程题。很多刚接触算法竞赛的同学一看到这种涉及“父子关系”的题目就容易发懵感觉像是在处理一个复杂的家谱理不清头绪。但实际上这道题的核心是把现实中的层级关系抽象成计算机能理解的树形数据结构然后通过递归遍历或动态规划的方法来求解一个最优化问题。这道题之所以让我印象深刻是因为它完美地融合了基础数据结构树、经典算法思想递归/动态规划和逻辑建模能力。它不要求你掌握多么高深的库或者框架但非常考验你对Python基础语法的熟练运用以及将实际问题转化为数学模型和代码的能力。这正是信息素养大赛乃至所有编程竞赛的精髓所在——不是比谁用的工具新而是比谁的基本功扎实思维更缜密。无论你是正在备赛的学生还是想巩固算法基础的开发者吃透这道题都能让你对“树”结构和递归有更深的理解。接下来我就带大家一步步拆解这道“父与子”看看它到底在考什么以及我们应该如何漂亮地解决它。2. 题目核心需求与逻辑模型解析2.1 问题场景还原与抽象首先我们得把题目描述从“谜语”变成清晰的“需求说明书”。典型的“父与子”类问题其背景往往是有若干个人物他们之间存在单向的“父子”关系即A是B的父亲。这种关系天然形成了一个或多个树形结构或者更严谨地说是一个森林多棵树。每个“节点”人可能有一个父亲也可能没有即根节点。题目通常会赋予每个节点一个权重值比如年龄、积分、某种能力值等然后要求我们求解基于这种父子关系约束下的某个最值问题。例如一个常见的变体是每个父亲在选择是否参加某个活动时会受其直接儿子们的影响比如儿子都参加父亲就不参加或者反之。我们需要找出一种安排方案使得所有参与者的总权重值如快乐值、收益最大或最小。这就是著名的“树形动态规划”问题在算法领域被称为“没有上司的舞会”或“树形DP”的经典模型。2022年的这道国赛题大概率就是这个模型的一个具体应用。所以我们的核心任务就明确了数据输入与建模如何接收并存储这种父子关系将其转化为程序内部的一棵树或森林。状态定义为树中的每个节点定义状态。在“父与子”模型中通常每个节点有两种状态选择参加或不选择不参加。状态转移方程定义父亲节点的状态值如何由其儿子节点的状态值计算而来。这是动态规划的核心。结果计算从树的根节点开始递归地计算每个节点的两个状态值最终在根节点处比较两种状态得到全局最优解。2.2 数据结构选型为什么用邻接表要表示树我们有好几种选择比如邻接矩阵、邻接表或者直接定义一个TreeNode类。对于这种节点数可能较多N可达10^5量级、且是稀疏树每个节点儿子数有限的竞赛题邻接表是绝对的首选。为什么不选邻接矩阵假设有N个节点邻接矩阵需要一个N×N的二维数组。即使大部分关系不存在也需要开辟O(N^2)的空间当N很大时比如10000内存消耗巨大10000*10000的布尔矩阵约100MB且遍历效率低。为什么不每次都定义类定义一个class Node包含val和children列表当然很直观。但在Python竞赛环境中创建大量对象会有一定的开销。而使用列表的邻接表用索引来代表节点更加轻量化和高效也便于进行递归或迭代操作。因此我们采用一个列表children其中children[i]是一个列表存储节点i的所有子节点编号。同时我们需要一个单独的列表values来存储每个节点的权重值。另外至关重要的一步是找根节点。由于输入通常只给出“谁是谁的父亲”我们需要通过计算每个节点的入度即被指向的次数来找到那个没有父亲的节点它就是整棵树的根。# 假设节点编号从1到n n int(input()) # 节点个数 values [0] list(map(int, input().split())) # 索引与节点编号对齐values[1]是节点1的权重 children [[] for _ in range(n 1)] # 邻接表存储子节点 has_parent [False] * (n 1) # 标记节点是否有父亲 for _ in range(n - 1): # 树有n-1条边 father, son map(int, input().split()) children[father].append(son) has_parent[son] True # 寻找根节点没有父亲的那个节点 root 1 for i in range(1, n 1): if not has_parent[i]: root i break注意题目有时给出的不一定是严格的一棵树可能是多棵树的森林。这时has_parent列表里会有多个False的节点。我们的算法需要稍作调整对每个根节点分别计算然后求和或取最优。但国赛题通常是一棵树我们先按一棵树来处理。3. 核心算法树形动态规划详解3.1 状态定义与递归函数设计这是整个问题的灵魂所在。我们为树上的每个节点u定义两个状态dp[u][0]: 表示不选择节点u即u不参加时以u为根的这棵子树能获得的最大总权重。dp[u][1]: 表示选择节点u即u参加时以u为根的这棵子树能获得的最大总权重。那么最终整棵树的最大权重就是max(dp[root][0], dp[root][1])。接下来我们需要思考如何计算这两个值。这需要从叶子节点开始向上回溯到根节点是一个典型的后序遍历过程。我们设计一个递归函数dfs(u)它的任务是计算并返回节点u的dp[u][0]和dp[u][1]。递归函数dfs(u)的内部逻辑初始化dp[u][0] 0,dp[u][1] values[u]。因为如果选择u至少能获得u本身的权重。遍历u的所有子节点v递归调用dfs(v)先得到子节点v的两个状态值dp[v][0]和dp[v][1]。现在根据u的状态来决定如何累加子树的贡献如果u被选择 (dp[u][1])那么根据常见的约束如“直接上下级不能同时参加”它的直接儿子v不能被选择。所以对于u被选择的情况我们只能加上子节点v在不被选择时的最优值即dp[v][0]。所以有dp[u][1] dp[v][0]。如果u不被选择 (dp[u][0])那么它的儿子v可以自由选择——参加或不参加。作为一个“最优决策者”u会为每个儿子v选择那个能带来更大收益的状态。所以对于u不被选择的情况我们累加每个儿子v的两种状态中的最大值即max(dp[v][0], dp[v][1])。所以有dp[u][0] max(dp[v][0], dp[v][1])。3.2 从递归到记忆化避免重复计算直接使用上述递归对于每个节点都会计算一次本身不会重复计算因为树的结构保证了每个节点只有一个父亲。但是在更复杂的树形DP问题中或者当我们使用递归时清晰的记忆化结构有助于理解和避免潜在错误。我们可以用一个二维列表dp来存储计算结果。import sys sys.setrecursionlimit(100000) # 递归深度可能等于节点数需要设大 def dfs(u): # 如果已经计算过直接返回虽然树结构不会重复调用同一个u但这样写是好习惯 if dp[u][0] ! -1: return dp[u][0], dp[u][1] # 初始化当前节点u的状态值 dp_u0 0 # 不选u dp_u1 values[u] # 选u初始化为u自身的权重 # 遍历所有子节点 for v in children[u]: dp_v0, dp_v1 dfs(v) # 递归计算子节点状态 # 状态转移 dp_u1 dp_v0 # u选则v不能选 dp_u0 max(dp_v0, dp_v1) # u不选则v可选可不选取最优 dp[u][0] dp_u0 dp[u][1] dp_u1 return dp_u0, dp_u1 # 初始化dp数组-1表示未计算 dp [[-1, -1] for _ in range(n 1)] dfs(root) answer max(dp[root][0], dp[root][1]) print(answer)这个模板就是解决此类“父与子”约束下最优化问题的核心代码非常经典且强大。4. 关键实现细节与边界处理4.1 输入格式处理与鲁棒性竞赛题目的输入格式必须严阵以待。题目通常会明确说明第一行是n第二行是n个权重值接下来n-1行是父子关系。但我们需要编写健壮的代码来处理可能存在的空格、换行等不规则情况。使用input().split()配合map是标准做法。对于可能的大数据量可以考虑使用sys.stdin.read()一次性读取再解析速度更快。import sys data sys.stdin.read().split() it iter(data) n int(next(it)) values [0] [int(next(it)) for _ in range(n)] children [[] for _ in range(n 1)] has_parent [False] * (n 1) for _ in range(n - 1): f int(next(it)) s int(next(it)) children[f].append(s) has_parent[s] True4.2 递归深度与栈溢出Python的默认递归深度限制通常是1000对于一棵深度可能达到n链状树的情况是远远不够的。因此在递归求解树问题前必须使用sys.setrecursionlimit设置一个足够大的值一般设为n10或一个较大的常数如1000000。sys.setrecursionlimit(1000000) # 非常重要这是一个非常容易忽略但一忽略就“爆零”的坑。我在初学时就曾因为没加这行代码在本地测试小数据通过提交后遇到深链树直接递归溢出得了0分。4.3 多棵树森林的处理如果题目没有明确说明是一棵树或者从输入格式无法判断例如给出的边数可能少于n-1我们就必须考虑森林的情况。处理起来也很直接对每一个没有父亲的节点即每个根节点分别调用dfs计算其子树的最优解然后将所有根节点的最优解累加如果是求总和或进行其他处理。total_answer 0 for i in range(1, n 1): if not has_parent[i]: # i是一个根节点 dfs(i) total_answer max(dp[i][0], dp[i][1]) # 假设题目要求总和 print(total_answer)4.4 权重值为负的情况我们的状态转移方程dp[u][0] max(dp[v][0], dp[v][1])已经天然处理了子节点权重为负的情况。因为max函数会自动选择较大的值如果两个都是负的它会选择“负得少”的那个这是符合逻辑的。但是我们需要思考一个特殊情况如果所有节点的权重都是负数我们是否可以选择一个都不选在我们的模型里dp[root][0]表示不选根节点并且其子树也按最优方式选择。如果所有权重为负那么对于任何子树最优选择肯定是“一个都不选”那么dp[root][0]最终就是0。而dp[root][1]是一个负数。所以最终max(0, 负数) 0结果是合理的表示最优方案是空集总权重为0。这通常也是题目的意图。如果题目要求必须至少选一个那模型就需要调整例如初始化dp[u][1]为负无穷并保证至少有一条转移路径。5. 完整代码实现与逐行解读下面我将结合一个具体的、假设的题目参数给出一份完整的、带有详细注释的代码。我们假设题目输入格式如下第一行整数n表示人数。第二行n个整数表示每个人的权重值快乐值。接下来n-1行每行两个整数a b表示a是b的父亲。目标是找出一个参与者的集合满足“父子不同时参加”使得总快乐值最大。import sys # 设置递归深度防止树退化成链时递归过深导致溢出 sys.setrecursionlimit(1000000) def main(): # 使用sys.stdin.read()快速读取所有输入适用于大数据量 data sys.stdin.read().strip().split() if not data: return it iter(data) # 1. 读取数据 n int(next(it)) # 权重列表让下标从1开始方便操作 values [0] [int(next(it)) for _ in range(n)] # 2. 构建树结构邻接表并标记父节点 children [[] for _ in range(n 1)] # children[i] 存储节点i的所有子节点 has_parent [False] * (n 1) # has_parent[i] 为True表示节点i有父亲 # 树有n个节点则有n-1条边 for _ in range(n - 1): father int(next(it)) son int(next(it)) children[father].append(son) has_parent[son] True # son节点有了父亲 # 3. 寻找根节点没有父亲的节点 root 1 for i in range(1, n 1): if not has_parent[i]: root i break # 4. 动态规划数组dp[i][0]表示不选idp[i][1]表示选i # 初始化为-1表示尚未计算也可以初始化为0但-1更清晰表示“未计算状态” dp [[-1, -1] for _ in range(n 1)] # 5. 深度优先搜索后序遍历函数 def dfs(u): 计算以节点u为根的子树的最大总权重。 返回一个元组 (not_pick, pick)分别对应不选u和选u的情况。 # 如果已经计算过直接返回结果记忆化避免重复计算 if dp[u][0] ! -1: return dp[u][0], dp[u][1] # 初始化如果不选u当前贡献为0如果选u当前贡献为u自身的权重 not_pick_u 0 pick_u values[u] # 遍历u的所有子节点v for v in children[u]: # 递归计算子节点v的状态 not_pick_v, pick_v dfs(v) # 状态转移方程 # 如果u被选中则其直接子节点v不能被选中 pick_u not_pick_v # 如果u没被选中则子节点v可以自由选择选或不选我们取最优情况 not_pick_u max(not_pick_v, pick_v) # 将计算结果存储到dp数组中 dp[u][0] not_pick_u dp[u][1] pick_u return not_pick_u, pick_u # 6. 从根节点开始进行DFS计算 dfs(root) # 7. 最终答案根节点选或不选两种情况中的最大值 answer max(dp[root][0], dp[root][1]) print(answer) if __name__ __main__: main()逐行解读与关键点第12-14行构建邻接表这是将父子关系转化为图结构的关键一步。children[father]列表存储了father的所有直接下属。同时用has_parent数组记录谁有父亲为找根节点做准备。第18-21行找根节点遍历所有人第一个或唯一一个has_parent[i]为False的节点i就是整棵树的根。这是处理树形问题的标准前奏。第24行DP数组初始化dp数组的维度是(n1) x 2。初始化为-1是一个小技巧在dfs函数开头可以用于判断该节点是否已计算实现隐式的记忆化。虽然树结构不会重复访问同一个节点但这样写更安全也是良好的习惯。第27-49行dfs函数这是核心中的核心。not_pick_u和pick_u的初始化体现了状态定义。遍历子节点v时必须先递归调用dfs(v)得到子节点的状态这保证了计算顺序是自底向上的后序遍历。pick_u not_pick_v和not_pick_u max(not_pick_v, pick_v)这两行就是状态转移方程的代码实现务必理解其逻辑。最后将结果存回dp[u]完成记忆化。第52行启动计算从根节点开始调用dfs会递归地计算整棵树所有节点的状态值。第55行获取答案根节点的两种状态的最大值就是整棵树在约束下的最大总权重。6. 变种与扩展思考“父与子”模型是树形DP的入门砖但它可以衍生出许多复杂的变种。理解基础模型后面对变种你就能更快地抓住本质。6.1 扩展一父子节点权重关联在基础模型中父亲和儿子的选择是互斥的。但可以扩展为如果父亲和儿子同时参加能获得额外的加成收益或惩罚。这时状态定义可能需要调整。例如除了dp[u][0/1]可能还需要定义dp[u][2]表示u和至少一个儿子同时参加的状态。状态转移方程会变得复杂需要仔细考虑所有组合情况。6.2 扩展二多叉树与二叉树我们的代码天然支持多叉树一个父亲有多个儿子。如果题目明确是二叉树每个节点最多两个儿子数据结构可以优化例如用left_child和right_child两个数组来表示。但算法思想完全不变遍历子节点时从遍历列表变成分别处理左儿子和右儿子即可。有时题目会给出一棵多叉树但我们可以将其转化为“左儿子-右兄弟”的二叉树表示法来简化某些操作不过对于DP来说多叉树的邻接表表示通常更直接。6.3 扩展三求具体方案有时题目不仅要求最大值还要求输出是哪些人参加了。这就需要我们在动态规划的过程中记录决策。我们可以用另一个数组choice来记录。例如choice[u][0]可以记录当dp[u][0]取得最大值时对于某个子节点v我们是从dp[v][0]还是dp[v][1]转移过来的。在计算出最终答案后我们可以从根节点开始根据choice数组进行第二次DFS来回溯构造出具体的选择方案。# 伪代码思路 choice [[-1 for _ in range(2)] for _ in range(n1)] # 记录决策 def dfs(u): ... for v in children[u]: not_pick_v, pick_v dfs(v) # 记录使dp[u][0]最大的子节点选择 if pick_v not_pick_v: dp[u][0] pick_v choice[u][0] 1 # 假设用1表示从pick_v转移 else: dp[u][0] not_pick_v choice[u][0] 0 # 用0表示从not_pick_v转移 ... def construct_solution(u, state): 根据最终状态state(0/1)和choice数组回溯构造方案 if state 1: # u被选中 selected_set.add(u) for v in children[u]: construct_solution(v, 0) # u选了v必须不选 else: # u没被选中 for v in children[u]: # 根据之前记录的choice决定v的状态 if choice[u][0] 1: # 当初是从pick_v转移的 construct_solution(v, 1) else: construct_solution(v, 0)6.4 扩展四树上背包问题这是更高级的变种。每个节点有其体积和價值整棵树有一个总体积限制。问题变为在满足“父子不同时选”等约束下选择一些节点使得总价值最大且总体积不超过限制。这需要在状态中增加一维表示体积dp[u][s][0/1]表示在以u为根的子树中使用不超过s的体积且u不选/选时能获得的最大价值。状态转移时需要对子节点进行背包问题的合并分组背包。难度和代码复杂度都会显著上升。7. 调试技巧与常见错误排查即使理解了算法在实现时也难免出错。以下是一些常见的“坑”和调试方法递归深度不足这是最典型的错误。表现是运行时报错“RecursionError: maximum recursion depth exceeded”。解决方法在程序开头加上sys.setrecursionlimit(一个足够大的数)。找错根节点如果has_parent数组初始化或更新有误可能导致找不到根节点或找错根节点使得程序访问无效索引或计算错误。调试方法在找根节点后打印root的值并检查children[root]是否合理。也可以打印整个has_parent数组看看。状态转移方程写反这是逻辑错误。比如误将dp[u][1] dp[v][0]写成了dp[u][1] max(dp[v][0], dp[v][1])。调试方法用小规模的、手工能算出答案的测试用例进行验证。例如构造一个3个节点的链1(权重10)是2(权重20)的父亲2是3(权重30)的父亲。手工计算最优解选2和3或不选2选1和3等再对比程序输出。权重值初始化错误dp[u][1]忘记初始化为values[u]而是初始化为0。这会导致选择节点的收益少算了自身价值。检查点在dfs函数开头确认pick_u values[u]。多棵树森林处理遗漏如果题目是森林而你的代码只找了一个根节点那么只会计算其中一棵树答案错误。检查方法读题时注意“树”还是“森林”。实现时可以用循环遍历所有节点对每个没有父亲的节点调用dfs。输入格式处理错误比如用input()读取时没有处理好行末空格或空行导致转换整数出错。稳健做法使用sys.stdin.read()一次性读取或者用try-except处理input()。一个实用的调试流程第一步极小化测试。用n1只有一个节点测试。答案应该是max(0, value[1])。第二步简单链测试。用n2或3构造一条链手工计算验证。第三步简单分叉测试。构造一个根节点带两个叶子节点的树验证计算是否正确。第四步对比暴力枚举。对于小数据n15可以写一个暴力枚举所有可能组合检查父子约束的程序与你的DP程序跑随机数据看结果是否一致。这是验证算法正确性的黄金标准。8. 性能分析与优化思路我们实现的算法时间复杂度是O(N)其中N是节点数。因为每个节点恰好被访问一次每条边父子关系也被访问一次在遍历子节点时。空间复杂度主要是children邻接表O(N)、dp数组O(N)和递归调用栈O(N)在最坏链状情况下。对于信息素养大赛的国赛题N通常会在10^5以内O(N)的算法是完全可行的。但在极端情况下或者面对更复杂的问题变种我们可以考虑一些优化递归转迭代使用显式的栈进行后序遍历可以完全避免递归深度限制的问题并且常数时间可能更优。但这通常比递归写法复杂。stack [root] order [] # 后序遍历顺序 parent [-1] * (n 1) while stack: u stack.pop() order.append(u) for v in children[u]: parent[v] u stack.append(v) # 按order逆序即从叶子到根计算dp for u in reversed(order): # 计算dp[u][0], dp[u][1]此时子节点都已计算好 ...滚动数组优化如果状态转移只依赖于子节点且子节点计算完后就不再需要其完整状态理论上可以节省一些空间但在树形DP中优化效果不明显且会牺牲代码清晰度通常不必要。剪枝在某些变种问题中如果可以根据权重提前判断某些分支不可能成为最优解的一部分可以进行剪枝。但在标准的“父与子”问题中通常没有明显的剪枝策略。对于竞赛掌握基础的递归实现已经足够应对绝大多数题目。关键是保证代码清晰正确而不是追求极致的微优化。9. 从这道题延伸的算法学习路径如果你通过这道“父与子”题对树形动态规划产生了兴趣那么你可以沿着以下路径继续深入学习这些都是在算法竞赛和面试中常见的题型树形DP基础巩固树的最大独立集和“父与子”几乎一模一样。树的最小点覆盖选择最少的点使得每条边至少有一个端点被选中。状态定义和转移与最大独立集有对称关系。树的重心找到一个点使得删除该点后形成的最大子树节点数最小。这通常用一次DFS计算子树大小即可是树形DP的简单应用。树上背包问题如前所述这是树形DP的进阶。LeetCode上有“在树上做背包”的题目如“分配礼物”等变种。需要理解如何将分组背包的思想融合到树形遍历中。换根DP有时我们需要对树上的每个节点都作为根计算一次答案。暴力做是O(N^2)。换根DP通过一次DFS预处理再利用父子关系在O(N)时间内计算出所有节点为根的答案。经典问题有求树上每个节点到其他所有节点的距离之和。结合数据结构更难的题目会将树形DP与线段树、树状数组、平衡树等数据结构结合用于高效地维护和查询子树信息。学习建议是从简单题开始理解状态定义和转移的本质然后尝试用同样的框架去解决描述不同但模型相同的问题。多画图多手动模拟小数据这是理解树形DP的不二法门。“父与子”这道题就是一个绝佳的起点。它代码量不大但蕴含的思想却非常深刻。吃透它你就打开了树形算法世界的一扇大门。