DSA sheet · Graphs · Topological sort pattern
Course Schedule II
This is a small variation of Course Schedule, and the teacher says that problem is the prerequisite for this video. Last time we only answered "can all courses be finished?". Now we must return the order in which to take them. That order is a topological order. The code is almost identical: where we used to count each popped course, we now write it down. She also shows a tempting but wrong way to detect failure, and why we must compare against numCourses.
Every problem below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the logic from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember
- Part 0 · What you must know before starting
- Part A · Kahn's algorithm, recording the order (the teacher's solution)
- Part B · Extra: the DFS way (reverse finishing order)
- Part C · Revision page
Part 0 · Before starting
Graph basics for this page
- Nodes are courses 0…n−1. A directed edge
(b)→(a)means "b must come before a". There are no weights. (Undirected edges, drawn(a)—(b), go both ways. They aren't used here.) - The input is an edge list of pairs
[a, b]= "a needs b". We turn it into an adjacency listgraph[b] = [a, …]("who is waiting for b?"). Course ids are 0…n−1, so a list of lists is enough, and we don't need a dictionary. - Indegree of a course = the number of arrows coming into it = how many of its prerequisites are still unfinished. Indegree 0 means it can be taken now.
- Visited: usually an array that stops us from handling a node twice. In Kahn's algorithm, the moment the indegree drops to 0 already happens only once, so it works as our visited signal.
- BFS uses a queue (
collections.deque), and DFS uses recursion. Graphs can be in several separate pieces (connected components), so we start from every free course, not just course 0.
numCourses = 3
prerequisites = [[1, 0], [2, 1]] # 1 needs 0, 2 needs 1
graph = [[] for _ in range(numCourses)]
indegree = [0] * numCourses
for course, pre in prerequisites:
graph[pre].append(course) # pre -> course
indegree[course] += 1
print(graph) # [[1], [2], []]
print(indegree) # [0, 1, 1]Topological order
A topological order lists every node so that each arrow points forward: for every edge b → a, b appears before a. It's exactly a valid timetable of courses. It exists only when there is no cycle. In a cycle every course waits for another one in the same cycle, so none of them can be placed first. A graph can have more than one valid order, and LeetCode accepts any of them.
Part A · Kahn's algorithm, recording the order
LeetCode 210 · Medium
1The question in simple words
The same setup as Course Schedule: numCourses courses, and pairs [a, b] meaning "take b before a". This time, return a list of all courses in an order you could take them. If it's impossible (there's a cycle), return an empty list [].
- n = 2,
[[1,0]]→[0, 1]. - n = 4,
[[1,0],[2,0],[3,1],[3,2]]→[0, 1, 2, 3]or[0, 2, 1, 3]. Both are correct. - n = 1,
[]→[0].
The teacher reuses her 5-course example: [[1,0],[0,2],[2,3],[2,4],[3,4]].
(4) ──→ (3) │ │ ↓ │ (2) ←────┘ │ ↓ (0) ──→ (1)
graph = {0:[1], 1:[], 2:[0], 3:[2], 4:[2, 3]}, and indegree for courses [0, 1, 2, 3, 4] = [1, 1, 2, 1, 0].
[0]?→ No. In Java she returns
new int[0], which is an array of size zero, i.e. an empty array. In Python that's []. Returning [0] would be wrong (it claims "take course 0").2What the constraints tell us
1 ≤ numCourses ≤ 2000→ at least one course, so a successful answer is never empty. An empty answer can only mean "impossible".0 ≤ prerequisites.length ≤ numCourses × (numCourses − 1)→ E can be large in theory (millions), but O(V + E) is still fine. With no pairs at all, any order works.- All pairs are distinct, and
a != b(no course needs itself). - The answer list has exactly n numbers, so the extra space for the output is O(V).
3Intuition: write down courses in the order you take them
In Course Schedule we already took the courses one by one: each pop from the queue was "I take this course now". That sequence of pops is a valid order, because a course only enters the queue after everything it needs has been popped earlier.
So the only change: instead of count += 1, append the course to order. At the end, if all n courses made it into order, return it. Otherwise return [].
4Building the logic, and the trap she shows
Recording the order
In Java she makes an int array of size n plus an index pointer starting at 0. Each pop does order[index] = course; index++. In Python we just keep a list and append. Then len(order) plays the role of her index.
How do we know it failed?
Fully stuck (her first cycle example): [[0,1],[1,0]]. Both indegrees are 1, nothing has indegree 0, the queue starts empty, the loop never runs, and order stays empty. So "index is still 0" means failure here.
That gives a tempting rule: "if at least one course was taken (index > 0), return the order". She shows why it's wrong:
n = 4, [[0,1],[1,2],[2,1],[1,3]]: 0 needs 1, 1 needs 2 and 3, 2 needs 1.
(3) ──→ (1) ──→ (0)
↑ │
│ ↓
(2) cycle 1 ⇄ 2
indegree = [1, 2, 1, 0]. Queue = [3]. Pop 3 → order = [3]. Lower 1: 2 → 1. Not 0. The queue is empty, so we stop with order = [3]. The index is 1, which is > 0, but we took only 1 of 4 courses. Courses 1 and 2 wait for each other, and 0 waits behind them.
len(order) == numCourses (her index == numCourses). Otherwise return [].→ (a) A cycle blocks itself: every course on a cycle needs the previous course on the cycle to be popped first, so none of them can be the first to reach indegree 0. They never enter the queue, and neither does any course that depends on them.
(b) Without a cycle we never get stuck: if all remaining courses had indegree ≥ 1, each would wait for another remaining course. Following "waits for" backwards, we'd eventually repeat a course (there are only n), which would be a cycle. So with no cycle there's always a free course left, and all n get popped.
So len(order) == n ⇔ no cycle, and when it's true the order is valid.
→ A course is pushed only when its indegree reaches 0, which means every one of its prerequisites has already been popped (each pop lowered it by one). So every prerequisite sits earlier in
order than the course itself.→ No, for the same reason as in Course Schedule: a course's indegree hits 0 only once, so it's pushed at most once.
5Approach steps
- Build
graph(pre → course) andindegree. - Push every course with indegree 0 into the queue.
- While the queue isn't empty: pop a course and append it to
order. - For each child: lower its indegree. If it reaches 0, push it.
- If
len(order) == numCourses, returnorder. Otherwise return[].
6Code (Python)
from collections import deque
class Solution:
def findOrder(self, numCourses, prerequisites):
graph = [[] for _ in range(numCourses)]
indegree = [0] * numCourses
for course, pre in prerequisites:
graph[pre].append(course)
indegree[course] += 1
queue = deque()
for i in range(numCourses):
if indegree[i] == 0:
queue.append(i)
order = [] # was "count" in Course Schedule
while queue:
course = queue.popleft()
order.append(course) # was "count += 1"
for child in graph[course]:
indegree[child] -= 1
if indegree[child] == 0:
queue.append(child)
if len(order) == numCourses: # every course was taken
return order
return [] # a cycle blocked some courses7Code line by line (what changed from Course Schedule)
| line | what it means |
|---|---|
| graph / indegree / first queue loop | Exactly as in Course Schedule: the edge goes pre → course, indegree[course] += 1, and every free course is queued. |
| order = [] | Replaces the counter. It's the timetable we're building. |
| order.append(course) | Taking a course = writing it down next. Its position is len(order) before the append (her index). |
| indegree[child] -= 1 ... | Unchanged: unlock the children. |
| if len(order) == numCourses: return order | All courses were placed, so the order is complete and valid. |
| return [] | Some courses never became free (cycle), so it's impossible. Not "if order is non-empty", because of the trap above. |
8Dry run: indegree, queue and order after every step
Her 5-course example. graph = {0:[1], 1:[], 2:[0], 3:[2], 4:[2, 3]}.
| step | popped | children checked | queue AFTER | indegree AFTER [0,1,2,3,4] | order so far |
|---|---|---|---|---|---|
| start | – | – | [4] | [1, 1, 2, 1, 0] | [] |
| 1 | 4 | 2: 2→1 · 3: 1→0 push | [3] | [1, 1, 1, 0, 0] | [4] |
| 2 | 3 | 2: 1→0 push | [2] | [1, 1, 0, 0, 0] | [4, 3] |
| 3 | 2 | 0: 1→0 push | [0] | [0, 1, 0, 0, 0] | [4, 3, 2] |
| 4 | 0 | 1: 1→0 push | [1] | [0, 0, 0, 0, 0] | [4, 3, 2, 0] |
| 5 | 1 | no children | [] | [0, 0, 0, 0, 0] | [4, 3, 2, 0, 1] |
| end | len 5 == 5 → return [4, 3, 2, 0, 1] | ||||
Check the arrows: 4→3 (4 before 3 ✓), 4→2 ✓, 3→2 ✓, 2→0 ✓, 0→1 ✓.
The trap example, step by step
n = 4, [[0,1],[1,2],[2,1],[1,3]] → graph = {1:[0, 2], 2:[1], 3:[1], 0:[]}.
| step | popped | children checked | queue AFTER | indegree AFTER [0,1,2,3] | order so far |
|---|---|---|---|---|---|
| start | – | – | [3] | [1, 2, 1, 0] | [] |
| 1 | 3 | 1: 2→1 (still waits for 2) | [] | [1, 1, 1, 0] | [3] |
| end | len 1 ≠ 4 → return [] (the "index > 0" rule would wrongly return [3]) | ||||
(3✓) ──→ (1:1) ──→ (0:1) ↑ │ │ ↓ (2:1)
(0:1) ──→ (1:1) ↑ │ └──────────┘ queue starts empty → []
9Complexity & remember
- Time O(V + E): the same as Course Schedule. Each course is pushed and popped at most once, and each edge lowers one indegree once.
- Space O(V + E): the graph (V lists, E entries), the indegree array and the queue. The
orderlist adds O(V), and she points out that this is the space the answer itself needs.
count += 1 replaced by order.append(course). Return order only if len(order) == numCourses, else []. "Non-empty" is not enough.Part B · Extra: the DFS way (reverse finishing order)
Not in this video. It continues the DFS homework from Course Schedule, because interviewers often ask for both.
1The question
The same: return a valid order, or [] if there's a cycle.
2What the constraints tell us
Up to 2000 courses in a chain → recursion up to 2000 deep, which is more than Python's default limit of ~1000. Raise it with sys.setrecursionlimit.
3Intuition: a course finishes after everything that depends on it
Run DFS along the arrows pre → course. A DFS call on course u finishes only after it has finished every course reachable from u, i.e. every course that needs u, directly or indirectly. So if we write u down at the moment it finishes, u lands after all the courses that need it. Reverse that list and u comes before all of them, which is exactly a topological order.
At the same time we detect cycles with the 3-state marker from Course Schedule (Part C): 0 = new, 1 = on the current path, 2 = done. An arrow into a state-1 course is a cycle → return [].
4Building the logic
dfs(u): set state 1. For each child: state 1 → cycle. State 0 → recurse. State 2 → skip.- When all children are done: set state 2 and append u to
post(the finishing order). - Start a DFS from every course still in state 0 (to cover separate pieces).
- The answer is
postreversed.
→ We might enter u before one of its own prerequisites has been visited. In the 5-course example, starting at course 0 would print 0 first, but 0 needs 2, 3 and 4. Only the finishing time is guaranteed to come after every course that depends on u, which is why we reverse the finishing order.
5Approach steps
- Build the graph. Set
state = [0] * nandpost = []. - For each course in state 0: run
dfs. If it found a cycle, return[]. - Return
post[::-1].
6Code (Python)
import sys
class Solution:
def findOrder(self, numCourses, prerequisites):
sys.setrecursionlimit(10000)
graph = [[] for _ in range(numCourses)]
for course, pre in prerequisites:
graph[pre].append(course)
state = [0] * numCourses # 0 = new, 1 = on current path, 2 = done
post = [] # courses in the order their DFS finishes
def dfs(u): # False means a cycle was found
state[u] = 1
for v in graph[u]:
if state[v] == 1:
return False
if state[v] == 0 and not dfs(v):
return False
state[u] = 2
post.append(u) # everything that needs u is already in post
return True
for c in range(numCourses):
if state[c] == 0 and not dfs(c):
return []
return post[::-1]7Code line by line
| line | what it means |
|---|---|
| state[u] = 1 | u is on the current DFS path. |
| if state[v] == 1: return False | We came back to a course on our own path, so it's a cycle and no order exists. |
| state[u] = 2 post.append(u) | u is finished. Every course reachable from u (everything that needs u) finished earlier. |
| return post[::-1] | Reverse it so that each course comes before the courses that need it. |
8Dry run (5-course example)
- c = 0: dfs(0) → child 1 → dfs(1) has no children → post = [1]. Back in 0 → post = [1, 0].
- c = 1: already done.
- c = 2: dfs(2) → child 0 is done → post = [1, 0, 2].
- c = 3: dfs(3) → child 2 is done → post = [1, 0, 2, 3].
- c = 4: dfs(4) → children 2 and 3 are done → post = [1, 0, 2, 3, 4].
- Reversed: [4, 3, 2, 0, 1], the same order as Kahn's gave.
On the trap example, dfs(1) → dfs(2) sees course 1 in state 1 → cycle → [].
9Complexity & remember
- Time O(V + E) and Space O(V + E) (graph, state, post, recursion stack).
[]).Part C · Revision page
| Course Schedule (207) | Course Schedule II (210) | |
|---|---|---|
| asks | can all courses be finished? (True/False) | give one valid order, or [] |
| on each pop | count += 1 | order.append(course) |
| final check | count == numCourses | len(order) == numCourses → order, else [] |
| everything else | same graph (pre → course), same indegree, same queue of indegree-0 courses | |
| time / space | O(V + E) / O(V + E) | |
| Kahn's (BFS) | DFS | |
|---|---|---|
| order comes from | the pop order | the finishing order, reversed |
| cycle detected by | fewer than n popped | an edge into a state-1 node |
| Python gotcha | none | raise the recursion limit |
indegree[course] += 1.2. Queue all indegree-0 courses.
3. Pop → append to
order → lower the children → push the ones that hit 0.4.
len(order) == numCourses → return order, else [].5. "At least one course taken" doesn't prove anything. A cycle can sit after some free courses.
order when it's non-empty but incomplete (her trap)✗ returning
[0] instead of [] for "impossible"✗ reversing the edge direction (that gives the order backwards)
✗ in DFS, recording pre-order instead of reversed finishing order
✗ starting only from course 0 instead of every indegree-0 course
s = Solution() print(s.findOrder(2, [[1, 0]])) # [0, 1] print(s.findOrder(5, [[1, 0], [0, 2], [2, 3], [2, 4], [3, 4]])) # [4, 3, 2, 0, 1] print(s.findOrder(4, [[1, 0], [2, 0], [3, 1], [3, 2]]) in ([0, 1, 2, 3], [0, 2, 1, 3])) # True print(s.findOrder(2, [[1, 0], [0, 1]])) # [] print(s.findOrder(4, [[0, 1], [1, 2], [2, 1], [1, 3]])) # [] print(s.findOrder(1, [])) # [0]
Based on this video: Course Schedule II | Graph · Topological Sort