选课

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

加虚拟根 0 把课程森林变成树,用树形背包 dp[v][j](v 子树内选 j 个点且必选 v)逐儿子合并,答案取 dp[0][M+1],O(NM)。

OJ: roj

题目 ID: 3149

难度:提高

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

创建: 2026-10-01 21:07

更新: 2026-10-10 11:30

后置题目
luogu P4037[JSOI2008] 魔兽地图B 的 solve(u) 把 A 教的那一步——子树压成 dp 表后用分组背包逐儿子合并、容量维倒序分配——原样用于 f[u][j][c] 的合并(main.cpp 中 g[cost] 与 f[v][total*need][used] 的 cost 倒序循环),只是把名额维换成金币维并额外叠加「留 j 件给父亲」与枚举合成件数、森林合并。luogu P4516[JSOI2018] 潜入行动A 教的「子树压成 dp[v][j](子树内选 j 个点)表、把预算在儿子之间切分并逐儿子合并两个名额」正是 B 实际使用的步骤:B 的 f[u][j][sel][cov] 同样以子树内设备数为维度,main.cpp 在 limit_u/limit_v 与 i+j<=k 下把已合并部分名额 i 与儿子名额 j 相加(tmp[i+j]+=ways_u*ways_v),B 只在其上叠加 sel/cov 覆盖状态与计数乘法。luogu P3177[HAOI2015] 树上染色B 的树形背包复用 A 教的「子树压成 dp 表后逐儿子枚举分给儿子的名额做分组背包合并」这一步:B 的 dp[u][a+b]=max(dp[u][a+b], dp[u][a]+dp[v][b]+add) 与 A 的 dp[v][j+k]=max(dp[v][j+k], dp[v][j]+dp[u][k]) 同型(main.cpp 的 a/b 双层循环即该合并),A 用「选课程得学分」教模板,B 只在其上叠加「边两侧黑/白同色点对数 × 边权」的贡献与 sz 维,难度从提高升到提高+/省选-,构成台阶。luogu P1642规划B 的二分答案内层判定直接复用 A 的树上分组背包(dp[u][j] 在 u 子树选 j 个必含 u 的最大权值和、儿子间分组背包合并、j 倒序),再叠加 01 分数规划二分与重定义权值 w−x·c

形式化题目

给定 NN 门课程,每门课 ii 有一个直接先修课 pip_i(pi=0p_i = 0 表示无先修课)和一个正整数学分 wiw_i。 选出一个恰好 MM 门课的集合 SS,满足先修闭包条件

∀i∈S: pi=0 ∨ pi∈S,\forall i \in S:\ p_i = 0 \ \lor\ p_i \in S,

最大化总学分 ∑i∈Swi\sum_{i \in S} w_i。

「每门课的直接先修课最多只有一门」意味着每个点最多有一条指向父亲的边,因此先修关系无环, 构成一片森林(森林中的有根树以 pi=0p_i = 0 的课为根)。题目没有限制一门课可以被几门课后修, 所以树可以是任意形状。

将森林中所有根都挂到一个虚拟点 00(w0=0w_0 = 0)下,森林变成一棵以 00 为根的树 TT, 问题等价于:在 TT 中选恰好 M+1M + 1 个点、必须包含 00、且若选 vv 则选其父亲,最大化点权和。

题面样例:N=7N = 7,M=4M = 4,各课的先修课与学分为 p=[_, 2, 0, 0, 2, 7, 7, 2]p = [\_,\,2,\,0,\,0,\,2,\,7,\,7,\,2]、w=[_, 2, 1, 4, 1, 1, 6, 2]w = [\_,\,2,\,1,\,4,\,1,\,1,\,6,\,2] (下标 0 是占位,下标 ii 对应课号 ii)。

按先修关系建树(括号内为学分):

text
0(0)
├── 2(1)
│   ├── 1(2)
│   ├── 4(1)
│   └── 7(2)
│       ├── 5(1)
│       └── 6(6)
└── 3(4)

最优解是 S={2,3,6,7}S = \{2, 3, 6, 7\}:2、3 先修课为 0 可以直接选;7 的先修课 2 已在 SS 中; 6 的先修课 7 也在 SS 中。学分 1+4+6+2=131 + 4 + 6 + 2 = 13。

正解

思路

先问枚举能不能做。 候选方案是全部 (74)\binom{7}{4}(一般地 (NM)\binom{N}{M})个课程子集, 逐个检查先修闭包花 O(N)O(N)。N=300N = 300 时 (300150)≈1089\binom{300}{150} \approx 10^{89},枚举不可行。

直接背包为什么也不对。 一个自然的想法是「设 f[j]f[j] 为选 jj 门课的最大学分」, 逐个课程做 0/1 背包。但这个状态没有记录先修课有没有被选:样例中课程 6 的学分是 6、 先修课是 7,只记门数的背包会在 7 还没被选上的情况下就给 6 记账,得到虚高的答案。 这就是「有依赖的背包」必须比普通背包多带一层结构的原因——决策变量之间存在 树形偏序,而纯粹的容量维度表达不了它。

关键观察:约束只沿父子边,所以子树可以独立压缩。 固定节点 vv 之后:

  • vv 的各个儿子子树之间没有任何边(树里没有横叉边),彼此独立;
  • vv 的子树与外界(vv 的祖先)唯一的联系就是 vv 自己。

所以对 vv 的父亲而言,它不关心 vv 子树内部到底选了哪些点,只关心 「在这棵子树里花掉 jj 个名额,最多能拿多少学分」。这正好可以把子树压成一张关于 jj 的表:

dp[v][j]=在 v 的子树中选 j 个点(必含 v)的最大点权和dp[v][j] = \text{在 } v \text{ 的子树中选 } j \text{ 个点(必含 } v\text{)的最大点权和}

转移。 初值 dp[v][1]=wvdp[v][1] = w_v,其余为 −∞-\infty(名额连 vv 自己都装不下就没有合法方案)。 然后依次把每个儿子 uu 的子树合并进来,枚举分给 uu 的名额 kk 与已合并部分的现有名额 jj:

dp[v][ j+k ]←max⁡(dp[v][j+k], dp[v][j]+dp[u][k])dp[v][\,j + k\,] \leftarrow \max\bigl(dp[v][j+k],\ dp[v][j] + dp[u][k]\bigr)

对照 3148「没有上司的舞会」可以看得更清楚:那道题没有名额限制,父亲对儿子只需要一个布尔约束 (来 / 不来),所以每个点压成两档就够;本题有全局名额预算 MM, 父亲必须把预算在儿子之间切分,所以状态必须带一维「用掉多少名额」, 合并动作也就从「取 max⁡\max」变成「分组背包」。

计算顺序。 dp[v]dp[v] 依赖儿子,所以必须后序遍历(子在父前)。 本题 N⩽300N \leqslant 300,递归深度不大,但代码仍用显式栈求后序,不依赖解释器的递归上限。

两个容易写错的细节。

  1. jj 必须倒序枚举。 best 在这一轮里既是「合并前的旧表」又是「合并中的新表」, 正序会让同一个儿子被重复使用(同一子树的收益被计两次)。这与一维 01 背包滚动数组的方向完全同源。
  2. kk 从 1 开始。 dp[u][0]dp[u][0] 是非法档(选了 uu 才谈得上选它子树里的点), 而「完全不要 uu 的子树」根本不需要在 kk 循环里表达——它对应「本轮不更新 bestbest」, 原值自然保留。

样例推演。 后序处理顺序是 1,4,5,6,7,2,3,01, 4, 5, 6, 7, 2, 3, 0。每个叶子只有 dp[v][1]=wvdp[v][1] = w_v 一档有值, 下表给出每个节点合并完全部儿子后的完整 dpdp 表(预算 M+1=5M + 1 = 5,-∞ 表示该档不可达):

处理顺序 节点 学分 wvw_v dp[v][1]dp[v][1] dp[v][2]dp[v][2] dp[v][3]dp[v][3] dp[v][4]dp[v][4] dp[v][5]dp[v][5]
1 1 2 2 −∞-\infty −∞-\infty −∞-\infty −∞-\infty
2 4 1 1 −∞-\infty −∞-\infty −∞-\infty −∞-\infty
3 5 1 1 −∞-\infty −∞-\infty −∞-\infty −∞-\infty
4 6 6 6 −∞-\infty −∞-\infty −∞-\infty −∞-\infty
5 7 2 2 8 9 −∞-\infty −∞-\infty
6 2 1 1 3 9 11 12
7 3 4 4 −∞-\infty −∞-\infty −∞-\infty −∞-\infty
8 0 0 0 4 5 9 13

四个叶子(1、4、5、6)只有第 1 列有值,印证了 dp[leaf][1]=wleafdp[\text{leaf}][1] = w_{\text{leaf}} 的初值。 节点 2 那一行是本题的缩影:dp[2][1]=1dp[2][1] = 1(只能是 2 自己)、dp[2][2]=3dp[2][2] = 3(2 加上学分最大的儿子 1)、 dp[2][3]=9dp[2][3] = 9(子集 {2,7,6}\{2, 7, 6\},学分 1+2+61 + 2 + 6)、 dp[2][5]=12dp[2][5] = 12——注意 2 的子树共 6 个点,名额只有 5 个,且选的集合必须封闭, 所以只能丢掉一个叶子;背包丢掉学分最小的叶子(1 分的 4 或 5), 例如选 {2,1,7,5,6}\{2, 1, 7, 5, 6\} 得 1+2+2+1+6=121 + 2 + 2 + 1 + 6 = 12。 下面把节点 7(它有两个儿子 5、6)的合并过程逐格展开,看清单个儿子的子树是如何被并入的:

合并到 操作 j=1j=1 j=2j=2 j=3j=3 j=4j=4 j=5j=5
初值 dp[7][1]=w7dp[7][1] = w_7 2 −∞-\infty −∞-\infty −∞-\infty −∞-\infty
并入儿子 5 kk 只能取 1 2 3 −∞-\infty −∞-\infty −∞-\infty
并入儿子 6 kk 只能取 1 2 8 9 −∞-\infty −∞-\infty

读表要点:单元格的值是「花掉该列名额能拿到的最大点权和」。并入儿子 5 时, j=1j = 1 的旧值 2 与 k=1k = 1 的 dp[5][1]=1dp[5][1] = 1 合成新档 j=2j = 2 的 3; 并入儿子 6 时同理,2+6=82 + 6 = 8 落在 j=2j = 2(7 与 6)、3+6=93 + 6 = 9 落在 j=3j = 3(再加一个儿子 5)。 注意 dp[7][2]dp[7][2] 拿到的是 6 的 6 分而不是 5 的 1 分——说明名额分配由背包自己挑最优的一支, 不需要预先指定要哪个儿子。 每一步只允许「保留旧值」或「用掉 kk 个名额换 dp[u][k]dp[u][k]」,绝对不会把同一个儿子用两次—— 这就是 jj 倒序枚举的效果。

再看根节点 0 的合并:初始只有 dp[0][1]=0dp[0][1] = 0(根白占一格)。并入儿子 2 时 用上表 dp[2]dp[2] 那一行,得到 (0,1,3,9,11)(0, 1, 3, 9, 11);再并入儿子 3(dp[3][1]=4dp[3][1] = 4), 最终 (0,4,5,9,13)(0, 4, 5, 9, \mathbf{13})。答案 dp[0][5]=13=dp[2][3]+dp[3][1]=9+4dp[0][5] = 13 = dp[2][3] + dp[3][1] = 9 + 4, 即「根 0 白占一格 + 节点 2 的子树里选 3 个点(取到 {2,7,6}\{2, 7, 6\})+ 节点 3 自己」。 这正好是样例的最优解 S={2,3,6,7}S = \{2, 3, 6, 7\},验证了 dp[0][M+1]dp[0][M+1] 就是答案。

代码

read_courses 一边读入一边把「先修课 → 本课」建成孩子表(虚拟根 0 的孩子们就是所有无先修课的课); postorder 用显式栈产出「子在父前」的处理顺序;best_credits 里局部变量 best 就是 dp[v]dp[v], NEG 就是 −∞-\infty,内层双重循环就是上面那条分组背包转移式。

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-10 11:30
 * update_at: 2026-10-10 11:30
 */
// 选课:先修关系构成森林,虚拟根 0 连成树,树上背包求最高学分。
#include <cstdio>
#include <vector>

typedef long long ll;

const ll NEG = -1000000000LL; // 「这个门数凑不出来」的哨兵

ll n, m;
std::vector<std::vector<int> > children;
std::vector<ll> credits;
std::vector<std::vector<ll> > dp; // dp[v][j]:在 v 的子树里选 j 门课(必含 v)的最高学分
std::vector<ll> subtree;          // 子树节点数,用于裁剪背包循环

int main() {
    if (scanf("%lld %lld", &n, &m) != 2) return 0; // 空输入安全返回
    children.assign(n + 1, std::vector<int>());
    credits.assign(n + 1, 0);
    for (ll course = 1; course <= n; course++) {
        int prereq;
        scanf("%d %lld", &prereq, &credits[course]);
        children[prereq].push_back((int)course); // 先修课就是父亲
    }

    ll budget = m + 1; // 虚拟根 0 也占一门课的预算

    // 后序遍历(显式栈,避免深递归)
    std::vector<int> order;
    std::vector<int> stk;
    stk.push_back(0);
    while (!stk.empty()) {
        int u = stk.back();
        stk.pop_back();
        order.push_back(u);
        for (size_t j = 0; j < children[u].size(); j++) stk.push_back(children[u][j]);
    }

    dp.assign(n + 1, std::vector<ll>());
    subtree.assign(n + 1, 0);
    for (size_t t = order.size(); t-- > 0;) {
        int node = order[t];
        std::vector<ll> best(budget + 1, NEG);
        if (budget >= 1) best[1] = credits[node]; // 先备好「只选 node 自己」
        ll used = 1; // 已合并部分最多能贡献的门数
        for (size_t c = 0; c < children[node].size(); c++) {
            int child = children[node][c];
            ll sub_cap = subtree[child] < budget ? subtree[child] : budget;
            for (ll j = (used < budget ? used : budget); j >= 1; j--) { // j 倒序扫
                if (best[j] == NEG) continue;
                ll kmax = (sub_cap < budget - j) ? sub_cap : (budget - j);
                for (ll k = 1; k <= kmax; k++) {
                    ll cand = best[j] + dp[child][k];
                    if (cand > best[j + k]) best[j + k] = cand;
                }
            }
            used += sub_cap;
            if (used > budget) used = budget;
        }
        subtree[node] = used;
        dp[node] = best;
    }

    printf("%lld\n", dp[0][budget]);
    return 0;
}
python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 21:08
# update_at: 2026-10-01 21:20

import sys
from collections.abc import Iterator

NEG = -10**9  # 「这个门数凑不出来」的哨兵;学分都是正整数,不会和真实答案混淆


def read_courses(data: Iterator[int], n: int) -> tuple[list[list[int]], list[int]]:
    """读 N 行「先修课 学分」,返回孩子表与学分表(课程编号 1..N)。

    「每门课的直接先修课最多只有一门」意味着 {先修课 -> 本课} 是一张森林;
    把虚拟根 0 当成所有无先修课的课的父亲,森林就变成以 0 为根的树。
    """
    children: list[list[int]] = [[] for _ in range(n + 1)]
    credits: list[int] = [0] * (n + 1)
    for course in range(1, n + 1):
        prereq = next(data)              # 本课的直接先修课,0 表示没有
        credits[course] = next(data)
        children[prereq].append(course)  # 先修课就是父亲
    return children, credits


def postorder(children: list[list[int]], root: int) -> Iterator[int]:
    """后序遍历整棵树:显式栈先压出「父在子前」的序列,反转就是「子在父前」。"""
    order: list[int] = []
    stack = [root]
    while stack:
        node = stack.pop()
        order.append(node)
        stack += children[node]
    yield from reversed(order)


def best_credits(children: list[list[int]], credits: list[int], m: int) -> int:
    """树上背包:dp[v][j] = 在 v 的子树里选 j 门课(必含 v)能拿到的最高学分。

    先修关系要求「选一门课就得选它的先修课」,于是 v 被选了,儿子们才有资格选;
    反过来,儿子子树之间互不影响,可以用「按门数合并」的背包式子拼起来。
    虚拟根 0 也占一门课的预算,所以总预算取 M+1,答案落在 dp[0][M+1]。
    """
    budget = m + 1
    dp: list[list[int]] = [[] for _ in range(len(children))]
    subtree = [0] * len(children)  # 子树节点数被 min(·, budget) 封顶,用于裁剪背包循环
    for node in postorder(children, 0):
        best = [NEG] * (budget + 1)
        best[1] = credits[node]  # 先备好「只选 node 自己」这一档
        used = 1                 # 已合并部分最多能贡献的门数
        for child in children[node]:
            sub, sub_cap = dp[child], min(subtree[child], budget)
            for j in range(min(used, budget), 0, -1):  # j 倒序扫,避免同一个儿子被选两次
                if best[j] == NEG:
                    continue
                for k in range(1, min(sub_cap, budget - j) + 1):
                    cand = best[j] + sub[k]
                    if cand > best[j + k]:
                        best[j + k] = cand
            used = min(used + sub_cap, budget)
        subtree[node] = used
        dp[node] = best
    return dp[0][budget]


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    n, m = next(data), next(data)
    children, credits = read_courses(data, n)

    print(best_credits(children, credits, m))


if __name__ == "__main__":
    solve()

复杂度

  • 时间复杂度:O(NM)O(NM)。建树与求后序各 O(N)O(N);背包合并的瓶颈是「已合并部分名额 jj」乘 「分给儿子 uu 的名额 kk」。代码用 used 把 jj 限制在已合并节点数内、用 sub_cap 把 kk 限制在儿子子树大小内,于是每一步相乘对应一对「已并入的点 ×\times 新并入的点」, 每对点只在它们的最小公共祖先处被计一次,总量为 O(NJ)=O(NM)O(NJ) = O(NM)(J=M+1J = M+1)。 实测 N=300N = 300 的链、完全二叉树、星形等最坏结构下内层比较约 104∼4.5×10410^4 \sim 4.5 \times 10^4 次, 单次运行约 0.02s,远低于 1000ms 限制。
  • 空间复杂度:O(NM)O(NM)。每个节点存一张长度 M+2M + 2 的表;N⩽300N \leqslant 300 时 峰值内存约 15MB,低于 128MB 限制。

总结

本题是树上背包(也叫「有依赖的背包」)的模板题,核心有三步:

  1. 把森林变成树:给所有无先修课的课加一个学分为 00 的虚拟根 00, 「选 MM 门课」就变成「在以 00 为根的树里选 M+1M + 1 个点、含根、且选了点就选其父亲」。
  2. 确认子树可独立压缩:约束只沿父子边,子树与外界只有根一个接触点, 所以每个子树可以压成一张「花 jj 个名额的最大收益」表 dp[v][j]dp[v][j]。
  3. 子树之间做分组背包:给 vv 的预算要分给各个儿子,合并时 jj 倒序、kk 从 1 开始, 答案取 dp[0][M+1]dp[0][M+1]。

值得记住的对照是:没有名额限制的树形 DP(两档状态)和有依赖的背包(带容量维)差别正在预算。 一旦父亲需要把预算在儿子之间切分,「每点两档」就不够用了。

图示解析

这张图串起从题意到答案的主线:

text
输入:N 门课,各自有「直接先修课」(最多一个)与学分
  |
  |  枚举 C(N,M) 个子集不可行(N=300)
  v
关键结构:每点最多一条指向父亲的边 -> 先修关系是一片森林
  |
  |  加虚拟根 0(学分 0),把森林变成一棵以 0 为根的树
  v
关键观察:约束只沿父子边
  |- 各儿子子树之间没有边,彼此独立
  `- 子树与外界的唯一联系是 v 自己
        |
        v
  每个 v 压成一张表:dp[v][j] = v 子树里选 j 个点(含 v)的最大点权和
        |
        |  初值 dp[v][1] = w_v
        |  合并儿子 u:dp[v][j+k] = max(dp[v][j+k], dp[v][j] + dp[u][k])
        |             j 倒序防重用;k >= 1(选了 u 才谈得上它子树)
        v
  计算顺序:后序遍历,儿子先于父亲(显式栈,不依赖递归上限)
        |
        |  预算 = M+1(虚拟根占一格)
        v
  答案 dp[0][M+1]                 O(NM) 时间、O(NM) 空间

观察重点:整条链路上真正的转折点只有一处——把「全局选一个子集」拆成「每个子树独立算一张表」。 能做到这一步,靠的是「每门课的直接先修课最多只有一门」这条限制带来的树形结构; 如果允许一门课有多个先修课,关系就变成一般 DAG,「取一个点必须取其全部前驱」不再是树上的父子约束, 本做法不再适用。最后答案之所以落在 dp[0][M+1]dp[0][M+1] 而不是某个真实课程上, 是因为虚拟根只是记账用的,它占掉的那一个名额正好把「MM 门课」换算成「M+1M+1 个点」。