Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

6.24 Parallel Courses II

Source: src/main/kotlin/google/SemesterScheduler.kt (+ CourseWithSemesterConstraint.kt, MinimumTimeToFinishBuildByKWorkers.kt — the greedy-fails note) Pattern: bitmask DP over taken courses · Core page

The Problem

n courses with prerequisites; take at most k per semester. Min semesters.

  • Constraints: n ≤ 15 (bitmask!), k ≤ n.

Examples

Input:  n = 4, dependencies = [[2,1],[3,1],[1,4]], k = 2
Output: 3   (sem 1: 2,3; sem 2: 1; sem 3: 4)

Intuition — the state is a bitmask of finished courses; pick any valid subset ≤ k

With n ≤ 15, the finished set fits in an Int/Long. DP over masks: minSemesters(finished) = 1 + min over all valid next subsets:

val allFinished = (1L shl n) - 1

fun minSemesters(finished: Long): Int = cache.getOrPut(finished) {
    when {
        finished == allFinished -> 0
        else -> {
            var availableMask = 0L
            for (i in 0 until n) {
                val isFinished = (finished and (1L shl i)) != 0L
                val prereqsMet = (finished and requirements[i]) == requirements[i]
                if (!isFinished && prereqsMet) availableMask = availableMask or (1L shl i)
            }

            val possibleCount = availableMask.countOneBits()

            when {
                possibleCount <= k -> 1 + minSemesters(finished or availableMask)   // take all
                else -> {
                    // enumerate subsets of availableMask of size <= k, take the best
                    1 + min over valid subsets of minSemesters(finished or subset)
                }
            }
        }
    }
}

Why bitmask? “Which courses are done” is the complete state — a Long bitmask makes it hashable and O(1)-comparable. The 17.4 bitmask-DP discipline (ch17 style).

Why enumerate subsets when availableCount > k? The semester cap forces a choice of which available courses to take — the optimal subset isn’t greedy (taking the most is NOT always best — that’s the repo’s MinimumTimeToFinishBuildByKWorkers comment: “greedy BFS won’t work, NP-complete DP required”).

Approach 1 — Greedy BFS per semester (wrong!)

Take any k available each round: fails — the repo’s own minTime notes it.

Approach 2 — Bitmask DP (the repo’s SemesterScheduler, optimal)

class SemesterScheduler(val n: Int, val k: Int, val requirements: LongArray) {
    private val cache = mutableMapOf<Long, Int>()
    private val allFinished = (1L shl n) - 1

    /**
     * @param finished bitmask of completed courses
     * @return        min semesters to finish the rest
     */
    fun minSemesters(finished: Long = 0L): Int = cache.getOrPut(finished) {
        when {
            finished == allFinished -> 0
            else -> {
                var availableMask = 0L
                for (i in 0 until n) {
                    val isFinished = (finished and (1L shl i)) != 0L
                    val prereqsMet = (finished and requirements[i]) == requirements[i]
                    if (!isFinished && prereqsMet) {
                        availableMask = availableMask or (1L shl i)
                    }
                }

                val possibleCount = availableMask.countOneBits()

                when {
                    possibleCount <= k -> 1 + minSemesters(finished or availableMask)
                    else -> {
                        var best = Int.MAX_VALUE
                        var subset = availableMask
                        while (subset > 0) {          // enumerate submasks
                            if (subset.countOneBits() <= k) {
                                best = minOf(best, 1 + minSemesters(finished or subset))
                            }
                            subset = (subset - 1) and availableMask
                        }
                        best
                    }
                }
            }
        }
    }
}
import java.util.*;

public class ParallelCoursesII {
    private Map<Integer, Integer> memo = new HashMap<>();
    private int n, k;
    private int[] prereqs;

    private int solve(int finished) {
        if (finished == (1 << n) - 1) return 0;
        if (memo.containsKey(finished)) return memo.get(finished);

        int available = 0;
        for (int i = 0; i < n; i++) {
            if ((finished & (1 << i)) == 0 && (finished & prereqs[i]) == prereqs[i]) {
                available |= (1 << i);
            }
        }

        if (Integer.bitCount(available) <= k) {
            return memo.put(finished, 1 + solve(finished | available));
        }

        int best = Integer.MAX_VALUE;
        for (int sub = available; sub > 0; sub = (sub - 1) & available) {
            if (Integer.bitCount(sub) <= k) {
                best = Math.min(best, 1 + solve(finished | sub));
            }
        }
        return memo.put(finished, best);
    }

    /**
     * @param n            course count
     * @param dependencies prerequisite pairs
     * @param k            semester cap
     * @return             min semesters
     */
    public int minNumberOfSemesters(int n, int[][] dependencies, int k) {
        this.n = n;
        this.k = k;
        prereqs = new int[n];

        for (int[] d : dependencies) {
            prereqs[d[1] - 1] |= (1 << (d[0] - 1));    // bit i = course i's prerequisite set
        }
        return solve(0);
    }
}
#include <vector>
#include <unordered_map>

class ParallelCoursesII {
    std::unordered_map<int, int> memo;
    int n, k;
    std::vector<int> prereqs;

    int solve(int finished) {
        if (finished == (1 << n) - 1) return 0;
        if (memo.count(finished)) return memo[finished];

        int available = 0;
        for (int i = 0; i < n; i++) {
            if ((finished & (1 << i)) == 0 && (finished & prereqs[i]) == prereqs[i]) {
                available |= (1 << i);
            }
        }

        if (__builtin_popcount(available) <= k) {
            return memo[finished] = 1 + solve(finished | available);
        }

        int best = INT_MAX;
        for (int sub = available; sub > 0; sub = (sub - 1) & available) {
            if (__builtin_popcount(sub) <= k) {
                best = std::min(best, 1 + solve(finished | sub));
            }
        }
        return memo[finished] = best;
    }

public:
    /**
     * @param n            course count
     * @param dependencies prerequisite pairs
     * @param k            semester cap
     * @return             min semesters
     */
    int minNumberOfSemesters(int n, std::vector<std::vector<int>>& dependencies, int k) {
        this->n = n;
        this->k = k;
        prereqs.assign(n, 0);

        for (auto& d : dependencies) {
            prereqs[d[1] - 1] |= (1 << (d[0] - 1));
        }
        return solve(0);
    }
};
from functools import lru_cache

def min_number_of_semesters(n: int, dependencies: list[list[int]], k: int) -> int:
    """
    @param n:            course count
    @param dependencies: prerequisite pairs
    @param k:            semester cap
    @return:             min semesters
    """
    prereqs = [0] * n
    for pre, course in dependencies:
        prereqs[course - 1] |= 1 << (pre - 1)

    all_finished = (1 << n) - 1

    @lru_cache(None)
    def solve(finished: int) -> int:
        if finished == all_finished:
            return 0

        available = 0
        for i in range(n):
            if (finished & (1 << i)) == 0 and (finished & prereqs[i]) == prereqs[i]:
                available |= 1 << i

        if available.bit_count() <= k:
            return 1 + solve(finished | available)

        best = float("inf")
        sub = available
        while sub:
            if sub.bit_count() <= k:
                best = min(best, 1 + solve(finished | sub))
            sub = (sub - 1) & available
        return best

    return solve(0)
#![allow(unused)]
fn main() {
use std::collections::HashMap;

impl Solution {
    /// @param n            course count
    /// @param dependencies prerequisite pairs
    /// @param k            semester cap
    /// @return             min semesters
    pub fn min_number_of_semesters(n: i32, dependencies: Vec<Vec<i32>>, k: i32) -> i32 {
        let n = n as usize;
        let mut prereqs = vec![0u32; n];
        for d in dependencies {
            prereqs[d[1] as usize - 1] |= 1 << (d[0] - 1);
        }

        let all = (1u32 << n) - 1;
        let mut memo: HashMap<u32, i32> = HashMap::new();

        fn solve(finished: u32, prereqs: &Vec<u32>, k: usize, n: usize,
                 all: u32, memo: &mut HashMap<u32, i32>) -> i32 {
            if finished == all { return 0; }
            if let Some(&v) = memo.get(&finished) { return v; }

            let mut available = 0u32;
            for i in 0..n {
                if finished & (1 << i) == 0 && finished & prereqs[i] == prereqs[i] {
                    available |= 1 << i;
                }
            }

            let result = if available.count_ones() as usize <= k {
                1 + solve(finished | available, prereqs, k, n, all, memo)
            } else {
                let mut best = i32::MAX;
                let mut sub = available;
                while sub > 0 {
                    if sub.count_ones() as usize <= k {
                        best = best.min(1 + solve(finished | sub, prereqs, k, n, all, memo));
                    }
                    sub = (sub - 1) & available;
                }
                best
            };
            memo.insert(finished, result);
            result
        }

        solve(0, &prereqs, k as usize, n, all, &mut memo)
    }
}
}

Dry run

Input: n = 4, dependencies = [[2,1],[3,1],[1,4]], k = 2.

prereqs: course 1 needs {2,3}, course 4 needs {1}.
solve(0): available = {2,3} (bits 1,2).  count 2 <= k=2 -> take both:
  1 + solve({2,3}).
solve({2,3}): available = {1} (prereqs met).  count 1 <= 2 -> take:
  1 + solve({2,3,1}).
solve({2,3,1}): available = {4}.  take: 1 + solve(all) = 1.
total: 3 ✓

The availableCount <= k shortcut (take everything) is the DP’s easy branch; the hard branch enumerates submasks when the cap binds. The repo’s MinimumTimeToFinishBuildByKWorkers is the same problem with the greedy-fails warning — the submask enumeration is the NP-hard core.

Complexity

Time. 2ⁿ masks × submask enumeration:

$$ T(n) = O(3^n) $$

Space. The memo:

$$ S(n) = O(2^n) $$

Variants & follow-ups

  • Course Schedule II (6.3) — the no-cap Kahn’s ancestor.
  • Travelling Salesman (17.4) — the bitmask-DP state idiom shared.
  • Interview follow-up: “Why is greedy wrong here?” Taking the most available courses can strand a critical prerequisite chain (the repo’s comment: “greedy BFS won’t work”). The subset enumeration explores the actual tradeoff — which available courses to defer — the NP-hard part the bitmask makes feasible at n ≤ 15.