Minimum Spanning Trees
Connect every node at the lowest total cost. Build the cheapest tree with Kruskal's algorithm and union-find, or with Prim's and a heap, in JS and Python.
On this page
Six villages, A to F, want fibre internet. A trench can be dug between some pairs of villages, and each one has a price. Every village must be able to reach every other one, directly or through other villages. You want to pay as little as possible in total.
This is not a shortest-path problem. You care about the total bill for all the trenches, not how long any one route is.
The idea: the answer is a tree
Look at any plan that contains a loop. Remove the most expensive cable in that loop. Every village is still connected, because the rest of the loop goes around the gap, and the bill went down. So the cheapest plan never has a loop. A connected set of edges with no loop is a tree, and a tree on V nodes always has exactly V − 1 edges. For six villages, you are looking for five cables.
A tree that reaches every node of a graph is a spanning tree. The cheapest one is the minimum spanning tree, or MST.
Both algorithms in this lesson rest on one rule. Split the villages into any two groups. At least one cable must cross between the groups, or they could not talk. The cheapest cable crossing the split is always safe to take. If some plan used a pricier crossing cable instead, you could swap in the cheapest one and the bill would drop or stay the same.
Here is why the swap always works. Add the cheapest crossing cable to any spanning tree that lacks it. That makes a loop, and the loop must cross the split a second time through another cable that costs at least as much. Remove that one. You still have a spanning tree, and it is no more expensive.
Kruskal: cheapest cables first
Sort every possible cable by price. Walk down the list. Take a cable if its two villages are not connected yet. Skip it if they already are, because it would only close a loop. Stop once you have V − 1 cables.
"Are these two already connected?" while groups keep merging is exactly the question Union-Find answers. union(u, v) returns false when u and v already share a root, which is your signal to skip.
sort cables by costfor each cable (u, v) in that order: if find(u) == find(v): skip (would close a loop) else: union(u, v), take the cable stop when you have V − 1 cables- cables, cheapest first0E–F 11B–C 22A–C 33A–B 44B–D 55C–D 66D–E 77C–E 88D–F 9
- total
- 0
- groups
- {A} {B} {C} {D} {E} {F}
1Sort the cables by cost. Nothing is built yet, so every village is its own group.
- cables, cheapest first0E–F 11B–C 22A–C 33A–B 44B–D 55C–D 66D–E 77C–E 88D–F 9
- total
- 1
- groups
- {A} {B} {C} {D} {E,F}
2E–F costs 1. E and F are in different groups, so this cable connects something new. Take it.
- cables, cheapest first0E–F 11B–C 22A–C 33A–B 44B–D 55C–D 66D–E 77C–E 88D–F 9
- total
- 3
- groups
- {A} {B,C} {D} {E,F}
3B–C costs 2. Different groups: take it.
- cables, cheapest first0E–F 11B–C 22A–C 33A–B 44B–D 55C–D 66D–E 77C–E 88D–F 9
- total
- 6
- groups
- {A,B,C} {D} {E,F}
4A–C costs 3. A is alone and C is with B, so this joins them. Take it. A, B and C are now one group.
- cables, cheapest first0E–F 11B–C 22A–C 33A–B 44B–D 55C–D 66D–E 77C–E 88D–F 9
- total
- 6
- groups
- {A,B,C} {D} {E,F}
5A–B costs 4. But
find(A)andfind(B)are the same root: A and B are already connected through C. This cable would only close a loop. Skip it. - cables, cheapest first0E–F 11B–C 22A–C 33A–B 44B–D 55C–D 66D–E 77C–E 88D–F 9
- total
- 11
- groups
- {A,B,C,D} {E,F}
6B–D costs 5. D was alone: take it.
- cables, cheapest first0E–F 11B–C 22A–C 33A–B 44B–D 55C–D 66D–E 77C–E 88D–F 9
- total
- 11
- groups
- {A,B,C,D} {E,F}
7C–D costs 6. C and D are already in the same group. Skip it.
- cables, cheapest first0E–F 11B–C 22A–C 33A–B 44B–D 55C–D 66D–E 77C–E 88D–F 9
- total
- 18
- groups
- {A,B,C,D,E,F}
8D–E costs 7. It joins the big group with {E,F}. Take it. That is 5 cables for 6 villages, a full tree, so stop.
- cables, cheapest first0E–F 11B–C 22A–C 33A–B 44B–D 55C–D 66D–E 77C–E 88D–F 9
- total
- 18
- groups
- {A,B,C,D,E,F}
9Done: total cost 1 + 2 + 3 + 5 + 7 = 18. C–E and D–F were never looked at, and two cables were skipped because they would close a loop.
Each cable Kruskal takes is safe by the rule above. Split the villages into "the group at one end of the cable" and "everything else". Every cheaper cable was already taken or skipped, so it lies inside a group and does not cross this split. The cable in hand is the cheapest that does.
// n villages numbered 0..n-1. edges: list of [cost, u, v].
// UnionFind: the class from the union-find lesson.
function kruskal(n, edges) {
const sorted = [...edges].sort((a, b) => a[0] - b[0]); // cheapest first
const uf = new UnionFind(n);
const chosen = [];
let total = 0;
for (const [cost, u, v] of sorted) {
if (uf.union(u, v)) { // false means u and v were already connected
chosen.push([u, v, cost]);
total += cost;
if (chosen.length === n - 1) break; // a spanning tree has n - 1 edges
}
}
return chosen.length === n - 1 ? { total, chosen } : null; // null: not connected
}# n villages numbered 0..n-1. edges: list of (cost, u, v).
# UnionFind: the class from the union-find lesson.
def kruskal(n, edges):
uf = UnionFind(n)
chosen = []
total = 0
for cost, u, v in sorted(edges): # cheapest first
if uf.union(u, v): # False means u and v were already connected
chosen.append((u, v, cost))
total += cost
if len(chosen) == n - 1: # a spanning tree has n - 1 edges
break
return (total, chosen) if len(chosen) == n - 1 else None # None: not connectedPrim: grow one tree outward
Prim's algorithm starts at any village and grows a single tree. At each step it takes the cheapest cable that leaves the tree, which is the cheapest cable crossing the split "in the tree" versus "not yet". A min-heap keeps those cables ready, just as in Dijkstra's algorithm. Cables that point back into the tree become stale and get skipped.
add start to the tree; push its cableswhile the tree is missing villages: pop the cheapest cable (cost, v) if v is already in the tree: skip add v to the tree; total += cost push v's cables to villages not in the tree- heap (cost, cable)03 A–C14 A–B
- total
- 0
- in tree
- 1
1Start at A, any village works. A joins the tree for free. Push both of A's cables: A–C 3 and A–B 4.
- heap (cost, cable)02 C–B14 A–B26 C–D38 C–E
- total
- 3
- in tree
- 2
2Pop the cheapest cable leaving the tree: A–C 3. C joins. Push C's cables to villages outside the tree: C–B 2, C–D 6, C–E 8.
- heap (cost, cable)04 A–B15 B–D26 C–D38 C–E
- total
- 5
- in tree
- 3
3Pop C–B 2. B joins. Push B–D 5. B–A is not pushed: A is already in the tree.
- heap (cost, cable)05 B–D16 C–D28 C–E
- total
- 5
- in tree
- 3
4Pop A–B 4. B is already in the tree, so this cable would close a loop. Skip it.
- heap (cost, cable)06 C–D17 D–E28 C–E39 D–F
- total
- 10
- in tree
- 4
5Pop B–D 5. D joins. Push D–E 7 and D–F 9.
- heap (cost, cable)07 D–E18 C–E29 D–F
- total
- 10
- in tree
- 4
6Pop C–D 6. D is already in. Skip.
- heap (cost, cable)01 E–F18 C–E29 D–F
- total
- 17
- in tree
- 5
7Pop D–E 7. E joins. Push E–F 1, the cheapest cable on the map. It only became useful once E was in the tree.
- heap (cost, cable)08 C–E19 D–F
- total
- 18
- in tree
- 6
8Pop E–F 1. F joins. All 6 villages are in, so stop. Total 3 + 2 + 5 + 7 + 1 = 18, the same as Kruskal, with the same five cables.
// graph: adjacency list, graph[u] = list of [v, cost]. Villages 0..n-1.
// heapPush / heapPop: the pair heap used in the Dijkstra lesson.
function prim(graph, start = 0) {
const inTree = new Set();
const heap = [[0, start]]; // [cost of the cable that reaches node, node]
let total = 0;
while (heap.length > 0 && inTree.size < graph.length) {
const [cost, node] = heapPop(heap);
if (inTree.has(node)) continue; // already joined by a cheaper cable
inTree.add(node);
total += cost;
for (const [next, cableCost] of graph[node]) {
if (!inTree.has(next)) heapPush(heap, [cableCost, next]);
}
}
return inTree.size === graph.length ? total : null; // null: not connected
}import heapq
# graph: adjacency list, graph[u] = list of (v, cost). Villages 0..n-1.
def prim(graph, start=0):
in_tree = set()
heap = [(0, start)] # (cost of the cable that reaches node, node)
total = 0
while heap and len(in_tree) < len(graph):
cost, node = heapq.heappop(heap)
if node in in_tree:
continue # already joined by a cheaper cable
in_tree.add(node)
total += cost
for nxt, cable_cost in graph[node]:
if nxt not in in_tree:
heapq.heappush(heap, (cable_cost, nxt))
return total if len(in_tree) == len(graph) else None # None: not connectedPut the code next to Dijkstra's and only one thing really differs: the number pushed onto the heap. Dijkstra pushes d + cost, the whole trip from the start. Prim pushes just cost, the price of the one cable. Dijkstra asks "how far is this from home?"; Prim asks "how cheap is it to hook this on?".
| Approach | Time | Space | Why |
|---|---|---|---|
| Kruskal | O(E log E) | O(V + E) | Sorting the edges dominates. The union-find work is almost O(1) per edge. |
| Prim (binary heap) | O(E log V) | O(V + E) | Each edge can push one heap entry, and each push or pop is O(log E) = O(log V). |
Pick Kruskal when the input is a plain list of edges, and Prim when you already have an adjacency list.
Common mistakes
Check yourself
A connected graph has 10 nodes and 25 edges. How many edges are in its minimum spanning tree?
Kruskal reaches the cable D–G. find(D) and find(G) return the same root. What should it do?
Prim and Dijkstra both pop from a min-heap. What does Prim push for a neighbour?
What to practise next
Kruskal and Prim are both greedy: each takes the cheapest safe step and never looks back, and the cut rule proves that never goes wrong. Greedy Algorithms shows when that kind of shortcut works, and how to spot when it does not.