【例 2】选课(信息学奥赛一本通- P1576)

【题目描述】

原题来自: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门课的前提下,能获得的最大总学分。

建模思路

  1. 依赖关系转树:如果A是B的先修课,连接一条边 A→B。A是父节点,B是子节点。

  2. 虚拟根节点 :原图可能是一个森林(有的课没有先修课)。为了方便处理,我们引入一个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. 总结

这道题是树形背包的教科书级题目,掌握以下三点是解题关键:

  1. 建图技巧:利用0号虚拟根节点将"森林"转化为"树"。

  2. 容量转换:题目要选N门课,加上虚拟根后,实际背包容量为N+1。

  3. 循环边界 :利用sz数组进行剪枝优化,且时刻牢记父节点必须占用1个份额(k<=j-1)。

相关推荐
每天要多喝水2 小时前
动态规划Day33:编辑距离
算法·动态规划
每天要多喝水2 小时前
动态规划Day34:回文
算法·动态规划
weixin_477271692 小时前
马王堆帛书《周易》系统性解读(《函谷门》原创)
算法·图搜索算法
AomanHao2 小时前
【ISP】基于暗通道先验改进的红外图像透雾
图像处理·人工智能·算法·计算机视觉·图像增强·红外图像
We་ct2 小时前
LeetCode 226. 翻转二叉树:两种解法(递归+迭代)详解
前端·算法·leetcode·链表·typescript
大熊背2 小时前
APEX系统中为什么 不用与EV0的差值计算曝光参数调整量
人工智能·算法·apex·自动曝光
REDcker2 小时前
HDR Vivid 技术介绍
数据库·算法·视频·sdr·屏幕·显示技术·dhr
ab1515172 小时前
2.18完成109、112、113
算法
追随者永远是胜利者3 小时前
(LeetCode-Hot100)64. 最小路径和
java·算法·leetcode·职场和发展·go