题目描述

✅ 1494. 并行课程 II

image-20260928230303012

image-20260928230303013

image-20260928230303014

题意分析

每学期最多修 k 门课,一门课的全部先修课都必须在此前学期完成,求修完所有课程的最少学期数。题目保证依赖图无环,而且课程数不超过 15。

当前能选的课超过容量时,不同组合会解锁不同的后续课程,不能任意挑选。课程数较少,可以枚举已修课程集合和本学期的选择,用动态规划比较所有必要方案。

解法:状态压缩 DP 枚举本学期课程

核心思路

[!blue]

用位掩码 mask 表示已经完成的课程,第 i 位为 1 表示课程 i+1 已修完。pre[i] 是第 i+1 门课的先修集合。只有该课不在 mask 中,并且 (pre[i] & mask) == pre[i],它才能加入本学期的可选集合 available。前置条件只看旧 mask,不能使用本学期新选的课。

每学期只需要考虑选满当前能够选的数量,也就是 min(k, available 的大小) 门。若某个完整安排本学期还有空位,却把一门现在就能修的课留到以后,可以把这门课提前到空位:它的前置已经完成,原来所在学期还会腾出位置,其后继也不会被延后。因此总学期数不会增加,总存在一个最优安排采用这种选择方式。

所以可选课程不超过 k 门时全部修完;超过 k 门时,只枚举恰好 k 门的组合。选满名额是安全剪枝,具体选哪几门却仍可能影响后续安排,必须比较各个子集。

dp[mask] 记录按上述选择方式到达已修集合 mask 的最少学期数。未来能修什么只由已修集合决定,与此前顺序无关,因此到达同一集合的更慢方案可以丢弃。初始化 dp[0] = 0,其余设为 n+1;无环图总能按拓扑顺序每学期修一门,所以合法最终答案不会超过 n。

对当前选择的非空子集 subset,转移到 next = mask | subset,用 dp[mask]+1 更新 dp[next]。新选课程都不在旧集合中,next 的整数值一定大于 mask,所以按掩码从小到大遍历时,所有来源状态都会先于目标状态处理。

当需要挑选 k 门时,代码从 subset = available 开始,反复执行 subset = (subset-1) & available。减一会清掉最低的一个 1 并打开更低位,再与 available 相与去掉不允许的位,从而按递减顺序遍历所有非空子集;只对其中置位数等于 k 的子集转移。最后读取全部课程都置位的状态。

解题步骤

  1. 将课程编号转成从 0 开始的位编号,根据先修关系构造每门课的 pre。
  2. 建立 2^n 个状态,只有空集合的学期数为 0,其他状态初始化为 n+1。
  3. 按掩码递增遍历可达状态,找出所有未完成且前置齐全的课程。
  4. 可选数不超过 k 时选全部,否则枚举其中恰好 k 门的子集,更新完成集合的最少学期数。
  5. 返回 dp[(1<<n)-1]。没有可选课程的状态不再转移,完整集合也会在此结束。

代码实现

class Solution {
    public int minNumberOfSemesters(int n, int[][] relations, int k) {
        int[] pre = new int[n];

        for (int[] r : relations) {
            pre[r[1] - 1] |= 1 << (r[0] - 1);
        }

        int total = 1 << n;
        int[] dp = new int[total];

        Arrays.fill(dp, n + 1);
        dp[0] = 0;

        for (int mask = 0; mask < total; mask++) {
            if (dp[mask] == n + 1) {
                continue;
            }

            int available = 0;

            for (int i = 0; i < n; i++) {
                // 只选尚未完成且全部前置已在旧集合中的课程。
                if ((mask & (1 << i)) == 0 && (pre[i] & mask) == pre[i]) {
                    available |= 1 << i;
                }
            }

            if (available == 0) {
                continue;
            }

            // 容量足够就全部提前修完,不会阻碍后续课程。
            if (Integer.bitCount(available) <= k) {
                int next = mask | available;

                dp[next] = Math.min(dp[next], dp[mask] + 1);
                continue;
            }

            int subset = available;

            while (subset > 0) {
                if (Integer.bitCount(subset) == k) {
                    int next = mask | subset;

                    dp[next] = Math.min(dp[next], dp[mask] + 1);
                }

                // 枚举可选集合的子集,只转移其中恰好 k 门的方案。
                subset = (subset - 1) & available;
            }
        }

        return dp[total - 1];
    }
}
func minNumberOfSemesters(n int, relations [][]int, k int) int {
    pre := make([]int, n)
    for _, r := range relations {
        pre[r[1]-1] |= 1 << (r[0] - 1)
    }

    total := 1 << n
    dp := make([]int, total)
    for i := 0; i < total; i++ {
        dp[i] = n + 1
    }
    dp[0] = 0

    for mask := 0; mask < total; mask++ {
        if dp[mask] == n+1 {
            continue
        }

        available := 0
        for i := 0; i < n; i++ {
            // 只选尚未完成且全部前置已在旧集合中的课程。
            if (mask&(1<<i)) == 0 && (pre[i]&mask) == pre[i] {
                available |= 1 << i
            }
        }

        if available == 0 {
            continue
        }
        // 容量足够就全部提前修完,不会阻碍后续课程。
        if bitsCount(available) <= k {
            next := mask | available
            if dp[mask]+1 < dp[next] {
                dp[next] = dp[mask] + 1
            }
            continue
        }

        subset := available
        for subset > 0 {
            if bitsCount(subset) == k {
                next := mask | subset
                if dp[mask]+1 < dp[next] {
                    dp[next] = dp[mask] + 1
                }
            }
            // 枚举可选集合的子集,只转移其中恰好 k 门的方案。
            subset = (subset - 1) & available
        }
    }

    return dp[total-1]
}

func bitsCount(x int) int {
    count := 0
    for x > 0 {
        x &= x - 1
        count++
    }
    return count
}

复杂度分析

  • 时间复杂度:Java 为 $O(n2^n+3^n)$。每个状态检查 n 门课;枚举已修集合与不相交选择子集时,每门课最多有“已修、本学期选、不选”三种归属,所以总枚举数以 $3^n$ 为上界。当前 Go 的 bitsCount 每次最多清除 n 个置位,因此其上界为 $O(n2^n+n3^n)$。
  • 空间复杂度:$O(2^n+n)$。保存状态表和每门课的先修掩码。

关键点总结

[!green]

  • 必须保存课程集合,单纯的已修数量无法决定下一学期哪些课能选。
  • 所有先修课都要在旧状态中完成,同一学期内不能沿依赖链连续解锁。
  • 空余名额可以用当前可选课补满而不增加总学期数,但满额组合仍需要枚举。
  • 每次转移都增加新课程,掩码递增顺序就是合法的状态处理顺序。

易错点总结

[!yellow]

  • 只判断先修集合与 mask 有交集,会把仅完成部分前置的课程误判为可选。
  • 构造可选集合时不断加入本学期新选课程,会让有先后依赖的课程被安排在同一学期。
  • 可选数超过 k 时随意挑选,会漏掉能更早解锁后续课程的组合。
  • 没排除已经修过的课程,会浪费名额,并让转移不再严格增加完成集合。
  • 子集枚举忘记与 available 相与,会带入不可选课程;计数应是置位数,而不是掩码数值。

相似题目

题目 难度 关联与区别
210. 课程表 II 中等 拓扑可用节点只是当前候选,有限并行容量下任意选k门不一定最优,需要比较候选子集。
1136. 并行课程 中等 原题每学期可并行所有就绪课程,本题增加每期最多k门的限制。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2022/59624894
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!