//

AIGC标识 P14471 [集训队互测 2025] 火花 题解报告

P14471 [集训队互测 2025] 火花 题解报告

1. 题意概括

给定一棵以 1 为根的树。

每个点 i 有:

  • c_i 个物品;

  • 每个物品权值为 v_i

第一步:

  • 选择 t 个不同的点;

  • 对于每个被选择的点,在它到根的路径上每个点各取一个物品。

第二步:

  • 最多再取 k 个物品;

  • 要求第二步取过物品的点构成一个包含根的连通块。

求最大总权值。

由于题目保证 t < c_i,所以第一步取物品时不会出现物品不足。


2. 核心观察

2.1 第一步选择一个点的贡献

如果选择点 x,第一步会在根到 x 的路径上各取一个物品。

因此选择点 x 的总贡献为:

sum[x] = v_1 + v_{fa[x]} + ... + v_x

所以,第一步本质上是:

  • 在树上选择 t 个不同的点;

  • 每个点 x 的收益为 sum[x]

  • 同时会对路径上的物品数量产生影响。

如果只考虑某个子树 u,为了在其中选择 q 个点并使第一步收益最大,只需要取该子树中 sum[x] 最大的 q 个值。


2.2 子树中路径和排序

代码中的 tmp[u] 保存子树 u 内所有点的 sum[x],并按从大到小排序。

tmp[u]

表示:

{ sum[x] | x 在 u 的子树中 }

排序后:

su[u][q]

表示子树 usum[x] 最大的前 q 个值之和。

也就是:

su[u][q] = tmp[u] 中前 q 大的数之和

代码中只需要计算到 t,因为总共最多选择 t 个点。


3. DFS 序处理树

代码对树进行欧拉序遍历:

eul[1],eul[2],...,eul[2*n]

每个点出现两次:

  • 第一次出现:进入该点,记为左端点;

  • 第二次出现:离开该点,记为右端点。

对应数组:

in[u] = u 第一次出现的位置
ou[u] = u 第二次出现的位置

于是点 u 的整个子树对应欧拉序上的区间:

[in[u],ou[u]]

这种处理方式可以将树上的子树操作转化为欧拉序上的区间操作。


4. DP 状态

代码使用:

dp[pos][j][q]

可以理解为:

  • 当前已经处理到欧拉序位置 pos

  • 第二步还保留 j 个物品数量状态;

  • q 表示第一步选择过程中的剩余配额状态;

  • dp[pos][j][q] 表示此时能够获得的最大收益。

其中:

  • j 的范围是 0..K

  • q 的范围是 0..t

初始化:

dp[1][K][t]=0;

表示:

  • 一开始第二步最多可以取 K 个物品;

  • 还需要完成 t 个点的选择;

  • 当前收益为 0。

最终要求恰好完成所有第一步选择,因此第三维必须为 0:

ans=max(dp[eid+1][j][0])

第二步是“至多取 k 个物品”,所以最终剩余数量 j 可以任意,直接取最大值。


5. 欧拉序上的转移

设当前处理点为 u,其权值为 v_u

如果从状态 q 转移到状态 j,则在点 u 上取了:

q-j

个物品。

因此收益增加:

v_u * (q-j)

这正是代码中反复出现的形式:

dp[i][q][k]+a[cur].v*(q-j)

为了保证物品数量合法,还需要满足:

q-j >= 0

并且根据当前是进入点还是离开点,有不同的上界限制。


6. 进入节点时的转移

在点 u 的左端点处,代码首先调用:

solve(cur,0,t,0,t,dp[ou[cur]+1][j],dp[i][j]);

这一步相当于:

  • 将子树 u 的所有选择统一处理;

  • 枚举有多少个被选择的点位于子树 u 中;

  • 加上这些点对应的最大路径和贡献;

  • 将剩余状态转移到子树处理结束之后。

如果从状态 i 转移到状态 mid,则在子树 u 中选择:

i-mid

个点。

相应增加的收益为:

su[u][i-mid]

因此转移形式是:

new[mid] = max(new[mid],old[i]+su[u][i-mid])

这实际上是一个最大值卷积。


7. 为什么可以使用分治优化

tmp[u] 按从大到小排列,因此:

tmp[u][0] >= tmp[u][1] >= tmp[u][2] >= ...

所以 su[u] 的相邻差值满足:

su[u][x]-su[u][x-1] = tmp[u][x-1]

这些差值是单调不增的,因此 su[u] 是一个离散凹函数。

对于转移:

new[j] = max(old[i]+su[u][i-j])

最优决策点具有单调性,可以使用分治优化。

代码中的函数:

void solve(int cur,int lt,int rt,int llt,int rrt,
	vector<int>&nxt,vector<int>&now)

含义是:

  • 当前要求计算 nxt[lt..rt]

  • 当前最优决策只可能在 now[llt..rrt] 中;

  • 由于决策点单调,左半段和右半段分别递归处理。

核心代码:

int mid=(lt+rt)>>1;
int pos=0;
for(int i=max(llt,mid);i<=rrt;i++){
	if(nxt[mid]<now[i]+su[cur][i-mid]){
		nxt[mid]=now[i]+su[cur][i-mid];
		pos=i;
	}
}
solve(cur,lt,mid-1,llt,pos,nxt,now);
solve(cur,mid+1,rt,pos,rrt,nxt,now);

这就是标准的分治优化转移。


8. 单调队列优化

进入节点和离开节点时,还需要进行如下形式的转移:

dp[i+1][j][k]
=
max dp[i][q][k] + v_u * (q-j)

其中 q 满足某个滑动窗口限制。

将式子改写为:

dp[i][q][k] + v_u*q - v_u*j

对于固定的 j,其中 -v_u*j 是常数,因此只需要维护:

dp[i][q][k] + v_u*q

的最大值。

同时,合法的 q 位于一个连续区间中,所以可以使用单调队列维护窗口最大值。

代码中的:

que[]

就是单调队列。

维护过程分为两步:

  1. 删除已经不在合法窗口内的状态;

  2. 删除队尾中不可能成为最优的状态;

  3. 将当前状态加入队列;

  4. 使用队首作为最大值。

每个状态最多入队和出队一次,因此单次转移复杂度为:

O(K)

对所有 k 进行转移后,一个欧拉序位置的复杂度为:

O(K*t)

9. 离开节点时的两类转移

在点 u 的右端点处,代码有两类转移。

9.1 不在点 u 上完成选择

dp[i+1][j][k]

第三维不变,表示当前点不作为第一步选择的点。

对应收益为:

v_u*(q-j)

并且要求取物品数量不超过当前限制:

q-j <= k

9.2 在点 u 上完成一个选择

dp[i+1][j][k-1]

第三维减少 1,表示在点 u 上完成一个选择。

由于选择点 u 后,第一步还会在根到 u 的路径上取物品,所以额外增加:

sum[u]

对应代码:

dp[i+1][j][k-1]
=
max(
	dp[i][q][k]
	+a[cur].v*(q-j)
	+sum[cur]
)

这正好表示:

  • 在当前点取 q-j 个物品;

  • 选择当前点;

  • 加上选择当前点所产生的整条根路径贡献。


10. 正确性证明

下面证明算法能够求出最优答案。

引理 1

对于任意点 u,在其子树中选择 q 个点时,第一步贡献的最大值为 su[u][q]

证明:

子树中每个点 x 被选择时的贡献互相独立,均为 sum[x]。因此要使总和最大,只需选择最大的 qsum[x]

tmp[u] 按从大到小保存了这些值,所以其前 q 项之和就是最大贡献,即:

su[u][q]

引理得证。


引理 2

solve 函数正确计算了子树选择与外部 DP 状态的最大值合并结果。

证明:

转移为:

new[j]=max(old[i]+su[u][i-j])

根据引理 1,su[u][i-j] 正是从子树中选择 i-j 个点的最大收益。

因此枚举所有合法的 i,即可得到 new[j] 的最优值。

由于 su[u] 是离散凹函数,最优决策点单调,分治优化只改变计算顺序,不会遗漏任何候选决策,因此 solve 得到的结果与朴素枚举完全一致。

引理得证。


引理 3

单调队列转移正确计算了在当前点取物品的最优值。

证明:

转移可以写成:

dp[i][q][k]+v_u*(q-j)
=
(dp[i][q][k]+v_u*q)-v_u*j

对固定的 j,只需在合法的 q 区间中寻找:

dp[i][q][k]+v_u*q

的最大值。

单调队列维护的正是这个滑动窗口最大值,因此得到的转移结果等于朴素枚举所有 q 的结果。

引理得证。


定理

算法最终输出的答案是题目的最大总权值。

证明:

  • solve 处理了子树中第一步选择的贡献;

  • 单调队列处理了当前节点上第二步取物品的贡献;

  • 在节点右端点完成选择时,额外加入对应的根路径贡献 sum[u]

  • 欧拉序保证每个节点的进入和离开事件均被恰好处理一次;

  • 最终第三维为 0,表示已经完成全部 t 个点的选择;

  • 枚举最终剩余的第二步物品数量,取最大值,正好对应“至多取 k 个物品”。

因此最终答案就是所有合法方案中的最大值。

定理得证。


11. 复杂度分析

设:

n <= 1e4
K = k
T = t

11.1 DFS 合并部分

代码中的 tmp[u] 保存整个子树的所有路径和,并使用向量合并。

单次合并复杂度为:

O(|tmp[u]|+|tmp[v]|)

由于代码没有截断 tmp,最坏情况下,例如树呈链状时,可能出现:

O(n^2)

的总复杂度。

如果只保留每个子树中前 t 大的路径和,则可以将这部分优化为:

O(n*t)

11.2 分治优化部分

每个左端点、每个 j 都会调用一次 solve

一次 solve 处理 O(t) 个状态,复杂度为:

O(t*log(t+2))

一共有 O(n*K) 次调用,因此:

O(n*K*t*log(t+2))

11.3 单调队列部分

每个欧拉序位置、每个 k、每个 j 都只会入队和出队常数次,因此:

O(n*K*t)

这一部分低于分治优化部分。


11.4 总时间复杂度

按照当前代码的实际实现:

O(n^2+n*K*t*log(t+2))

如果忽略 tmp 合并的最坏情况,DP 部分复杂度为:

O(n*K*t*log(t+2))

11.5 空间复杂度

dp 的大小为:

O(n*K*t)

su 的大小为:

O(n*t)

当前代码中的 tmp 可能保存所有子树元素,最坏为:

O(n^2)

因此总空间复杂度为:

O(n*K*t+n^2)

如果 tmp 只保留前 t 大的值,则空间可降为:

O(n*K*t+n*t)

12. 代码中的一个重要问题

函数声明为:

void solve(...,vector<int>&now)

其中 now 是值传递。

每次递归调用 solve 时,都会复制整个 vector<int> now,会产生额外的复制开销,甚至使分治部分的复杂度退化。

建议修改为常量引用:

void solve(int cur,int lt,int rt,int llt,int rrt,
	vector<int>&nxt,const vector<int>&now)

这样可以避免向量复制。

修改后,分治 DP 的预期复杂度才是:

O(n*K*t*log(t+2))

而不是额外产生大量向量复制开销。


13. 总结

本题的核心技术有三部分:

  1. 将第一步选择点 x 的贡献转化为根路径和 sum[x]

  2. 使用欧拉序将树上子树操作转化为区间处理;

  3. 使用:

    • 分治优化处理子树选择的最大值卷积;

    • 单调队列优化当前节点上的滑动窗口转移。

算法主体复杂度为:

O(n*k*t*log(t+2))

但当前实现中 tmp 的完整合并以及 vector<int> now 的值传递会带来额外开销,实际实现时建议:

  • now 改成 const vector<int>&

  • tmp[u] 只保留前 t 大的路径和;

  • 如果内存限制较紧,还需要进一步优化 dp 的存储方式。

posted @ 2026-09-01 07:58  Gao_l  阅读(7)  评论(0)    收藏  举报