目录
暴力
暴力: 显然01序列,先写
1. 题目核心分析
目标:在一棵树上,拆除
这道题是两个经典模型的结合:
- 数学模型:01 分数规划(处理比值最大化)。
- 算法模型:树上背包 DP(处理树上连通块的最优选法)。
2. 第一步:数学推导 (01 分数规划)
直接求比值很难,我们换个思路:二分答案。
假设我们猜测最终的最大比值是
如果存在一种选法,使得
通过移项变形(这是分数规划的套路):
结论:
二分一个
问题就变成了
这里显然具体二分性
- 对于一个
, 如果 成立, 则对于 一定也成立 - 思维模型: 想象 节点
是一个 高度 的柱子, 想象成水位的高度 - 也可以用数学证明,关系那个最大污染值的 答案集合
- A 如果在 x 成立,则一定在
成立
- A 如果在 x 成立,则一定在
- 思维模型: 想象 节点
3. 第二步:算法设计 (树上背包 DP)
现在问题简化为:树上选
这里我来详细的写一下,我是如何理解 树上选
对于一个有根(root)的树, 最终的 最大权值的
1. 包含root
1. 这个问题就变成了: 把 $K-1$ 名额分配 给孩子$v$的能得到最大值是什么(枚举)
2. 不包含root, 那么就是在root的子树上
1. 这个问题就变成了 $\max(dp[v][K])$ ,其中 $v$ 是root的孩子
显然,得益于树的递归结构,孩子的问题依然可以拆分
3.1 状态定义
:在以 为根的子树中,选出 个点(必须包含 u 自己),所能得到的最大权值和。 - 这种点i,选或不选的,设DP的时候,通常设为一定选
- 方便思考
- 不选这个节点的情况,被其他问题包含了
- 这种点i,选或不选的,设DP的时候,通常设为一定选
- 为什么必须选 u? 如果不选
,它的子节点就没法和上面的节点连通了,连通性会断裂。
3.2 转移方程
我们在处理节点
初始状态: (只选自己),其余为 。 - 我们要决定给子树
分配多少个名额(设为 )。
4. 第三步:代码实现的 3 个关键细节 (易错点)
这是你之前最容易卡住的地方,请仔细阅读:
🛑 细节 1:初始化为负无穷
因为
C++
for (int i = 1; i <= K; i++) dp[u][i] = -1e9; // 也就是 -INF
dp[u][1] = w[u] - x * c[u]; // 只有选1个点(自己)是合法的初始状态
🛑 细节 2:循环的顺序 (背包的维度)
这是一个分组背包。
- 第一层:枚举子节点
(物品组)。 - 第二层:枚举当前背包容量
(必须倒序!,防止重复选取)。 - 第三层:枚举分给子树
的容量 。
不优化的代码
cpp
/**
* 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-05 23:31:22
* Problem: P1642 规划
* Algorithm: 01分数规划 + 树形DP (分组背包)
* * 核心思想:
* 1. 二分答案 x (假设最大比值为 x)
* 2. 判定不等式 sum(w) / sum(c) >= x => sum(w - x*c) >= 0
* 3. 问题转化为:在树上选 K 个连通点,使得新权值 (w - x*c) 的和最大
*/
#include <bits/stdc++.h>
#include <iomanip>
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
const int maxn = 2e2+5;
const double NINF = -1e9; // 负无穷:因为权值可能是负数,初始化必须足够小
int n, m, K; // K: 最终需要保留的节点数量 (N - M)
int w[maxn]; // 产值
int c[maxn]; // 污染值
double dp[maxn][maxn]; // dp[u][j]: 以u为根的子树,选j个点(必须包含u)的最大权值
int sz[maxn]; // 子树大小
std::vector<int> g[maxn]; // 邻接表存图
// 预处理子树大小 (虽然N=100时可以不优化,但这是个好习惯)
void dfs_get_sz(int u, int fa) {
sz[u] = 1;
for(int v : g[u]) {
if(v == fa) continue;
dfs_get_sz(v, u);
sz[u] += sz[v];
}
}
// 读入数据并建图
void init() {
std::cin >> n >> m;
K = n - m; // 题目要求拆除M个,也就是保留 N-M 个
for(int i = 1; i <= n; ++i) std::cin >> w[i];
for(int i = 1; i <= n; ++i) std::cin >> c[i];
for(int i = 2; i <= n; ++i) {
int u, v;
std::cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
dfs_get_sz(1, 0); // 先算出每个子树有多大
}
/**
* 核心 DP 函数
* u: 当前节点
* fa: 父节点 (防止往回走)
* x: 当前二分检查的比值
*/
void dfs(int u, int fa, double x) {
// 【关键点1】权值转换
// 将求比值转化为求和:新的点权 = w[u] - x * c[u]
// 状态初始化:只选 u 自己 1 个点的情况
dp[u][1] = (double)w[u] - x * c[u];
// 初始化其他状态为负无穷
// 因为必须选 u 才能连通,如果凑不够 i 个点,该状态就是非法的
for(int i = 2; i <= K; ++i)
dp[u][i] = NINF;
// 遍历每一个子节点 (相当于分组背包中的“物品组”)
for(int v : g[u]) {
if(v == fa) continue;
// 递归处理子树,先把子树的最优解算出来
dfs(v, u, x);
// 【关键点2】树上分组背包合并
// j: 当前 u 子树准备选的总点数 (背包容量)
// 必须倒序枚举!防止同一个子树被重复利用 (和 0/1 背包同理)
for(int j = K; j >= 2; j--) {
// k: 分给子树 v 的点数 (组内物品枚举)
// k 的上限是 j-1,这是为了保证 u 自己必须占 1 个位置!
// 如果 k = j,说明全部分给了子树,根节点 u 就没被选中,连通性就断了
for(int k = 1; k <= j - 1; k++) {
// 状态转移:尝试把子树 v 选 k 个的方案合并进来
dp[u][j] = max(dp[u][j], dp[u][j-k] + dp[v][k]);
}
}
}
}
// 检查函数:判断是否存在一种选法,使得平均值 >= x
bool check(double x) {
dfs(1, 0, x); // 以1为根进行DP
// 只要树中任意一个位置,凑够了 K 个点,且权值和 >= 0
// 说明实际比值可以达到 x (甚至更大)
for(int i = 1; i <= n; ++i) {
if(dp[i][K] >= 0) return true;
}
return false;
}
signed main() {
// 加速 C++ 输入输出
ios::sync_with_stdio(false); cin.tie(0);
init();
// 【关键点3】二分答案
// 答案的范围:最小是 0,最大也就是 max(w_i) / min(c_i) <= 10000
double l = 0, r = 10000;
// 固定循环 100 次
// 这是竞赛常用技巧:比 while(r - l > eps) 更快且不用担心精度死循环
// 2^100 次方在这个范围内精度已经极高了
for(int i = 0; i < 100; i++) {
double mid = (l + r) / 2;
if(check(mid))
l = mid; // 可行,说明答案可能更大,尝试往右找
else
r = mid; // 不可行,说明猜大了,往左找
}
// 输出结果,保留一位小数
std::cout << fixed << std::setprecision(1) << l << "\n";
return 0;
}
优化的代码1
🛑 细节 3:循环的边界 (连通性的保证)
这是最难的一点。
cpp
// j 从大到小枚举
// 上限:不能超过 K,也没必要超过当前两棵树的大小之和
for (int j = min(K, sz[u] + sz[v]); j >= 2; j--) {
// k 枚举分给子树 v 的点数
// 上限 1:k <= sz[v] (子树只有这么大)
// 上限 2:k <= j - 1 (必须至少留 1 个名额给 u 自己!)
for (int k = 1; k <= min(sz[v], j - 1); k++) {
dp[u][j] = max(dp[u][j], dp[u][j-k] + dp[v][k]);
}
}
PS: 可以不优化,这个题目
-
。 -
我的代码1复杂度最坏是
(其实是 )。 -
计算量大约是
(一百万) 次运算。 -
现在的评测机通常一秒钟能跑
(一亿) 次运算。 -
为什么要
j - 1? 如果,意味着把所有名额都给了子树 ,那根节点 就没名额了。 不选, 就断连了。
5. 优化的代码2
cpp
#include <iostream>
#include <vector>
#include <algorithm>
#include <cstdio>
using namespace std;
const int MAXN = 105;
const double INF = 1e9;
int N, M, K;
int w[MAXN], c[MAXN];
vector<int> adj[MAXN];
double dp[MAXN][MAXN];
int sz[MAXN];
void dfs(int u, int fa, double x) {
sz[u] = 1;
dp[u][1] = (double)w[u] - x * c[u];
// 初始化:除了选1个点,其他都视为非法(-INF)
for (int j = 2; j <= K; j++) dp[u][j] = -INF;
for (int v : adj[u]) {
if (v == fa) continue;
dfs(v, u, x); // 1. 先把子树处理好
// 2. 分组背包合并
// j 倒序枚举,利用 sz 剪枝优化复杂度到 O(N^2)
for (int j = min(K, sz[u] + sz[v]); j >= 2; j--) {
// k 不能取到 j,必须给 u 留至少 1 个位置
for (int k = 1; k <= min(sz[v], j - 1); k++) {
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
}
}
sz[u] += sz[v];
}
}
bool check(double x) {
dfs(1, 0, x);
for (int i = 1; i <= N; i++) {
if (dp[i][K] >= 0) return true; // 只要有一个连通块满足条件即可
}
return false;
}
int main() {
scanf("%d %d", &N, &M);
K = N - M;
for (int i = 1; i <= N; i++) scanf("%d", &w[i]);
for (int i = 1; i <= N; i++) scanf("%d", &c[i]);
for (int i = 1; i < N; i++) {
int u, v;
scanf("%d %d", &u, &v);
adj[u].push_back(v);
adj[v].push_back(u);
}
// 二分答案
double l = 0, r = 10000;
for (int i = 0; i < 60; i++) { // 循环 60 次精度足够
double mid = (l + r) / 2.0;
if (check(mid)) l = mid;
else r = mid;
}
printf("%.1lf\n", l);
return 0;
}
6. 学习总结
- 看到“最大化比值”
想到 01分数规划 (二分答案 + 转换权值)。 - 看到“树上选连通块”
想到 树形 DP。 - 树形 DP 的核心
状态必须包含根节点,合并时注意倒序和给根节点留位。 - 复杂度优化
利用 sz[u] + sz[v]控制循环上限,可以将树上背包的复杂度从降低到 。