Topological Sort: DAG Ordering Algorithm

Topological sort answers a fundamental question: given a set of tasks with dependencies, in what order should you execute them so that every dependency is satisfied before the task that needs it?

Key Insights

  • Topological sort produces a linear ordering of vertices in a directed acyclic graph where every edge points forward—essential for dependency resolution in build systems, package managers, and task schedulers.
  • Two primary algorithms exist: Kahn’s algorithm (BFS-based, naturally detects cycles) and DFS-based (often simpler to implement, uses recursion and a stack).
  • The algorithm runs in O(V+E) time, making it efficient for large dependency graphs, but requires the graph to be acyclic—cycles make ordering impossible.

Introduction to Topological Ordering

Topological sort answers a fundamental question: given a set of tasks with dependencies, in what order should you execute them so that every dependency is satisfied before the task that needs it?

Consider university course prerequisites. You can’t take Advanced Algorithms before Data Structures, and you can’t take Data Structures before Introduction to Programming. A topological sort of these courses produces a valid semester-by-semester plan where you never encounter a course before its prerequisites.

Build systems face this exact problem. When compiling a project, source files depend on headers, libraries depend on other libraries, and the final executable depends on all compiled objects. Get the order wrong, and the build fails. Get it right, and everything compiles smoothly.

Topological sort only works on Directed Acyclic Graphs (DAGs). The “directed” part means dependencies have a direction—A depends on B, not the other way around. The “acyclic” part means no circular dependencies exist. If A depends on B, B depends on C, and C depends on A, you’re stuck in an infinite loop with no valid starting point.

Understanding DAGs and Their Properties

A Directed Acyclic Graph consists of vertices connected by directed edges, with no path that starts and ends at the same vertex. This constraint is what makes topological ordering possible.

Think of it visually: if you arrange all vertices in a line according to topological order, every edge points from left to right. No edge ever points backward. This property is impossible to achieve when cycles exist—you’d need some edge to point backward to complete the cycle.

Here’s a practical graph representation using an adjacency list:

from collections import defaultdict
from typing import List, Set

class DirectedGraph:
    def __init__(self):
        self.graph = defaultdict(list)
        self.vertices = set()
    
    def add_edge(self, from_vertex: str, to_vertex: str):
        """Add directed edge from from_vertex to to_vertex.
        Meaning: from_vertex depends on to_vertex, or
                 to_vertex must come before from_vertex.
        """
        self.graph[from_vertex].append(to_vertex)
        self.vertices.add(from_vertex)
        self.vertices.add(to_vertex)
    
    def get_neighbors(self, vertex: str) -> List[str]:
        return self.graph[vertex]
    
    def get_all_vertices(self) -> Set[str]:
        return self.vertices

# Example: Build dependencies
# main.c depends on utils.h and math.h
# utils.c depends on utils.h
# math.c depends on math.h
build_graph = DirectedGraph()
build_graph.add_edge("main.o", "utils.o")
build_graph.add_edge("main.o", "math.o")
build_graph.add_edge("app", "main.o")
build_graph.add_edge("app", "utils.o")
build_graph.add_edge("app", "math.o")

In this representation, an edge from A to B means “A depends on B” or equivalently “B must come before A in the final ordering.” This convention matters—some implementations reverse it, so always clarify the edge direction semantics in your code.

Kahn’s Algorithm (BFS Approach)

Kahn’s algorithm takes an intuitive approach: find all vertices with no incoming edges (in-degree of zero), process them, remove their outgoing edges, and repeat. Vertices with zero in-degree have no unsatisfied dependencies, making them safe to process immediately.

The algorithm maintains a queue of “ready” vertices and processes them one by one. When you process a vertex, you decrement the in-degree of all its dependents. Any dependent that reaches zero in-degree joins the queue.

from collections import deque
from typing import List, Optional, Dict

def kahns_topological_sort(graph: DirectedGraph) -> Optional[List[str]]:
    """
    Perform topological sort using Kahn's algorithm.
    Returns sorted list or None if cycle detected.
    """
    # Calculate in-degrees for all vertices
    in_degree: Dict[str, int] = {v: 0 for v in graph.get_all_vertices()}
    
    # Build reverse adjacency for in-degree calculation
    for vertex in graph.get_all_vertices():
        for neighbor in graph.get_neighbors(vertex):
            # vertex depends on neighbor, so vertex has incoming edge
            pass
    
    # Actually, we need to reconsider: if edge A->B means A depends on B,
    # then B has an outgoing edge to A in dependency terms
    # Let's rebuild with clearer semantics
    
    # Rebuild: edge from A to B means B must come before A
    for vertex in graph.get_all_vertices():
        for dependency in graph.get_neighbors(vertex):
            # vertex depends on dependency
            # In processing order: dependency comes first
            # So vertex has in-degree from dependency
            pass
    
    # Cleaner approach: compute in-degrees directly
    in_degree = {v: 0 for v in graph.get_all_vertices()}
    reverse_graph: Dict[str, List[str]] = defaultdict(list)
    
    for vertex in graph.get_all_vertices():
        for dependency in graph.get_neighbors(vertex):
            # dependency -> vertex in execution order
            reverse_graph[dependency].append(vertex)
    
    for source, targets in reverse_graph.items():
        for target in targets:
            in_degree[target] += 1
    
    # Initialize queue with zero in-degree vertices
    queue = deque([v for v, deg in in_degree.items() if deg == 0])
    result = []
    
    while queue:
        current = queue.popleft()
        result.append(current)
        
        # Process all vertices that depend on current
        for dependent in reverse_graph[current]:
            in_degree[dependent] -= 1
            if in_degree[dependent] == 0:
                queue.append(dependent)
    
    # Cycle detection: if we didn't process all vertices, cycle exists
    if len(result) != len(graph.get_all_vertices()):
        return None  # Cycle detected
    
    return result

# Test with build graph
result = kahns_topological_sort(build_graph)
if result:
    print("Build order:", " -> ".join(result))
else:
    print("Circular dependency detected!")

Kahn’s algorithm naturally detects cycles. If the algorithm terminates before processing all vertices, remaining vertices form one or more cycles—they’ll never reach zero in-degree because they’re waiting on each other.

DFS-Based Approach

The DFS approach uses a different insight: in a depth-first traversal, a vertex’s dependencies are fully explored before the vertex itself finishes. By pushing vertices onto a stack as they finish (post-order), then reversing, you get a valid topological order.

This approach requires tracking three states per vertex: unvisited, currently being processed (on the recursion stack), and fully processed. Finding an edge to a vertex currently being processed indicates a cycle.

from enum import Enum
from typing import List, Optional

class VisitState(Enum):
    UNVISITED = 0
    IN_PROGRESS = 1
    COMPLETED = 2

def dfs_topological_sort(graph: DirectedGraph) -> Optional[List[str]]:
    """
    Perform topological sort using DFS.
    Returns sorted list or None if cycle detected.
    """
    state = {v: VisitState.UNVISITED for v in graph.get_all_vertices()}
    result_stack = []
    
    def dfs(vertex: str) -> bool:
        """Returns False if cycle detected, True otherwise."""
        if state[vertex] == VisitState.IN_PROGRESS:
            return False  # Cycle detected: back edge found
        
        if state[vertex] == VisitState.COMPLETED:
            return True  # Already processed
        
        state[vertex] = VisitState.IN_PROGRESS
        
        # Visit all dependencies first
        for dependency in graph.get_neighbors(vertex):
            if not dfs(dependency):
                return False
        
        state[vertex] = VisitState.COMPLETED
        result_stack.append(vertex)
        return True
    
    # Process all vertices (handles disconnected components)
    for vertex in graph.get_all_vertices():
        if state[vertex] == VisitState.UNVISITED:
            if not dfs(vertex):
                return None  # Cycle detected
    
    # Stack naturally gives us reverse post-order
    # Since we append after processing dependencies, order is correct
    return result_stack

# Test
result = dfs_topological_sort(build_graph)
print("DFS Build order:", result)

The DFS approach often requires less code in practice and integrates naturally with other DFS-based graph algorithms. However, Kahn’s algorithm provides a more intuitive mental model for many developers and makes parallel processing opportunities more obvious—vertices in the queue simultaneously have zero in-degree and could theoretically be processed in parallel.

Practical Applications

Beyond build systems, topological sort appears throughout software engineering. Here’s a practical dependency resolver similar to what package managers use:

from dataclasses import dataclass
from typing import Dict, List, Set, Optional

@dataclass
class Package:
    name: str
    version: str
    dependencies: List[str]

class DependencyResolver:
    def __init__(self):
        self.packages: Dict[str, Package] = {}
    
    def register(self, package: Package):
        self.packages[package.name] = package
    
    def resolve_install_order(self, target: str) -> Optional[List[str]]:
        """
        Determine installation order for target package and all dependencies.
        Returns None if circular dependency detected.
        """
        # First, collect all required packages via BFS
        required: Set[str] = set()
        queue = deque([target])
        
        while queue:
            pkg_name = queue.popleft()
            if pkg_name in required:
                continue
            if pkg_name not in self.packages:
                raise ValueError(f"Unknown package: {pkg_name}")
            
            required.add(pkg_name)
            for dep in self.packages[pkg_name].dependencies:
                queue.append(dep)
        
        # Build graph and run topological sort
        graph = DirectedGraph()
        for pkg_name in required:
            pkg = self.packages[pkg_name]
            if not pkg.dependencies:
                graph.vertices.add(pkg_name)  # Ensure isolated nodes included
            for dep in pkg.dependencies:
                graph.add_edge(pkg_name, dep)
        
        return dfs_topological_sort(graph)

# Example usage
resolver = DependencyResolver()
resolver.register(Package("app", "1.0", ["web-framework", "database-driver"]))
resolver.register(Package("web-framework", "2.1", ["http-client", "json-parser"]))
resolver.register(Package("database-driver", "3.0", ["connection-pool"]))
resolver.register(Package("http-client", "1.5", []))
resolver.register(Package("json-parser", "2.0", []))
resolver.register(Package("connection-pool", "1.2", []))

install_order = resolver.resolve_install_order("app")
print("Install order:", install_order)
# Output: ['http-client', 'json-parser', 'web-framework', 'connection-pool', 
#          'database-driver', 'app']

Other applications include spreadsheet cell evaluation (cells referencing other cells form a DAG), database migration ordering, course scheduling systems, and determining compilation order in languages with module dependencies.

Complexity Analysis and Edge Cases

Both algorithms run in O(V + E) time, where V is the number of vertices and E is the number of edges. Each vertex and edge is processed exactly once. Space complexity is also O(V + E) for storing the graph and auxiliary data structures.

Key edge cases to handle:

  1. Disconnected components: Both algorithms handle these correctly if you iterate over all vertices, not just those reachable from a single source.

  2. Multiple valid orderings: Topological sort isn’t unique. For a graph with edges A→C and B→C, both [A, B, C] and [B, A, C] are valid. If you need deterministic output, sort the candidates at each step.

  3. Empty graphs: Return an empty list, not an error.

  4. Self-loops: These are cycles of length 1. Both algorithms detect them correctly.

Conclusion

Choose Kahn’s algorithm when you want explicit cycle detection with clear error reporting, or when you’re interested in processing vertices in “waves” (all zero in-degree vertices form a wave). Choose DFS when you’re already using depth-first traversal for other purposes or prefer the recursive implementation style.

Most languages offer library support: Python’s graphlib.TopologicalSorter (Python 3.9+), Java’s various graph libraries, and JavaScript’s ecosystem packages. For production systems, prefer battle-tested libraries over hand-rolled implementations.

Understanding topological sort opens doors to related algorithms: shortest paths in DAGs (linear time!), critical path analysis, and strongly connected components. Master this fundamental algorithm, and you’ll recognize its applications everywhere dependency ordering matters.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.