Shortest Paths & Topological Sort
Dijkstra for weighted graphs, topological order for dependencies, and how to recognize a graph problem in disguise.
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.
| BFS | Dijkstra | Bellman-Ford | Floyd-Warshall | |
|---|---|---|---|---|
| Edge weights | unweighted | non-negative | negative OK | negative OK |
| Sources | single | single | single | all pairs |
| Time | O(V+E) | O(E log V) | O(V·E) | O(V³) |
| Detects neg. cycle | no | no | yes | yes |
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.
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_coursesComplexity. 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.
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.
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 answerComplexity. 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.