目录
1. 题目结构对比
让我们把这道题和 P2014 选课 做个对比,你会发现它们是一模一样的:
| 特性 | P2014 选课 | P4322 最佳团体 |
|---|---|---|
| 依赖关系 | 选修课必须先修基础课 (父子关系) | 招募人必须招募推荐人 (父子关系) |
| 树的结构 | 森林 (连向虚拟根节点 0) | 树 (JYY 是 0 号,也是根) |
| 选择数量 | 选 |
选 |
| 目标 | 学分之和最大 | |
| 算法核心 | 纯树形背包 DP | 二分答案 + 树形背包 DP |
唯一的区别在于:
- 目标不同:选课是直接求和,最佳团体是求比值(所以要用 01 分数规划)。
- 数据范围:选课
,最佳团体 。这意味着你的 DP 写法必须非常优秀(必须加上我们之前讨论的 sz剪枝优化),否则会超时。
2. 解题步骤拆解
第一步:建树
题目说“候选人
这说明
连边。 - 0 号节点(JYY)是整棵树的根。
- 题目要求招募
个人,算上 JYY 自己(0号点),我们实际上要在树上选 个点。
第二步:01 分数规划 (套路)
假设最大性价比是
我们要判断:是否存在一种选法,使得
变形为:
Check 函数的逻辑:
- 给每个节点
赋予新的权值: 。 - 特别地,JYY (0号点) 的
,所以 。 - 问题转化为:在树上选
个连通点(必须包含根),使得权值和最大。
第三步:树形背包 DP (Check 函数内部)
这和你在 P1642 写的一模一样:
:在 的子树里选 个点(含 )的最大权值。 - 转移方程:分组背包,枚举分配给子树
的点数 。
3. 代码实现的陷阱 (必看 ⚠️)
虽然逻辑简单,但这道题
为了不超时,你必须严格遵守下面两点优化:
- 上下界剪枝:
- 枚举
j时,上限是min(K+1, sz[u] + sz[v])。 - 枚举
k时,上限是min(sz[v], j-1)。 - 这就是你在 P1642 里学会的那个优化,在这里是必须的,它能保证树形背包的总复杂度严格是
。
- 枚举
- 初始化细节:
数组要初始化为 。 。
代码1
/**
* Author by Rainboy blog: https://rainboylv.com github : https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* date: 2026-01-07 09:17:48
*/
#include <bits/stdc++.h>
#include <iomanip>
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
const double ninf = -1e10;
const int maxn = 2505;
const int maxe = 5005;
int n,m;
int K;
int a[maxn];
int S[maxn]; // 招募费用
int P[maxn]; // 战斗值
double dp[maxn][maxn]; // dp[u][i] 表示子树u选i个点的最大权值
//oisnip_beginlinklist_mini_struct.cpp
struct linkList {
struct edge { int u, v,w, next; };
edge e[maxe];
int h[maxn], edge_cnt;
linkList() { edge_cnt = 0; memset(h, -1, sizeof(h)); }
void add(int u, int v,int w = 0) { e[edge_cnt] = {u, v,w, h[u]}; h[u] = edge_cnt++; }
void add2(int u, int v,int w = 0) {
add(u, v,w); add(v, u,w);
}
} e;
//oisnip_end
void init(){
std::cin >> K >> n;
for(int i = 1;i <= n ;++i ) // i: 1->n
{
int s,p,r;
std::cin >> s >> p >> r;
e.add(r,i);
S[i] = s;
P[i] = p;
}
}
void dfs_dp_tree(int u,double x) {
if( u == 0)
dp[u][1] = 0;
else
dp[u][1] = (double)P[u] - x * S[u];
// 初始化
for(int i = 2 ;i <= K+1 ;i++) dp[u][i] = ninf;
for(int i = e.h[u] ; ~i ;i = e.e[i].next){
int v = e.e[i].v;
dfs_dp_tree(v,x);
//无优化, 容量算到2就可以了
for(int j = K+1; j>=2;j--) {
// 分配给子树的容量,也是物品分组
for(int k = 1 ;k <= j-1;k++) {
if( dp[v][k] > ninf && dp[u][j-k] > ninf)
dp[u][j] = max(dp[u][j],dp[u][j-k] + dp[v][k]);
}
}
}
}
bool check(double x) {
dfs_dp_tree(0,x);
return dp[0][K+1] >=0;
}
signed main () {
ios::sync_with_stdio(false); cin.tie(0);
init();
//即使全是收益没有花费,最大值也就这么多
double l = 0, r= 10000;
for(int i = 1;i <= 60 ;++i ) // i: 1->60
{
double mid = (l+r) /2;
if( check(mid))
l = mid; //成立
else
r = mid;
}
std::cout << fixed << setprecision(3) << l << "\n";
return 0;
}

超时,有测试点TLE,原因 没有 sz[i] 数组优化
这完全符合预期!因为
你需要非常清楚地理解为什么会 TLE 以及 sz 优化为什么能救命。下面是详细的数学分析。
1. 为什么会 TLE?(未优化的时间复杂度)
你的当前代码中,DP 的合并部分是这样写的(伪代码逻辑):
// 未优化的逻辑
for (int j = K + 1; j >= 2; j--) { // 外层循环:常数 K
for (int k = 1; k <= j - 1; k++) { // 内层循环:常数 K
// 状态转移 ...
}
}
🧮 算一笔账:
- 二分外壳:循环
60次。 - DP 遍历:树有
个节点。 - 单次合并代价:
- 在最坏情况下(比如树是一条链,或者
很大接近 ),对于每个节点 ,你的两层循环基本上跑满了 次。 - 也就是单次合并复杂度是
。
- 在最坏情况下(比如树是一条链,或者
📉 总计算量:
代入数据
9300亿次运算!
目前的评测机,1秒钟大约能跑
你的代码需要跑 几千秒 才能跑完,所以必然 TLE。
2. sz 优化原理与复杂度 (Optimized Complexity)
加上 sz 优化后,代码变成了这样:
C++
// 优化后的逻辑
for (int j = min(K + 1, sz[u] + sz[v]); j >= 2; j--) { // 上限受限于当前子树大小
for (int k = 1; k <= min(sz[v], j - 1); k++) { // 上限受限于子节点大小
// ...
}
}
sz[u] += sz[v];
🚀 为什么这会快?(数学证明)
这个复杂度的证明非常经典,被称为 “树上点对贡献证明法”。
- 看内层的状态转移
dp[u][j-k] + dp[v][k]。 - 这本质上是将 子树
中的任意一个节点 和 子树 中的任意一个节点 进行配对计算。 - 对于整棵树中的任意两个节点
和 : - 它们只会在它们的 最近公共祖先 (LCA) 处被合并一次。
- 在
和 被合并到同一个父节点之前,它们在不同的子树里。 - 在合并之后,它们属于同一个
sz,不会再被重新合并。
- 因此,树形背包的 DP 循环总执行次数,严格等同于树中点对
的数量。
还不懂就去看:
book/dynamic_programming/树形DP/树上分组背包sz优化原理与复杂度.md
📉 优化后的计算量:
树中任意两个点都可以组成一对,总点对数量是
考虑到我们有一个上限
代入数据
3.75亿次运算。
虽然这个数字略微超过了
3. 最终 AC 代码修改 (Copy This!)
你需要把 dfs_dp_tree 函数替换为下面的代码。重点看我标注 【关键优化】 的地方。
C++
void dfs_dp_tree(int u, double x) {
// 1. 初始化 sz
sz[u] = 1;
if(u == 0)
dp[u][1] = 0;
else
dp[u][1] = (double)P[u] - x * S[u];
// 初始化其他状态
for(int i = 2; i <= K + 1; i++) dp[u][i] = ninf;
for(int i = e.h[u]; ~i; i = e.e[i].next) {
int v = e.e[i].v;
dfs_dp_tree(v, x);
// 【关键优化 1】j 的上限取 min(K+1, sz[u] + sz[v])
// 解释:我们不需要遍历到 K,只需要遍历到“目前总共拥有的节点数”
for(int j = min(K + 1, sz[u] + sz[v]); j >= 2; j--) {
// 【关键优化 2】k 的上限取 min(sz[v], j - 1)
// 解释:子树 v 只有 sz[v] 个节点,不可能拿出更多
for(int k = 1; k <= min(sz[v], j - 1); k++) {
// 必须判断状态是否合法,防止 -inf + -inf 溢出或者逻辑错误
if(dp[u][j - k] > ninf && dp[v][k] > ninf) {
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
}
}
}
// 【关键优化 3】合并完一个子树后,立刻更新当前子树大小
sz[u] += sz[v];
}
}
总结
- 不优化:
必死无疑 (TLE)。 - 优化:
刚刚好 (AC)。
这就是为什么在 sz 优化是必须写的标准套路。
代码2: 优化后
/**
* Author by Rainboy blog: https://rainboylv.com github : https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* date: 2026-01-07 09:17:48
*/
#include <bits/stdc++.h>
#include <iomanip>
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
const double ninf = -1e10;
const int maxn = 2505;
const int maxe = 5005;
int n,m;
int K;
int a[maxn];
int S[maxn]; // 招募费用
int P[maxn]; // 战斗值
double dp[maxn][maxn]; // dp[u][i] 表示子树u选i个点的最大权值
int sz[maxn]; //子树的大小
//oisnip_beginlinklist_mini_struct.cpp
struct linkList {
struct edge { int u, v,w, next; };
edge e[maxe];
int h[maxn], edge_cnt;
linkList() { edge_cnt = 0; memset(h, -1, sizeof(h)); }
void add(int u, int v,int w = 0) { e[edge_cnt] = {u, v,w, h[u]}; h[u] = edge_cnt++; }
void add2(int u, int v,int w = 0) {
add(u, v,w); add(v, u,w);
}
} e;
//oisnip_end
void init(){
std::cin >> K >> n;
for(int i = 1;i <= n ;++i ) // i: 1->n
{
int s,p,r;
std::cin >> s >> p >> r;
e.add(r,i);
S[i] = s;
P[i] = p;
}
}
void dfs_dp_tree(int u,double x) {
sz[u] = 1;
if( u == 0)
dp[u][1] = 0;
else
dp[u][1] = (double)P[u] - x * S[u];
// 初始化
for(int i = 2 ;i <= K+1 ;i++) dp[u][i] = ninf;
for(int i = e.h[u] ; ~i ;i = e.e[i].next){
int v = e.e[i].v;
dfs_dp_tree(v,x);
sz[u] += sz[v];
//无优化, 容量算到2就可以了
// 合并后的上限
int J_UP = std::min(K+1, sz[u]);
for(int j = J_UP; j>=2;j--) {
int K_UP = std::min(sz[v],j-1);
// 分配给子树的容量,也是物品分组
for(int k = 1 ;k <= K_UP;k++) {
if( dp[v][k] > ninf && dp[u][j-k] > ninf)
dp[u][j] = max(dp[u][j],dp[u][j-k] + dp[v][k]);
}
}
}
}
bool check(double x) {
dfs_dp_tree(0,x);
return dp[0][K+1] >=0;
}
signed main () {
ios::sync_with_stdio(false); cin.tie(0);
init();
//即使全是收益没有花费,最大值也就这么多
double l = 0, r= 10000;
for(int i = 1;i <= 60 ;++i ) // i: 1->60
{
double mid = (l+r) /2;
if( check(mid))
l = mid; //成立
else
r = mid;
}
std::cout << fixed << setprecision(3) << l << "\n";
return 0;
}