本题改编自《数据结构与算法专题实验》第10–11页实验六。OJ: * 学期教学计划 - 题目详情 - Endermite OJ
一道典型的状态压缩dp题目. 题目如下:
学期教学计划-题目
一个专业有 n 门课程和 s 个学期。课程各占一个学期,每门课程有正整数学分;每学期总学分不能超过 c。所有直接先修课程都必须在本课程之前的学期完成。
给定一种策略,输出任意一个最优教学计划:
B(均匀):令第i学期的总学分为L_i,最小化L_1²+...+L_s²。F(前置):最大化向量(L_1,...,L_s)的字典序。即先尽量增加第1学期学分;第1学期相同时再比较第2学期,以此类推。
空学期的学分为0,两个策略均允许空学期。每门课程必须且只能安排一次。
从 curriculum.in 读取输入,将答案写入 curriculum.out。
输入格式
第一行是 n s c mode,mode 是 B 或 F。
随后 n 行,第 i 行为:
id_i credit_i k_i pre_1 ... pre_k
课程号是恰好3位ASCII字母或数字组成的字符串,区分大小写;n 个课程号互不相同。k_i 是本课程的直接先修课程数,同一行的先修号不重复。先修号可能没有对应课程,此时应报告 INVALID。若存在未知先修号,无论其它条件如何均输出 INVALID。
输出
若存在未知先修号,输出 INVALID。
否则,若不存在满足学分和先修条件的计划,输出 IMPOSSIBLE。先修关系有环、自身先修、单课学分超过上限等均可能导致无解。
否则,第一行输出 OK;第二行输出 n 个整数,依次表示输入中各门课程安排的学期,编号为 1..s。存在多个最优计划时输出任意一个,评测器会验证可行性及所选策略的最优性。
范围
1 ≤ n ≤ 12,1 ≤ s ≤ 12,1 ≤ c ≤ 100,1 ≤ credit_i ≤ 100,0 ≤ k_i ≤ 12。
样例
输入数据 1
3 3 5 B
A01 2 0
B02 3 0
C03 2 1 A01
输出数据 1
OK
1 2 3
该计划的学期学分为 (2,3,2),平方和为17,达到最小值。1 3 2 也是合法最优答案。
相比原题的改进
- 目标更明确。 原题要求“尽量均匀”和“尽可能集中在前几个学期”,本题分别用学分平方和最小、学分向量字典序最大来定义,使方案优劣有明确的比较标准。
- 更便于自动评测。 统一输入输出格式,明确
INVALID与IMPOSSIBLE的含义和优先级,并接受任意最优计划,兼顾判定的一致性与答案的多样性。 - 更聚焦精确优化。 将原题建议的课程数小于 100 收紧为不超过 12,便于用状态压缩 DP 求出全局最优,突出建模与最优性分析,更适合作为算法训练题。
样例解读
总学分为 \(7\),分到三个学期时,整数意义下最均匀的分配是 \(2,2,3\),因此平方和不可能小于 \(17\)。上述安排满足先修关系和学分上限,所以达到最优值。
如果只把样例的策略改为 F,则可以在第 1 学期修 A01 和 B02,第 2 学期修 C03,得到 \((5,2,0)\)。第 1 学期已达到上限 \(5\),剩下的 \(2\) 学分也尽早完成,因而这是前置策略下的最优方案。

Go 的文件输入输出
实验题使用文件输入输出。下面分别说明文件的打开与关闭、缓冲读写器的创建,以及输入输出函数的使用。
打开输入、输出文件,并用 defer 安排函数返回前的资源处理:
in, err := os.Open("curriculum.in")
if err != nil {
panic(err)
}
out, err := os.Create("curriculum.out")
if err != nil {
panic(err)
}
defer in.Close()
defer out.Close()
defer writer.Flush()
defer 会在当前函数返回前执行,多个 defer 按后注册、先执行的顺序调用。因此,退出时依次执行 writer.Flush()、out.Close()、in.Close():先把缓冲区中尚未写出的内容提交给输出文件,再关闭文件。
缓冲读写器由下面两行创建。组合这些片段时,将它们放在 defer writer.Flush() 之前;文末完整代码展示了相应顺序。
reader := bufio.NewReader(in)
writer := bufio.NewWriter(out)
fmt.Fscan 和 fmt.Fprint 系列函数通过第一个参数指定输入来源或输出目标。这里传入 reader 或 writer,例如:
fmt.Fscan(reader, &n, &s, &c, &mode)
用位掩码表示课程集合
动态规划需要记录“已经修完哪些课”。每门课只有已修和未修两种状态,正好可以用一个二进制位表示。
我们按输入顺序给课程编号 \(0,1,\ldots,n-1\),用整数 mask 的第 \(j\) 位表示第 \(j\) 门课程是否已修:
- 第 \(j\) 位为
1:已修。 - 第 \(j\) 位为
0:未修。
位编号从最低位开始,第 0 位位于二进制表示的最右侧。 例如,样例中的 A01、B02、C03 分别对应第 0、1、2 位,mask = 0b011 就表示已经修完 A01 和 B02。

位运算可以直接完成集合操作:按位与用于检查某一位,按位或用于将指定位置为 1,Go 的按位清除运算符 &^ 则可将指定位置为 0。
总共有 \(2^n\) 种课程集合。即使 \(n=12\),也只有 \(4096\) 种状态,适合直接作为 DP 数组的下标。空集为 0;完整代码中用 total 表示状态总数,用 fullMask 表示全部课程组成的集合。
输入与预处理
先读课程,再解析先修关系
课程号是字符串,位运算需要整数编号,因此用 map[string]int 建立课程号到输入下标的映射。
这里需要分两步处理:读入时保存先修课程号,所有课程读完后,再把先修课程号转换为位掩码。 因为一门课的先修课程可能出现在输入的后面,不能在读到它时就判断课程号是否未知。
idMap := make(map[string]int)
ids := make([]string, n)
credits := make([]int, n)
preMask := make([]int, n)
preIds := make([][]string, n)
for i := 0; i < n; i++ {
fmt.Fscan(reader, &ids[i], &credits[i], &preCount)
idMap[ids[i]] = i
preMask[i] = 0
for j := 0; j < preCount; j++ {
var tempPreCourse string
fmt.Fscan(reader, &tempPreCourse)
preIds[i] = append(preIds[i], tempPreCourse)
}
}
用 preMask[i] 表示第 \(i\) 门课程的直接先修课程集合。如果它要求先修第 \(j\) 门课程,就将第 \(j\) 位置为 1:
// Mask init
for i := 0; i < n; i++ {
for _, preId := range preIds[i] {
index, ok := idMap[preId]
if !ok {
fmt.Fprintln(writer, "INVALID")
return
}
preMask[i] |= 1 << index
}
}
样例中只有 C03 有先修课,因此 preMask[2] = 0b001,其余两门课的先修掩码为 0。未知课程号在这一步统一处理,之后才判断计划是否可行,满足 INVALID 的优先级要求。
预计算每个集合的学分和
两种策略都需要反复计算“本学期选课集合的总学分”。可以提前枚举所有集合,将结果存入 creditsForMask:
total := 1 << n
creditsForMask := make([]int, total)
for mask := 0; mask < total; mask++ { //credits init
for j := 0; j < n; j++ {
if mask&(1<<j) != 0 {
creditsForMask[mask] += credits[j]
}
}
}
预处理后,选课时可以直接查询集合的学分和。下文用 \(w(T)\) 表示集合 \(T\) 的学分和,即代码中的 creditsForMask[take]。
按学期进行动态规划
状态:前几个学期结束后,已经修完哪些课
两种策略共享相同的状态结构:
(i, mask)表示前 \(i\) 个学期已经结束,恰好修完mask中的课程。
学期数 \(i\) 决定还剩多少时间,mask 决定哪些课程已经完成、哪些课程可以继续修。未来的可选课程只与这两项有关,不需要保存完整的历史排课过程。
初始状态为 (0, 0),目标状态为 (s, fullMask)。同一个状态可能由多种排课方式到达;我们只保留其中目标值最优的一种。
转移:从可选课程中选出本学期的课程
在状态 (i, mask) 下,第 \(j\) 门课程能够安排到第 \(i+1\) 学期,当且仅当:
- 课程还没有修过:
mask & (1 << j) == 0。 - 所有直接先修课程都已经修完:
mask & preMask[j] == preMask[j]。
据此构造可选课程集合,代码中记为 availble:
availble := 0
for j := 0; j < n; j++ {
bit := 1 << j
if bit&mask == 0 && mask&preMask[j] == preMask[j] {
//not learned and learned all pre lesson
availble |= bit
}
}
先修条件必须用上一学期结束后的 mask 判断。 本学期新选的课程不能立刻用来解锁另一门课,否则就会把先修课和后续课安排在同一学期。

注意,可选课程集合只包含满足先修条件的候选课程,学分总和可能超过上限。真正选取的 take 应是它的某个子集,并且满足 creditsForMask[take] <= c。
可以用下面的写法枚举这个集合的所有子集:
for take := availble; ; take = (take - 1) & availble {
//here is your dp
if take == 0 {
break
}
}
take - 1 将最低的一个 1 变为 0,并将其右侧的位变为 1;再与 availble 按位与,就只保留候选集合中的位。这样能按数值递减的顺序遍历所有子集。例如 availble = 0b110 时,枚举顺序是 110 → 100 → 010 → 000。
必须枚举空集 take = 0,因为题目允许空学期。处理完空集后要立即退出;否则 (0 - 1) & availble 又会回到 availble,造成循环。
B 策略:保存最小平方和
定义 dp[i][mask] 为到达状态 (i, mask) 时,前 \(i\) 个学期的学分平方和的最小值。初始值为:
其余状态初始化为 INF,表示不可达。
选定合法的 take 后,令 nextMask = mask | take,状态转移为:
也就是在历史最优值的基础上,加上本学期总学分的平方。这里平方的是学分和,而不是把每门课的学分分别平方后相加。
只有目标值严格变优时才更新;目标值相同可以保留原方案,因为题目允许输出任意最优计划。
F 策略:保存字典序最大的学分向量
F 策略比较的是各学期的学分向量,而不是课程编号或课程顺序。因此,dp[i][mask] 保存到达该状态时,字典序最大的学分前缀 \((L_1,\ldots,L_i)\)。
由于最多有 12 个学期,代码使用 [12]int 保存向量。先把当前状态的向量复制到 candidate,再将本学期的学分写入 candidate[i]。向量下标从 0 开始,因此该位置对应第 \(i+1\) 学期。
Go 中数组赋值会复制整个数组,因此修改 candidate[i] 不会改动原状态。
比较候选向量与已有向量时,从前往后找到第一个不同的位置,该位置学分更大的方案更优。前面的学期一旦分出高下,后面的学期就不再影响比较结果。
向量全为零也可能是合法状态,例如前几个学期都为空,所以另外使用 reachable[i][mask] 标记状态是否可达。更新条件是:
- 目标状态尚不可达;或
- 目标状态可达,但候选向量的字典序更大。
不能只按“本学期学分最多”选定一组课程后就丢掉其他选择。不同选课集合会解锁不同的后续课程,仍需通过 mask 区分,并在最终能完成全部课程的方案中比较字典序。
为什么每个状态只保留一个最优值就够了
假设两种历史方案都到达 (i, mask)。它们剩余的学期数相同、已修课程相同,因此后续能采用的排课方案也相同。
- 对 B 策略,两种历史方案接上同一后续方案,会增加相同的平方和。历史值较大的方案不可能反超。
- 对 F 策略,若一个历史学分前缀的字典序更小,无论后面接上什么,都无法改变前面第一个不同位置的大小关系。
因此,可以舍弃同一状态下较差的历史方案。同时,算法枚举了每个状态下所有合法的选课子集,任何可行计划都能沿着相应转移到达终点,最优计划也不会遗漏。
判断可行性并还原方案
所有转移结束后,检查 (s, fullMask):B 策略看其值是否仍为 INF,F 策略看 reachable 是否为 false。终点不可达就输出 IMPOSSIBLE。
这里不必单独写拓扑排序来判环。环中的课程无法满足“全部先修课程已在之前学期完成”,因此无法通过合法转移修完;自身先修同理。单课学分超限、学期数不足等情况,也会表现为终点不可达。
题目要求输出具体排课,因此每次更新最优值时,还需用 route[i+1][nextMask] 记录该学期选取的课程集合 take。
从 (s, fullMask) 开始倒推:读取本学期的 take,把其中各门课程的答案记为当前学期;然后从 mask 中移除这些课程,再继续回溯上一学期。
回溯时,take 一定是当前 mask 的子集,异或会将本学期所选课程对应的位清零,恢复上一学期的课程集合。若本学期为空,mask 保持不变,但学期编号仍减一,因此同样能正确回溯。
实现亮点
- 全局字典序最优。 F 策略按已修课程集合保留最优学分前缀,兼顾后续可行性,避免逐学期贪心选课。
- 约束与求解统一。 先修条件直接融入状态转移,环、自身先修和学期不足统一由终点不可达识别,空学期也能自然处理。
- 减少无效枚举与重复计算。 只枚举可选课程的子集,并预存各集合的学分和。
- 回溯记录紧凑。
route只保存本学期的选课集合,通过异或即可恢复前驱状态。 - 输入处理完整。 先读完课程再解析先修号,支持向后引用,并保证
INVALID的判定优先级。 - 候选状态互不影响。 利用 Go 定长数组的值复制语义,修改候选学分向量不会影响原状态。
AC代码
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
in, err := os.Open("curriculum.in")
if err != nil {
panic(err)
}
defer in.Close()
out, err := os.Create("curriculum.out")
if err != nil {
panic(err)
}
defer out.Close()
reader := bufio.NewReader(in)
writer := bufio.NewWriter(out)
defer writer.Flush()
var n, s, c, preCount int
var mode string
fmt.Fscan(reader, &n, &s, &c, &mode)
idMap := make(map[string]int)
ids := make([]string, n)
credits := make([]int, n)
preMask := make([]int, n)
preIds := make([][]string, n)
for i := 0; i < n; i++ {
fmt.Fscan(reader, &ids[i], &credits[i], &preCount)
idMap[ids[i]] = i
preMask[i] = 0
for j := 0; j < preCount; j++ {
var tempPreCourse string
fmt.Fscan(reader, &tempPreCourse)
preIds[i] = append(preIds[i], tempPreCourse)
}
}
// Mask init
for i := 0; i < n; i++ {
for _, preId := range preIds[i] {
index, ok := idMap[preId]
if !ok {
fmt.Fprintln(writer, "INVALID")
return
}
preMask[i] |= 1 << index
}
}
// Main
if mode == "B" {
total := 1 << n
const INF = 1 << 30
//dp init
dp := make([][]int, s+1) // dp[i] stores information in semester i
creditsForMask := make([]int, total)
for mask := 0; mask < total; mask++ { //credits init
for j := 0; j < n; j++ {
if mask&(1<<j) != 0 {
creditsForMask[mask] += credits[j]
}
}
}
for i := 0; i <= s; i++ {
dp[i] = make([]int, total)
for mask := 0; mask < total; mask++ {
dp[i][mask] = INF
}
}
// Route init
route := make([][]int, s+1)
for i := 0; i <= s; i++ {
route[i] = make([]int, total)
}
dp[0][0] = 0 //begin
for i := 0; i < s; i++ {
for mask := 0; mask < total; mask++ {
if dp[i][mask] == INF {
continue //INF from last semester
}
availble := 0
for j := 0; j < n; j++ {
bit := 1 << j
if bit&mask == 0 && mask&preMask[j] == preMask[j] {
//not learned and learned all pre lesson
availble |= bit
}
}
for take := availble; ; take = (take - 1) & availble {
nextMask := mask | take
if creditsForMask[take] <= c {
sum := creditsForMask[take] * creditsForMask[take]
if dp[i+1][nextMask] > dp[i][mask]+sum {
dp[i+1][nextMask] = dp[i][mask] + sum
route[i+1][nextMask] = take
}
//dp[i+1][nextMask] = min(dp[i+1][nextMask], dp[i][mask]+creditsForMask[mask]*creditsForMask[mask])
}
if take == 0 {
break
}
}
}
}
fullMask := total - 1
if dp[s][fullMask] == INF {
fmt.Fprintln(writer, "IMPOSSIBLE")
return
}
semesters := make([]int, n)
mask := fullMask
for i := s; i >= 1; i-- {
take := route[i][mask]
for j := 0; j < n; j++ {
if take&(1<<j) != 0 {
semesters[j] = i
}
}
mask ^= take
}
//print Ans
fmt.Fprintln(writer, "OK")
for i := 0; i < n; i++ {
fmt.Fprintf(writer, "%v ", semesters[i])
}
}
if mode == "F" {
total := 1 << n
//init
dp := make([][][12]int, s+1) // dp[i] stores information in semester i
reachable := make([][]bool, s+1)
route := make([][]int, s+1)
creditsForMask := make([]int, total)
for mask := 0; mask < total; mask++ { //credits init
for j := 0; j < n; j++ {
if mask&(1<<j) != 0 {
creditsForMask[mask] += credits[j]
}
}
}
for i := 0; i <= s; i++ {
dp[i] = make([][12]int, total)
reachable[i] = make([]bool, total)
route[i] = make([]int, total)
}
reachable[0][0] = true //begin
for i := 0; i < s; i++ {
for mask := 0; mask < total; mask++ {
if !reachable[i][mask] {
continue //unreachable from last semester
}
availble := 0
for j := 0; j < n; j++ {
bit := 1 << j
if bit&mask == 0 && mask&preMask[j] == preMask[j] {
//not learned and learned all pre lesson
availble |= bit
}
}
for take := availble; ; take = (take - 1) & availble {
nextMask := mask | take
if creditsForMask[take] <= c {
nowBetter := false
candidate := dp[i][mask]
candidate[i] = creditsForMask[take]
if reachable[i+1][nextMask] == false {
//Need to record
nowBetter = true
} else {
for k := 0; k <= i; k++ {
if candidate[k] != dp[i+1][nextMask][k] {
nowBetter = candidate[k] > dp[i+1][nextMask][k]
break
}
}
}
if nowBetter {
dp[i+1][nextMask] = candidate
reachable[i+1][nextMask] = true
route[i+1][nextMask] = take
}
}
if take == 0 {
break
}
}
}
}
fullMask := total - 1
if !reachable[s][fullMask] {
fmt.Fprintln(writer, "IMPOSSIBLE")
return
}
semesters := make([]int, n)
mask := fullMask
for i := s; i >= 1; i-- {
take := route[i][mask]
for j := 0; j < n; j++ {
if take&(1<<j) != 0 {
semesters[j] = i
}
}
mask ^= take
}
//print Ans
fmt.Fprintln(writer, "OK")
for i := 0; i < n; i++ {
fmt.Fprintf(writer, "%v ", semesters[i])
}
}
}

Comments NOTHING