Shortest Paths & Union-Find: Weighted Roads and Cheap Networks
What you'll learn
- How weights change the shortest-path question, and the single operation behind every shortest-path algorithm: relaxing an edge
- Dijkstra's algorithm with a heap: why its greedy choice is safe, how to rebuild the route, and why it costs O((V + E) log V)
- Why negative edges break Dijkstra, and how Bellman-Ford copes with them and detects negative cycles
- The Union-Find (disjoint set) structure, with union by size and path compression
- Minimum spanning trees and Kruskal's algorithm, built on Union-Find
- You will write: Dijkstra, Bellman-Ford with cycle detection, and Kruskal
The GPS and the cable company
In Trees & Graphs, BFS found the fewest moves through a maze, because every step cost the same. A road map is different. A route of five short city streets can be faster than two long country roads, so each road (edge) carries a weight: minutes, kilometres or money. The question becomes: which route has the smallest total weight? Your GPS answers it on a graph with millions of junctions, and the internet answers it continuously: in the OSPF routing protocol (RFC 2328) every router builds a shortest-path tree of the network with Dijkstra's algorithm, while RIP (RFC 2453) is a distance-vector protocol built on the Bellman-Ford idea.
A cable company asks a different question. It must connect every town to the network, and laying cable between two towns has a cost. It does not care about the shortest route from one town to another; it wants the cheapest set of cables that keeps everything connected. That is a minimum spanning tree, and the tool that makes it fast is Union-Find, a structure that answers "are these two already connected?" almost instantly.
Weighted graphs and Dijkstra's algorithm
Store a weighted graph as an adjacency list of (neighbour, weight) pairs. Every shortest-path algorithm keeps dist[v], the best distance from the source found so far (0 for the source, infinity for the rest), and improves it with one operation, relaxing an edge u -> v of weight w:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w # going through u is better
parent[v] = u # remember how we got here, to rebuild the route
The algorithms differ only in the order in which they relax edges. BFS's order is wrong as soon as weights differ:
A --5--> C
A --1--> B --1--> C
fewest edges: A -> C (1 edge, cost 5)
cheapest: A -> B -> C (2 edges, cost 2)
Dijkstra's algorithm (Edsger Dijkstra, 1959) works when no weight is negative. Repeatedly take the vertex with the smallest tentative distance that is not final yet, declare its distance final, and relax its outgoing edges. A min-heap hands you that vertex. Why may it be declared final? Any other route to it must leave the finished vertices at some unfinished vertex whose tentative distance is already at least as large, and edges that cannot be negative never make a route shorter.
Library heaps cannot lower a priority in place, so use lazy deletion: push (new distance, v) every time dist[v] improves, and when you pop an entry whose distance is larger than dist[v], it is outdated: skip it. With weights that are never negative, each vertex passes this check exactly once, at its final distance.
import heapq
def dijkstra(graph, source):
dist = {v: float("inf") for v in graph}
dist[source] = 0
heap = [(0, source)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]:
continue # outdated entry
for v, w in graph[u]:
if d + w < dist[v]: # relax u -> v
dist[v] = d + w
heapq.heappush(heap, (dist[v], v))
return dist
S --4--> A, S --1--> B, B --2--> A, A --1--> C, B --5--> C
pop S (0) relax: A = 4, B = 1
pop B (1) relax: A = 1 + 2 = 3 (better than 4), C = 1 + 5 = 6
pop A (3) relax: C = 3 + 1 = 4 (better than 6)
pop A (4) outdated (dist[A] is 3): skip
pop C (4) final. Distances: S 0, B 1, A 3, C 4. Route to C: S -> B -> A -> C
Every improvement pushes one entry, so the heap holds O(E) entries and the algorithm runs in O((V + E) log V). To print a route, follow parent back from the target and reverse the list.
On the road map Dijkstra finds the cost-4 route S, B, A, C, the one with more edges. On the 'refund' graph it expands B at 2, the distance it should be able to call final, and only then the edge C -> B of weight -2 lowers B to 1, so B is expanded a second time and D drops to 2 before it is ever expanded. The distances match Bellman-Ford (B = 1, D = 2) only because this code never trusts 'final'; a version that marks popped vertices as done and skips them would keep B = 2 and D = 3. On the negative cycle Bellman-Ford reports the cycle, while Dijkstra keeps lowering B and C and would never stop: the limit makes it give up.
import heapq
def dijkstra(graph, source, limit=1000):
dist = {v: float("inf") for v in graph}
dist[source] = 0
parent = {source: None}
expanded = [] # every (vertex, distance) whose edges we relax
heap = [(0, source)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]:
continue # an outdated entry: u was reached more cheaply
expanded.append((u, d))
if len(expanded) > limit:
raise RuntimeError(f"still running after {limit} expansions")
for v, w in graph[u]:
if d + w < dist[v]:
dist[v] = d + w # relax the edge u -> v
parent[v] = u
heapq.heappush(heap, (dist[v], v))
return dist, parent, expanded
def path_to(parent, target):
path = []
while target is not None:
path.append(target)
target = parent[target]
return path[::-1]
roads = {"S": [("A", 4), ("B", 1)], "A": [("C", 1)], "B": [("A", 2), ("C", 5)], "C": []}
dist, parent, _ = dijkstra(roads, "S")
print("distances from S:", dist)
print("route to C:", " -> ".join(path_to(parent, "C")), "| cost", dist["C"])
def bellman_ford(graph, source):
dist = {v: float("inf") for v in graph}
dist[source] = 0
edges = [(u, v, w) for u in graph for v, w in graph[u]]
for _ in range(len(graph) - 1): # V - 1 rounds of relaxing every edge
for u, v, w in edges:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
cycle = any(dist[u] + w < dist[v] for u, v, w in edges) # a V-th round still improves?
return dist, cycle
refund = {"A": [("B", 2), ("C", 3)], "B": [("D", 1)], "C": [("B", -2)], "D": []}
dist, _, expanded = dijkstra(refund, "A")
print("Dijkstra, negative edge:", dist)
print(" expanded in this order:", ", ".join(f"{u} at {d}" for u, d in expanded))
print("Bellman-Ford: ", bellman_ford(refund, "A")[0])
loop = {"A": [("B", 1)], "B": [("C", -2)], "C": [("B", 1)]}
print("negative cycle B -> C -> B detected:", bellman_ford(loop, "A")[1])
try:
dijkstra(loop, "A")
except RuntimeError as err:
print("Dijkstra on the same graph:", err)
Lab exercise 1
PythonGraded in Code LabDijkstra's shortest distances. A directed graph has n vertices (0 to n - 1) and m weighted edges, all weights 0 or more. Print the shortest distance from the source s to every vertex.
Input: the first line has n m s. Then m lines, each u v w: an edge from u to v with weight w. Edges can repeat, and u can equal v.
Output: one line with n numbers separated by spaces: the distance to vertex 0, 1, ..., n - 1, or -1 for a vertex that cannot be reached.
Example: input
4 5 0
0 1 4
0 2 1
2 1 2
1 3 1
2 3 5
prints 0 3 1 4: this is the road map from the lesson with S = 0, A = 1, B = 2, C = 3.
Use heapq with (distance, vertex) entries and skip outdated entries (if d > dist[u]: continue).
- The example
- Fewest edges is not cheapest
- An unreachable vertex
- Zero-weight edges
- Repeated edges and a source that is not 0
- 800 vertices and 2,200 edges
Example run
- Input
4 5 0 0 1 4 0 2 1 2 1 2 1 3 1 2 3 5
- Expected output
0 3 1 4
import sys
import heapq
data = sys.stdin.read().split()
n, m, s = int(data[0]), int(data[1]), int(data[2])
graph = [[] for _ in range(n)] # graph[u] = list of (v, w)
for i in range(m):
u, v, w = int(data[3 + 3 * i]), int(data[4 + 3 * i]), int(data[5 + 3 * i])
graph[u].append((v, w))
INF = float("inf")
dist = [INF] * nMore lines in Code Lab: 8
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Negative edges and Bellman-Ford
Dijkstra's promise "the smallest tentative distance is final" relies on edges never being negative. Break that rule and the promise fails:
2 1
A ------> B ------> D
\ ^
3 \ | -2
\----> C
pop A (0) relax: B = 2, C = 3
pop B (2) B "final" at 2; relax: D = 3
pop C (3) relax C -> B: 3 - 2 = 1 < 2
real: B = 1 (A -> C -> B), D = 2 (A -> C -> B -> D)
The argument breaks the moment a popped vertex is treated as final: B was popped at 2, but a later edge of weight -2 makes it 1. A version that marks popped vertices as done and never touches them again keeps B = 2 and D = 3, both wrong. The lazy code above has no such mark: when C -> B lowers B, it pushes B again, expands it a second time and fixes D. It returns correct distances as long as no negative cycle is reachable, but it is no longer the algorithm whose cost you know: a vertex can be expanded many times, exponentially many in the worst case, and if a negative cycle is reachable the loop never ends. Negative weights need an algorithm built for them.
Negative weights are not exotic: a step that earns a refund or a bonus, or currency exchange, where taking -log(rate) as the weight turns "multiply the rates" into "add the weights", so a profitable loop of trades (arbitrage) becomes a cycle of negative total weight.
Bellman-Ford (Richard Bellman and Lester Ford, 1950s) makes no assumption about the order. It simply relaxes every edge, V - 1 times:
dist = {v: float("inf") for v in graph}
dist[source] = 0
for _ in range(V - 1):
for u, v, w in edges:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
Why V - 1 rounds? A shortest path that does not repeat a vertex has at most V - 1 edges. After round 1, every vertex whose shortest path has 1 edge is correct; after round k, every vertex whose shortest path has at most k edges is correct. You can stop early when a round changes nothing. The cost is O(V x E): slower than Dijkstra, but it accepts negative weights.
Negative cycles. If a cycle has negative total weight and the source can reach it, you can go around it forever and every loop makes the route cheaper: the shortest distance is not defined. Bellman-Ford detects this with one extra round: if any edge can still be relaxed after V - 1 rounds, a negative cycle is reachable. (A negative cycle the source cannot reach does no harm; skip edges that start at an unreachable vertex.) Distance-vector routing protocols are distributed Bellman-Ford: routers repeatedly send their distance tables to their neighbours, and RIP caps a route at 15 hops, using a metric of 16 to mean "unreachable".
Which tool when? All steps cost the same: BFS. Weights 0 or more: Dijkstra. Negative weights, or you must detect negative cycles: Bellman-Ford. (For all pairs on a small graph, Floyd-Warshall runs in O(V³).)
Lab exercise 2
JavaGraded in Code LabBellman-Ford with negative edges. A directed graph has n vertices and m edges whose weights can be negative. Print the shortest distance from s to every vertex, unless a negative cycle can be reached from s.
Input: the first line has n m s. Then m lines, each u v w (from u to v, weight w between -1,000 and 1,000).
Output: NEGATIVE CYCLE if a cycle of negative total weight is reachable from s. Otherwise one line with n values separated by spaces: each distance, or INF for a vertex that cannot be reached. (-1 cannot mean "unreachable" here, because it can be a real distance.)
Example: input
4 4 0
0 1 2
0 2 3
2 1 -2
1 3 1
prints 0 1 3 2: the 'refund' graph from the lesson, where Dijkstra's "final" B = 2 turned out wrong.
Relax every edge n - 1 times (stop early if a round changes nothing), then do one more pass: if any edge still improves a distance, print NEGATIVE CYCLE. Never relax from a vertex whose distance is still infinite.
- The example
- A reachable negative cycle
- A negative cycle the source cannot reach
- Only negative weights, edges listed backwards
- A single vertex
- 300 vertices, 2,000 edges, weights -50 to 50
Example run
- Input
4 4 0 0 1 2 0 2 3 2 1 -2 1 3 1
- Expected output
0 1 3 2
import java.io.*;
import java.util.*;
public class Main {
public static void main(String[] args) throws IOException {
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
StringBuilder all = new StringBuilder();
for (String line; (line = in.readLine()) != null; ) all.append(line).append(' ');
StringTokenizer tok = new StringTokenizer(all.toString());
int n = Integer.parseInt(tok.nextToken());
int m = Integer.parseInt(tok.nextToken());
int s = Integer.parseInt(tok.nextToken());More lines in Code Lab: 27
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Union-Find: who is connected to whom?
Links keep appearing (friendships, cables, merged accounts) and you must answer, at any moment, "are x and y in the same group?". Running a BFS for each question costs O(V + E). Union-Find (a disjoint-set structure) answers in almost constant time.
The idea: every group is a small tree, stored in a parent array; the root of the tree represents the group.
find(x): follow parents fromxup to the root and return it. Two elements are in the same group exactly whenfindreturns the same root.union(a, b): find both roots; if they differ, hang one root under the other.
Two small tricks make the trees almost flat:
- Union by size: hang the smaller tree under the larger one. A tree can then only get one level deeper when its size at least doubles, so heights stay O(log n).
- Path compression: during
find, point the nodes you pass closer to the root, so the nextfindis shorter. The one-line form below is called path halving.
parent = list(range(n)) # everyone starts alone
size = [1] * n
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]] # path halving: skip a level while climbing
x = parent[x]
return x
def union(a, b):
ra, rb = find(a), find(b)
if ra == rb:
return False # already in the same group
if size[ra] < size[rb]:
ra, rb = rb, ra
parent[rb] = ra # the smaller tree goes under the larger
size[ra] += size[rb]
return True
union(1, 2), union(3, 4): 1 3
| | (a line joins a child to its parent)
2 4
union(2, 4): roots 1 and 3 1
/ \
2 3
|
4 find(4) then re-points 4 straight at 1
With both tricks, a sequence of operations costs O(α(n)) amortised per operation, where α is the inverse Ackermann function (Robert Tarjan proved the bound for full path compression in 1975, and Tarjan and Jan van Leeuwen showed in 1984 that path halving achieves it too). α(n) is at most 4 for any n you could ever store, so in practice it is constant time.
After union(2, 4) the parent array is 0 1 1 1 1: the root 1 now represents {1, 2, 3, 4}, and the last find(4) re-pointed 4 straight at 1. The Kruskal part walks the edges from lightest to heaviest and skips 0-2 and 1-3 because their ends are already connected; the three edges it keeps weigh 7 in total.
// Union-Find with union by size and path halving
function makeSets(n) {
const parent = Array.from({ length: n }, (_, i) => i);
const size = new Array(n).fill(1);
const find = (x) => {
while (parent[x] !== x) {
parent[x] = parent[parent[x]]; // path halving: skip a level while climbing
x = parent[x];
}
return x;
};
const union = (a, b) => {
let ra = find(a);
let rb = find(b);
if (ra === rb) return false; // already in the same group
if (size[ra] < size[rb]) [ra, rb] = [rb, ra];
parent[rb] = ra; // hang the smaller tree under the larger one
size[ra] += size[rb];
return true;
};
return { find, union, parent };
}
const s = makeSets(5);
s.union(1, 2);
s.union(3, 4);
console.log("1 and 4 connected?", s.find(1) === s.find(4));
s.union(2, 4);
console.log("after union(2, 4):", s.find(1) === s.find(4), "| parent array:", s.parent.join(" "));
// Kruskal: lightest edges first, skip any edge whose ends are already connected
const edges = [[0, 1, 1], [1, 2, 2], [0, 2, 3], [2, 3, 4], [1, 3, 5]];
const k = makeSets(4);
let total = 0;
for (const [u, v, w] of [...edges].sort((a, b) => a[2] - b[2])) {
if (k.union(u, v)) {
total += w;
console.log(`take ${u}-${v} (${w})`);
} else {
console.log(`skip ${u}-${v} (${w}): it would close a cycle`);
}
}
console.log("minimum spanning tree weight:", total);
Minimum spanning trees with Kruskal
A spanning tree of a connected, undirected graph keeps all V vertices connected using exactly V - 1 edges and no cycle. A minimum spanning tree (MST) is one with the smallest total weight: the cheapest cable network, the cheapest set of roads that links every village. MSTs are also used for clustering and as a building block of approximation algorithms.
Kruskal's algorithm (Joseph Kruskal, 1956) is greedy:
- Sort the edges by weight, lightest first.
- For each edge
(u, v, w): iffind(u) != find(v), take it andunion(u, v); otherwise skip it, because it would close a cycle. - Stop after V - 1 edges. If you run out of edges first, the graph is not connected and has no spanning tree.
edges sorted: 0-1 (1) 1-2 (2) 0-2 (3) 2-3 (4) 1-3 (5)
0-1 (1) different groups -> take {0,1} {2} {3}
1-2 (2) different groups -> take {0,1,2} {3}
0-2 (3) same group -> skip (0 and 2 are already connected)
2-3 (4) different groups -> take {0,1,2,3}: V - 1 = 3 edges, done
total weight 1 + 2 + 4 = 7
Why is greedy safe here? The cut property: split the vertices into two sides in any way; the lightest edge crossing between the sides belongs to a minimum spanning tree (with equal weights, to at least one of them). When Kruskal takes an edge, every lighter edge has already been considered, so it is the lightest edge leaving the group of one of its ends. Negative weights are fine too: only the order of the weights matters.
Cost: sorting is O(E log E); the Union-Find work is nearly linear. Prim's algorithm is the alternative: grow one tree from a start vertex, always adding the cheapest edge that leaves it, with a heap, in O(E log V). Prim's idea is close to Dijkstra's, and Dijkstra's 1959 paper actually describes both problems.
Lab exercise 3
GoGraded in Code LabThe cheapest network. An undirected graph has n vertices and m weighted edges. Print the total weight of a minimum spanning tree, or IMPOSSIBLE if the graph is not connected.
Input: the first line has n m (n is at least 1). Then m lines, each u v w: an edge between u and v with weight w (it can be negative). Edges can repeat, and u can equal v.
Output: the total weight of the minimum spanning tree (0 for a single vertex), or IMPOSSIBLE.
Example: input
4 5
0 1 1
1 2 2
0 2 3
2 3 4
1 3 5
prints 7, as traced in the lesson.
The starter already sorts the edges by weight. Write find (with path halving) and union (by size), then walk the sorted edges, add the weight of every edge that union accepts, and count them: a spanning tree needs exactly n - 1.
- The example
- A disconnected graph
- Repeated edges and a self-loop
- Negative weights
- A single vertex
- 500 vertices and 2,200 edges
Example run
- Input
4 5 0 1 1 1 2 2 0 2 3 2 3 4 1 3 5
- Expected output
7
package main
import (
"bufio"
"fmt"
"os"
"sort"
)
type edge struct{ u, v, w int }
func main() {More lines in Code Lab: 17
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Question 1 of 5
Why does BFS not find the cheapest route in a weighted graph?
Question 2 of 5
Which condition must hold for Dijkstra's algorithm to be correct?
Question 3 of 5
Bellman-Ford has run its V - 1 rounds. In one more pass, an edge still lowers a distance. What does that tell you?
Question 4 of 5
Union-Find uses union by size and path compression. What does an operation cost?
Question 5 of 5
Kruskal's algorithm reaches an edge whose two ends are already in the same group. What does it do?
Summary
- In a weighted graph the shortest route minimises the sum of weights, not the number of edges. Every algorithm improves
dist[v]by relaxing edges; they differ in the order. - Dijkstra (weights 0 or more) settles the closest unfinished vertex with a min-heap, skips outdated entries, and runs in O((V + E) log V). Keep
parentto rebuild the route. - Negative edges break Dijkstra's promise that a popped vertex is final. Bellman-Ford relaxes every edge V - 1 times in O(V x E), and an improvement in one extra pass reveals a reachable negative cycle.
- Union-Find answers "same group?" and merges groups in O(α(n)) amortised with union by size and path compression.
- Kruskal sorts the edges and takes every edge that joins two different groups; the result is a minimum spanning tree with V - 1 edges, or proof that the graph is disconnected.
Practice next: compute how long a signal takes to reach every node of a network (Dijkstra), find the cheapest flight with at most k stops (a Bellman-Ford variant), count groups of friends and find the one redundant cable with Union-Find, and implement Prim's algorithm with a heap. Next on the roadmap is Greedy & Backtracking: Dijkstra and Kruskal were greedy algorithms whose choices you proved safe.
Practice
Reading is not learning until you run something. Pick one.
Graded lab exercises
- Lab exercise 1PythonNot passed yet Try in Code Lab
- Lab exercise 2JavaNot passed yet Try in Code Lab
- Lab exercise 3GoNot passed yet Try in Code Lab