← Back to DevBytes

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

Introduction to the Course Schedule Problem

The Course Schedule problem is one of the most popular algorithmic challenges you will encounter in coding interviews and competitive programming. Given a number of courses and a list of prerequisite pairs, the task is to determine whether it is possible to finish all the courses. At its core, this problem is about detecting cycles in a directed graph, and it serves as a perfect introduction to topological sorting.

In this tutorial, we will walk through the problem step by step, understand the underlying graph theory, and implement two distinct solutions in Go: one using Depth-First Search (DFS) for cycle detection and another using Breadth-First Search (BFS) with Kahn's algorithm. By the end, you will have a solid grasp of how to model real-world dependency problems as graphs and solve them efficiently.

What Is the Course Schedule Problem?

Formally, the problem is stated as follows: You are given an integer numCourses representing the total number of courses labeled from 0 to numCourses - 1. You are also given an array prerequisites where prerequisites[i] = [ai, bi] indicates that you must take course bi before course ai. You need to return true if you can finish all courses, otherwise return false.

For example, if numCourses = 2 and prerequisites = [[1, 0]], you can finish both courses by taking course 0 first, then course 1. However, if prerequisites = [[1, 0], [0, 1]], there is a circular dependency, and it is impossible to finish all courses.

Modeling the Problem as a Graph

The key insight is to treat each course as a node in a directed graph. Each prerequisite pair [ai, bi] represents a directed edge from bi to ai, meaning bi must be completed before ai. The question then becomes: does this directed graph contain a cycle? If it does, there is no valid ordering of courses, and the answer is false. If the graph is acyclic, the answer is true.

A Directed Acyclic Graph (DAG) always has at least one topological ordering. Therefore, the Course Schedule problem reduces to checking whether the graph is a DAG.

Why It Matters

Understanding how to solve the Course Schedule problem is valuable for several reasons:

Setting Up the Graph Representation

Before diving into the algorithms, we need to build an adjacency list from the input prerequisites. An adjacency list is the most efficient representation for sparse graphs, which is typically the case for course prerequisite scenarios.

package main

import "fmt"

func buildGraph(numCourses int, prerequisites [][]int) [][]int {
    graph := make([][]int, numCourses)
    for i := 0; i < numCourses; i++ {
        graph[i] = []int{}
    }
    for _, prereq := range prerequisites {
        course, prereqCourse := prereq[0], prereq[1]
        graph[prereqCourse] = append(graph[prereqCourse], course)
    }
    return graph
}

func main() {
    numCourses := 4
    prerequisites := [][]int{{1, 0}, {2, 1}, {3, 2}}
    graph := buildGraph(numCourses, prerequisites)
    fmt.Println(graph) // Output: [[1] [2] [3] []]
}

In this representation, graph[i] contains all courses that depend on course i. This direction is important for both DFS and BFS approaches.

Solution 1: DFS Cycle Detection

The first approach uses DFS to detect cycles. We maintain a state array where each node is marked as 0 (unvisited), 1 (visiting, meaning it is in the current DFS path), or 2 (visited, meaning it has been fully processed). If during traversal we encounter a node marked as 1, we have found a cycle.

How DFS Cycle Detection Works

When we start exploring a node, we mark it as 1. We then recursively visit all its neighbors. If any neighbor is already marked 1, a cycle exists. After fully exploring all neighbors, we mark the node as 2. This three-state approach is what makes cycle detection reliable in directed graphs.

package main

import "fmt"

func canFinishDFS(numCourses int, prerequisites [][]int) bool {
    graph := buildGraph(numCourses, prerequisites)
    state := make([]int, numCourses) // 0: unvisited, 1: visiting, 2: visited

    var dfs func(node int) bool
    dfs = func(node int) bool {
        if state[node] == 1 {
            return false // cycle detected
        }
        if state[node] == 2 {
            return true // already processed
        }

        state[node] = 1 // mark as visiting
        for _, neighbor := range graph[node] {
            if !dfs(neighbor) {
                return false
            }
        }
        state[node] = 2 // mark as visited
        return true
    }

    for i := 0; i < numCourses; i++ {
        if !dfs(i) {
            return false
        }
    }
    return true
}

func buildGraph(numCourses int, prerequisites [][]int) [][]int {
    graph := make([][]int, numCourses)
    for i := 0; i < numCourses; i++ {
        graph[i] = []int{}
    }
    for _, prereq := range prerequisites {
        course, prereqCourse := prereq[0], prereq[1]
        graph[prereqCourse] = append(graph[prereqCourse], course)
    }
    return graph
}

func main() {
    fmt.Println(canFinishDFS(2, [][]int{{1, 0}}))       // true
    fmt.Println(canFinishDFS(2, [][]int{{1, 0}, {0, 1}})) // false
    fmt.Println(canFinishDFS(4, [][]int{{1, 0}, {2, 1}, {3, 2}})) // true
}

Complexity Analysis of DFS Approach

The time complexity is O(V + E) where V is the number of courses (vertices) and E is the number of prerequisite pairs (edges). Each node and edge is visited at most once. The space complexity is O(V + E) for the adjacency list and the recursion stack, which in the worst case can be O(V) deep.

Solution 2: BFS with Kahn's Algorithm

The second approach uses BFS and is known as Kahn's algorithm. Instead of detecting cycles directly, it iteratively removes nodes with no incoming edges (in-degree of zero). If we can remove all nodes this way, the graph is acyclic. If some nodes remain, a cycle exists.

How Kahn's Algorithm Works

First, we compute the in-degree of every node, which is the number of edges pointing to it. We initialize a queue with all nodes that have an in-degree of zero. We then process nodes from the queue: for each node, we decrement the in-degree of its neighbors. If a neighbor's in-degree becomes zero, we add it to the queue. We count how many nodes we process. If the count equals numCourses, all courses can be finished.

package main

import "fmt"

func canFinishBFS(numCourses int, prerequisites [][]int) bool {
    graph := make([][]int, numCourses)
    inDegree := make([]int, numCourses)

    for i := 0; i < numCourses; i++ {
        graph[i] = []int{}
    }

    for _, prereq := range prerequisites {
        course, prereqCourse := prereq[0], prereq[1]
        graph[prereqCourse] = append(graph[prereqCourse], course)
        inDegree[course]++
    }

    queue := []int{}
    for i := 0; i < numCourses; i++ {
        if inDegree[i] == 0 {
            queue = append(queue, i)
        }
    }

    count := 0
    for len(queue) > 0 {
        node := queue[0]
        queue = queue[1:]
        count++

        for _, neighbor := range graph[node] {
            inDegree[neighbor]--
            if inDegree[neighbor] == 0 {
                queue = append(queue, neighbor)
            }
        }
    }

    return count == numCourses
}

func main() {
    fmt.Println(canFinishBFS(2, [][]int{{1, 0}}))         // true
    fmt.Println(canFinishBFS(2, [][]int{{1, 0}, {0, 1}})) // false
    fmt.Println(canFinishBFS(5, [][]int{{1, 0}, {2, 1}, {3, 2}, {4, 3}})) // true
    fmt.Println(canFinishBFS(3, [][]int{{0, 1}, {1, 2}, {2, 0}})) // false
}

Complexity Analysis of BFS Approach

The time complexity is also O(V + E) since each node is enqueued and dequeued once, and each edge is examined once when decrementing in-degrees. The space complexity is O(V + E) for the graph and in-degree array, plus O(V) for the queue in the worst case.

Comparing the Two Approaches

Both approaches have the same asymptotic complexity, but they differ in practical characteristics:

Extending to Course Schedule II

Once you understand cycle detection, producing an actual valid ordering is a small modification. Using Kahn's algorithm, you simply record each node as it is dequeued. If the final count equals numCourses, the recorded order is a valid topological sort.

package main

import "fmt"

func findOrder(numCourses int, prerequisites [][]int) []int {
    graph := make([][]int, numCourses)
    inDegree := make([]int, numCourses)

    for i := 0; i < numCourses; i++ {
        graph[i] = []int{}
    }

    for _, prereq := range prerequisites {
        course, prereqCourse := prereq[0], prereq[1]
        graph[prereqCourse] = append(graph[prereqCourse], course)
        inDegree[course]++
    }

    queue := []int{}
    for i := 0; i < numCourses; i++ {
        if inDegree[i] == 0 {
            queue = append(queue, i)
        }
    }

    order := []int{}
    for len(queue) > 0 {
        node := queue[0]
        queue = queue[1:]
        order = append(order, node)

        for _, neighbor := range graph[node] {
            inDegree[neighbor]--
            if inDegree[neighbor] == 0 {
                queue = append(queue, neighbor)
            }
        }
    }

    if len(order) == numCourses {
        return order
    }
    return []int{} // cycle exists, no valid order
}

func main() {
    order := findOrder(4, [][]int{{1, 0}, {2, 0}, {3, 1}, {3, 2}})
    fmt.Println(order) // Possible output: [0 1 2 3] or [0 2 1 3]
}

Best Practices

When implementing graph algorithms in Go, keep the following best practices in mind:

Testing Your Implementation

Thorough testing is essential. Here is a simple test suite covering common scenarios:

package main

import "testing"

func TestCanFinishBFS(t *testing.T) {
    tests := []struct {
        name          string
        numCourses    int
        prerequisites [][]int
        expected      bool
    }{
        {"no prerequisites", 3, [][]int{}, true},
        {"simple chain", 3, [][]int{{1, 0}, {2, 1}}, true},
        {"cycle of two", 2, [][]int{{1, 0}, {0, 1}}, false},
        {"self loop", 1, [][]int{{0, 0}}, false},
        {"disconnected acyclic", 5, [][]int{{1, 0}, {3, 2}}, true},
        {"large cycle", 4, [][]int{{0, 1}, {1, 2}, {2, 3}, {3, 0}}, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := canFinishBFS(tt.numCourses, tt.prerequisites)
            if result != tt.expected {
                t.Errorf("got %v, want %v", result, tt.expected)
            }
        })
    }
}

Common Pitfalls to Avoid

Even experienced developers make mistakes when solving this problem. Here are some common pitfalls:

Conclusion

The Course Schedule problem is a fantastic way to build your understanding of directed graphs, cycle detection, and topological sorting. By implementing both the DFS and BFS approaches in Go, you now have two reliable tools for solving dependency resolution problems. The DFS approach with its three-state marking system is elegant and intuitive, while Kahn's BFS algorithm is robust and easily extensible to producing actual course orderings. Remember to always model the problem as a graph first, choose the right representation, handle edge cases carefully, and test thoroughly. With these techniques in your toolkit, you are well-equipped to tackle not only Course Schedule but also a wide range of graph problems that appear in real-world systems and technical interviews alike.

— Ad —

Google AdSense will appear here after approval

← Back to all articles