AI SWE Prep
0/23in Data Structures & Algorithms
Data Structures & Algorithms

Shortest Paths & Topological Sort

Dijkstra for weighted graphs, topological order for dependencies, and how to recognize a graph problem in disguise.

~18 minLesson 28 of 60
Your progressNot started

Basic BFS and DFS answer “what can I reach?” Advanced graph interviews usually ask one of two sharper questions: what order respects these dependencies? or what is the cheapest way to get there? Those are different tools, not just different traversals.

Topological sort works on directed acyclic graphs, where each edge says “this must come before that.” Dijkstra works on weighted graphs with non-negative edge costs, where each relaxation asks whether a newly discovered route is cheaper than the best one you already know.

Both patterns are bookkeeping problems. Topological sort tracks how many prerequisites remain. Dijkstra tracks the best known distance and always expands the currently cheapest unfinished node.

The mental model

A topological order is an ordering of nodes where every directed edge points forward. If a -> b, then a must appear before b. That only makes sense in a DAG; a cycle means there is no valid dependency order.

Kahn’s algorithm treats the graph like a queue of tasks that are ready now:

from collections import deque

def topo_order(n, edges):
    graph = [[] for _ in range(n)]
    indegree = [0] * n

    for before, after in edges:
        graph[before].append(after)
        indegree[after] += 1

    q = deque(i for i in range(n) if indegree[i] == 0)
    order = []

    while q:
        node = q.popleft()
        order.append(node)
        for nxt in graph[node]:
            indegree[nxt] -= 1
            if indegree[nxt] == 0:
                q.append(nxt)

    return order if len(order) == n else []

The DFS version uses colors: unseen, visiting, done. Reaching a visiting node means you found a back edge, which is a cycle. If there is no cycle, appending a node after its descendants finish gives a postorder that reverses into a valid topological order.

For weighted shortest paths, Dijkstra keeps a min-heap of candidate routes:

import heapq

def dijkstra(graph, source):
    dist = {source: 0}
    heap = [(0, source)]

    while heap:
        cost, node = heapq.heappop(heap)
        if cost != dist[node]:
            continue

        for nxt, weight in graph.get(node, []):
            new_cost = cost + weight
            if new_cost < dist.get(nxt, float("inf")):
                dist[nxt] = new_cost
                heapq.heappush(heap, (new_cost, nxt))

    return dist

Use BFS instead when every edge has the same cost: the first time BFS reaches a node is already the shortest path in number of edges. If edges can be negative, Dijkstra’s “cheapest node is final” assumption breaks; reach for Bellman-Ford or another negative-edge algorithm instead.

Shortest-path algorithm choosertap a column to focus
BFSDijkstraBellman-FordFloyd-Warshall
Edge weightsunweightednon-negativenegative OKnegative OK
Sourcessinglesinglesingleall pairs
TimeO(V+E)O(E log V)O(V·E)O(V³)
Detects neg. cyclenonoyesyes
Pick the cheapest algorithm your edge weights allow.

Pattern recognition

Variations

Worked problems

Try each yourself before revealing the solution. The first two are the same dependency graph viewed two ways; the third switches to weighted shortest paths.

LeetCode 207Medium
  • Graph
  • Topological sort
  • Cycle detection

Course Schedule

You are given num_courses labeled 0 through num_courses - 1 and prerequisite pairs [course, prereq]. Return whether it is possible to finish every course.

Approach. Model each prerequisite as prereq -> course. Courses with indegree 0 are available immediately. Every time you take a course, decrement the indegree of courses that depended on it. If you can take all courses, the graph was acyclic; if some remain blocked forever, those courses sit inside a cycle.

Show solution
from collections import deque

def can_finish(num_courses, prerequisites):
    graph = [[] for _ in range(num_courses)]
    indegree = [0] * num_courses

    for course, prereq in prerequisites:
        graph[prereq].append(course)
        indegree[course] += 1

    q = deque(i for i in range(num_courses) if indegree[i] == 0)
    taken = 0

    while q:
        course = q.popleft()
        taken += 1

        for nxt in graph[course]:
            indegree[nxt] -= 1
            if indegree[nxt] == 0:
                q.append(nxt)

    return taken == num_courses

Complexity. O(V + E) time and O(V + E) space, where V is the number of courses and E is the number of prerequisite pairs. Every node enters the queue once, and every edge is relaxed once.

LeetCode 210Medium
  • Graph
  • DFS
  • Topological order

Course Schedule II

Given the same course labels and prerequisite pairs, return one valid ordering of courses. If no ordering can satisfy all prerequisites, return an empty list.

Approach. Use DFS colors. 0 means unseen, 1 means currently on the recursion stack, and 2 means fully processed. A 1 encountered from a child is a cycle. When a node finishes, append it; reversing the finishing order puts every prerequisite before the courses that depend on it.

Show solution
def find_order(num_courses, prerequisites):
    graph = [[] for _ in range(num_courses)]
    for course, prereq in prerequisites:
        graph[prereq].append(course)

    color = [0] * num_courses
    order = []

    def dfs(course):
        if color[course] == 1:
            return False
        if color[course] == 2:
            return True

        color[course] = 1
        for nxt in graph[course]:
            if not dfs(nxt):
                return False

        color[course] = 2
        order.append(course)
        return True

    for course in range(num_courses):
        if color[course] == 0 and not dfs(course):
            return []

    return order[::-1]

Complexity. O(V + E) time and O(V + E) space. The graph stores every edge, and the recursion stack can grow to O(V) in the longest dependency chain.

LeetCode 743Medium
  • Graph
  • Dijkstra
  • Heap

Network Delay Time

A directed network has n nodes labeled from 1 to n. Each edge gives a travel time from one node to another. Starting at node k, return how long it takes for a signal to reach every node, or -1 if some node is unreachable.

Approach. This is single-source shortest path with non-negative weights, so run Dijkstra from k. The heap always pops the node with the smallest known arrival time. Ignore stale heap entries that are worse than the current dist value, then relax outgoing edges.

Show solution
import heapq
from collections import defaultdict

def network_delay_time(times, n, k):
    graph = defaultdict(list)
    for u, v, w in times:
        graph[u].append((v, w))

    dist = [float("inf")] * (n + 1)
    dist[k] = 0
    heap = [(0, k)]

    while heap:
        time, node = heapq.heappop(heap)
        if time != dist[node]:
            continue

        for nxt, weight in graph[node]:
            new_time = time + weight
            if new_time < dist[nxt]:
                dist[nxt] = new_time
                heapq.heappush(heap, (new_time, nxt))

    answer = max(dist[1:])
    return -1 if answer == float("inf") else answer

Complexity. O((V + E) log V) time and O(V + E) space. Each successful relaxation pushes a heap entry, and heap operations cost O(log V).

The classic pitfall

When the graph has unit weights, choose BFS. When it has non-negative weights, choose Dijkstra. When the graph is about prerequisites, choose topological sort. Most mistakes come from reaching for the right family one step too late.

Your progressNot started