【题目描述】
原题来自:CTSC 1997
大学实行学分制。每门课程都有一定的学分,学生只要选修了这门课并通过考核就能获得相应学分。学生最后的学分是他选修各门课的学分总和。
每个学生都要选择规定数量的课程。有些课程可以直接选修,有些课程需要一定的基础知识,必须在选了其他的一些课程基础上才能选修。例如《数据结构》必须在选修了《高级语言程序设计》后才能选修。我们称《高级语言程序设计》是《数据结构》的先修课。每门课的直接先修课最多只有一门。两门课也可能存在相同的先修课。为便于表述,每门课都有一个课号,课号依次为 1,2,3,⋯。
下面举例说明:
|----|------|----|
| 课号 | 先修课号 | 学分 |
| 1 | 无 | 1 |
| 2 | 1 | 1 |
| 3 | 2 | 3 |
| 4 | 无 | 3 |
| 5 | 2 | 4 |
上例中课号 1 是课号 2 的先修课,即如果要先修课号 2,则课号 1 必定已被选过。同样,如果要选修课号 33 ,那么课号 1 和 课号 2 都一定被选修过。
学生不可能学完大学开设的所有课程,因此必须在入学时选定自己要学的课程。每个学生可选课程的总数是给定的。请找出一种选课方案使得你能得到的学分最多,并满足先修课优先的原则。假定课程间不存在时间上的冲突。
【输入】
输入的第一行包括两个正整数 M,N,分别表示待选课程数和可选课程数。
接下来 M 行每行描述一门课,课号依次为 1,2,⋯,M。每行两个数,依次表示这门课先修课课号(若不存在,则该项值为 0)和该门课的学分。
各相邻数值间以空格隔开。
【输出】
输出一行,表示实际所选课程学分之和。
【输入样例】
7 4
2 2
0 1
0 4
2 1
7 1
7 6
2 2
【输出样例】
13
【提示】
数据范围与提示:
1≤N≤M≤100,学分不超过 20。
在动态规划中,树形DP 是一类非常重要的题型,而 "有依赖的背包问题"(也称为树上背包)则是树形DP中最经典的模型之一。
本文以经典的 "选课" (CTSC 1997)为例,解析如何将背包问题搬到树上,并利用 Size 优化 将复杂度降至 O(N⋅M)。
1. 题目背景与分析
问题简述: 我们要选N门课,每门课有学分。课程之间存在"先修关系":想修B课,必须先修A 课。这种依赖关系构成了森林(多棵树)。求在修满N门课的前提下,能获得的最大总学分。
建模思路:
-
依赖关系转树:如果A是B的先修课,连接一条边 A→B。A是父节点,B是子节点。
-
虚拟根节点 :原图可能是一个森林(有的课没有先修课)。为了方便处理,我们引入一个0 号节点 作为虚拟根节点,让所有无先修课的节点都连在0号节点上。
-
注意:0号节点本身没有学分,但它算作一个"节点"。
-
所以,原本要选N门课,加上0号节点后,我们的背包容量变成了N+1。
-
2. 状态定义
这道题本质上是分组背包。对于父节点u,它的每一个子树v都可以看作一组物品,我们需要决定给子树v分配多少容量。
- dp[u][j] :表示在以 u 为根的子树中,选修j门课(包含u自己)所能获得的最大各分。
初始化: 进入节点u时,因为必须选u才能选它的子节点,所以初始状态为:
dp[u][1]=wt[u]
即:只选自己1门课,获得自己的学分。
3. 状态转移方程
对于节点u和它的子节点v,我们枚举分给子树v的课程数量k:
dp[u][j]=max(dp[u][j],dp[u][j−k]+dp[v][k])
注意边界:
-
j:当前子树的总容量(倒序遍历)。
-
k:分给子节点v的容量。
-
k 的上限:不能超过j−1。为什么是j−1?因为必须留1个位置给父节点u自己。
4. 核心优化:Size 优化 (上下界剪枝)
普通树形背包的复杂度是 O(N⋅M^2)。但在您的代码中,使用了sz数组记录子树大小,进行了严格的循环边界控制:
原理:
-
我们不需要在空子树上浪费循环(比如子树只有2个点,却尝试分配100个名额)。
-
通过维护
sz[x],我们只计算"有效"的状态。 -
经过证明,这种优化的复杂度会降为O(N⋅M)。这是通过此题的关键!
5. 完整代码
以下是基于上述思路的完整代码,包含详细注释:
cpp
#include <iostream>
#include <cstring>//对应memset函数
#include <algorithm>//对应min函数
using namespace std;
int m,n;
int h[110];
int vtex[110];
int nxt[110];
int wt[110];//每门课程学分
int idx;
int sz[110];//sz[i]代表i节点团队共有多少节点
int dp[110][110];//dp[i][j]代表第i个节点的团队在j个可选课程的情况下最大学分
void addedge(int u,int v){
vtex[idx]=v;
nxt[idx]=h[u];
h[u]=idx++;
}
void dfs(int x){//当前根节点
sz[x]=1;//初始只有自己一个节点
//初始化只选自己时的学分
dp[x][1]=wt[x];
int p=h[x];
while(p!=-1){//遍历x所有子节点
int v=vtex[p];
dfs(v);//先递归到叶子节点再进行计算
sz[x]+=sz[v];//x的团队节点数要加上子树的团队节点数
//分组背包 倒序遍历j 防止同一子树被重复利用
//因为把0号也视为一门课,所以总结点数要多一个
//范围从当前实际最大节点数和背包容量上限中取较小值
for(int j=min(n+1,sz[x]);j>=1;j--){
//k代表分给子树 v 的课程数
//k的范围:
//1.k <= sz[v] (子树只有这么多课)
//2.k <= j - 1 (必须留 1 个位置给 x 自己)
for(int k=0;k<=min(j-1,sz[v]);k++){
dp[x][j]=max(dp[x][j],dp[x][j-k]+dp[v][k]);
}
}
p=nxt[p];
}
}
int main(){
cin>>m>>n;
//初始化头指针数组为空
memset(h,-1,sizeof(h));
for(int i=1;i<=m;i++){
int u,w;//先修课就是父节点
cin>>u>>wt[i];
addedge(u,i);
}
//从虚拟根节点0开始dfs
dfs(0);
//因为加了0号点,所以实际要选n+1个点
cout<<dp[0][n+1];
return 0;
}
6. 总结
这道题是树形背包的教科书级题目,掌握以下三点是解题关键:
-
建图技巧:利用0号虚拟根节点将"森林"转化为"树"。
-
容量转换:题目要选N门课,加上虚拟根后,实际背包容量为N+1。
-
循环边界 :利用
sz数组进行剪枝优化,且时刻牢记父节点必须占用1个份额(k<=j-1)。