← Back to DevBytes

Solving Course Schedule in Python: Step-by-Step Guide

Introduction to the Course Schedule Problem

The Course Schedule problem is one of the most popular algorithmic challenges you'll encounter in coding interviews and competitive programming platforms like LeetCode. At its core, it tests your understanding of graph theory, specifically topological sorting and cycle detection in directed graphs.

The problem statement is deceptively simple: given a number of courses and a list of prerequisite pairs, determine whether it's possible to finish all courses. Each prerequisite pair [a, b] means you must complete course b before taking course a. If the dependency graph contains a cycle, completing all courses becomes impossible.

Why This Problem Matters

Beyond interview preparation, the Course Schedule problem models real-world scenarios everywhere. University registrars use similar logic to validate degree plans. Build systems like Make and Bazel detect circular dependencies between modules. Package managers like pip and npm resolve installation orders based on dependencies. Even task schedulers in distributed systems rely on the same underlying algorithms.

Mastering this problem equips you with essential skills in graph traversal, adjacency list construction, and algorithmic thinking that transfer directly to production engineering work.

Understanding the Problem with an Example

Let's break down a concrete example. Suppose we have numCourses = 4 and prerequisites [[1, 0], [2, 1], [3, 2]]. This means:

The valid completion order is 0 → 1 → 2 → 3. No cycles exist, so we return True.

Now consider prerequisites [[1, 0], [0, 1]]. Course 1 needs Course 0, but Course 0 also needs Course 1. This circular dependency makes completion impossible, so we return False.

Building the Graph Representation

Before solving the problem, we need to represent the courses and their dependencies as a graph. The most efficient representation for sparse graphs is an adjacency list. We also track the in-degree of each node, which counts how many prerequisites point to it.

from collections import defaultdict, deque

def build_graph(numCourses, prerequisites):
    adjacency = defaultdict(list)
    in_degree = [0] * numCourses
    
    for course, prereq in prerequisites:
        adjacency[prereq].append(course)
        in_degree[course] += 1
        
    return adjacency, in_degree

Here, adjacency[prereq] stores all courses that depend on prereq. The in_degree array tracks how many prerequisites each course still has unmet.

Solution 1: Topological Sort Using Kahn's Algorithm (BFS)

Kahn's Algorithm is a breadth-first approach to topological sorting. The idea is straightforward: start with courses that have no prerequisites (in-degree of zero), process them, and reduce the in-degree of their dependents. If we can process all courses, no cycle exists.

Step-by-Step Implementation

from collections import defaultdict, deque

def canFinish(numCourses, prerequisites):
    # Build the graph and in-degree array
    adjacency = defaultdict(list)
    in_degree = [0] * numCourses
    
    for course, prereq in prerequisites:
        adjacency[prereq].append(course)
        in_degree[course] += 1
    
    # Initialize queue with courses that have no prerequisites
    queue = deque()
    for i in range(numCourses):
        if in_degree[i] == 0:
            queue.append(i)
    
    # Process courses in topological order
    completed = 0
    while queue:
        current = queue.popleft()
        completed += 1
        
        # Reduce in-degree of dependent courses
        for neighbor in adjacency[current]:
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)
    
    # If we completed all courses, no cycle exists
    return completed == numCourses

How Kahn's Algorithm Works

First, we identify all courses with zero in-degree and add them to a queue. These courses have no prerequisites and can be taken immediately. As we process each course, we decrement the in-degree of every course that depends on it. When a dependent's in-degree reaches zero, it becomes eligible for processing and joins the queue.

If a cycle exists, the courses involved in the cycle will never reach zero in-degree because they depend on each other circularly. The completed counter will be less than numCourses, and we correctly return False.

Time and Space Complexity

The time complexity is O(V + E), where V is the number of courses and E is the number of prerequisite pairs. We visit each course once and traverse each edge once. The space complexity is also O(V + E) for storing the adjacency list and in-degree array.

Solution 2: Cycle Detection Using DFS

An alternative approach uses depth-first search to detect cycles. We maintain a state array where each course is marked as unvisited, visiting (currently in the recursion stack), or visited (fully processed). If we encounter a course marked as visiting during traversal, we've found a cycle.

Step-by-Step Implementation

from collections import defaultdict

def canFinish(numCourses, prerequisites):
    # Build adjacency list
    adjacency = defaultdict(list)
    for course, prereq in prerequisites:
        adjacency[prereq].append(course)
    
    # State: 0 = unvisited, 1 = visiting, 2 = visited
    state = [0] * numCourses
    
    def has_cycle(course):
        if state[course] == 1:
            return True  # Found a back edge, cycle detected
        if state[course] == 2:
            return False  # Already fully processed, no cycle here
        
        # Mark as visiting
        state[course] = 1
        
        for neighbor in adjacency[course]:
            if has_cycle(neighbor):
                return True
        
        # Mark as visited after processing all neighbors
        state[course] = 2
        return False
    
    # Check each course as a potential starting point
    for i in range(numCourses):
        if has_cycle(i):
            return False
    
    return True

Understanding the Three-State Approach

The three-state system is crucial for correctness. A course marked 1 (visiting) is currently somewhere in our recursion stack. If we reach it again, we've looped back, confirming a cycle. A course marked 2 (visited) has been fully explored with no cycles found, so we skip reprocessing it for efficiency.

This approach has the same O(V + E) time and space complexity as Kahn's Algorithm. The choice between them often comes down to personal preference and whether you need the actual topological ordering (Kahn's naturally produces one) or just cycle detection (DFS is slightly more intuitive for some developers).

Extending to Course Schedule II

A common variation asks you to return a valid ordering of courses rather than just a boolean. Kahn's Algorithm makes this trivial since we already process courses in topological order.

from collections import defaultdict, deque

def findOrder(numCourses, prerequisites):
    adjacency = defaultdict(list)
    in_degree = [0] * numCourses
    
    for course, prereq in prerequisites:
        adjacency[prereq].append(course)
        in_degree[course] += 1
    
    queue = deque()
    for i in range(numCourses):
        if in_degree[i] == 0:
            queue.append(i)
    
    order = []
    while queue:
        current = queue.popleft()
        order.append(current)
        
        for neighbor in adjacency[current]:
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)
    
    if len(order) == numCourses:
        return order
    return []  # Cycle detected, no valid ordering

If a cycle exists, order won't contain all courses, and we return an empty array as specified by the problem.

Best Practices and Common Pitfalls

Choose the Right Data Structures

Always use defaultdict(list) for adjacency lists rather than manually checking for key existence. It keeps your code clean and avoids off-by-one errors. For the queue in Kahn's Algorithm, use collections.deque for O(1) popleft operations. Using a regular list with pop(0) would degrade performance to O(V^2).

Handle Edge Cases

Always consider edge cases in your solution. What if numCourses is zero? What if prerequisites is empty? What if there are duplicate prerequisite pairs? Your code should handle these gracefully. The implementations above naturally handle empty prerequisites and zero courses, but duplicate pairs could inflate in-degree counts incorrectly in some variations.

Avoid Recursion Limits with DFS

Python's default recursion limit is around 1000. For large graphs, the DFS approach may hit RecursionError. You can increase the limit with sys.setrecursionlimit(), but Kahn's Algorithm is inherently iterative and avoids this issue entirely. For production code processing large dependency graphs, prefer the BFS approach.

Validate Input Early

def canFinish(numCourses, prerequisites):
    if numCourses <= 0:
        return True
    if not prerequisites:
        return True
    
    # Validate prerequisite pairs
    for course, prereq in prerequisites:
        if course < 0 or course >= numCourses:
            raise ValueError(f"Invalid course index: {course}")
        if prereq < 0 or prereq >= numCourses:
            raise ValueError(f"Invalid prerequisite index: {prereq}")
    
    # ... rest of implementation

Defensive programming saves debugging time when integrating with larger systems.

Consider Memory for Large Graphs

For graphs with millions of nodes, consider using more memory-efficient representations. Instead of defaultdict(list), you might use arrays of integers with offset indexing. The algorithm remains the same, but the constant factors improve significantly.

Testing Your Solution

Thorough testing ensures correctness across all scenarios. Here's a comprehensive test suite:

def test_can_finish():
    # Test 1: Linear chain, no cycle
    assert canFinish(4, [[1, 0], [2, 1], [3, 2]]) == True
    
    # Test 2: Simple cycle
    assert canFinish(2, [[1, 0], [0, 1]]) == False
    
    # Test 3: No prerequisites
    assert canFinish(3, []) == True
    
    # Test 4: Single course
    assert canFinish(1, []) == True
    
    # Test 5: Diamond dependency, no cycle
    assert canFinish(4, [[1, 0], [2, 0], [3, 1], [3, 2]]) == True
    
    # Test 6: Larger cycle
    assert canFinish(3, [[1, 0], [2, 1], [0, 2]]) == False
    
    # Test 7: Multiple independent chains
    assert canFinish(5, [[1, 0], [3, 2]]) == True
    
    print("All tests passed!")

test_can_finish()

These tests cover linear chains, simple cycles, empty inputs, diamond dependencies, larger cycles, and disconnected components. Running them after any code change catches regressions immediately.

Conclusion

The Course Schedule problem is a gateway to understanding graph algorithms and dependency resolution. Whether you choose Kahn's Algorithm for its natural topological ordering or DFS for its intuitive cycle detection, the key insight remains the same: a valid course schedule exists if and only if the dependency graph is a directed acyclic graph. By mastering both approaches, understanding their trade-offs, and following best practices around data structures, edge cases, and testing, you'll be well-equipped to tackle not only this problem but any dependency resolution challenge you encounter in production systems. Practice these implementations until they become second nature, and you'll find the underlying patterns appearing throughout your engineering career.

— Ad —

Google AdSense will appear here after approval

← Back to all articles