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]
表示子树 u 内 sum[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[]
就是单调队列。
维护过程分为两步:
-
删除已经不在合法窗口内的状态;
-
删除队尾中不可能成为最优的状态;
-
将当前状态加入队列;
-
使用队首作为最大值。
每个状态最多入队和出队一次,因此单次转移复杂度为:
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]。因此要使总和最大,只需选择最大的 q 个 sum[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. 总结
本题的核心技术有三部分:
-
将第一步选择点
x的贡献转化为根路径和sum[x]; -
使用欧拉序将树上子树操作转化为区间处理;
-
使用:
-
分治优化处理子树选择的最大值卷积;
-
单调队列优化当前节点上的滑动窗口转移。
-
算法主体复杂度为:
O(n*k*t*log(t+2))
但当前实现中 tmp 的完整合并以及 vector<int> now 的值传递会带来额外开销,实际实现时建议:
-
将
now改成const vector<int>&; -
tmp[u]只保留前t大的路径和; -
如果内存限制较紧,还需要进一步优化
dp的存储方式。

浙公网安备 33010602011771号