LeetCode 210 - Course Schedule II
This problem asks us to determine a valid order in which courses can be completed given prerequisite relationships between them. You are given numCourses, which represents the total number of courses labeled from 0 to numCourses - 1.
Difficulty: 🟡 Medium
Topics: Depth-First Search, Breadth-First Search, Graph Theory, Topological Sort
Solution
Problem Understanding
This problem asks us to determine a valid order in which courses can be completed given prerequisite relationships between them.
You are given numCourses, which represents the total number of courses labeled from 0 to numCourses - 1. You are also given a list called prerequisites, where each entry [a, b] means that course b must be completed before course a.
In graph terminology, this forms a directed graph:
- Each course is a node.
- A prerequisite relationship is a directed edge.
- If
[a, b]exists, we draw an edgeb → a, meaningbmust come beforea.
The goal is to return one valid ordering of all courses such that every prerequisite requirement is satisfied. If multiple valid orderings exist, returning any one of them is acceptable. However, if no valid ordering exists because of a cycle in the dependency graph, we must return an empty array.
For example, if course 1 depends on course 0, then 0 must appear earlier in the output ordering. A valid answer for:
[[1,0],[2,0],[3,1],[3,2]]
could be:
[0,1,2,3]
or:
[0,2,1,3]
because both satisfy all prerequisite dependencies.
The constraints are important for selecting an efficient algorithm:
numCourses <= 2000- The number of prerequisite pairs can approach
numCourses * (numCourses - 1)
This means the graph can become fairly dense. A brute-force strategy that repeatedly checks permutations would be computationally infeasible. Instead, we need an algorithm that scales close to linear time relative to the number of nodes and edges.
The major edge cases include:
- No prerequisites, where every course is immediately available.
- Cycles, where completing all courses becomes impossible.
- Disconnected components, where independent groups of courses exist.
- Courses with no dependencies and no dependents, which still must appear in the result.
- Multiple valid answers, where any correct ordering should be accepted.
Approaches
Brute Force Approach
A brute-force approach would attempt to generate all possible permutations of courses and check whether each permutation satisfies all prerequisite constraints.
For every ordering, we could verify whether every prerequisite [a, b] satisfies the condition that b appears before a. The first valid ordering would be returned.
This approach is correct because it exhaustively searches the entire solution space. If a valid ordering exists, it will eventually be found.
However, the problem is computationally infeasible because there are n! possible permutations for n courses. Even for relatively small values of n, this becomes astronomically expensive.
For example:
10! = 3,628,800
15! = 1,307,674,368,000
Since numCourses can reach 2000, brute force is entirely impractical.
Key Insight
The important observation is that this problem is actually asking for a topological ordering of a directed graph.
A topological sort exists only if the graph is a Directed Acyclic Graph (DAG). If a cycle exists, then no valid ordering can satisfy all prerequisite requirements.
We can solve this efficiently using Kahn's Algorithm, which is a Breadth-First Search based topological sorting technique.
The idea is:
- Count how many prerequisites each course has, this is called the in-degree.
- Any course with in-degree
0can be taken immediately. - Once a course is completed, remove its outgoing dependencies by reducing the in-degree of dependent courses.
- Newly unlocked courses become available.
- Continue until all courses are processed.
If we process all numCourses, we found a valid ordering. Otherwise, a cycle prevented completion.
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n! × m) | O(n) | Tries every ordering and validates prerequisites |
| Optimal, Topological Sort (Kahn's Algorithm) | O(V + E) | O(V + E) | Processes each course and prerequisite once |
Here:
V = numCoursesE = len(prerequisites)
Algorithm Walkthrough
- Build the graph representation
Create an adjacency list where each course points to the courses that depend on it.
If [a, b] exists, then we add:
b → a
This direction matters because completing b unlocks a.
2. Compute in-degrees
Create an array called in_degree where in_degree[i] stores how many prerequisites course i still requires.
Every prerequisite edge increases the destination node's in-degree. 3. Initialize the queue
Add every course with in_degree == 0 into a queue.
These courses have no prerequisites, so they can be taken immediately. 4. Process courses using BFS
Repeatedly remove a course from the queue.
Add it to the result ordering because it is now considered completed.
For every dependent course:
- Reduce its in-degree by
1 - If its in-degree becomes
0, push it into the queue
This simulates unlocking new courses after prerequisites are completed. 5. Check for cycles
After processing, compare the result size with numCourses.
- If all courses were processed, return the ordering.
- Otherwise, a cycle exists, so return an empty list.
Why it works
The key invariant is that a course enters the queue only when all of its prerequisites have already been processed. This guarantees that when we append a course to the final ordering, every required dependency already appears earlier in the result.
If a cycle exists, no node in the cycle can ever reach in-degree 0, so some courses remain unprocessed. Detecting this condition allows us to correctly return an empty array.
Python Solution
from collections import deque
from typing import List
class Solution:
def findOrder(self, numCourses: int, prerequisites: List[List[int]]) -> List[int]:
graph = [[] for _ in range(numCourses)]
in_degree = [0] * numCourses
# Build graph and calculate in-degrees
for course, prereq in prerequisites:
graph[prereq].append(course)
in_degree[course] += 1
# Add all courses with no prerequisites
queue = deque()
for course in range(numCourses):
if in_degree[course] == 0:
queue.append(course)
order = []
# Perform BFS topological sort
while queue:
current_course = queue.popleft()
order.append(current_course)
for neighbor in graph[current_course]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
# Check if all courses were processed
return order if len(order) == numCourses else []
Implementation Explanation
The implementation begins by constructing the graph using an adjacency list. Since each prerequisite pair [a, b] means b must come before a, we store a inside graph[b].
At the same time, we maintain an in_degree array that counts how many prerequisites remain for every course.
We then initialize a queue containing all courses with zero prerequisites. These are immediately eligible to be taken.
The BFS loop repeatedly removes a course from the queue and adds it to the final ordering. Once a course is completed, we reduce the in-degree of all dependent courses. Any course whose in-degree reaches zero becomes newly available and enters the queue.
Finally, we validate whether every course was processed. If the result contains fewer than numCourses items, some courses were trapped inside a cycle, making completion impossible.
Go Solution
func findOrder(numCourses int, prerequisites [][]int) []int {
graph := make([][]int, numCourses)
inDegree := make([]int, numCourses)
// Build graph and calculate in-degrees
for _, prereq := range prerequisites {
course := prereq[0]
required := prereq[1]
graph[required] = append(graph[required], course)
inDegree[course]++
}
// Initialize queue with zero in-degree nodes
queue := make([]int, 0)
for course := 0; course < numCourses; course++ {
if inDegree[course] == 0 {
queue = append(queue, course)
}
}
order := make([]int, 0, numCourses)
index := 0
// BFS topological sort
for index < len(queue) {
currentCourse := queue[index]
index++
order = append(order, currentCourse)
for _, neighbor := range graph[currentCourse] {
inDegree[neighbor]--
if inDegree[neighbor] == 0 {
queue = append(queue, neighbor)
}
}
}
// Cycle detection
if len(order) != numCourses {
return []int{}
}
return order
}
Go-Specific Implementation Differences
The Go solution follows the exact same algorithm but differs slightly in implementation details.
Instead of using a deque structure, we simulate a queue with a slice and an index pointer. This avoids repeatedly removing elements from the front of a slice, which would be inefficient.
We use:
index := 0
to track the current front of the queue.
Go slices are dynamic, so appending newly unlocked courses is straightforward. Returning an empty result for cycles is done with:
return []int{}
instead of nil, which better matches LeetCode expectations.
Integer overflow is not a concern because the constraints remain well within Go's integer limits.
Worked Examples
Example 1
Input
numCourses = 2
prerequisites = [[1,0]]
Step 1: Build graph
| Course | Neighbors |
|---|---|
| 0 | [1] |
| 1 | [] |
In-degree array:
[0, 1]
Step 2: Initialize queue
Courses with in-degree 0:
queue = [0]
Step 3: BFS processing
| Step | Queue | Current | In-degree | Result |
|---|---|---|---|---|
| 1 | [0] | 0 | [0,0] | [0] |
| 2 | [1] | 1 | [0,0] | [0,1] |
Final result:
[0,1]
Example 2
Input
numCourses = 4
prerequisites = [[1,0],[2,0],[3,1],[3,2]]
Step 1: Graph
| Course | Neighbors |
|---|---|
| 0 | [1,2] |
| 1 | [3] |
| 2 | [3] |
| 3 | [] |
In-degree:
[0,1,1,2]
Queue:
[0]
Step 2: BFS processing
| Step | Queue | Current | In-degree | Result |
|---|---|---|---|---|
| 1 | [0] | 0 | [0,0,0,2] | [0] |
| 2 | [1,2] | 1 | [0,0,0,1] | [0,1] |
| 3 | [2] | 2 | [0,0,0,0] | [0,1,2] |
| 4 | [3] | 3 | [0,0,0,0] | [0,1,2,3] |
A valid ordering is:
[0,1,2,3]
Another equally valid ordering could be:
[0,2,1,3]
Example 3
Input
numCourses = 1
prerequisites = []
Graph:
[[]]
In-degree:
[0]
Queue:
[0]
Processing:
| Step | Queue | Current | Result |
|---|---|---|---|
| 1 | [0] | 0 | [0] |
Final answer:
[0]
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(V + E) | Every course and prerequisite edge is processed once |
| Space | O(V + E) | Graph storage, queue, in-degree array, and result list |
The adjacency list stores every prerequisite edge exactly once, giving O(E) space. The in-degree array, queue, and result list together require O(V) space. During BFS, each node enters the queue once and every edge is processed once, resulting in total linear complexity.
Test Cases
def is_valid_order(order, num_courses, prerequisites):
if len(order) != num_courses:
return False
position = {course: i for i, course in enumerate(order)}
for course, prereq in prerequisites:
if position[prereq] > position[course]:
return False
return True
solution = Solution()
# Example 1
result = solution.findOrder(2, [[1, 0]])
assert is_valid_order(result, 2, [[1, 0]]) # simple dependency
# Example 2
result = solution.findOrder(4, [[1,0],[2,0],[3,1],[3,2]])
assert is_valid_order(
result,
4,
[[1,0],[2,0],[3,1],[3,2]]
) # multiple valid orderings
# Example 3
assert solution.findOrder(1, []) == [0] # single course
# No prerequisites
result = solution.findOrder(5, [])
assert sorted(result) == [0,1,2,3,4] # any ordering works
# Cycle detection
assert solution.findOrder(2, [[0,1],[1,0]]) == [] # impossible schedule
# Larger cycle
assert solution.findOrder(3, [[0,1],[1,2],[2,0]]) == [] # three-node cycle
# Disconnected graph
result = solution.findOrder(6, [[1,0],[3,2]])
assert is_valid_order(result, 6, [[1,0],[3,2]]) # independent components
# Chain dependency
assert solution.findOrder(4, [[1,0],[2,1],[3,2]]) == [0,1,2,3] # linear chain
# Multiple independent starting nodes
result = solution.findOrder(4, [[2,0],[2,1]])
assert is_valid_order(result, 4, [[2,0],[2,1]]) # several zero in-degree nodes
| Test | Why |
|---|---|
2, [[1,0]] |
Validates simple dependency ordering |
4, [[1,0],[2,0],[3,1],[3,2]] |
Tests branching dependencies |
1, [] |
Minimum input size |
5, [] |
No prerequisites |
2, [[0,1],[1,0]] |
Detects cycle |
3, [[0,1],[1,2],[2,0]] |
Detects larger cycle |
6, [[1,0],[3,2]] |
Handles disconnected components |
4, [[1,0],[2,1],[3,2]] |
Linear dependency chain |
4, [[2,0],[2,1]] |
Multiple independent prerequisites |
Edge Cases
Cyclic Dependencies
A cycle is the most important edge case because it makes course completion impossible. For example:
0 → 1 → 0
Neither course can be taken first, since each depends on the other. A naive implementation might loop forever or return an invalid ordering. Our implementation detects this naturally because some courses never reach in-degree 0, meaning the final result length becomes smaller than numCourses.
Courses With No Prerequisites
If there are no prerequisite relationships, every course can be taken immediately. A fragile implementation might accidentally return only a subset of courses or assume dependencies exist. Since we initially enqueue every zero in-degree course, our algorithm correctly returns all courses.
Disconnected Components
The course graph may contain independent groups of courses. For example:
0 → 1
2 → 3
A naive DFS starting from a single node could miss entire sections of the graph. Our implementation iterates through all courses during queue initialization, ensuring every disconnected component is included.
Multiple Valid Orderings
Some inputs allow many valid schedules. For example:
[[2,0],[2,1]]
Both:
[0,1,2]
and
[1,0,2]
are correct. Our implementation does not force a unique answer. Instead, it returns whichever valid ordering naturally emerges from queue processing, which fully satisfies the problem requirements.