[CTSC1997] 选课

GitHub跳转原题关系图返回列表

加入虚拟根把课程森林变成树,用树形背包维护每个子树选若干课程的最大学分。

OJ: luogu

题目 ID: P2014

难度:普及+/提高

标签:树上背包树形DP动态规划

日期: 2025-12-30 17:17

题意

n 门课程,每门课程有学分,并且可能有一门直接先修课。

要选择恰好 m 门课。如果选择某门课,必须先选择它的先修课。求最大总学分。

思路

先看一个可以直接验证想法的朴素解:

cpp
#include <bits/stdc++.h>
using namespace std;

// brute.cpp:小数据暴力解,使用 01 序列枚举每门课程选或不选。

const int MAXN = 25;

int n, m;
int parent_course[MAXN];
int credit[MAXN];
int choose_flag[MAXN]; // choose_flag[i] = 0/1,表示第 i 门课程不选/选
int answer;

int calc_chosen_count() {
    int cnt = 0;
    for (int i = 1; i <= n; i++) {
        if (choose_flag[i] == 1) cnt++;
    }
    return cnt;
}

bool check() {
    if (calc_chosen_count() != m) {
        return false;
    }
    for (int i = 1; i <= n; i++) {
        if (choose_flag[i] == 1 && parent_course[i] != 0 && choose_flag[parent_course[i]] == 0) {
            return false;
        }
    }
    return true;
}

int calc_answer() {
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        if (choose_flag[i] == 1) sum += credit[i];
    }
    return sum;
}

void dfs_choose(int dep) {
    if (dep == n + 1) {
        if (check()) {
            int value = calc_answer();
            if (answer < value) answer = value;
        }
        return;
    }

    // 第 dep 门课程的 01 选择:0 不选,1 选。
    for (int i = 0; i <= 1; i++) {
        choose_flag[dep] = i;
        dfs_choose(dep + 1);
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        cin >> parent_course[i] >> credit[i];
    }

    answer = 0;
    dfs_choose(1);
    cout << answer << '\n';

    return 0;
}

brute.cpp 把每门课程看成一个 01 选择:choose_flag[i] = 0/1 表示不选或选。递归先生成完整选择,叶子节点再检查是否恰好选了 m 门课、先修课是否都满足,并统计总学分。

课程依赖可能是一片森林。为了统一处理,加入一个学分为 0 的虚拟根 0,让所有没有先修课的课程都成为它的孩子。

这样原问题变成:在以 0 为根的树中选 m+1 个节点,并且必须选根 0

定义:

text
dp[u][j] = 在 u 的子树中选 j 门课,并且必须选 u,能得到的最大学分

初始化:

text
dp[u][1] = credit[u]

处理一个孩子 v 时,把名额分给 v 的子树:

text
dp[u][j] = max(dp[u][j], dp[u][j-k] + dp[v][k])

这里的 k 表示从孩子 v 的子树中选多少门课。每个孩子子树相当于一组物品,合并过程就是树上的分组背包。

最终答案:

text
dp[0][m+1]

代码

cpp
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 305;
const int NEG = -1000000000;

int n, m;
int credit[MAXN];
vector<int> children[MAXN];
int dp[MAXN][MAXN]; // dp[u][j]:在 u 的子树中选 j 门课,并且必须选 u,能得到的最大学分。

void read_input() {
    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        int parent;
        cin >> parent >> credit[i];
        children[parent].push_back(i);
    }
}

void dfs(int u) {
    for (int j = 0; j <= m + 1; j++) {
        dp[u][j] = NEG;
    }

    // 若选择 u,至少占用 1 个名额。虚拟根 0 的学分为 0。
    dp[u][1] = credit[u];

    for (int idx = 0; idx < (int)children[u].size(); idx++) {
        int v = children[u][idx];
        dfs(v);

        // 树上背包:把总名额 j 分一部分 k 给孩子 v 的子树。
        for (int j = m + 1; j >= 1; j--) {
            for (int k = 1; k < j; k++) {
                if (dp[u][j - k] == NEG || dp[v][k] == NEG) {
                    continue;
                }
                dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
            }
        }
    }
}

void solve() {
    dfs(0);
    cout << dp[0][m + 1] << '\n';
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    read_input();
    solve();

    return 0;
}

复杂度

时间复杂度约为 O(nm2)O(nm^2),空间复杂度为 O(nm)O(nm)

总结

树上背包的关键是把“每个孩子子树选多少个”看成容量分配。

虚拟根能把森林问题统一成一棵树,避免对多个根单独处理。