树上背包

树上的背包问题,也就是背包问题与树形 DP 的结合。

树上背包往往是设 \(dp_{u,i}\) 表示以 \(u\) 为根的子树放了体积 \(i\)(又或者是选 \(i\) 个节点)时的最优解。

进行状态转移时,依次进入每一个子节点 \(v\),处理完 \(v\) 的子树后将目前的 \(dp_{u,i}\)\(dp_{v,j}\) 合并成 \(dp_{u,i+j}\),注意此时 \(i\) 应当倒序循环,另外建议枚举 \(v\) 中选择的数量的时候也写成倒序,因为有时要用 \(dp_{u,0}\)\(dp_{v,j}\) 更新出 \(dp_{u,j}\),而这必须在最后进行。

时间复杂度貌似是 \(O(n^3)\) 的。

但实际上每个节点的背包容量为子树的大小,如果在合并过程中计算子树大小,每次合并相当于是将 \(size_u\)\(size_v\) 的两部分合并成 \(size_u + size_v\) 的部分,合并花费的时间是 \(O(size_u \times size_v)\)

这个合并时间可以形象化地转化为在 \(u\) 的子树和 \(v\) 的子树中各选一点进行匹配,那么显然每个点就是和其他点都进行一次匹配,时间复杂度为 \(O(n^2)\)

如果限制选的数量不能超过 \(k\) 个,那么在枚举 \(u,v\) 选多少个的时候还能把上界限制在 \(k\),总的时间复杂度为 \(O(nk)\)

证明见 子树合并背包类型的dp的复杂度证明

例题:P2014 [CTSC1997] 选课

\(n\) 门课程,第 \(i\) 门课程的学分为 \(a_i\),每门课程有零门或一门先修课,有先修课的课程需要先学完其先修课,才能学习该课程。一位学生要学习 \(m\) 门课程,求其能获得的最多学分数。
数据范围:\(n,m \le 300\)

分析:由于每门课最多只有一门先修课,与有根树中一个点最多只有一个父亲节点的特点类似。可以利用这个性质来建树,从而所有课程形成了一个森林结构。为了方便起见,可以新增一门 \(0\) 学分的课程(编号为 \(0\)),作为所有无先修课课程的先修课,这样原本的森林就变成了一棵以 \(0\) 号节点为根的树。

\(dp_{u,i,j}\) 表示以 \(u\) 为根的子树中,已经遍历了 \(u\) 号点的前 \(i\) 棵子树,选了 \(j\) 门课程的最大学分。

转移的过程结合了树形 DP 和背包问题的特点,枚举点 \(u\) 的每个子节点 \(v\),同时枚举以 \(v\) 为根的子树选了几门课程,将子树的结果合并到 \(u\) 上。

将点 \(x\) 的子节点个数记为 \(s_x\),以 \(x\) 为根的子树大小为 \(sz_x\),则有状态转移方程:\(dp_{u,i,j} = \max \{ dp_{u,i-1,j-k} + dp_{v,s_v,k} \}\),注意有一些状态是无效的,比如 \(k>j\) 或是 \(k>sz_v\) 时。

第二维可以通过滚动数组优化掉,此时需要倒序枚举 \(j\) 的值,同 0-1 背包问题。

先修课这种关系是要求必须选一棵子树的根才能选子树中其他点的,所以枚举 \(u\) 中选择的数量的时候要注意是 \(\ge 1\) 的。

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using std::vector;
using std::min;
using std::max;
const int N = 305;
vector<int> tree[N];
int n, m, s[N], sz[N], dp[N][N];
void dfs(int u) {
    sz[u] = 1;
    dp[u][1] = s[u];
    for (int v : tree[u]) {
        dfs(v);
        for (int i = min(sz[u], m + 1); i >= 1; i--) {
            for (int j = min(sz[v], m + 1 - i); j >= 1; j--) {
                dp[u][i + j] = max(dp[u][i + j], dp[u][i] + dp[v][j]);
            }
        }
        sz[u] += sz[v];
    }
}
int main()
{
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        int k; scanf("%d%d", &k, &s[i]);
        tree[k].push_back(i);
    }
    dfs(0);
    printf("%d\n", dp[0][m + 1]);
    return 0;
}

习题:U53204 【数据加强版】选课

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using std::vector;
using std::min;
using std::max;

const int N = 100005;
vector<int> tree[N]; // 邻接表存树
int n, m;
int s[N], sz[N]; // s[i]: 课程i的学分, sz[u]: 以u为根的子树大小

// dp[u][j] 表示在以 u 为根的子树中,选择了 j 门课程(包括u)所能获得的最大学分
vector<int> dp[N];

// DFS 后序遍历进行树形DP
void dfs(int u) {
    sz[u] = 1; // 初始化子树大小为1(只包含节点u)
    dp[u][1] = s[u]; // base case: 在u的子树里只选1门课,那必然是u本身

    // 遍历 u 的所有子节点 v
    for (int v : tree[u]) {
        dfs(v); // 递归处理子树

        // --- 背包合并过程 ---
        // 将子树 v 的结果合并到父节点 u 的结果中
        // i 是在合并v之前,已在u的子树中选择的课程数
        // j 是要从v的子树中选择的课程数
        // 倒序循环是 0-1 背包的优化,避免状态被重复计算
        for (int i = min(sz[u], m + 1); i >= 1; i--) {
            for (int j = min(sz[v], m + 1 - i); j >= 1; j--) {
                // 状态转移:
                // 从 u 的子树(不含v)选 i 门,从 v 的子树选 j 门,总共选 i+j 门
                // 总学分 = dp[u][i] + dp[v][j]
                dp[u][i + j] = max(dp[u][i + j], dp[u][i] + dp[v][j]);
            }
        }
        sz[u] += sz[v]; // 更新 u 的子树大小
    }
}

int main()
{
    scanf("%d%d", &n, &m);

    // 动态分配DP数组空间,避免MLE
    for (int i = 0; i <= n; i++) {
        dp[i].resize(m + 2, 0); // 初始化为0,因为学分非负
    }

    // 建树
    // 课程的先修关系构成了一个森林
    // 可以设置一个虚拟根节点 0,所有没有先修课的课程都作为 0 的子节点
    // 这样森林就变成了一棵以 0 为根的树
    for (int i = 1; i <= n; i++) {
        int k; 
        scanf("%d%d", &k, &s[i]);
        tree[k].push_back(i);
    }

    // 从虚拟根节点 0 开始 DFS
    // 节点0的学分为0,不影响结果
    dfs(0);

    // 最终答案是 dp[0][m+1]
    // 因为在以0为根的树中选择了 m+1 个节点:
    // 虚拟根节点0 + m门真实课程
    printf("%d\n", dp[0][m + 1]);

    return 0;
}

习题:P2015 二叉苹果树

解题思路

定义 \(f_{u,i}\) 表示在以节点 \(u\) 为根的子树中,保留 \(i\) 条边所能获得的最大苹果数量。

对于当前节点 \(u\),设其子节点为 \(v\),边上的苹果数为 \(w\),这个状态转移方程与 P2014 [CTSC1997] 选课 有一点点细微的区别:\(f_{u, i+j+1} = \max \{ f_{u,i}+f_{v,j}+w \}\),因为如果要连接 \(v\) 的子树,必须保留 \(u\)\(v\) 之间的边,这条边本身消耗 1 个名额,并贡献其对应的苹果数 \(w\)

参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <algorithm>
using namespace std;
using pi = pair<int, int>;
const int N = 100;
vector<pi> tr[N + 1]; // 邻接表存树,pair中存{邻接点, 边权}
int n, q;
int f[N + 1][N + 1]; // DP数组, f[u][j]表示在u的子树中保留j条边的最大苹果数
int sz[N + 1]; // sz[u]表示u的子树中的节点数量

// 树形DP的DFS函数
void dfs(int u, int from) {
    sz[u] = 1; // 初始化子树大小为1(只有自己)
    // 遍历u的所有邻接边
    for (const pi& e : tr[u]) {
        int v = e.first, w = e.second;
        if (v == from) continue; // 避免向上走到父节点
        
        dfs(v, u); // 递归处理子树

        // --- 树上分组背包合并过程 ---
        // 将子节点v的DP结果合并到父节点u
        // i: 在u已经处理过的子树中保留的边数
        // j: 在v的子树中保留的边数
        // i+j+1: 总边数(+1是因为要保留u-v这条边)
        // 倒序循环是为了保证DP更新的无后效性(01背包空间优化技巧)
        for (int i = min(sz[u] - 1, q); i >= 0; i--) {
            for (int j = min(sz[v] - 1, q - i - 1); j >= 0; j--) {
                f[u][i + j + 1] = max(f[u][i + j + 1], f[u][i] + f[v][j] + w);
            }
        }
        sz[u] += sz[v]; // 更新u的子树大小
    }
}

int main()
{
    scanf("%d%d", &n, &q);
    for (int i = 1; i < n; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        // 无向边,双向建图
        tr[u].push_back({v, w});
        tr[v].push_back({u, w});
    }
    
    dfs(1, 0); // 从根节点1开始进行树形DP
    
    printf("%d\n", f[1][q]); // 最终答案
    return 0;
}

习题:P1273 有线电视网

解题思路

这是一个在树形结构上进行带限制的优化选择问题,是典型的树形动态规划,并且具有背包模型的特征。

主目标是最大化用户数量,但有一个“不亏本”的限制条件。这种“在满足 A 条件的同时最大化 B”的问题,通常可以把 A 或 B 中的一个作为 DP 状态的维度(背包的容量),另一个作为 DP 存储的值。

可以定义 \(f_{u,i}\) 为在以 \(u\) 为根的子树中,服务 \(i\) 个用户所能达到的最大净收益(净收益=收入-成本)。如果能计算出所有 \(f_{1,i}\) 的值,那么只需要从 \(M\)\(0\) 遍历 \(i\),找到第一个使得 \(f_{1,i} \ge 0\)\(i\),这个 \(i\) 就是答案。

要给一个用户(或一个子树下的用户)提供信号,必须首先将信号传输到其父节点。这意味着,如果决定服务一个子树内中的任何用户,那么连接这个子树与其父节点的边的成本就必须被计算在内。

使用深度优先搜索(DFS),以后序遍历的方式自底向上地计算 DP 值。

对于一个节点 \(u\)

  • 初始化
    • 如果 \(u\) 是一个叶子节点(用户),那么在它的子树里只有自己。服务 1 个用户(自己)的净收益就是它的缴费,所以 \(f_{u,1}=p_u\),其中 \(p_u\) 代表该用户的缴费。
    • 如果 \(u\) 是一个中转站,在考虑它的任何子树之前,服务 0 个用户的净收益是 0,所以 \(f_{u,0}=0\)
  • 合并子树(背包合并)
    • 遍历 \(u\) 的每一个子节点 \(v\),并将 \(v\) 的 DP 结果合并到 \(u\) 中。
    • 假设在考虑子节点 \(v\) 之前,已经决定在 \(u\) 和它其他已处理的子树中服务 \(i\) 个用户,最大净收益为 \(f_{u,i}\)
    • 现在,决定从 \(v\) 的子树中服务 \(j\) 个用户,其最大净收益为 \(f_{v,j}\)
    • 为了给 \(v\) 子树中的 \(j\) 个用户提供信号,必须支付连接 \(u\)\(v\) 的边的成本,设为 \(\text{cost}(u,v)\)。因此,从 \(v\) 子树部分贡献的净收益实际上是 \(f_{v,j}-\text{cost}(u,v)\)
    • 合并后,总用户数为 \(i+j\),总净收益为 \(f_{u,i}+f_{v,j}-\text{cost}(u,v)\)
    • 需要用这个新的总收益去更新 \(f_{u,i+j}\)

为了避免一个子树的物品被重复计算,实现时需要像 0-1 背包一样倒序遍历 \(i\)\(j\)

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>

using namespace std;
const int N = 3005;
const int INF = 1e9;

// dp[u][i] 表示:在以 u 为根的子树中,满足 i 个用户观看所能获得的最大净收益
// 净收益 = 用户缴费总和 - 传输成本总和
int dp[N][N];
// sz[u] 表示以 u 为根的子树中,包含的用户(叶子节点)数量
int sz[N];

struct Edge {
	int to, c; // to: 子节点, c: 传输费用
};
vector<Edge> tree[N];

// DFS 后序遍历进行树形 DP
void dfs(int cur) {
    // 对于非叶子节点,满足0个用户的收益是0
    dp[cur][0] = 0;

	// 遍历当前节点的所有子节点
	for (const Edge& e : tree[cur]) {
		dfs(e.to); // 递归处理子树
		sz[cur] += sz[e.to]; // 更新当前子树的用户总数

		// --- 背包合并过程 ---
		// 将子节点 e.to 的结果合并到父节点 cur 的结果中
        // i 是合并后的总用户数,j 是从子树 e.to 中选择的用户数
        // 倒序循环是 0-1 背包的优化,防止一个子树的物品被重复选择
		for (int i = sz[cur]; i >= 1; i--) {
			for (int j = 1; j <= sz[e.to]; j++) {
                if (i - j >= 0) { // 保证从 cur 的已有部分能选出 i-j 个用户
                    // 状态转移方程:
                    // 新的收益 = (合并前cur的收益) + (从子树e.to获得的收益)
                    // 从子树e.to获得的收益 = dp[e.to][j] - e.c (子树的净收益 - 连接这条边的成本)
				    dp[cur][i] = max(dp[cur][i], dp[cur][i-j] + dp[e.to][j] - e.c);
                }
			}
		}
	}
}

int main()
{
	int n, m;
	scanf("%d%d", &n, &m);

	// 初始化 dp 数组为一个极小值,因为净收益可能为负
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= n; j++) {
			dp[i][j] = -INF;
        }
    }

	// 读入树的结构(中转站)
	for (int i = 1; i <= n - m; i++) {
		int k; scanf("%d", &k);
		for (int j = 0; j < k; j++) {
			int a, c; scanf("%d%d", &a, &c);
			tree[i].push_back({a, c});
		}
	}

	// 读入叶子节点(用户)的缴费,作为 DP 的 base case
	for (int i = n - m + 1; i <= n; i++) {
		scanf("%d", &dp[i][1]); // 在该叶子节点满足1个用户的收益就是其缴费
		sz[i] = 1; // 叶子节点的子树只包含1个用户
	}

	// 从根节点 1 开始进行树形 DP
	dfs(1);

	// 寻找答案
    // 从多到少枚举用户数量 i
	for (int i = m; i >= 0; i--) {
        // 找到第一个使得净收益不小于0的用户数 i,这个 i 就是最大用户数
		if (dp[1][i] >= 0) {
			printf("%d\n", i);
			break;
		}
    }

	return 0;
}

例题:P3177 [HAOI2015] 树上染色

分析:显然设 \(dp_{u,i}\) 代表以 \(u\) 为根节点的子树,将其中 \(i\) 个点染成黑色的状态。

但是这个值存什么呢?如果直接表示子树内黑点、白点间的收益,这个状态没有办法转移,因为子树内最大化收益的染色方案被合并上去后未必是最优的方案,也就是有后效性。

考虑每条边对最终答案的贡献,如果一条边的两侧有一对黑点或白点,则这条边对这两个点构成的路径是有贡献的。也就是说,一条边对总答案的贡献次数等于边的两侧同色点个数的乘积。而子树内每条边对总答案的贡献这个状态值在子树合并过程中是可以向上传递的。

因此 \(dp_{u,i}\) 代表以 \(u\) 为根节点的子树中有 \(i\) 个点被染成黑色后子树内每一条边对总答案的贡献,类似树上背包,在合并 \(dp_{u,i}\)\(dp_{v,j}\) 时,要算上新加的 \((u,v)\) 这条边的贡献。而经过一条边的路径数等于边两端的点数的乘积,因此这个贡献就是 \(v\) 子树内和子树外黑点数的乘积加上白点数的乘积,再乘上边权。

如果指定 \(1\) 为根节点进行计算,则最后答案为 \(dp_{1,k}\)

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
#include <utility>
using std::vector;
using std::pair;
using std::min;
using std::max;
using edge = pair<int, int>; // 点,边权
using ll = long long;
const int N = 2005;
vector<edge> tree[N];
int n, k, sz[N];
ll dp[N][N];
void dfs(int u, int fa) {
    sz[u] = 1;
    for (edge e : tree[u]) {
        int v = e.first, w = e.second;
        if (v == fa) continue;
        dfs(v, u);
        for (int i = min(k, sz[u]); i >= 0; i--) { 
            for (int j = min(k - i, sz[v]); j >= 0; j--) { // 子树内的黑点数量
                int black = j * (k - j); // 子树内*子树外
                int white = (sz[v] - j) * (n - sz[v] - (k - j)); // 子树内*子树外
                ll c = 1ll * w * (black + white);
                dp[u][i + j] = max(dp[u][i + j], dp[u][i] + dp[v][j] + c);
            }
        }
        sz[u] += sz[v];
    }
}
int main()
{
    scanf("%d%d", &n, &k);
    for (int i = 1; i < n; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        tree[u].push_back({v, w}); tree[v].push_back({u, w});
    }
    dfs(1, 0);
    printf("%lld\n", dp[1][k]);
    return 0;
}

习题:CF815C Karen and Supermarket

解题思路

这是一个在树形结构上,带有依赖关系的组合优化问题,是典型的树形背包模型。

  1. 树形结构:优惠券的使用条件“要用 \(i\) 必须用 \(x_i\)”构成了一个依赖关系,由于 \(x_i \lt i\),这保证了依赖关系无环,且最终都指向商品 1,从而形成一棵以商品 1 为根的树。
  2. 购买决策:对于树上的每一个节点(商品)\(u\),有三种决策:
    • 不购买 \(u\)
    • 购买 \(u\),但不使用优惠券。
    • 购买 \(u\),并使用优惠券(前提是 \(u\) 的所有祖先节点也都使用了优惠券)。
  3. 问题转化:目标是最大化商品数量,这是一个背包问题,但直接求解“预算 \(b\) 内能买多少”比较困难。可以转化问题为,对于给定的商品数量 \(k\),购买它们所需的最小花费是多少?如果能求出所有 \(k\)(从 \(1\)\(n\))的最小花费,就可以找到满足花费 \(\text{cost}_k \le b\) 的最大 \(k\)
  4. 状态的细分:在树形 DP 中,父节点的决策会影响子节点。具体来说,节点 \(u\) 是否使用优惠券,决定了它的所有子孙节点是否有资格使用优惠券。因此,DP 状态必须包含这一维度信息。

定义 \(f_{u,k,0}\) 表示在以 \(u\) 为根的子树中,购买 \(k\) 件商品,并且不使用\(u\) 的优惠券(但购买了 \(u\))的最小花费;\(f_{u,k,1}\) 表示在以 \(u\) 为根的子树中,购买 \(k\) 件商品,并且使用\(u\) 的优惠券的最小花费。

其中,\(f_{u,0,0}\) 状态表示在 \(u\) 的子树中购买 0 件商品,花费应为 0。注意,\(f_{u,0,1}\) 是一个无效状态,因为使用优惠券必须购买商品。

使用深度优先搜索(DFS),以后序遍历的方式自底向上地计算 DP 值。

对于一个节点 \(u\)

  • 初始化:在考虑任何子树之前,先计算只购买 \(u\) 本身(即 \(k=1\))的情况,以及不购买任何商品(\(k=0\))的情况。
    • \(f_{u,0,0}=0\)
    • \(f_{u,1,0}=c_u\)(原价购买)
    • \(f_{u,1,1}=c_u-d_u\)(折扣价购买)
    • 其他所有状态初始化为无穷大
  • 合并子树(背包合并):遍历 \(u\) 的每一个子节点 \(v\),将其 DP 结果合并到 \(u\) 的 DP 表中。假设在合并 \(v\) 之前,\(u\) 的(部分)子树已经购买了 \(i\) 件商品,现在计划从 \(v\) 的子树中购买 \(j\) 件商品。
    • 更新 \(f_{u,i+j,0}\)(当 \(u\) 不使用优惠券时):由于 \(u\) 不使用优惠券,根据依赖关系,其子树中的任何节点(包括 \(v\))都不能使用优惠券。因此,从 \(v\) 的子树中购买 \(j\) 件商品的唯一方式是不使用 \(v\) 的优惠券。\(f_{u,i+j,0}=\min \{ f_{u,i,0}+f_{v,j,0} \}\)
    • 更新 \(f_{u,i+j,1}\)(当 \(u\) 使用优惠券时):由于 \(u\) 使用了优惠券,它的子节点 \(v\) 就有了使用优惠券的资格,但 \(v\) 也可以选择不用,应该选择对 \(v\) 子树花费更小的那种方案。\(f_{u,i+j,1}=\min \{ f_{u,i,1}+\min(f_{v,j,0},f_{v,j,1}) \}\)

这个合并过程是典型的分组背包合并,为了节省空间,实现时需要用倒序循环来遍历 \(i\)\(j\)

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>

using ll = long long;
using std::vector;
using std::min;
using std::max;

const int N = 5005;
int c[N], d[N], sz[N]; // c:原价, d:折扣, sz:子树大小

// dp[u][i][0]: 在以u为根的子树中,购买i件商品,且u号商品不使用优惠券的最小花费
// dp[u][i][1]: 在以u为根的子树中,购买i件商品,且u号商品使用优惠券的最小花费
ll dp[N][N][2];
vector<int> tr[N]; // 邻接表存树

// 工具函数,用于更新最小值,-1代表无穷大
void upd_min(ll &x, ll y) {
    if (x == -1 || y < x) x = y;
}

// DFS 后序遍历进行树形 DP
void dfs(int u) {
    sz[u] = 1; // 初始化子树大小

    // base case: 只买 u 自己
    dp[u][1][0] = c[u]; // 不用券,花费为原价
    dp[u][1][1] = c[u] - d[u]; // 用券,花费为折扣价

    // 遍历 u 的所有子节点 v
    for (int v : tr[u]) {
        dfs(v); // 递归处理子树

        // --- 背包合并过程 ---
        // 将子树 v 的结果合并到父节点 u 的结果中
        // i: 在合并v之前,已在u的子树中购买的商品数
        // j: 要从v的子树中购买的商品数
        // 倒序循环是 0-1 背包的优化
        for (int i = sz[u]; i >= 0; i--) {
            for (int j = sz[v]; j >= 0; j--) {
                if (i + j == 0) continue; // 购买商品数至少为1

                // 状态转移
                // 1. 更新 dp[u][i+j][0] (u不用券)
                // 如果 u 不用券,那么 v 也不能用券。所以只能从 dp[v][j][0] 转移。
                if (dp[u][i][0] != -1 && dp[v][j][0] != -1) {
                    upd_min(dp[u][i + j][0], dp[u][i][0] + dp[v][j][0]);
                }

                // 2. 更新 dp[u][i+j][1] (u用券)
                // 如果 u 用券,那么 v 可以用券,也可以不用券,取其中花费较小者。
                // 2a. v 不用券
                if (dp[u][i][1] != -1 && dp[v][j][0] != -1) {
                    upd_min(dp[u][i + j][1], dp[u][i][1] + dp[v][j][0]);
                }
                // 2b. v 用券
                if (dp[u][i][1] != -1 && dp[v][j][1] != -1) {
                    upd_min(dp[u][i + j][1], dp[u][i][1] + dp[v][j][1]);
                }
            }
        }
        sz[u] += sz[v]; // 更新子树大小
    }
}

int main()
{
    int n, b;
    scanf("%d%d", &n, &b);

    // 初始化 dp 数组为 -1 (代表无穷大/不可达)
    for (int i = 1; i <= n; i++) {
        // dp[i][0][0] = 0 表示在子树i中买0件商品,不考虑i的优惠券,花费为0。
        // dp[i][0][1] 表示“在子树i中买0件商品,但使用了i的优惠券”,
        // 这是一个矛盾的状态,因为用券必须购买商品。它应该是不可达的,应初始化为-1。
        dp[i][0][0] = 0; dp[i][0][1] = -1;
        for (int j = 1; j <= n; j++) {
            dp[i][j][0] = dp[i][j][1] = -1;
        }
    }
        
    // 读入数据并建树
    for (int i = 1; i <= n; i++) {
        scanf("%d%d", &c[i], &d[i]);
        if (i > 1) {
            int x; scanf("%d", &x);
            tr[x].push_back(i);
        }
    }

    // 从根节点 1 开始 DP
    dfs(1);

    // 寻找答案
    // 从多到少枚举购买的商品数量 i
    for (int i = n; i >= 0; i--) {
        // 购买 i 件商品的最小花费是 min(用券, 不用券)
        ll cost = dp[1][i][1];
        if (dp[1][i][0] != -1) {
            if (cost == -1 || dp[1][i][0] < cost) {
                cost = dp[1][i][0];
            }
        }

        // 找到第一个花费小于等于预算的 i,这个 i 就是能购买的最大商品数
        if (cost != -1 && cost <= b) {
            printf("%d\n", i);
            return 0; // 找到就退出
        }
    }

    return 0;
}
posted @ 2024-11-08 16:17  RonChen  阅读(303)  评论(0)    收藏  举报