尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

USACO P3018 Tree Decoration:树上贪心与后序遍历入门解析

USACO P3018 Tree Decoration:树上贪心与后序遍历入门解析 USACO老题有个特点题意包装华丽剥开之后就是一个很朴素的问题。P3018 [USACO11MAR] Tree Decoration G就是这样题目背景是装饰圣诞树实际上考察的是树上的资源流动和自底向上的贪心思维。我刷这道题的时候第一眼觉得要搞什么树形DP结果把样例手算一遍就发现从叶子往根一遍后序遍历答案自己就出来了。如果你是信奥选手正在学树DFS、递归、贪心这道题非常适合拿来做入门练习C实现只有几十行但里面值得抠的细节并不少。1. 题目理解这棵树到底在问什么1.1 题意逐句拆解先别急着写代码把题意搞清楚比什么都重要。输入给出一棵以1号节点为根的树N个节点。每个节点会输入三样东西父节点编号、需求值、产出值。注意根节点1的父节点位置通常填0表示它没有父亲。这里的需求指的是以该节点为根的整棵子树最终挂上的装饰总数至少达到这个数。所以根节点的需求约束的是整棵树一共要有多少装饰子节点的需求则约束它自己的子树。这里的产出则是该节点自带可以免费使用的装饰数量用不完的部分可以往上交给父节点用。如果某节点把子树里的免费装饰都用完了还达不到需求那就只能花钱买一个装饰一块钱问最少买多少。我最初理解的时候踩过一个误区以为每个节点挂的装饰只能放在这个节点上。不是的装饰品是资源整个子树是一个资源池节点产出和子节点富余都在池子里父节点可以随便调配。这也正是题目叫 Tree Decoration 的微妙之处——需求是自上而下覆盖的但资源是自下而上汇聚的。1.2 用一个例子把过程跑一遍我造一个小数据手动推一遍后面看代码就顺了。树的结构是这样1号是根2号、3号是1号的儿子4号是2号的儿子。输入如下4 0 12 3 1 5 4 1 8 1 2 4 6第二列是需求第三列是产出所以节点1需求12、产出3节点2需求5、产出4节点3需求8、产出1节点4需求4、产出6。从叶子节点开始处理。4号节点是叶子手上只有自己产出的6个需求4满足后剩余2个把这2个上交给父节点2号。接下来看2号节点它自己产出4个再加上4号上交的2个一共6个需求是5满足后还剩1个继续上交1号。3号节点是另一个叶子自己产出只有1个需求8缺口7个只能买7个没有富余可以上交。最后是1号根节点自己产出3个加上2号上交1个、3号上交0个一共4个需求12缺口8个买8个。总购买数就是7加8等于15。这个手算过程很直白地展示了核心操作每个节点先把子节点上交的资源全部收齐加上自己的产出然后看能不能满足自己的子树需求能就上交剩余量不能就买缺口。1.3 三个关键性质这道题能贪心依赖三个性质建议做题前先自己品一品。第一需求的覆盖关系是树形的父节点需求大于等于派生需求的总和这让把缺口补在任意上层节点都合法。第二节点富余资源如果不上交对上层是纯损失上交并不会让任何后代节点变差。第三最终答案只关心购买总量不关心买在哪所以局部凑不齐就买凑齐了就把剩余往上送每一步都简单直接。看透这三点这题就从装饰圣诞树变成了后序统计富余、累计缺口的模板题。2. 解题思路与算法选型为什么从叶子往上贪心就是最优解2.1 贪心策略自己的富余全部上交算法描述起来一句话从叶子向根做后序遍历对每个节点先调用子节点的处理逻辑拿到每个子节点能上交多少把它们加上该节点自身产出得到当前节点手上的总资源。如果总资源大于等于需求就把超出部分作为该节点的上交量返回给父节点如果不够把差额累加进答案上交量记0。为什么这个策略合理因为对任何一个中间节点来说它看到的总资源已经是在所有后代尽量满足自身之后还能余出来的部分。后代已经把自己的需求满足完了剩余的就是可以自由支配的纯增量没有任何代价。把这些增量全部上交给父节点对上层只会有好处不会有坏处。既然上交不可能让全局变差那么贪心地在每个节点都把富余全部上传就构造出一个局部不劣的解归纳到根节点就是全局最优。2.2 正确性论证上传越多花费只会更少用一个小的逻辑链把贪心为什么对锁死。假设当前处理到节点u所有子节点已经处理完毕每个子节点都能给出自己最大可能的上交量。此时u手上的自由资源tot等于自身产出加所有子节点上交量总和。如果tot大于等于需求need[u]节点u的子树已经满足此时多出的tot减去need[u]如果不上交父节点就需要用其他渠道采购这些数量的装饰来补自己的缺口。多采购一点购买量只可能增加不可能减少。反过来把这部分全部上交父节点可支配资源变多后续采购量只可能减少。所以对u来说上交最大剩余量是不劣的决策。如果tot小于需求need[u]这个缺口是硬缺口不可能通过后代再挤出资源因为后代已经处理完毕且能够上交的都上交了因此唯一的办法就是购买购买量就是差值。这个差值无论记在u头上还是记在父节点头上都不影响总金额。于是每个节点的操作都是局部唯一且最优的归纳到整棵树得到的解就是全局最优。2.3 为什么不用复杂写法很多树形题动不动就是dp数组、状态转移、换根但这题完全不需要。一个明显的错误倾向是把答案定义成dp[u]表示满足子树u需要花多少钱然后试图在父子之间做转移。其实你仔细想从下往上传递的信息只有一样东西——这个子树还能给父亲多少资源它只是一个数值不需要二维状态不需要背包合并不需要转移方程里的max和min。另一个容易走偏的方向是写一个从根向下的DFS试图先给父节点分配资源再处理孩子。这个方向是反的因为父节点到底缺多少取决于孩子能提供多少你不先把孩子跑完根本不知道父节点该买多少。所以这题天然就是后序不是先序也不是中序。想通这一点代码结构就非常固定了。3. C实现完整可提交代码与逐段解析3.1 建树与读入细节建树用vector存孩子列表就够了。N的数据范围如果是10的5次方量级vectorvector 没有任何问题不需要手写邻接表。读入时每一行先给父节点编号p再给需求和产出保存到对应数组。如果p大于0就执行children[p].push_back(i)把当前节点挂到父节点下面。根节点1的父节点是0不要把它也push进某个child数组里否则会出现多余的一条边。读入这里有个小坑USACO老题的输入顺序是父节点、需求、产出网上有些题解会写成需求、产出、父节点千万别照着错的格式抄。拿到题先看Input格式说明或者直接看样例前几行确认第二列是不是需求。3.2 递归版dfs返回值就是上供量递归版本最符合直觉代码量也最小。dfs函数返回一个long long表示当前节点在满足自身需求后还能向父节点上交多少装饰。实现如下#include bits/stdc.h using namespace std; int n; vectorvectorint children; vectorlong long need, own; long long ans; long long dfs(int u) { long long total own[u]; for (int v : children[u]) { total dfs(v); } if (total need[u]) { return total - need[u]; } ans need[u] - total; return 0; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin n; children.resize(n 1); need.resize(n 1); own.resize(n 1); for (int i 1; i n; i) { int p; long long needVal, ownVal; cin p needVal ownVal; need[i] needVal; own[i] ownVal; if (p 0) children[p].push_back(i); } dfs(1); cout ans \n; return 0; }代码逻辑和手算过程完全一致。关键在于dfs的返回值对叶子节点循环为空total就是own[u]满足需求后的剩余量上传对内部节点先递归孩子把所有孩子的上交量加进去再判断缺口。3.3 迭代版防爆栈的后序实现递归虽然好看但有个现实风险如果数据是一条长链深度到10的5次方很多评测环境会栈溢出。USACO老题测试点里出现链状数据不是新鲜事所以我很推荐养成用迭代写后序的习惯。思路是先搞一个栈做前序式的节点记录再把记录反转变成子节点都排在父节点之前最后顺序处理。#include bits/stdc.h using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin n; vectorvectorint children(n 1); vectorlong long need(n 1), own(n 1); for (int i 1; i n; i) { int p; long long needVal, ownVal; cin p needVal ownVal; need[i] needVal; own[i] ownVal; if (p 0) children[p].push_back(i); } vectorint order; stackint st; st.push(1); while (!st.empty()) { int u st.top(); st.pop(); order.push_back(u); for (int v : children[u]) { st.push(v); } } reverse(order.begin(), order.end()); vectorlong long supply(n 1, 0); long long ans 0; for (int u : order) { long long total own[u]; for (int v : children[u]) { total supply[v]; } if (total need[u]) { supply[u] total - need[u]; } else { ans need[u] - total; } } cout ans \n; return 0; }为什么反转就能保证后序因为第一次遍历时父节点一定比子节点先进入order反转之后子节点必然排在父节点之前。处理到某个节点u的时候它的所有子节点的supply值已经算好了直接累加就行。栈里压入子节点时顺序无所谓不影响这个性质。3.4 复杂度与平台注意事项时间复杂度和空间复杂度都是O(N)每个节点进出栈一次每个孩子关系遍历一次。这个复杂度在USACO的Gold组题目里属于非常友好的数据量再大一倍也跑得动。平台方面提醒两个点第一USACO老题的编译器环境可能偏老bits/stdc.h不是所有平台都认如果你在别的OJ上编译失败改成显式的标准头文件include比如#include cstdio、#include vector、#include stack。第二所有涉及累加的量都用long long不要因为样例小就用int后面测试点一上来可能直接爆掉。4. 现场调试实录提交代码时踩过的坑4.1 long long第一个容易忽略的坑我第一次做这类题用int存答案前几个样例全过结果交上去有一个测试点WA。查了半天才意识到需求值和产出值单个看不大但多个节点累加以后完全可能超过int范围。AC代码和WA代码之间只隔了一个long long这个学费交得冤枉。建议从读入到输出全部统一long long不要混用。提示涉及求和、累加、比较资源总量的树题默认用long long几乎没有坏处。4.2 递归爆栈链状树专治花活递归版本在正常随机数据上跑得飞快但USACO很喜欢出极端数据。有一类链状树每个节点只有一个孩子深度直接拉满。递归深度过大时程序不是报错就是运行时溢出表现可能是TLE也可能是RE。用迭代版之后我再没遇到这个问题。如果你的本地环境能过但OJ过不了优先怀疑栈深度。4.3 建树方向与根节点特殊处理建树方向千万别搞反。题目给的是父节点编号应该把当前节点挂到父节点的children里。有人图省事用邻接矩阵或双向边结果DFS时要额外判断parent徒增麻烦。还有一个细节根节点1的父节点输入是0只有p大于0才执行push。有人不判断把根节点也塞进某个编号为0的虚拟节点的孩子里等于多了一条不存在的边输出就会多算东西。4.4 常见错误速查表错误现象可能原因排查/对策小样例过大测试点WA用了int存累加和全部换成long long链状数据直接宕机递归深度爆栈换成stack迭代后序答案比预期大根节点p0也被push了建边时判断p0答案少算子节点还没处理就累加supply检查处理顺序是否为后序样例格式对不上需求、产出读反看输入说明确认哪列是需求这五个问题基本覆盖了这道题八成以上的提交失败原因。我自己做题时每次WA都会按这个表过一遍能省很多时间。5. 从这道题延伸出去树形题的通用思路5.1 后序遍历的通用模板P3018的本质是后序收集节点聚和的通用思路。你可以把树上问题里常见的子节点向父节点汇报信息的流程抽象成固定模板先递归或迭代处理所有孩子然后拿到每个孩子返回的结果在父节点做合并最后把父节点自己的结果返回给上一层。这个模板可以解决很多树形题。比如计算子树节点数量就是每个孩子返回1加子树大小父节点累加比如计算子树最大值就是父节点取所有孩子最大值和自己比再比如经典树形DP的入门题没有上司的舞会也是后序处理孩子然后父节点状态转移。把P3018吃透相当于把树上信息传递的脚手架搭起来了。5.2 相似题型对比同样叫Tree Decoration的题在不同OJ上还有变体。有的版本要求输出方案数有的版本在边上加了传送损耗还有的版本把富余上传改成只能存放到最近的祖先节点。核心区别就看一件事子节点把资源给父节点时有没有附加成本。没有成本就是本题纯粹贪心后序有成本就要在合并状态时把损耗算进去复杂度立刻上升一个档次。对比一下另一个方向如果需求不是子树总量而是根到每个节点的路径上至少多少个问题性质就变了不再适合后序贪心需要往树上差分或者前缀和方向想。所以做题时一定要先确认约束是子树还是路径这两个词决定了完全不同的算法方向。5.3 一个亲测管用的练习节奏如果你想拿这类题练手我建议按三步走。第一步先不写代码对样例做一次手算把每个节点能给父节点上传多少这个数值算出来。第二步用迭代后序自写一遍代码不要复制别人的写错了也没关系调试过程才是收获。第三步造几个小数据验证包括一条链和一个深度很浅但广度大的树确保极端形态下逻辑依然正确。我自己刷USACO老题的习惯是每道题通关之后顺手写一段这题的信息流动方向记录。P3018的记录就五个字富余往上走。这五个字在后来的树形DP、树上贪心甚至并查集做题里都反复派上用场。最后分享一个实际体会这道题最值钱的不是代码本身而是把树上的资源分配从抽象变成一个固定的处理流程。我后来遇到不少更复杂的树形题状态多、转移也复杂但底层还是这棵后序递归的骨架。刷完一遍P3018之后再回头看那些装饰品“美元”“需求”这类花哨包装你就能一眼看到它们底下那棵光秃秃的树以及装饰品一路向上汇聚的路径了。
返回列表