252 lines
11 KiB
YAML
252 lines
11 KiB
YAML
title: Course Schedule
|
|
slug: course-schedule
|
|
difficulty: medium
|
|
leetcode_id: 207
|
|
leetcode_url: https://leetcode.com/problems/course-schedule/
|
|
categories:
|
|
- graphs
|
|
patterns:
|
|
- dfs
|
|
- bfs
|
|
|
|
description: |
|
|
There are a total of `numCourses` courses you have to take, labeled from `0` to `numCourses - 1`. You are given an array `prerequisites` where `prerequisites[i] = [a`<sub>`i`</sub>`, b`<sub>`i`</sub>`]` indicates that you **must** take course `b`<sub>`i`</sub> first if you want to take course `a`<sub>`i`</sub>.
|
|
|
|
For example, the pair `[0, 1]` indicates that to take course `0` you have to first take course `1`.
|
|
|
|
Return `true` *if you can finish all courses*. Otherwise, return `false`.
|
|
|
|
constraints: |
|
|
- `1 <= numCourses <= 2000`
|
|
- `0 <= prerequisites.length <= 5000`
|
|
- `prerequisites[i].length == 2`
|
|
- `0 <= a`<sub>`i`</sub>`, b`<sub>`i`</sub>` < numCourses`
|
|
- All the pairs `prerequisites[i]` are **unique**
|
|
|
|
examples:
|
|
- input: "numCourses = 2, prerequisites = [[1,0]]"
|
|
output: "true"
|
|
explanation: "There are a total of 2 courses to take. To take course 1 you should have finished course 0. So it is possible."
|
|
- input: "numCourses = 2, prerequisites = [[1,0],[0,1]]"
|
|
output: "false"
|
|
explanation: "There are a total of 2 courses to take. To take course 1 you should have finished course 0, and to take course 0 you should also have finished course 1. So it is impossible."
|
|
|
|
explanation:
|
|
intuition: |
|
|
Imagine you're planning which courses to take in university. Some courses have prerequisites — you can't take Advanced Calculus without first completing Calculus 101. The question is: given all these dependency rules, is there a valid order to take all courses?
|
|
|
|
This is fundamentally a **cycle detection problem** in a directed graph. Each course is a node, and each prerequisite creates a directed edge from the required course to the dependent course. If you can find an ordering where all prerequisites are satisfied, that ordering is called a **topological sort**.
|
|
|
|
The key insight is: **a valid ordering exists if and only if the graph has no cycles**. Why? If there's a cycle like A → B → C → A, then A requires B, B requires C, and C requires A — an impossible circular dependency where no course can be taken first.
|
|
|
|
Think of it like this: you're trying to complete tasks where some tasks depend on others. If task A depends on B, and B depends on A, you're stuck in an infinite waiting loop — neither can start.
|
|
|
|
approach: |
|
|
We can solve this using **DFS with cycle detection** (also known as detecting back edges). The idea is to track the state of each node during traversal:
|
|
|
|
**Step 1: Build the adjacency list**
|
|
|
|
- Create a graph where `graph[course]` contains all courses that depend on `course`
|
|
- For each prerequisite `[a, b]`, add an edge from `b` to `a` (course `b` must be taken before course `a`)
|
|
|
|
|
|
|
|
**Step 2: Set up state tracking**
|
|
|
|
- `WHITE (0)`: Not visited yet
|
|
- `GRAY (1)`: Currently being processed (in the current DFS path)
|
|
- `BLACK (2)`: Completely processed (all descendants visited)
|
|
|
|
|
|
|
|
**Step 3: DFS from each unvisited node**
|
|
|
|
- Mark the current node as `GRAY` (we're exploring it)
|
|
- Visit all its neighbours recursively
|
|
- If we encounter a `GRAY` node, we've found a cycle — return `False`
|
|
- After exploring all neighbours, mark the node as `BLACK`
|
|
- If we complete without finding a cycle, return `True`
|
|
|
|
|
|
|
|
**Step 4: Return the result**
|
|
|
|
- If DFS completes for all nodes without detecting a cycle, return `True`
|
|
- Otherwise, return `False`
|
|
|
|
|
|
|
|
The three-colour approach is crucial: `GRAY` nodes indicate "we're currently on this path", so encountering a `GRAY` node means we've looped back — a cycle.
|
|
|
|
common_pitfalls:
|
|
- title: Confusing "Visited" with "In Current Path"
|
|
description: |
|
|
A simple visited set isn't enough for cycle detection in directed graphs. Consider:
|
|
```
|
|
A → B → C
|
|
A → C
|
|
```
|
|
When exploring A → C directly, node C might already be visited from the A → B → C path. But that's not a cycle!
|
|
|
|
The key distinction is:
|
|
- **Visited (BLACK)**: We've fully explored this node and all its descendants — safe to skip
|
|
- **In current path (GRAY)**: We're currently exploring this node — encountering it again means a cycle
|
|
|
|
Using only a visited set would incorrectly report cycles or miss them entirely.
|
|
wrong_approach: "Single visited set for all nodes"
|
|
correct_approach: "Three-state tracking (WHITE/GRAY/BLACK)"
|
|
|
|
- title: Wrong Edge Direction
|
|
description: |
|
|
The prerequisite format `[a, b]` means "to take `a`, you must first take `b`". This creates a dependency edge from `b` to `a`.
|
|
|
|
If you build the graph with edges pointing the wrong direction, your cycle detection will still work, but the semantics will be inverted.
|
|
|
|
For this problem, either direction works for cycle detection, but getting it right matters for follow-up problems like Course Schedule II where you need the actual ordering.
|
|
wrong_approach: "Adding edge from a to b"
|
|
correct_approach: "Adding edge from b to a (prerequisite points to dependent)"
|
|
|
|
- title: Not Checking All Components
|
|
description: |
|
|
The graph might be disconnected — some courses may have no prerequisites and no dependents. If you only start DFS from one node, you might miss cycles in other components.
|
|
|
|
Always iterate through all nodes and run DFS on any unvisited node.
|
|
wrong_approach: "DFS from only node 0"
|
|
correct_approach: "DFS from every unvisited node"
|
|
|
|
key_takeaways:
|
|
- "**Cycle detection pattern**: Use three states (unvisited/processing/done) to detect back edges in directed graphs"
|
|
- "**Graph modelling skill**: Recognising that dependency problems map to directed graph cycle detection is a key insight"
|
|
- "**Topological sort foundation**: No cycles means a topological ordering exists — this is the basis for Course Schedule II"
|
|
- "**DFS vs BFS**: Both work here. DFS with colouring is elegant; BFS with in-degree counting (Kahn's algorithm) is an alternative approach"
|
|
|
|
time_complexity: "O(V + E). We visit each node once and traverse each edge once, where V is `numCourses` and E is the number of prerequisites."
|
|
space_complexity: "O(V + E). We store the adjacency list (O(E)) and the state array (O(V)), plus recursion stack space (O(V) in worst case)."
|
|
|
|
solutions:
|
|
- approach_name: DFS with Cycle Detection
|
|
is_optimal: true
|
|
code: |
|
|
def can_finish(num_courses: int, prerequisites: list[list[int]]) -> bool:
|
|
# Build adjacency list: graph[b] = [courses that require b]
|
|
graph = [[] for _ in range(num_courses)]
|
|
for course, prereq in prerequisites:
|
|
graph[prereq].append(course)
|
|
|
|
# States: 0 = unvisited, 1 = visiting (in current path), 2 = visited
|
|
state = [0] * num_courses
|
|
|
|
def has_cycle(node: int) -> bool:
|
|
if state[node] == 1: # Found a back edge - cycle detected!
|
|
return True
|
|
if state[node] == 2: # Already fully processed - no cycle here
|
|
return False
|
|
|
|
state[node] = 1 # Mark as currently visiting
|
|
|
|
# Check all dependent courses
|
|
for neighbour in graph[node]:
|
|
if has_cycle(neighbour):
|
|
return True
|
|
|
|
state[node] = 2 # Mark as fully processed
|
|
return False
|
|
|
|
# Check for cycles starting from each course
|
|
for course in range(num_courses):
|
|
if has_cycle(course):
|
|
return False
|
|
|
|
return True
|
|
explanation: |
|
|
**Time Complexity:** O(V + E) — Each node and edge is visited once.
|
|
|
|
**Space Complexity:** O(V + E) — Adjacency list storage plus recursion stack.
|
|
|
|
We use DFS with three-state colouring to detect cycles. A node in state `1` (visiting) that we encounter again indicates a back edge, meaning we've found a cycle. If we complete DFS on all nodes without finding a cycle, a valid course ordering exists.
|
|
|
|
- approach_name: BFS with In-degree (Kahn's Algorithm)
|
|
is_optimal: true
|
|
code: |
|
|
from collections import deque
|
|
|
|
def can_finish(num_courses: int, prerequisites: list[list[int]]) -> bool:
|
|
# Build adjacency list and count in-degrees
|
|
graph = [[] for _ in range(num_courses)]
|
|
in_degree = [0] * num_courses
|
|
|
|
for course, prereq in prerequisites:
|
|
graph[prereq].append(course)
|
|
in_degree[course] += 1
|
|
|
|
# Start with courses that have no prerequisites
|
|
queue = deque()
|
|
for course in range(num_courses):
|
|
if in_degree[course] == 0:
|
|
queue.append(course)
|
|
|
|
courses_taken = 0
|
|
|
|
while queue:
|
|
course = queue.popleft()
|
|
courses_taken += 1
|
|
|
|
# "Complete" this course - reduce in-degree of dependent courses
|
|
for dependent in graph[course]:
|
|
in_degree[dependent] -= 1
|
|
# If all prerequisites met, this course is now available
|
|
if in_degree[dependent] == 0:
|
|
queue.append(dependent)
|
|
|
|
# If we took all courses, no cycle exists
|
|
return courses_taken == num_courses
|
|
explanation: |
|
|
**Time Complexity:** O(V + E) — Process each node and edge once.
|
|
|
|
**Space Complexity:** O(V + E) — Adjacency list, in-degree array, and queue.
|
|
|
|
Kahn's algorithm takes a different approach: start with courses that have no prerequisites (in-degree 0), "complete" them, and see which courses become available. If we can complete all courses, no cycle exists. If some courses remain with non-zero in-degree, they're part of a cycle.
|
|
|
|
- approach_name: DFS with Visited Set (Simplified)
|
|
is_optimal: false
|
|
code: |
|
|
def can_finish(num_courses: int, prerequisites: list[list[int]]) -> bool:
|
|
# Build adjacency list
|
|
graph = [[] for _ in range(num_courses)]
|
|
for course, prereq in prerequisites:
|
|
graph[prereq].append(course)
|
|
|
|
# Track globally visited and current path
|
|
visited = set()
|
|
path = set()
|
|
|
|
def dfs(node: int) -> bool:
|
|
if node in path: # Cycle detected
|
|
return False
|
|
if node in visited: # Already processed
|
|
return True
|
|
|
|
path.add(node) # Add to current path
|
|
|
|
for neighbour in graph[node]:
|
|
if not dfs(neighbour):
|
|
return False
|
|
|
|
path.remove(node) # Remove from current path
|
|
visited.add(node) # Mark as fully visited
|
|
|
|
return True
|
|
|
|
# Check all courses
|
|
for course in range(num_courses):
|
|
if not dfs(course):
|
|
return False
|
|
|
|
return True
|
|
explanation: |
|
|
**Time Complexity:** O(V + E) — Same as the array-based approach.
|
|
|
|
**Space Complexity:** O(V) — Two sets instead of an array.
|
|
|
|
This uses sets instead of a state array, which some find more intuitive. The `path` set tracks the current DFS path (equivalent to state `1`), and `visited` tracks fully processed nodes (equivalent to state `2`). Functionally identical to the optimal solution.
|