In the previous lesson we learned to move around Rutalia's network at minimum cost. This lesson poses a different problem that sounds similar and gets confused with it constantly: we don't want to go from one place to another, we want to connect everything while spending the least. Rutalia is going to deploy a network of smart lockers across the city's 9 zones, linked by fiber optic laid along the streets: which segments should be cabled so that all the lockers are connected at the lowest total construction cost? The answer is the minimum spanning tree (MST), and it brings two gifts: the second seed of 01-04 — the union-find that powers Kruskal — and a deep lesson about greedy algorithms: here, unlike the counterexamples of 02-02, greed is provably optimal.

Contents

  1. Spanning trees: definition and why the minimum matters
  2. The cut property: why greedy DOES win here
  3. Kruskal: sort the edges and cash in the union-find seed
  4. Prim: grow the tree with a heap
  5. Kruskal vs Prim: table and selection criteria
  6. MST vs shortest paths: Dijkstra's tree is NOT the MST

Spanning trees: definition and why the minimum matters

A spanning tree of a connected graph G = (V, E) is a subset of edges that connects all vertices and contains no cycles. Like every tree, it has exactly n − 1 edges: not one more (there would be a cycle) nor one fewer (something would be left dangling). A graph generally has a huge number of spanning trees; the minimum spanning tree (MST) is the one with the smallest total weight.

Why a "tree"? Think of the lockers' fiber: if the cabling formed a cycle, you could remove any edge of the cycle and everything would stay connected — you would have paid for a useless trench. The optimal solution to a pure connection problem never contains cycles, hence it is a tree.

For the locker network we reuse the canonical graph of 03-01, reinterpreting the weights: where we used to read "van minutes", we now read "construction cost in thousands of euros" (proportional to the segment's length, so the same numbers work). The question: which 8 of the 15 segments get cabled?

Applications of the same pattern, beyond fiber: power and water networks, circuit design, data clustering (the hierarchical clustering of 05-05 has an MST hidden inside), and TSP approximations (the famous 2-approximation is built by walking around an MST).

The cut property: why greedy DOES win here

In 02-02 we watched greedy algorithms crash: the TSP's "nearest neighbor" gave 43.1 km against the optimum of 35.22, and the value/weight knapsack heuristic failed on three-item counterexamples. The honest question is: why should picking "the cheapest edge" work here? The answer has a name of its own:

Cut property. Split the vertices into two non-empty groups (S, V∖S) — a cut. Among all the edges crossing the cut, let e be the one of minimum weight (assume distinct weights for simplicity). Then e belongs to every MST.

Proof by exchange (the classic technique for justifying greedy algorithms): suppose a spanning tree T does not contain e = {u, v}. Since T connects u with v, it contains a path between them, and that path crosses the cut through at least one edge f (≠ e, and by e's definition: weight(f) > weight(e)). Remove f and add e: the result T′ still connects everything (e re-splices the two halves that removing f separated), still has n−1 edges, and weighs less than T. Hence T was not minimum. ∎

graph LR
    subgraph S
        u((u)); a((·))
    end
    subgraph "V - S"
        v((v)); b((·))
    end
    u -.e minimum.- v
    a -. f, more expensive .- b

The general moral, worth its weight in gold: greedy is neither trustworthy nor suspect by default; it is correct exactly when the problem has an exchange structure that guarantees it. The MST has it (mathematicians say that cycle-free edge sets form a matroid); the TSP and the 0/1 knapsack do not. Dijkstra (03-03) was the other certified greedy: its invariant was also an exchange argument in disguise.

Both algorithms in this lesson are direct applications of the cut property; only the cuts they look at differ.

Kruskal: sort the edges and cash in the union-find seed

Kruskal's strategy: scan the edges from cheapest to most expensive, accepting each one unless it forms a cycle with those already accepted. The cut property backs it up: when an edge {u, v} is accepted, it is the cheapest one crossing the cut "u's component versus the rest" (all cheaper edges were already processed and did not separate those halves).

And how is "forms a cycle" detected efficiently? An edge forms a cycle if its two endpoints are already in the same component. In other words: we need component membership with edges arriving one at a time — exactly the case that in 03-02 we said BFS handles poorly and union-find handles beautifully. The seed of 01-04, cashed in:

class UnionFind:
    """Union-find with path compression and union by rank (01-04)."""
    def __init__(self, elements):
        self.parent = {x: x for x in elements}
        self.rank = {x: 0 for x in elements}

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])   # path compression
        return self.parent[x]

    def union(self, x, y):
        rx, ry = self.find(x), self.find(y)
        if rx == ry:
            return False                # already connected: the edge would form a cycle
        if self.rank[rx] < self.rank[ry]:
            rx, ry = ry, rx             # hang the short tree under the tall one
        self.parent[ry] = rx
        if self.rank[rx] == self.rank[ry]:
            self.rank[rx] += 1
        return True                     # union performed

def kruskal(nodes, edges):
    """edges: list of (weight, u, v). Returns (total_cost, mst_edges)."""
    uf = UnionFind(nodes)
    mst, total = [], 0
    for weight, u, v in sorted(edges):          # cheapest to most expensive
        if uf.union(u, v):                      # no cycle: accept
            mst.append((u, v, weight))
            total += weight
            if len(mst) == len(nodes) - 1:      # tree complete: stop
                break
    return total, mst

NODES = ["ALM", "MER", "EST", "UNI", "RIO", "CEN", "IND", "HOS", "PAR"]
EDGES = [
    (3, "ALM", "RIO"), (4, "ALM", "MER"), (7, "ALM", "EST"), (12, "ALM", "CEN"),
    (5, "MER", "CEN"), (6, "MER", "UNI"), (3, "EST", "UNI"), (9, "EST", "IND"),
    (8, "UNI", "CEN"), (5, "UNI", "HOS"), (6, "RIO", "CEN"), (8, "RIO", "PAR"),
    (4, "CEN", "HOS"), (6, "IND", "HOS"), (7, "PAR", "HOS"),
]

total, mst = kruskal(NODES, EDGES)
print(total)   # 37
print(mst)
# [('ALM','RIO',3), ('EST','UNI',3), ('ALM','MER',4), ('CEN','HOS',4),
#  ('MER','CEN',5), ('UNI','HOS',5), ('IND','HOS',6), ('PAR','HOS',7)]

Full trace on the locker network — the algorithm's "video":

Edge (cost) Cycle? Decision Components after the decision
ALM–RIO (3) No ✓ accept {ALM,RIO} and 7 loose
EST–UNI (3) No ✓ accept {ALM,RIO} {EST,UNI} …
ALM–MER (4) No ✓ accept {ALM,RIO,MER} {EST,UNI} …
CEN–HOS (4) No ✓ accept {ALM,RIO,MER} {EST,UNI} {CEN,HOS} …
MER–CEN (5) No ✓ accept {ALM,RIO,MER,CEN,HOS} {EST,UNI} …
UNI–HOS (5) No ✓ accept {ALM,RIO,MER,CEN,HOS,EST,UNI} {IND} {PAR}
MER–UNI (6) Yes ✗ reject no change
RIO–CEN (6) Yes ✗ reject no change
IND–HOS (6) No ✓ accept only PAR missing
ALM–EST (7) Yes ✗ reject no change
PAR–HOS (7) No ✓ accept tree complete: 8 edges, cost 37

Total construction cost: €37,000, versus the €87,000 it would cost to cable all 15 segments. Notice what Kruskal rejects: precisely the "redundant" edges that would close rings — including the ALM–CEN avenue at 12, which never even gets examined.

Complexity: sorting dominates, O(m log m) = O(m log n); the 2m union-find operations cost O(m · α(n)) — that α, the inverse Ackermann function we met in 01-04, is ≤ 4 for any graph in the physical world. In practice: "sort + almost free".

Prim: grow the tree with a heap

Prim applies the same cut property with a different tactic: maintain a single growing tree from a starting node, adding at each step the cheapest edge leaving the tree towards the outside (the cut is always "current tree versus the rest"). And who delivers "the cheapest frontier edge"? Once again the heap of 01-04 — the same engine as Dijkstra, with one crucial difference in the priority:

import heapq

def prim(graph, start):
    """graph: {node: {neighbor: weight}}. Returns (total_cost, mst_edges)."""
    in_tree = {start}
    mst, total = [], 0
    # priority = EDGE WEIGHT (not accumulated distance: that was Dijkstra)
    heap = [(weight, start, v) for v, weight in graph[start].items()]
    heapq.heapify(heap)

    while heap and len(in_tree) < len(graph):
        weight, u, v = heapq.heappop(heap)
        if v in in_tree:
            continue                    # stale entry: v was already captured
        in_tree.add(v)
        mst.append((u, v, weight))
        total += weight
        for w, wt in graph[v].items():  # new frontier edges
            if w not in in_tree:
                heapq.heappush(heap, (wt, v, w))
    return total, mst

NETWORK = {u: {} for u in NODES}
for wt, u, v in EDGES:
    NETWORK[u][v] = wt
    NETWORK[v][u] = wt

total, mst = prim(NETWORK, "ALM")
print(total)   # 37 — the same tree as Kruskal

Compare mentally with the Dijkstra of 03-03: same skeleton (heap, settled set, lazy deletion), but the heap tuple carries edge_weight instead of accumulated_distance. That single change turns "being close to the source" into "being close to the tree". Once again the big family: queue→BFS, stack→DFS, heap by distance→Dijkstra, heap by edge→Prim.

Complexity with a binary heap: each edge enters the heap at most once ⇒ O(m log n), like Kruskal. With an adjacency matrix and no heap there is an O(n²) variant which, in dense graphs (m ≈ n²), is in fact better.

Kruskal vs Prim: table and selection criteria

Kruskal Prim
Idea Global edges, cheap to expensive One tree growing from a node
Key structure Union-find (01-04) Heap (01-04)
Intermediate state Forest (several fragments) Always one connected tree
Cost O(m log n) O(m log n) heap; O(n²) matrix
Shines with… Sparse graphs; edges already sorted or streaming Dense graphs (O(n²) variant); when you already have an adjacency list
Extra With pre-sorted edges it is nearly linear; gives clustering if stopped early Doesn't need to see all edges if the heap is managed well

For the locker network (sparse: 15 edges, 9 nodes) both are instantaneous and identical in result; at city scale, Kruskal is usually the most convenient on sparse road graphs, and O(n²) Prim wins on dense "all-pairs distance matrix" graphs. If all weights are distinct, the MST is unique and both return exactly the same tree; with ties they may differ in edges, never in total cost.

MST vs shortest paths: Dijkstra's tree is NOT the MST

Dijkstra also produces a tree (the parent tree, with n−1 edges). It is tempting to believe that tree is the MST: it is not, and confusing them is an expensive design error. They optimize different things:

  • Dijkstra's tree from s: minimizes the distance from s to each node. It serves the courier leaving the warehouse.
  • MST: minimizes the total sum of the chosen edges. It serves whoever pays for the fiber trenches.

Minimal counterexample, three zones:

graph LR
    A((A)) ---|1| B((B))
    B ---|1| C((C))
    A ---|1.5| C
  • MST: {A–B, B–C}, total cost 2 (it discards the 1.5 edge).
  • Dijkstra's tree from A: to reach C the direct edge is better (1.5 < 1+1), so {A–B, A–C}, total cost 2.5.

Neither one is "wrong": in the MST, going from A to C costs 2 (detouring through B) even though the network as a whole is cheaper; in Dijkstra's tree, C sits at 1.5 but the total infrastructure is more expensive. And in Rutalia's network the difference is scandalous: Dijkstra's tree from ALM weighs 46 (it uses ALM–EST 7, MER–UNI 6, RIO–PAR 8, EST–IND 9), while the MST weighs 37 (it prefers EST–UNI 3, UNI–HOS 5, IND–HOS 6, PAR–HOS 7). 24% more expensive for optimizing the wrong metric.

Dijkstra's tree MST
Minimizes Distance source→each node Total sum of edges
Depends on the source Yes No
Path within the tree Optimal from the source Can be arbitrarily bad
Business question "How do I deliver from the warehouse?" "What infrastructure do I build?"

Common Mistakes and Tips

  • Using Dijkstra's tree as an infrastructure network (or the MST as a routing table): the three-node counterexample should be enough; in Rutalia the confusion costs 24% in overruns or absurd routes. First decide what is being minimized, then pick the algorithm.
  • Detecting cycles in Kruskal with BFS/DFS over the accepted edges: correct but O(n) per edge ⇒ O(n·m) total. Union-find brings it down to α(n) per query; that is what we planted it for in 01-04.
  • Skipping path compression or union by rank: the union-find degenerates into lists and find becomes O(n). They are four lines; always write them.
  • Not stopping at n−1 edges in Kruskal: the result is still correct (everything else would be rejected), but expensive edges get processed uselessly. The break is free.
  • Applying MST to a disconnected graph: no spanning tree exists; Kruskal silently returns a minimum spanning forest. Check len(mst) == n - 1 at the end and decide what the shortfall means in your problem (in Rutalia: zones without fiber).
  • Tip: when a greedy approach tempts you, look for the exchange argument ("if the optimal solution did not use my choice, I swap it in and nothing gets worse"). If you find it, you have both an algorithm and a proof; if it doesn't materialize, be suspicious — remember the TSP's nearest neighbor.

Exercises

  1. The missing fiber. The city council forbids digging up the UNI–HOS segment (cost 5). Recompute the MST with Kruskal without that edge. How much does the ban add to the cost? Which edge comes in as a replacement, and why that one, in light of the cut property?
  2. A safe edge by hand. Without running any algorithm, use the cut property with S = {IND} to prove which edge incident to IND is in every MST of the locker network. Verify against the Kruskal trace.
  3. Kruskal as a clusterer. If you stop Kruskal when exactly 3 components remain, you get a partition of the zones into 3 "naturally close" groups (that is how single-linkage clustering works, which will reappear in 05-05). Modify kruskal to accept a k_components parameter and compute the 3 groups of the locker network.

Solutions

Exercise 1:

without_uni_hos = [(w, u, v) for w, u, v in EDGES if {u, v} != {"UNI", "HOS"}]
total2, mst2 = kruskal(NODES, without_uni_hos)
print(total2)   # 38  (was 37): the ban costs 1,000 EUR

MER–UNI (6) comes in instead of UNI–HOS (5). Explanation by cut: once UNI–HOS is removed, at the moment Kruskal holds the fragments {ALM,RIO,MER,CEN,HOS} and {EST,UNI}, the edges crossing that cut are MER–UNI (6), RIO–CEN… no — RIO and CEN sit on the same side; the crossers are MER–UNI (6), UNI–CEN (8) and ALM–EST (7): the minimum available becomes MER–UNI, and the cut property makes it mandatory. The MST worsens only by the difference 6 − 5 = 1.

Exercise 2:

With S = {IND}, the edges crossing the cut are all the edges incident to IND: EST–IND (9) and IND–HOS (6). The minimum is IND–HOS (6), so it belongs to every MST — without running anything. The Kruskal trace confirms it (IND–HOS accepted; EST–IND never even considered before the tree completes). This "single-node cut" trick yields one safe edge per vertex for free: each node's cheapest edge is always in the MST (with distinct weights).

Exercise 3:

def kruskal_clusters(nodes, edges, k_components):
    uf = UnionFind(nodes)
    accepted = 0
    for weight, u, v in sorted(edges):
        if len(nodes) - accepted == k_components:
            break                        # exactly k groups already
        if uf.union(u, v):
            accepted += 1
    groups = {}
    for n in nodes:
        groups.setdefault(uf.find(n), set()).add(n)
    return list(groups.values())

print(kruskal_clusters(NODES, EDGES, 3))
# [{'ALM', 'RIO', 'MER', 'CEN', 'HOS', 'EST', 'UNI'}, {'IND'}, {'PAR'}]

Each union reduces the number of components by 1 (we start with n = 9); that is why counting accepted edges suffices. The resulting groups are eloquent: the densely connected urban core on one side, and the Industrial Park and West Park — the degree-2 peripheral zones we have been flagging since 03-01 — as satellites. Kruskal doesn't just build networks: it reveals structure.

Conclusion

The MST answers the infrastructure question — connect everything at minimum total cost — and its greedy solution is no stroke of luck: the cut property, proved by exchange, certifies that the cheapest edge of any cut is mandatory. Kruskal exploits it by sorting edges and policing cycles with the union-find of 01-04 (second seed cashed in: both of that lesson's promises are now settled); Prim exploits it by growing a tree with the same heap as Dijkstra but prioritizing the edge's weight, not the accumulated distance. And we have fenced off with a counterexample what the MST is not: Dijkstra's tree optimizes distances from one source, the MST optimizes the sum — confusing them would cost Rutalia 24% in trenches. So far, our edges measured the cost of traversing them. The next lesson gives them a new meaning: capacity — how many packages per hour fit through each street. With that we enter the problems of maximum flow (03-05): what is the maximum delivery throughput between the warehouse and a neighborhood at rush hour, and which bottleneck limits it.

© Copyright 2026. All rights reserved