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.

18 minLesson 4 of 4
On this page
  1. The idea: the answer is a tree
  2. Kruskal: cheapest cables first
  3. Prim: grow one tree outward
  4. Common mistakes
  5. Check yourself
  6. What to practise next

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.

5 6 8 4 3 2 7 9 1 A B C D E F
Split the villages into {A, B, C} and {D, E, F}. Three cables cross the split. The cheapest, B–D at 5, belongs in a minimum spanning tree.

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.

Kruskal on the six villagesUse the arrows or ← → keys
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
  1. cables, cheapest first
    0E–F 1
    1B–C 2
    2A–C 3
    3A–B 4
    4B–D 5
    5C–D 6
    6D–E 7
    7C–E 8
    8D–F 9
    432568791ABCDEF
    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.

  2. cables, cheapest first
    0E–F 1
    1B–C 2
    2A–C 3
    3A–B 4
    4B–D 5
    5C–D 6
    6D–E 7
    7C–E 8
    8D–F 9
    432568791ABCDEF
    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.

  3. cables, cheapest first
    0E–F 1
    1B–C 2
    2A–C 3
    3A–B 4
    4B–D 5
    5C–D 6
    6D–E 7
    7C–E 8
    8D–F 9
    432568791ABCDEF
    total
    3
    groups
    {A} {B,C} {D} {E,F}

    3B–C costs 2. Different groups: take it.

  4. cables, cheapest first
    0E–F 1
    1B–C 2
    2A–C 3
    3A–B 4
    4B–D 5
    5C–D 6
    6D–E 7
    7C–E 8
    8D–F 9
    432568791ABCDEF
    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.

  5. cables, cheapest first
    0E–F 1
    1B–C 2
    2A–C 3
    3A–B 4
    4B–D 5
    5C–D 6
    6D–E 7
    7C–E 8
    8D–F 9
    432568791ABCDEF
    total
    6
    groups
    {A,B,C} {D} {E,F}

    5A–B costs 4. But find(A) and find(B) are the same root: A and B are already connected through C. This cable would only close a loop. Skip it.

  6. cables, cheapest first
    0E–F 1
    1B–C 2
    2A–C 3
    3A–B 4
    4B–D 5
    5C–D 6
    6D–E 7
    7C–E 8
    8D–F 9
    432568791ABCDEF
    total
    11
    groups
    {A,B,C,D} {E,F}

    6B–D costs 5. D was alone: take it.

  7. cables, cheapest first
    0E–F 1
    1B–C 2
    2A–C 3
    3A–B 4
    4B–D 5
    5C–D 6
    6D–E 7
    7C–E 8
    8D–F 9
    432568791ABCDEF
    total
    11
    groups
    {A,B,C,D} {E,F}

    7C–D costs 6. C and D are already in the same group. Skip it.

  8. cables, cheapest first
    0E–F 1
    1B–C 2
    2A–C 3
    3A–B 4
    4B–D 5
    5C–D 6
    6D–E 7
    7C–E 8
    8D–F 9
    432568791ABCDEF
    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.

  9. cables, cheapest first
    0E–F 1
    1B–C 2
    2A–C 3
    3A–B 4
    4B–D 5
    5C–D 6
    6D–E 7
    7C–E 8
    8D–F 9
    432568791ABCDEF
    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.

1 / 9

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 connected

Prim: 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.

Prim from AUse the arrows or ← → keys
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
  1. heap (cost, cable)
    03 A–C
    14 A–B
    432568791ABCDEF
    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.

  2. heap (cost, cable)
    02 C–B
    14 A–B
    26 C–D
    38 C–E
    432568791ABCDEF
    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.

  3. heap (cost, cable)
    04 A–B
    15 B–D
    26 C–D
    38 C–E
    432568791ABCDEF
    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.

  4. heap (cost, cable)
    05 B–D
    16 C–D
    28 C–E
    432568791ABCDEF
    total
    5
    in tree
    3

    4Pop A–B 4. B is already in the tree, so this cable would close a loop. Skip it.

  5. heap (cost, cable)
    06 C–D
    17 D–E
    28 C–E
    39 D–F
    432568791ABCDEF
    total
    10
    in tree
    4

    5Pop B–D 5. D joins. Push D–E 7 and D–F 9.

  6. heap (cost, cable)
    07 D–E
    18 C–E
    29 D–F
    432568791ABCDEF
    total
    10
    in tree
    4

    6Pop C–D 6. D is already in. Skip.

  7. heap (cost, cable)
    01 E–F
    18 C–E
    29 D–F
    432568791ABCDEF
    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.

  8. heap (cost, cable)
    08 C–E
    19 D–F
    432568791ABCDEF
    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.

1 / 8
// 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 connected

Put 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?".

ApproachTimeSpaceWhy
KruskalO(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

Quick check

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.