Data Structures & Algorithms (DSA) Module 12 – Shortest paths, spanning trees and flows
Shortest paths: relaxation and DAGs
What a shortest path is, why breadth-first search fails on weighted edges, how relaxation works, and how a DAG is solved in one pass, longest paths too.
What you will learn
- Define single-source shortest paths, distances and relaxation
- Explain why breadth-first search fails on weighted graphs and when negative edges matter
- Compute shortest and longest paths in a DAG in O(V + E) time
- Choose a shortest-path algorithm from the weights and the shape of a graph
Before you start
What you’ll learn · Before you start
What you will learn
- Define single-source shortest paths, distances and relaxation
- Explain why breadth-first search fails on weighted graphs and when negative edges matter
- Compute shortest and longest paths in a DAG in O(V + E) time
- Choose a shortest-path algorithm from the weights and the shape of a graph
Before you start
On this page
A shortest path from a vertex s to a vertex v of a weighted graph is a path whose edge weights add up to the
smallest possible total, and that total is the distance from s to v. The single-source problem asks for the
distance from one source to every vertex, plus a way to rebuild each path. Every classic algorithm for it keeps an
estimate dist[v] for each vertex and improves it with one step, relaxation: if going to u and then along the edge
u → v is cheaper than the best route to v found so far, remember the cheaper total. The algorithms differ only in the
order in which they relax edges. In a directed acyclic graph (DAG) the right order is known in advance: relax the
edges in topological order, and one pass gives every distance in O(V + E), even with negative weights. The same pass with
“longer” in place of “shorter” finds the longest path, the critical path of a project plan.
What a shortest path is
Take a directed graph whose edges carry weights: minutes, fares, metres, risk. The weight of a path is the sum of the
weights of its edges, and the distance δ(s, v) is the smallest weight of any path from s to v. When no path leads from s
to v, the distance is infinite, and code stores it as math.inf in Python or Infinity in JavaScript. An undirected
road is two directed edges, one each way, so everything here covers undirected graphs too.
A solution returns two arrays. dist[v] holds the distance, and parent[v] the vertex before v on one shortest path,
so the path to any vertex is read backwards from it to s through the parents. The parent edges together form a
shortest-path tree rooted at s. One fact makes that tree possible: every part of a shortest path is itself a
shortest path. If the best route from s to v passes through u, its first stretch is a best route from s to u, because a
cheaper way to u would make a cheaper way to v.
Why breadth-first search is not enough
Breadth-first search (BFS) finds the path with the fewest edges, which is the shortest path only when every edge weighs the same. On a weighted map, a route with more roads can take fewer minutes:
Fewest roads is not fewest minutes
Text description of the diagram
Five places are joined by one-way roads, each labelled with the minutes it takes.
- The direct road from the depot to the market takes 9 minutes. It is drawn dashed: breadth-first search picks it, because it is a single road.
- The route depot to school (2 minutes), school to bank (3) and bank to market (2) uses three roads and takes 7 minutes. It is drawn thick: it is the shortest path by weight.
- The third route, depot to school (2), school to park (4) and park to market (4), takes 10 minutes.
# A rider's map (made up): one-way roads between five places, weighted by minutes. Breadth-first search finds the
# route with the fewest roads; the route with the fewest minutes can use more roads.
from collections import deque
from itertools import count
roads = [
("depot", "market", 9),
("depot", "school", 2),
("school", "bank", 3),
("bank", "market", 2),
("school", "park", 4),
("park", "market", 4),
]
graph = {}
for u, v, minutes in roads:
graph.setdefault(u, []).append((v, minutes))
graph.setdefault(v, [])
def every_route(u, target, route):
"""All routes from u to target that repeat no place (fine for five places, hopeless for five thousand)."""
if u == target:
yield route
return
for v, _ in graph[u]:
if v not in route:
yield from every_route(v, target, route + [v])
def minutes_of(route):
weight = {(u, v): m for u, v, m in roads}
return sum(weight[step] for step in zip(route, route[1:]))
def bfs_route(graph, source, target):
"""Breadth-first search with parent pointers: the route with the fewest edges."""
parent = {source: None}
queue = deque([source])
while queue:
u = queue.popleft()
for v, _ in graph[u]:
if v not in parent:
parent[v] = u
queue.append(v)
route = [target]
while parent[route[-1]] is not None:
route.append(parent[route[-1]])
return route[::-1]
print("every route from the depot to the market:")
routes = list(every_route("depot", "market", ["depot"]))
for route in routes:
print(f" {' -> '.join(route):34} {len(route) - 1} road{'s' if len(route) > 2 else ' '} {minutes_of(route):2} min")
fewest = bfs_route(graph, "depot", "market")
best = min(routes, key=minutes_of)
print(f"BFS, fewest roads: {' -> '.join(fewest)}, {minutes_of(fewest)} min")
print(f"fewest minutes: {' -> '.join(best)}, {minutes_of(best)} min")
# The repair that works only for small whole-number weights: cut every road into 1-minute pieces, then run BFS.
fresh = count()
pieces = {place: [] for place in graph}
for u, v, minutes in roads:
previous = u
for _ in range(minutes - 1):
stop = f"piece {next(fresh)}"
pieces[stop] = []
pieces[previous].append((stop, 1))
previous = stop
pieces[previous].append((v, 1))
cut = bfs_route(pieces, "depot", "market")
edges = sum(len(out) for out in pieces.values())
print(f"cut into 1-minute pieces: {len(pieces)} vertices and {edges} edges instead of {len(graph)} and {len(roads)};"
f" BFS now needs {len(cut) - 1} steps, the {minutes_of(best)} minutes") Output
every route from the depot to the market: depot -> market 1 road 9 min depot -> school -> bank -> market 3 roads 7 min depot -> school -> park -> market 3 roads 10 min BFS, fewest roads: depot -> market, 9 min fewest minutes: depot -> school -> bank -> market, 7 min cut into 1-minute pieces: 23 vertices and 24 edges instead of 5 and 6; BFS now needs 7 steps, the 7 minutes
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bfs_trap.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
BFS took the single road of 9 minutes, while the three roads through the school and the bank take 7. One repair works for small whole-number weights: cut every edge of weight w into w edges of weight 1, and BFS on the bigger graph is correct. Here the map grew from 5 vertices and 6 edges to 23 and 24. With weights in seconds, or up to 10⁹ as in typical problem limits, the cut graph would not fit in memory, so weighted graphs need algorithms that work with the weights themselves. When every weight is 0 or 1, a deque instead of a queue (0-1 BFS) keeps the search at O(V + E).
Relaxation, the step every algorithm repeats
Start with dist[s] = 0 and every other dist[v] = ∞. Relaxing the edge u → v of weight w means:
- if
dist[u] + w < dist[v], setdist[v] = dist[u] + wandparent[v] = u; - otherwise, change nothing.
This trace relaxes the six edges of a small graph in two orders:
# Relaxation, one edge at a time, on a small directed graph. Every shortest-path algorithm of this module is this
# step repeated; they differ only in the order in which they pick the edges.
import math
edges = [("s", "a", 4), ("s", "b", 1), ("b", "a", 2), ("a", "c", 1), ("b", "c", 5), ("c", "d", 3)]
def show(x):
return "inf" if x == math.inf else str(x)
def relax(dist, parent, u, v, w, log):
"""If going through u makes v cheaper, remember the cheaper distance and the edge that gave it."""
if dist[u] + w < dist[v]:
log.append(f" relax {u} -> {v} ({w}): {show(dist[u])} + {w} = {dist[u] + w} < {show(dist[v])}, so dist[{v}] = {dist[u] + w}")
dist[v] = dist[u] + w
parent[v] = u
return True
log.append(f" relax {u} -> {v} ({w}): {show(dist[u])} + {w} is not below {show(dist[v])}, no change")
return False
def run(order, passes):
dist = {v: math.inf for v in "sabcd"}
parent = {v: None for v in "sabcd"}
dist["s"] = 0
relaxations = 0
for p in range(1, passes + 1):
log = []
changed = False
for u, v, w in order:
changed |= relax(dist, parent, u, v, w, log)
relaxations += 1
if not changed:
print(f" pass {p}: none of the {len(order)} edges changes anything, so the distances are final")
break
print(f" pass {p}:", *log, sep="\n")
print(f" {relaxations} relaxations; dist: " + ", ".join(f"{v} {show(d)}" for v, d in dist.items()))
route = ["d"]
while parent[route[-1]] is not None:
route.append(parent[route[-1]])
print(f" shortest path to d, read back through the parents: {' -> '.join(reversed(route))}")
print("Order 1: the edges in an unlucky order, repeated until a pass changes nothing")
unlucky = [edges[i] for i in (3, 5, 0, 1, 2, 4)]
run(unlucky, passes=10)
print()
print("Order 2: the edges leaving s, then b, a, c (a topological order), one pass")
by_vertex = sorted(edges, key=lambda e: "sbacd".index(e[0]))
run(by_vertex, passes=1) Output
Order 1: the edges in an unlucky order, repeated until a pass changes nothing pass 1: relax a -> c (1): inf + 1 is not below inf, no change relax c -> d (3): inf + 3 is not below inf, no change relax s -> a (4): 0 + 4 = 4 < inf, so dist[a] = 4 relax s -> b (1): 0 + 1 = 1 < inf, so dist[b] = 1 relax b -> a (2): 1 + 2 = 3 < 4, so dist[a] = 3 relax b -> c (5): 1 + 5 = 6 < inf, so dist[c] = 6 pass 2: relax a -> c (1): 3 + 1 = 4 < 6, so dist[c] = 4 relax c -> d (3): 4 + 3 = 7 < inf, so dist[d] = 7 relax s -> a (4): 0 + 4 is not below 3, no change relax s -> b (1): 0 + 1 is not below 1, no change relax b -> a (2): 1 + 2 is not below 3, no change relax b -> c (5): 1 + 5 is not below 4, no change pass 3: none of the 6 edges changes anything, so the distances are final 18 relaxations; dist: s 0, a 3, b 1, c 4, d 7 shortest path to d, read back through the parents: s -> b -> a -> c -> d Order 2: the edges leaving s, then b, a, c (a topological order), one pass pass 1: relax s -> a (4): 0 + 4 = 4 < inf, so dist[a] = 4 relax s -> b (1): 0 + 1 = 1 < inf, so dist[b] = 1 relax b -> a (2): 1 + 2 = 3 < 4, so dist[a] = 3 relax b -> c (5): 1 + 5 = 6 < inf, so dist[c] = 6 relax a -> c (1): 3 + 1 = 4 < 6, so dist[c] = 4 relax c -> d (3): 4 + 3 = 7 < inf, so dist[d] = 7 6 relaxations; dist: s 0, a 3, b 1, c 4, d 7 shortest path to d, read back through the parents: s -> b -> a -> c -> d
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 relax_trace.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Three facts explain everything the algorithms of this module do. First, dist[v] is always the weight of some walk
from s to v, or ∞, so it never drops below the true distance. Second, relaxation only ever lowers it, so once it equals
the distance it stays there. Third, if the edges of a shortest path are relaxed in order, from s outwards, then the
last vertex of that path has its true distance afterwards, whatever other relaxations happened in between. In the
unlucky order, dist[c] first became 6 through b, and only the second pass, after dist[a] had dropped to 3, carried
the better value along a → c and then c → d. In the topological order every edge into a vertex was relaxed before any
edge out of it, so one pass of 6 relaxations did what the unlucky order did in 18.
Every algorithm in this module is a rule for choosing the order. Dijkstra’s algorithm, published in 1959, always relaxes the edges out of the closest vertex it has not finished. Bellman-Ford, from Richard Bellman’s 1958 paper, relaxes every edge in rounds until nothing changes. The DAG algorithm uses a topological order.
Negative edges and negative cycles
Weights can be negative: a cashback on a fare, a refund, energy recovered on a downhill stretch, a lead in a project plan. Negative edges are harmless in themselves. A negative cycle is not: a cycle whose weights add up to less than zero can be walked again and again, each lap making the total smaller.
# What a negative cycle does to "shortest". The cycle a -> b -> c -> a weighs 2 + (-4) + 1 = -1, so every lap
# around it lowers the cost of reaching a, b, c and t by 1, and relaxation never settles.
import math
def rounds(vertices, edges, source, count):
"""Relaxes every edge `count` times and prints the distances after each round."""
dist = {v: math.inf for v in vertices}
dist[source] = 0
print("round " + "".join(f"{v:>6}" for v in vertices))
for r in range(1, count + 1):
changed = 0
for u, v, w in edges:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
changed += 1
row = "".join(f"{'inf' if d == math.inf else d:>6}" for d in dist.values())
print(f"{r:>5} {row} ({changed} changed)")
vertices = ["s", "a", "b", "c", "t"]
cycle = [("a", "b", 2), ("b", "c", -4), ("c", "a", 1)]
print("1. The cycle can be reached from s (edge s -> a):")
rounds(vertices, [("s", "a", 1), *cycle, ("c", "t", 3)], "s", 8)
print()
print("2. The same cycle, but s has no edge into it (s -> t only, and c -> t):")
rounds(vertices, [("s", "t", 5), *cycle, ("c", "t", 3)], "s", 3) Output
1. The cycle can be reached from s (edge s -> a):
round s a b c t
1 0 0 3 -1 2 (5 changed)
2 0 -1 2 -2 1 (4 changed)
3 0 -2 1 -3 0 (4 changed)
4 0 -3 0 -4 -1 (4 changed)
5 0 -4 -1 -5 -2 (4 changed)
6 0 -5 -2 -6 -3 (4 changed)
7 0 -6 -3 -7 -4 (4 changed)
8 0 -7 -4 -8 -5 (4 changed)
2. The same cycle, but s has no edge into it (s -> t only, and c -> t):
round s a b c t
1 0 inf inf inf 5 (1 changed)
2 0 inf inf inf 5 (0 changed)
3 0 inf inf inf 5 (0 changed)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 negative_cycle.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
In the first graph the cycle a → b → c → a weighs −1, and after round 1 every round lowered a, b, c and t by one more; there is no shortest walk to them, and their distances are −∞. In the second graph the same cycle sits where s cannot reach it, and the distance to t settled at 5 after one round. So distances are well defined exactly when no negative cycle lies on a walk from s to the vertex. Asking for the cheapest path that repeats no vertex does not rescue the first graph: with every weight set to −1 that path would be the one through the most vertices, so a fast method for it would also decide whether a graph has a Hamiltonian cycle, a problem Richard Karp proved NP-complete in 1972. The algorithms therefore report a reachable negative cycle instead of a distance, as the Bellman-Ford lesson shows.
Shortest paths in a DAG, in one pass
A DAG has a topological order, a listing of its vertices in which every edge goes from an earlier vertex to a later
one; Kahn’s 1962 algorithm finds it by repeatedly taking a vertex with no edges left coming in. Every path visits its
vertices in that order, so by the time the algorithm reaches a vertex u, every edge into u has already been relaxed and
dist[u] is final. Relaxing u’s outgoing edges then passes final values on. Each vertex is taken once and each edge
relaxed once, which is O(V + E), and nothing in the argument cares about the sign of the weights, because a DAG has no
cycle at all, negative or not.
The van in this example works through a day of deliveries. A vertex is a stop at a time, so every edge points forward in time and the graph cannot have a cycle. An edge weighs the fuel for the drive minus the fee paid at the stop it reaches, so a drive that earns money weighs less than zero:
Python · dag/dag_paths.py
# Shortest paths in a DAG: a topological order (Kahn's algorithm), then one relaxation of every edge in that order.
# O(V + E), and negative weights are fine. The graph is a delivery van's day (made up): a vertex is a stop at a time,
# so every edge goes forward in time and the graph has no cycle. An edge weighs the fuel to drive it minus the fee
# paid at the stop it reaches, so a negative weight is a drive that earns more than it costs.
import math
from collections import deque
def dag_shortest_paths(n, edges, source):
"""Returns (dist, parent) for vertices 0..n-1; raises ValueError if the graph has a cycle."""
out = [[] for _ in range(n)]
indegree = [0] * n
for u, v, w in edges:
out[u].append((v, w))
indegree[v] += 1
order = []
ready = deque(v for v in range(n) if indegree[v] == 0)
while ready: # Kahn: take a vertex with no edge left coming in, then remove its edges
u = ready.popleft()
order.append(u)
for v, _ in out[u]:
indegree[v] -= 1
if indegree[v] == 0:
ready.append(v)
if len(order) < n:
raise ValueError("the graph has a cycle, so it has no topological order")
dist = [math.inf] * n
parent = [None] * n
dist[source] = 0
for u in order:
if dist[u] == math.inf:
continue # not reachable from the source: nothing to relax
for v, w in out[u]:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
parent[v] = u
return dist, parent, order
def path_to(parent, v):
path = [v]
while parent[path[-1]] is not None:
path.append(parent[path[-1]])
return path[::-1]
stops = ["depot 8:00", "bakery 9:00", "school 10:00", "clinic 10:30", "market 12:00", "office 13:00", "depot 17:00"]
fee = [0, 0, 60, 90, 40, 70, 0]
drives = [(0, 1, 30), (0, 2, 50), (1, 2, 20), (1, 3, 35), (2, 4, 25), (2, 5, 45),
(3, 4, 30), (3, 5, 40), (4, 5, 15), (4, 6, 30), (5, 6, 35)]
edges = [(u, v, fuel - fee[v]) for u, v, fuel in drives]
print("edge weights (fuel minus fee):", ", ".join(f"{u}->{v} {w}" for u, v, w in edges))
dist, parent, order = dag_shortest_paths(len(stops), edges, 0)
print("topological order:", ", ".join(stops[v] for v in order))
for v in range(1, len(stops)):
route = " -> ".join(stops[x].split()[0] for x in path_to(parent, v))
print(f" {stops[v]:13} {dist[v]:4} {route}")
print(f"best day: net cost {dist[6]}, so the van earns {-dist[6]} more than its fuel costs") Output
edge weights (fuel minus fee): 0->1 30, 0->2 -10, 1->2 -40, 1->3 -55, 2->4 -15, 2->5 -25, 3->4 -10, 3->5 -30, 4->5 -55, 4->6 30, 5->6 35 topological order: depot 8:00, bakery 9:00, school 10:00, clinic 10:30, market 12:00, office 13:00, depot 17:00 bakery 9:00 30 depot -> bakery school 10:00 -10 depot -> school clinic 10:30 -25 depot -> bakery -> clinic market 12:00 -35 depot -> bakery -> clinic -> market office 13:00 -90 depot -> bakery -> clinic -> market -> office depot 17:00 -55 depot -> bakery -> clinic -> market -> office -> depot best day: net cost -55, so the van earns 55 more than its fuel costs
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dag_paths.py
JavaScript · dag/dag_paths.mjs
// Shortest paths in a DAG: a topological order (Kahn's algorithm), then one relaxation of every edge in that order.
// O(V + E), and negative weights are fine. The same delivery van's day as dag_paths.py.
function dagShortestPaths(n, edges, source) {
const out = Array.from({ length: n }, () => []);
const indegree = new Array(n).fill(0);
for (const [u, v, w] of edges) {
out[u].push([v, w]);
indegree[v] += 1;
}
const order = [];
const ready = [];
for (let v = 0; v < n; v++) if (indegree[v] === 0) ready.push(v);
for (let head = 0; head < ready.length; head++) {
// a queue with a head index: shift() would move every item each time
const u = ready[head];
order.push(u);
for (const [v] of out[u]) if (--indegree[v] === 0) ready.push(v);
}
if (order.length < n) throw new Error('the graph has a cycle, so it has no topological order');
const dist = new Array(n).fill(Infinity);
const parent = new Array(n).fill(null);
dist[source] = 0;
for (const u of order) {
if (dist[u] === Infinity) continue; // not reachable from the source: nothing to relax
for (const [v, w] of out[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
parent[v] = u;
}
}
}
return { dist, parent, order };
}
function pathTo(parent, v) {
const path = [v];
while (parent[path[path.length - 1]] !== null) path.push(parent[path[path.length - 1]]);
return path.reverse();
}
const stops = ['depot 8:00', 'bakery 9:00', 'school 10:00', 'clinic 10:30', 'market 12:00', 'office 13:00', 'depot 17:00'];
const fee = [0, 0, 60, 90, 40, 70, 0];
const drives = [[0, 1, 30], [0, 2, 50], [1, 2, 20], [1, 3, 35], [2, 4, 25], [2, 5, 45],
[3, 4, 30], [3, 5, 40], [4, 5, 15], [4, 6, 30], [5, 6, 35]];
const edges = drives.map(([u, v, fuel]) => [u, v, fuel - fee[v]]);
console.log('edge weights (fuel minus fee): ' + edges.map(([u, v, w]) => `${u}->${v} ${w}`).join(', '));
const { dist, parent, order } = dagShortestPaths(stops.length, edges, 0);
console.log('topological order: ' + order.map((v) => stops[v]).join(', '));
for (let v = 1; v < stops.length; v++) {
const route = pathTo(parent, v).map((x) => stops[x].split(' ')[0]).join(' -> ');
console.log(` ${stops[v].padEnd(13)} ${String(dist[v]).padStart(4)} ${route}`);
}
console.log(`best day: net cost ${dist[6]}, so the van earns ${-dist[6]} more than its fuel costs`); Output
edge weights (fuel minus fee): 0->1 30, 0->2 -10, 1->2 -40, 1->3 -55, 2->4 -15, 2->5 -25, 3->4 -10, 3->5 -30, 4->5 -55, 4->6 30, 5->6 35 topological order: depot 8:00, bakery 9:00, school 10:00, clinic 10:30, market 12:00, office 13:00, depot 17:00 bakery 9:00 30 depot -> bakery school 10:00 -10 depot -> school clinic 10:30 -25 depot -> bakery -> clinic market 12:00 -35 depot -> bakery -> clinic -> market office 13:00 -90 depot -> bakery -> clinic -> market -> office depot 17:00 -55 depot -> bakery -> clinic -> market -> office -> depot best day: net cost -55, so the van earns 55 more than its fuel costs
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node dag_paths.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The cheapest day goes through the bakery, the clinic, the market and the office, with a net cost of −55: the fees
earned beat the fuel by 55. The school is a tie at −10, either directly from the depot or through the bakery. One line
deserves attention: the loop skips a vertex whose distance is still infinite. Python’s math.inf plus −55 is still
infinite, but code that stores ∞ as a large integer would turn “unreachable” into a real-looking distance after one
negative edge, unless it skips such vertices.
Three orders of relaxation, counted
The order decides the work, not the answer. This run relaxes the edges of one random DAG, with 1,000 vertices, 6,000 edges and weights from −10 to 100, in three orders, and counts the relaxations:
# One random DAG, three orders of relaxing its edges. Each order ends with the same distances; the work differs.
import math
import random
from collections import deque
rng = random.Random(12)
V, E = 1_000, 6_000
rank = list(range(V))
rng.shuffle(rank) # rank[v]: v's place in a hidden order; every edge goes from a lower rank to a higher one
by_rank = sorted(range(V), key=rank.__getitem__)
source = by_rank[0]
pairs = set()
for r in range(1, V): # one edge into every vertex from an earlier one, so all of them can be reached
pairs.add((by_rank[rng.randrange(r)], by_rank[r]))
while len(pairs) < E:
a, b = sorted(rng.sample(range(V), 2))
pairs.add((by_rank[a], by_rank[b]))
edges = [(u, v, rng.randint(-10, 100)) for u, v in sorted(pairs)] # sorted: a set's order differs between builds
rng.shuffle(edges)
def topological_order():
out = [[] for _ in range(V)]
indegree = [0] * V
for u, v, _ in edges:
out[u].append(v)
indegree[v] += 1
ready = deque(v for v in range(V) if indegree[v] == 0)
order = []
while ready:
u = ready.popleft()
order.append(u)
for v in out[u]:
indegree[v] -= 1
if indegree[v] == 0:
ready.append(v)
return order
def relax_in_passes(edge_order, max_passes):
"""Relaxes the edges in this order, pass after pass, until a pass lowers nothing (or max_passes)."""
dist = [math.inf] * V
dist[source] = 0
passes = relaxed = 0
while passes < max_passes:
passes += 1
changed = False
for u, v, w in edge_order:
relaxed += 1
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
changed = True
if not changed:
break
return dist, passes, relaxed
order = topological_order()
position = {v: i for i, v in enumerate(order)}
by_tail = sorted(edges, key=lambda e: position[e[0]])
runs = {
"topological order, one pass": relax_in_passes(by_tail, 1),
"input order, until a pass is quiet": relax_in_passes(edges, V),
"reverse topological order, likewise": relax_in_passes(by_tail[::-1], V),
}
print(f"a random DAG: {V:,} vertices, {E:,} edges, weights from -10 to 100")
print(f"{'order of the relaxations':37} {'passes':>6} {'relaxations':>12}")
for name, (_, passes, relaxed) in runs.items():
print(f"{name:37} {passes:>6} {relaxed:>12,}")
answers = [run[0] for run in runs.values()]
print(f"all three agree on every distance: {answers[0] == answers[1] == answers[2]}")
# Why the last order needs so many passes: each pass carries a distance only one edge further along a path.
# hops[v]: the fewest edges on any shortest path to v, filled in topological order.
dist = answers[0]
hops = [math.inf] * V
hops[source] = 0
for u, v, w in by_tail:
if dist[u] + w == dist[v]:
hops[v] = min(hops[v], hops[u] + 1)
print(f"some vertex needs {max(hops)} edges on its shortest path: {max(hops)} passes, then one quiet pass") Output
a random DAG: 1,000 vertices, 6,000 edges, weights from -10 to 100 order of the relaxations passes relaxations topological order, one pass 1 6,000 input order, until a pass is quiet 7 42,000 reverse topological order, likewise 10 60,000 all three agree on every distance: True some vertex needs 9 edges on its shortest path: 9 passes, then one quiet pass
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 three_orders.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
All three orders agree on every distance. The topological order relaxed each edge once, 6,000 relaxations. Repeating passes over the edges in the order they were stored needed 7 passes, the last of them only to see that nothing changed: 42,000 relaxations. The reverse topological order is the worst order there is. Each pass carries a distance just one edge further along every path, so it needed one pass per edge of the longest shortest path, 9, plus the quiet one: 60,000. The second and third orders are Bellman-Ford’s rounds, which work on any graph but pay for not knowing the order; the DAG algorithm knows it.
Longest paths and the critical path
For general graphs the longest path that repeats no vertex is as hard as the Hamiltonian cycle problem. In a DAG it is
easy: negate every weight and find the shortest path, or, equivalently, relax with > instead of <. That is the
critical-path method of project planning, described by James Kelley and Morgan Walker in 1959. Each task can start
when all its prerequisites have finished, so its earliest start is the longest path to it, and the length of the whole
project is the longest path through the plan.
The plan as a DAG, with its critical path
Text description of the diagram
Each box is a task with its duration in days; an arrow runs from a task to a task that cannot start before it ends.
- Fixing the date (2 days) comes first. Three tasks wait for it: booking the venue (7), drawing up the guest list (5) and choosing the outfits (4).
- Booking the caterer (4), decorating (2) and printing the invitations (6) wait for the venue; printing also waits for the guest list. Sending the invitations (3) follows printing.
- Tailoring and fittings (15 days) follow the choice of outfits.
- The mehendi and sangeet (2) wait for both the tailoring and the invitations. The wedding day (1) waits for the mehendi and sangeet, the caterer and the decoration.
- The thick arrows mark the critical path: fix the date, choose the outfits, tailoring and fittings, mehendi and sangeet, wedding day. Its 2 + 4 + 15 + 2 + 1 = 24 days are the longest path through the plan, so the wedding day starts on day 23 and everything ends after 24 days.
# The critical path of a wedding plan (tasks and durations made up). Each task can start only when its prerequisites
# are done, so the earliest start of a task is the LONGEST path to it: relaxation with max instead of min, in
# topological order. graphlib.TopologicalSorter (standard library) supplies the order and refuses a cycle.
from graphlib import TopologicalSorter
tasks = { # task: (days, prerequisites)
"fix the date": (2, []),
"book the venue": (7, ["fix the date"]),
"draw up the guest list": (5, ["fix the date"]),
"book the caterer": (4, ["book the venue"]),
"print the invitations": (6, ["book the venue", "draw up the guest list"]),
"send the invitations": (3, ["print the invitations"]),
"choose the outfits": (4, ["fix the date"]),
"tailoring and fittings": (15, ["choose the outfits"]),
"decorate the venue": (2, ["book the venue"]),
"mehendi and sangeet": (2, ["tailoring and fittings", "send the invitations"]),
"wedding day": (1, ["mehendi and sangeet", "book the caterer", "decorate the venue"]),
}
days = {t: d for t, (d, _) in tasks.items()}
after = {t: [] for t in tasks}
for t, (_, before) in tasks.items():
for p in before:
after[p].append(t)
order = list(TopologicalSorter({t: before for t, (_, before) in tasks.items()}).static_order())
# Forward pass: relax each edge t -> s with "can s start later?", the longest-path version of relaxation.
start = {t: 0 for t in tasks}
waits_for = {t: None for t in tasks}
for t in order:
for s in after[t]:
if start[t] + days[t] > start[s]:
start[s] = start[t] + days[t]
waits_for[s] = t
finish = max(start[t] + days[t] for t in tasks)
# Backward pass, in reverse topological order: the latest start that still finishes everything on time.
latest = {}
for t in reversed(order):
latest[t] = min([latest[s] for s in after[t]], default=finish) - days[t]
print(f"{'task':24} {'days':>4} {'earliest':>9} {'latest':>7} {'slack':>6}")
for t in order:
slack = latest[t] - start[t]
print(f"{t:24} {days[t]:>4} {start[t]:>9} {latest[t]:>7} {slack:>6}{' critical' if slack == 0 else ''}")
path = ["wedding day"]
while waits_for[path[-1]] is not None:
path.append(waits_for[path[-1]])
print(f"everything is done after {finish} days, the length of the longest path")
print("critical path:", " -> ".join(reversed(path))) Output
task days earliest latest slack fix the date 2 0 0 0 critical book the venue 7 2 5 3 draw up the guest list 5 2 7 5 choose the outfits 4 2 2 0 critical book the caterer 4 9 19 10 decorate the venue 2 9 21 12 print the invitations 6 9 12 3 tailoring and fittings 15 6 6 0 critical send the invitations 3 15 18 3 mehendi and sangeet 2 21 21 0 critical wedding day 1 23 23 0 critical everything is done after 24 days, the length of the longest path critical path: fix the date -> choose the outfits -> tailoring and fittings -> mehendi and sangeet -> wedding day
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 critical_path.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The forward pass in topological order gives each task’s earliest start. A backward pass in reverse order gives the
latest start that still lets everything finish on day 24, and the difference is the task’s slack. The tasks with no
slack form the critical path: the date, the outfits, 15 days of tailoring, the mehendi and sangeet, and the wedding
day. The venue, which feels like the big decision, has 3 days of slack, and the caterer has 10. Finishing the venue
early gains nothing; one more day of tailoring moves the wedding by one day. Python’s graphlib.TopologicalSorter
supplied the order and would have raised CycleError if two tasks waited for each other.
Choosing a shortest-path algorithm
The weights and the shape of the graph decide the algorithm. The first two rows belong to the graph-traversal module; the lessons after this one cover the others:
| The graph | Algorithm | Time |
|---|---|---|
| All weights equal | BFS | O(V + E) |
| Weights 0 or 1 | 0-1 BFS | O(V + E) |
| A DAG, any weights | Topological order | O(V + E) |
| No weight below 0 | Dijkstra | O((V + E) log V) |
| Some weights below 0 | Bellman-Ford | O(VE) |
| Every pair, dense | Floyd-Warshall | O(V³) |
| Every pair, sparse | Johnson | O(VE log V) |
Exercise: the critical path of a project
A project plan gives each pair of milestones a minimum time between their starts, some of them negative, and asks for the earliest start of the last milestone. The tests check small plans against every path and end with a plan of 100,000 milestones.
Exercise · Medium · Python, JavaScript
The longest path between two milestones of a project
A project plan is a directed acyclic graph on the milestones 0 to n - 1. An edge (u, v, w) (JavaScript: a three-element array) says that v can start no earlier than w days after u starts. w may be negative: then v may start before u (a lead). Write longest_path(n, edges, s, t) (JavaScript: longestPath(n, edges, s, t)), which returns the earliest day on which t can start when s starts on day 0, that is, the largest total weight of a path from s to t, together with one path that has it, as a list of milestones from s to t.
Python returns a tuple (length, path), JavaScript an array [length, path]. When no path leads from s to t, return None (JavaScript: null). When s and t are the same milestone, the answer is (0, [s]).
The edges come in any order, and the numbers of the milestones say nothing about their order in the project. The tests include negative edges that a longest path still uses, milestones that cannot be reached, 300 small random projects checked against every path, and a project of 100,000 milestones and about 200,000 edges whose longest path runs through every milestone: trying every path, or recursing once per milestone, does not finish.
Python · Starter code · critical.py
def longest_path(n, edges, s, t):
"""DAG on milestones 0..n-1, edges (u, v, w). Returns (length, path) of a longest s-t path, or None."""
raise NotImplementedError("write longest_path") The sample tests · test_critical.py
import random
from critical import longest_path
def best_by_every_path(n, edges, s, t):
"""The slow answer: the largest weight over every path from s to t (None if there is none)."""
out = [[] for _ in range(n)]
for u, v, w in edges:
out[u].append((v, w))
best = None
stack = [(s, 0)]
while stack:
u, total = stack.pop()
if u == t and (best is None or total > best):
best = total
for v, w in out[u]:
stack.append((v, total + w))
return best
def check_path(edges, s, t, length, path):
"""The path starts at s, ends at t, uses only edges of the graph and weighs exactly `length`."""
weight = {}
for u, v, w in edges:
weight[(u, v)] = max(w, weight.get((u, v), w))
assert path[0] == s and path[-1] == t, path
assert all(step in weight for step in zip(path, path[1:])), path
assert sum(weight[step] for step in zip(path, path[1:])) == length, path
def test_small_project():
"""two routes to the finish: 0, 2, 3, 4, 5 weighs 14 and beats 0, 1, 3, 4, 5 with 13"""
edges = [(0, 1, 3), (0, 2, 2), (1, 3, 4), (2, 3, 6), (3, 4, 1), (2, 4, 2), (4, 5, 5)]
assert longest_path(6, edges, 0, 5) == (14, [0, 2, 3, 4, 5])
def test_negative_edge_on_the_longest_path():
"""a lead of -2 days still lies on the longest path, because the edge after it is long"""
edges = [(0, 1, -2), (1, 3, 10), (0, 2, 4), (2, 3, 3)]
assert longest_path(4, edges, 0, 3) == (8, [0, 1, 3])
def test_labels_are_not_the_order():
"""milestone 4 comes first and 0 last"""
edges = [(4, 2, 1), (2, 0, 7), (4, 3, 2), (3, 0, 1), (2, 3, 5)]
assert longest_path(5, edges, 4, 0) == (8, [4, 2, 0])
def test_unreachable_target():
"""no path leads from 0 to 2"""
assert longest_path(3, [(1, 2, 5)], 0, 2) is None
def test_source_is_target():
"""the answer for s = t is a path of one milestone"""
assert longest_path(3, [(0, 1, 4)], 0, 0) == (0, [0])
def test_random_projects_against_every_path():
"""300 small random projects: the length matches every path tried, and the path is a real one"""
rng = random.Random(11)
for _ in range(300):
n = rng.randint(1, 7)
hidden = list(range(n))
rng.shuffle(hidden)
pairs = {(hidden[i], hidden[j]) for i in range(n) for j in range(i + 1, n) if rng.random() < 0.45}
edges = [(u, v, rng.randint(-5, 9)) for u, v in sorted(pairs)]
rng.shuffle(edges)
s, t = rng.randrange(n), rng.randrange(n)
best = best_by_every_path(n, edges, s, t)
result = longest_path(n, edges, s, t)
if best is None:
assert result is None, (n, edges, s, t)
else:
assert result is not None and result[0] == best, (n, edges, s, t, result)
check_path(edges, s, t, *result)
def test_large_project():
"""100,000 milestones in a hidden order and about 200,000 edges: the chain through all of them is longest"""
rng = random.Random(21)
n = 100_000
order = list(range(n))
rng.shuffle(order)
steps = [rng.randint(-3, 10) for _ in range(n - 1)]
prefix = [0]
for w in steps:
prefix.append(prefix[-1] + w)
edges = [(order[i], order[i + 1], steps[i]) for i in range(n - 1)]
for _ in range(100_000): # shortcuts that skip milestones, each a little shorter than the chain it skips
i, j = rng.randrange(n), rng.randrange(n)
if i > j:
i, j = j, i
if j > i + 1:
edges.append((order[i], order[j], prefix[j] - prefix[i] - rng.randint(1, 20)))
rng.shuffle(edges)
length, path = longest_path(n, edges, order[0], order[-1])
assert length == prefix[-1]
assert path == order JavaScript · Starter code · critical.mjs
/** DAG on milestones 0..n-1, edges [u, v, w]. Returns [length, path] of a longest s-t path, or null. */
export function longestPath(n, edges, s, t) {
throw new Error('write longestPath');
} The sample tests · critical.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { longestPath } from './critical.mjs';
function bestByEveryPath(n, edges, s, t) {
const out = Array.from({ length: n }, () => []);
for (const [u, v, w] of edges) out[u].push([v, w]);
let best = null;
const stack = [[s, 0]];
while (stack.length) {
const [u, total] = stack.pop();
if (u === t && (best === null || total > best)) best = total;
for (const [v, w] of out[u]) stack.push([v, total + w]);
}
return best;
}
function checkPath(edges, s, t, length, path) {
const weight = new Map();
for (const [u, v, w] of edges) weight.set(`${u},${v}`, Math.max(w, weight.get(`${u},${v}`) ?? w));
assert.ok(path[0] === s && path[path.length - 1] === t, JSON.stringify(path));
let total = 0;
for (let i = 0; i + 1 < path.length; i++) {
const key = `${path[i]},${path[i + 1]}`;
assert.ok(weight.has(key), `no edge ${key} in the graph`);
total += weight.get(key);
}
assert.equal(total, length, JSON.stringify(path));
}
function seeded(seed) {
return () => (seed = (seed * 48271) % 2147483647) / 2147483647;
}
const below = (random, k) => Math.floor(random() * k);
function shuffle(random, a) {
for (let i = a.length - 1; i > 0; i--) {
const j = below(random, i + 1);
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
test('two routes to the finish: 0, 2, 3, 4, 5 weighs 14 and beats 0, 1, 3, 4, 5 with 13', () => {
const edges = [[0, 1, 3], [0, 2, 2], [1, 3, 4], [2, 3, 6], [3, 4, 1], [2, 4, 2], [4, 5, 5]];
assert.deepEqual(longestPath(6, edges, 0, 5), [14, [0, 2, 3, 4, 5]]);
});
test('a lead of -2 days still lies on the longest path, because the edge after it is long', () => {
const edges = [[0, 1, -2], [1, 3, 10], [0, 2, 4], [2, 3, 3]];
assert.deepEqual(longestPath(4, edges, 0, 3), [8, [0, 1, 3]]);
});
test('milestone 4 comes first and 0 last', () => {
const edges = [[4, 2, 1], [2, 0, 7], [4, 3, 2], [3, 0, 1], [2, 3, 5]];
assert.deepEqual(longestPath(5, edges, 4, 0), [8, [4, 2, 0]]);
});
test('no path leads from 0 to 2', () => {
assert.equal(longestPath(3, [[1, 2, 5]], 0, 2), null);
});
test('the answer for s = t is a path of one milestone', () => {
assert.deepEqual(longestPath(3, [[0, 1, 4]], 0, 0), [0, [0]]);
});
test('300 small random projects: the length matches every path tried, and the path is a real one', () => {
const random = seeded(11);
for (let k = 0; k < 300; k++) {
const n = 1 + below(random, 7);
const hidden = shuffle(random, Array.from({ length: n }, (_, i) => i));
const edges = [];
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) if (random() < 0.45) edges.push([hidden[i], hidden[j], below(random, 15) - 5]);
shuffle(random, edges);
const s = below(random, n);
const t = below(random, n);
const best = bestByEveryPath(n, edges, s, t);
const result = longestPath(n, edges, s, t);
const where = JSON.stringify([n, edges, s, t]);
if (best === null) assert.equal(result, null, where);
else {
assert.ok(result !== null && result[0] === best, where);
checkPath(edges, s, t, result[0], result[1]);
}
}
});
test('100,000 milestones in a hidden order and about 200,000 edges: the chain through all of them is longest', () => {
const random = seeded(21);
const n = 100000;
const order = shuffle(random, Array.from({ length: n }, (_, i) => i));
const prefix = [0];
const edges = [];
for (let i = 0; i + 1 < n; i++) {
const w = below(random, 14) - 3;
prefix.push(prefix[i] + w);
edges.push([order[i], order[i + 1], w]);
}
for (let k = 0; k < 100000; k++) {
let i = below(random, n);
let j = below(random, n);
if (i > j) [i, j] = [j, i];
if (j > i + 1) edges.push([order[i], order[j], prefix[j] - prefix[i] - 1 - below(random, 20)]);
}
shuffle(random, edges);
const [length, path] = longestPath(n, edges, order[0], order[n - 1]);
assert.equal(length, prefix[n - 1]);
assert.deepEqual(path, order);
}); A hint
Find a topological order first, with Kahn's algorithm and a queue rather than recursion, because the largest test is a chain 100,000 milestones long. Then relax every edge in that order with "longer" instead of "shorter": start every milestone at minus infinity except s at 0, skip milestones still at minus infinity, and keep the edge that last raised each milestone, so the path can be read back from t.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
What does it mean to relax an edge, and what do Dijkstra’s algorithm, Bellman-Ford and the DAG algorithm have in
common? Relaxing the edge u → v of weight w checks whether the best known route to u, followed by that edge, is
cheaper than the best known route to v; if it is, dist[v] becomes dist[u] + w and u becomes v’s parent. The
estimate is always the weight of some real walk, so it never falls below the true distance, and relaxing the edges of a
shortest path in order makes it exact. All three algorithms start from dist[s] = 0 and infinity elsewhere and do
nothing but relax edges. They differ only in the order: Dijkstra relaxes the edges out of the closest unfinished vertex
and needs non-negative weights, Bellman-Ford relaxes every edge in up to V − 1 rounds, and the DAG algorithm relaxes
each edge once in topological order.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A monorepo has 40,000 build targets and 150,000 "needs" edges between them, and you know how long each target takes to build. With as many machines as you like, how long does a clean build take, and which targets should the team speed up first? Explain the computation and its cost.
-
Fresher to mid
Every road segment in a city map takes 1, 2 or 3 minutes, and a teammate proposes plain breadth-first search for the fastest route because the weights are almost the same. When does that give a wrong answer, and what would you use for a map of 10⁶ segments?
Key takeaways
- A shortest path minimises the sum of the edge weights; a solution returns
distandparent, and the parents form a shortest-path tree. - BFS minimises the number of edges, which is the right answer only when all weights are equal.
- Relaxation keeps
dist[v]an upper bound that only goes down; relaxing a shortest path’s edges in order makes it exact, and the algorithms differ only in the order they relax. - A negative cycle on a walk from s makes the distances it reaches −∞; negative edges alone are fine.
- In a DAG, relaxing in topological order solves the problem in O(V + E) with any weights, and relaxing with
>gives the longest path, the critical path of a plan.
References
- Dictionary of Algorithms and Data Structures: shortest path (National Institute of Standards and Technology (NIST))
- Dictionary of Algorithms and Data Structures: single-source shortest-path problem (National Institute of Standards and Technology (NIST))
- Dictionary of Algorithms and Data Structures: DAG shortest paths (National Institute of Standards and Technology (NIST))
- Dictionary of Algorithms and Data Structures: topological order (National Institute of Standards and Technology (NIST))
- On a routing problem (Richard Bellman, Quarterly of Applied Mathematics 16(1), 1958) (American Mathematical Society)
- A note on two problems in connexion with graphs (E. W. Dijkstra, Numerische Mathematik 1, 1959) (Springer)
- Topological sorting of large networks (A. B. Kahn, Communications of the ACM 5(11), 1962) (Association for Computing Machinery (ACM))
- Critical-path planning and scheduling (James E. Kelley and Morgan R. Walker, Eastern Joint Computer Conference, 1959) (Association for Computing Machinery (ACM))
- Reducibility among combinatorial problems (Richard M. Karp, Complexity of Computer Computations, 1972) (Springer)
- graphlib: functionality to operate with graph-like structures (Python Software Foundation)
- collections.deque (Python Software Foundation)
- math.inf (Python Software Foundation)
- GATE 2027 syllabus for Computer Science and Information Technology (CS) (IIT Madras (GATE 2027 organising institute))
- Computer Science Curricula 2023: Algorithmic Foundations (AL) (ACM, IEEE Computer Society and AAAI)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress