让程序按答案出生的顺序工作
市政厅要在周日开一场没有观众的展览。
展品藏在一栋旧楼里。楼里有走廊,有房间,有房间里的小房间。
每件展品都要花时间搬出来,走廊还收一笔来回通行费。
实习生负责排工。他拿着总表,从大厅开始逐格询问:左边房间准备好了吗,右边房间准备好了吗,上一层还缺哪一栏。
房间里的工人已经算过一遍。实习生仍然一次次敲门,把同一张表拆成许多小问题。
馆长看了十分钟,拿走总表。他把开门顺序改成:先让最里面的房间交货,再让走廊收货,最后大厅签字。
第二天,展览按时开门。没有观众,程序倒是终于看懂了这栋楼。
简化题面
┌─ [P1270] 访问美术馆
├─ 平台: 洛谷
├─ 题意: 给定一棵展馆树和参观时间预算,求最多能收集的画作数量。
├─ 输入: 根树的递归描述;节点表示走廊或展室,展室带有画作数量。
├─ 输出: 在总时间限制内最多收集的画作数。
├─ 数据范围: 节点数与时间预算有限,时间上限决定状态规模。
├─ 关键限制: 进入子树和返回走廊消耗时间,左右子树预算需要分配。
└─ 完整题面: https://www.luogu.com.cn/problem/P1270
先别急着追问大厅
定义 dp[x][t]:给节点 x 的子树 t 秒,最多收集多少幅画。
叶子展室先扣掉进入和返回走廊的时间,再按剩余时间计算能拿到的画。内部节点把预算分给左右子树,枚举分配点后合并两张表。
for (int t = 0; t <= T; ++t) {
dp[x][t] = dp[left][t];
for (int k = 0; k <= t; ++k)
dp[x][t] = max(dp[x][t], dp[left][k] + dp[right][t-k]);
}
转移式本身很早就能写出来。慢点出在访问顺序。
第一版把递归函数写成 dfs(x,t)。父节点每问一个预算,就重新走一遍左右子树,再从记忆化表里取答案。
公式没有变,重复访问堆成了墙。
楼梯给了拓扑顺序
题目输入采用递归描述。读到一个内部节点,后面紧跟左子树,再跟右子树。
这份输入顺序已经把依赖写出来:孩子先完成,父亲再合并。程序不用带着预算参数到处敲门,可以在每个节点读完后一次性填表。
void build(int x) {
read_node(x);
if (is_leaf(x)) {
fill_leaf(dp[x]);
return;
}
build(left[x]);
build(right[x]);
merge_table(dp[x], dp[left[x]], dp[right[x]]);
}
递归仍然存在。它只负责安排依赖顺序,不再负责携带每个预算的询问。
这就是树上背包和普通记忆化搜索的分水岭之一:父状态需要孩子整张表时,先把孩子整张表算完,通常比逐个预算访问更干净。
up[x] 让表提前收工
每个节点有一个上界 up[x]。超过“取完这棵子树全部画作所需的时间”以后,答案不会继续增加。
for (int t = 0; t <= up[x]; ++t) {
// 只计算仍可能改变答案的预算
}
up[x] 收缩状态数量。build 固定计算时机。
一个让表变小,一个让同一张表只填一次。
旧测试脚本记录了 287 组通过和约 0.02 秒的最慢样例。脚本没有标准答案,随机树最多 25 个节点。
这些数字只能说明程序在那批输入上运行顺利,不能替代转移证明。
先问答案依赖谁
很多 TLE 看起来像公式太慢。继续改公式之前,可以检查:
- 当前状态要读取哪些子状态?
- 这些子状态何时全部就绪?
- 父状态是在等待整张表,还是只等一个格子?
- 输入顺序能否直接提供一条依赖顺序?
- 哪些预算超过上界以后不会再改变答案?
如果父状态需要孩子的整张表,逐格询问通常会制造重复劳动。让答案按依赖顺序出生,缓存才真正有用。
馆长关掉大厅的总表。最里面的房间先交货,走廊随后盖章,最后才轮到大厅。
没有观众的展览照常结束。门票打印机只吐出一行:0.02s。
发布信息草案
- 标题:
让程序按答案出生的顺序工作 - 摘要:树上背包的状态依赖应直接决定计算顺序,子树整表先完成,父节点再合并。
- 标签:树上背包、区间 DP、依赖顺序、记忆化、性能优化

