砍树:题解¶
解法比较¶
标程采用静态最长链分解。它把收益提取阶段从动态子树修改的 \(O(n\log(n+1))\) 降至 \(O(n)\) ,并省去区间维护数组。最终排序使总时间仍为 \(O(n\log(n+1))\) .
出题人解法在每棵树上按 DFS 序建线段树,初值为各点的根路径长度。反复选当前得分最高的端点,沿父链走到已经使用的部分;每新覆盖一个点,就把该点子树的所有端点得分减一。这样叶子得分始终是该根路径尚未覆盖的点数。每点只触发一次子树修改,便得到各次操作的边际收益。最后将各树收益合并排序;并列时优先已经启用的树,以减少启用树数。
Codex(GPT-6)采用的实现直接按最长孩子分解树,收集相同的边际收益,用“首项/非首项”标记表达并列规则,证明见下文。两者总时间均为 \(O(n\log(n+1))\) 、空间为 \(O(n)\) ,其中Codex 实现的对数来自排序。收益是免去区间减法结构和沿父链的动态扣减,并采用显式遍历;收益提取为线性时间,总时间中的对数来自最终排序。
一棵树的收益序列¶
选中内部节点不会比选中它的某个后代叶子更好,因此可以把预算理解为选择若干条通向叶子的根路径。砍完整棵树时,每个叶子都必须被选,选全部叶子也足够;\(Need\) 是森林中所有有根叶子的数量,孤立节点也是叶子。
定义 \(F_u(k)\) 为在 \(u\) 的子树里选至多 \(k\) 个节点,能覆盖的最多节点数,路径只延伸到 \(u\) .定义边际收益为连续两项之差,只保留正收益。我们将证明它构成非增序列。
叶子的收益序列是 \([1]\) .对内部节点,假设各孩子的收益序列已非增。先把孩子们的序列合并并降序排列。取前 \(k\) 项就能在总预算 \(k\) 下获得孩子子树的最优收益:若取了某个孩子的较晚一项,却没取它前面不小的项,可以交换,所以始终能取成各孩子序列的前缀。
只要 \(k\ge1\) ,节点 \(u\) 还会被覆盖一次。因此 \(u\) 的收益序列等于合并后的序列中最大的一项加一,其余项不变。加一后仍然非增。这同时证明了递推正确性和收益递减性质。
不必真的合并所有序列¶
一个子树的首项等于其高度(按节点数计)。为每个节点选取高度最大的一个孩子:将该孩子首项向上传递并加一;其他孩子的首项留下来,成为一段最终收益。更深处已经留下的收益也保留。叶子向上传递 \(1\) ,整棵树的根再留下一项。
于是遍历所有节点,每个非最长孩子贡献它的高度,每棵树的根贡献自己的高度。这些数恰好是该树的完整边际收益多重集。无需线段树或重复删除根路径。
森林合并与最少树数¶
把所有树的收益项合并排序,前 \(k\) 项的和就是第一个答案。标记每棵树的根所贡献的项为“首次使用该树”。它严格大于同一棵树的其他项:其他项都是某个真子树的高度,不超过整树高度减一。因此只要取到了某棵树的非首项,它的首项一定已经被取到。
同一收益值下,先取非首项,再取首项。证明这个规则能最小化树数:大于当前阈值的收益项都必须取;阈值处的非首项来自已经使用过的树,不增加树数,而每取一个首项都必须新增一棵树。尽量先取非首项正好使用最少的新树。较小的收益无法替换阈值项而仍保持最大覆盖数。
依次累加收益和首次使用标记,得到全部答案。虽然递推使用“至多”,前 \(Need\) 次的边际收益都为正,所以相应最大值必能由恰好 \(k\) 次选择实现。
实现与复杂度¶
按节点编号递增寻找未访问的连通分量,第一个遇到的节点就是该树的最小编号根。用显式遍历序列建立父子关系,再逆序求高度,避免五十万节点长链上的递归栈溢出。
建图与求高度为 \(O(n)\) ,排序至多 \(n\) 个收益项为 \(O(n\log(n+1))\) ,空间为 \(O(n)\) .独立对拍枚举小森林的所有选点集合,直接合并根路径,并逐预算比较覆盖数和树数。
程序与复现¶
参考程序见 std.cpp。使用 C++17 编译后,可用 tests/generate.py --out-dir <题目目录以外的路径> --std <程序路径> 生成输入和答案。固定种子与用例目的见 生成计划。性能测量见 评测环境与性能。