6.3 Course Schedule II
Source:
src/main/kotlin/graph/topological_sort/CourseSchedule_II_BFS.ktPattern: Kahn’s topological sort · Core page
The Problem
There are numCourses courses labeled 0..numCourses-1. You’re given prerequisites[i] = [course, preReq], meaning preReq must be taken before course. Return any valid ordering of all courses, or an empty array if it’s impossible (a cycle exists).
- Constraints: $1 \le numCourses \le 2000$; no duplicate prerequisites.
Examples
Input: numCourses = 4, prerequisites = [[1,0],[2,0],[3,1],[3,2]]
Output: [0,1,2,3] or [0,2,1,3] (0 before 1&2, 1&2 before 3 — both valid)
Input: numCourses = 2, prerequisites = [[1,0]]
Output: [0,1]
Input: numCourses = 2, prerequisites = [[0,1],[1,0]] (cycle)
Output: []
Intuition — “who has nothing pending?” is a queue
Model courses as a directed graph: edge preReq -> course (“prereq unlocks course”). A valid ordering is a topological order — every edge points forward in the output. A topological order exists iff the graph is a DAG, so this one problem asks two things at once: find the order and detect the cycle.
Kahn’s algorithm is the BFS-flavored engine:
- Compute
inDegreefor every vertex (number of edges pointing at it). - Any vertex with
inDegree == 0has no remaining prerequisites — it’s ready. Seed a queue with all of them. - Pop a ready vertex, append it to the result, and “unlock” its outgoing edges: decrement each neighbor’s
inDegree; the moment one hits0, enqueue it.
The result accumulates exactly one topological order. At the end, if we processed fewer than numCourses vertices, some cycle never had an in-degree-0 member — return []. That single comparison is the entire cycle detector.
Why is the output a valid order? Every vertex enters the result only after all its prerequisites were already popped (that’s what inDegree == 0 means at pop time). So every edge preReq -> course has preReq earlier in the result. Invariant held, no extra proof needed.
Approach 1 — DFS with 3-color marking
Mark each vertex white/gray/black while DFS-ing; a gray back-edge means a cycle; append on finishing. $O(V+E)$ — correct, but the bookkeeping (three states, “is this gray?”) is fiddlier than Kahn’s and the code says less about why it works.
Approach 2 — Kahn’s algorithm (the repo’s version, optimal)
class CourseSchedule_II_BFS {
/**
* @param numCourses total number of courses
* @param prerequisites pairs [course, preReq]: preReq must come before course
* @return any valid course order, or an empty array if a cycle exists
*/
fun findOrder(numCourses: Int, prerequisites: Array<IntArray>): IntArray {
val graph = Array<MutableList<Int>>(numCourses) { mutableListOf() }
val inDegree = IntArray(numCourses)
val result = mutableListOf<Int>()
// Build the graph and calculate in-degrees
prerequisites.forEach { (course, preReq) ->
graph[preReq].add(course)
inDegree[course]++
}
// Initialize queue with courses having no prerequisites
val queue = ArrayDeque<Int>().apply {
inDegree.indices.filter { inDegree[it] == 0 }.forEach { add(it) }
}
// Perform BFS (Kahn's Algorithm)
while (queue.isNotEmpty()) {
queue.removeFirst().also {
result.add(it)
graph[it].forEach { neighbor ->
if (--inDegree[neighbor] == 0) queue.add(neighbor)
}
}
}
return if (result.size == numCourses) result.toIntArray() else intArrayOf()
}
}
import java.util.*;
public class CourseScheduleII {
/**
* @param numCourses total number of courses
* @param prerequisites pairs [course, preReq]: preReq must come before course
* @return any valid course order, or an empty array if a cycle exists
*/
public int[] findOrder(int numCourses, int[][] prerequisites) {
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < numCourses; i++) graph.add(new ArrayList<>());
int[] inDegree = new int[numCourses];
for (int[] pre : prerequisites) {
graph.get(pre[1]).add(pre[0]);
inDegree[pre[0]]++;
}
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < numCourses; i++) {
if (inDegree[i] == 0) queue.offer(i);
}
int[] result = new int[numCourses];
int written = 0;
while (!queue.isEmpty()) {
int course = queue.poll();
result[written++] = course;
for (int next : graph.get(course)) {
if (--inDegree[next] == 0) queue.offer(next);
}
}
return written == numCourses ? result : new int[0];
}
}
#include <queue>
#include <vector>
class CourseScheduleII {
public:
/**
* @param numCourses total number of courses
* @param prerequisites pairs [course, preReq]: preReq must come before course
* @return any valid course order, or an empty array if a cycle exists
*/
std::vector<int> findOrder(int numCourses, std::vector<std::vector<int>>& prerequisites) {
std::vector<std::vector<int>> graph(numCourses);
std::vector<int> inDegree(numCourses, 0);
for (auto& pre : prerequisites) {
graph[pre[1]].push_back(pre[0]);
inDegree[pre[0]]++;
}
std::queue<int> q;
for (int i = 0; i < numCourses; i++) {
if (inDegree[i] == 0) q.push(i);
}
std::vector<int> result;
while (!q.empty()) {
int course = q.front();
q.pop();
result.push_back(course);
for (int next : graph[course]) {
if (--inDegree[next] == 0) q.push(next);
}
}
return result.size() == (size_t)numCourses ? result : std::vector<int>{};
}
};
from collections import deque
def find_order(num_courses: int, prerequisites: list[list[int]]) -> list[int]:
"""
@param num_courses: total number of courses
@param prerequisites: pairs [course, pre_req]: pre_req must come before course
@return: any valid course order, or an empty array if a cycle exists
"""
graph = [[] for _ in range(num_courses)]
in_degree = [0] * num_courses
for course, pre_req in prerequisites:
graph[pre_req].append(course)
in_degree[course] += 1
queue = deque(i for i in range(num_courses) if in_degree[i] == 0)
result = []
while queue:
course = queue.popleft()
result.append(course)
for next_course in graph[course]:
in_degree[next_course] -= 1
if in_degree[next_course] == 0:
queue.append(next_course)
return result if len(result) == num_courses else []
#![allow(unused)]
fn main() {
use std::collections::VecDeque;
impl Solution {
/// @param num_courses total number of courses
/// @param prerequisites pairs [course, pre_req]: pre_req must come before course
/// @return any valid course order, or an empty array if a cycle exists
pub fn find_order(num_courses: i32, prerequisites: Vec<Vec<i32>>) -> Vec<i32> {
let n = num_courses as usize;
let mut graph = vec![Vec::new(); n];
let mut in_degree = vec![0i32; n];
for pre in prerequisites {
graph[pre[1] as usize].push(pre[0] as usize);
in_degree[pre[0] as usize] += 1;
}
let mut queue: VecDeque<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
let mut result = Vec::new();
while let Some(course) = queue.pop_front() {
result.push(course as i32);
for &next in &graph[course] {
in_degree[next] -= 1;
if in_degree[next] == 0 {
queue.push_back(next);
}
}
}
if result.len() == n { result } else { Vec::new() }
}
}
}
The full AlienDictionary_BFS.kt — the BFS twin of 6.8
class AlienDictionary_BFS {
fun alienOrder(words: Array<String>): String {
val graph = mutableMapOf<Char, HashSet<Char>>()
val inDegree = mutableMapOf<Char, Int>()
// Every letter is a node with in-degree 0 to start
words.forEach { word ->
word.forEach { char ->
inDegree.putIfAbsent(char, 0)
graph.putIfAbsent(char, hashSetOf())
}
}
// Adjacent word pairs reveal one edge each
for (i in 0 until words.size - 1) {
val currentWord = words[i]
val nextWord = words[i + 1]
val minLength = minOf(currentWord.length, nextWord.length)
for (j in 0 until minLength) {
val currentChar = currentWord[j]
val nextChar = nextWord[j]
if (currentChar != nextChar) {
if (graph[currentChar]!!.add(nextChar)) { // new edge?
inDegree[nextChar] = inDegree[nextChar]!! + 1
}
break
}
// Prefix contradiction: "abc" can't come before "ab"
if (j == minLength - 1 && currentWord.length > nextWord.length) return ""
}
}
// Kahn's with the filter-seeded queue
val queue = ArrayDeque<Char>().apply {
addAll(inDegree.filter { it.value == 0 }.keys)
}
val result = StringBuilder()
while (queue.isNotEmpty()) {
val char = queue.removeFirst()
result.append(char)
graph[char]?.forEach { neighbor ->
inDegree[neighbor] = inDegree[neighbor]!! - 1
if (inDegree[neighbor] == 0) queue.add(neighbor)
}
}
// Cycle check: all letters emitted?
return if (result.length == inDegree.size) result.toString() else ""
}
}
vs 6.8’s three-state DFS — this is the Kahn’s BFS twin: same edges, opposite traversal, and the cycle test is result.length == inDegree.size instead of a VISITING flag. The filter-seed is the one-line signature of the BFS family.
Dry run
Input: numCourses = 4, prerequisites = [[1,0],[2,0],[3,1],[3,2]]
graph: 0 -> [1,2], 1 -> [3], 2 -> [3], 3 -> []
inDegree: [0, 1, 1, 2]
queue: [0]
pop 0 -> result=[0]; unlock 1 (deg 1->0, enqueue), unlock 2 (deg 1->0, enqueue)
queue: [1,2]
pop 1 -> result=[0,1]; unlock 3 (deg 2->1, not ready)
queue: [2]
pop 2 -> result=[0,1,2]; unlock 3 (deg 1->0, enqueue)
queue: [3]
pop 3 -> result=[0,1,2,3]; no neighbors
queue: []
result.size == 4 == numCourses -> return [0,1,2,3] ✓ (valid: every prereq precedes its course)
With prerequisites = [[0,1],[1,0]]: both in-degrees are 1, the queue starts empty, result stays empty, 0 != 2 → return []. The cycle detector is literally “did we run out of ready vertices?”
Complexity
Time. Each vertex dequeued once; each edge examined once (when its source is popped):
$$ T(V, E) = O(V + E) $$
Space. Graph, in-degree array, queue, result:
$$ S(V, E) = O(V + E) $$
Variants & follow-ups
- Course Schedule I (
src/main/kotlin/graph/) — the same engine minus the output: just return whetherresult.size == numCourses. - Parallel Courses (
src/main/kotlin/graph/dp/ParallelCourses_II.kt) — topological order with levels: Kahn’s queue processes one level at a time (the fence from 5.2); minimum semesters = number of levels. - Alien Dictionary — topological sort where the graph is derived: compare adjacent words to infer letter precedence edges, then Kahn’s.
- DFS variant (
src/main/kotlin/graph/topological_sort/) — 3-color marking with finish-time appending; same $O(V+E)$, different flavor. The repo ships both. - Interview follow-up: “Why is the empty result the only failure signal?” A DAG with $V$ vertices always lets Kahn’s process all $V$ (every vertex eventually has all prerequisites popped). Only a cycle — where every member has a prerequisite inside the cycle — strands vertices at in-degree ≥ 1 forever. So
count == V⟺ no cycle. One comparison, complete answer.