AI Knowledge Tree

Minimum spanning trees

Build minimum spanning trees with Kruskal and Prim, and prove the cut property.

⏱ 30 minPrerequisites:Shortest paths and Dijkstra

A spanning tree of a connected graph is a subgraph that includes every vertex and just enough edges to keep it connected (exactly V−1V - 1 for a graph with VV vertices, and no cycles). The minimum spanning tree (MST) is the spanning tree of smallest total edge weight. It is the cheapest way to wire up a network, build a hierarchy, or cluster points[1]. Two greedy algorithms find it; both are proved correct by the cut property[2].

Spanning trees

For a connected undirected weighted graph with VV vertices and EE edges, any spanning tree has exactly V−1V - 1 edges and no cycles, and connects every vertex. There are usually many spanning trees; the MST is the one minimizing the sum of edge weights. If edge weights are distinct, the MST is unique; with ties, multiple MSTs may share the same total weight.

The cut property

A cut (S,V∖S)(S, V \setminus S) partitions the vertices into two nonempty sets. A crossing edge has one endpoint in each part.

Cut property. For any cut, the minimum-weight crossing edge belongs to every MST.

Proof. Suppose an MST TT does not contain the minimum-weight crossing edge ee. Adding ee to TT creates a unique cycle, since TT has V−1V - 1 edges and is acyclic. That cycle must contain at least one other crossing edge e′e' (it leaves SS at some point and returns, requiring two crossings). Because ee is the lightest crossing edge, w(e)≤w(e′)w(e) \le w(e'). Replacing e′e' with ee keeps the structure connected and acyclic (same number of edges, the cycle broken), and the total weight does not increase. So T∖{e′}∪{e}T \setminus \{e'\} \cup \{e\} is also a spanning tree of weight no greater than TT, hence also an MST, and it contains ee. ■\qquad\blacksquare

Both algorithms below are different ways of repeatedly applying the cut property.

Kruskal's algorithm

Build the MST edge by edge, smallest first:

  1. Sort all edges by weight.
  2. Iterate through them in order; add an edge if and only if it does not create a cycle.

The cycle check uses a union-find (disjoint-set) data structure: each vertex starts in its own set, and an edge (u,v)(u, v) creates a cycle iff uu and vv are already in the same set. Union-find with path compression and union by rank supports both operations in nearly constant amortized time, and Kruskal's running time is O(Elog⁡E)O(E \log E), dominated by the sort. Each edge added is the lightest in the cut separating its component from everything else, so the cut property applies.

Prim's algorithm

Grow a single tree from a starting vertex:

  1. Start with one vertex in the tree.
  2. Repeatedly add the lightest edge crossing from the tree to a vertex outside, using a min-priority queue.

This is Dijkstra's algorithm with "edge weight" replacing "distance from source". The running time is O((V+E)log⁡V)O((V + E)\log V) with a binary heap. The cut between the current tree and the rest of the graph is recomputed implicitly at each step, and the lightest crossing edge gets added.

Choosing between them

Kruskal is conceptually simpler (sort and union-find) and works well when edges are already sorted or sorting is cheap, especially on sparse graphs. Prim shines on dense graphs (with a Fibonacci heap it runs in O(E+Vlog⁡V)O(E + V \log V)) and naturally extends to greedy network-growing applications.

Check it on arrays

python · numpy
# Union-Find with path compression and union by rank.
class UF:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank   = [0] * n
    def find(self, x):
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]    # path compression
            x = self.parent[x]
        return x
    def union(self, x, y):
        rx, ry = self.find(x), self.find(y)
        if rx == ry: return False
        if self.rank[rx] < self.rank[ry]: rx, ry = ry, rx
        self.parent[ry] = rx
        if self.rank[rx] == self.rank[ry]: self.rank[rx] += 1
        return True

def kruskal(n, edges):
    """edges: list of (weight, u, v)."""
    uf = UF(n)
    mst, cost = [], 0
    for w, u, v in sorted(edges):
        if uf.union(u, v):
            mst.append((u, v, w)); cost += w
            if len(mst) == n - 1: break
    return mst, cost

# Sample graph (vertices 0..5)
edges = [
    (4, 0, 1), (8, 0, 7),
    (8, 1, 2), (11, 1, 7),
    (7, 2, 3), (4, 2, 5), (2, 2, 8),
    (9, 3, 4), (14, 3, 5),
    (10, 4, 5),
    (2, 5, 6),
    (1, 6, 7), (6, 6, 8),
    (7, 7, 8),
]
n = 9
mst, cost = kruskal(n, edges)
print("Kruskal MST cost:", cost)
print("MST edges:", mst)

# Prim's algorithm using a priority queue
import heapq
def prim(n, adj, start=0):
    in_tree = [False] * n
    pq = [(0, start, -1)]                                  # (weight, vertex, parent)
    mst, cost = [], 0
    while pq and len(mst) < n - 1:
        w, u, p = heapq.heappop(pq)
        if in_tree[u]: continue
        in_tree[u] = True
        if p != -1:
            mst.append((p, u, w)); cost += w
        for v, wv in adj[u]:
            if not in_tree[v]: heapq.heappush(pq, (wv, v, u))
    return mst, cost

adj = {i: [] for i in range(n)}
for w, u, v in edges:
    adj[u].append((v, w)); adj[v].append((u, w))           # undirected
mst_p, cost_p = prim(n, adj)
print("Prim MST cost:   ", cost_p)
print("equal totals:", cost == cost_p)

Both algorithms find spanning trees of the same total weight, the cut property guaranteeing they reach the same optimal cost.

Exercises

Exercise 1. Run Kruskal's algorithm by hand on the graph with vertices {0,1,2,3}\{0, 1, 2, 3\} and edges (weight, endpoints): (1,0,1)(1, 0, 1), (2,1,2)(2, 1, 2), (2,0,2)(2, 0, 2), (3,2,3)(3, 2, 3), (4,0,3)(4, 0, 3). List the edges added in order and the total weight.

Solution

Step 1. Sort edges by weight: (1,0,1)(1, 0, 1), (2,1,2)(2, 1, 2), (2,0,2)(2, 0, 2), (3,2,3)(3, 2, 3), (4,0,3)(4, 0, 3).

Step 2. Process in order, adding an edge only if its endpoints are in different components.

  • (1,0,1)(1, 0, 1): components {0},{1}\{0\}, \{1\} differ. Add it. Components: {0,1}\{0, 1\}.
  • (2,1,2)(2, 1, 2): 11 and 22 differ. Add it. Components: {0,1,2}\{0, 1, 2\}.
  • (2,0,2)(2, 0, 2): 00 and 22 are already joined. Skip (would form a cycle).
  • (3,2,3)(3, 2, 3): 22 and 33 differ. Add it. Components: {0,1,2,3}\{0, 1, 2, 3\}.
  • (4,0,3)(4, 0, 3): all joined; the tree has V−1=3V - 1 = 3 edges. Stop.

Step 3. Edges added: (0,1)(0,1), (1,2)(1,2), (2,3)(2,3). Total weight 1+2+3=61 + 2 + 3 = 6.

Exercise 2. Using the cut property, prove that if every edge weight is distinct, the MST is unique.

Solution

Step 1. Suppose, for contradiction, that two distinct MSTs T1T_1 and T2T_2 exist. Since they differ, some edge is in one but not the other; let ee be the minimum-weight edge among all edges in exactly one of T1,T2T_1, T_2. Say e∈T1∖T2e \in T_1 \setminus T_2.

Step 2. Removing ee from T1T_1 splits it into two components, defining a cut (S,V∖S)(S, V \setminus S) with ee crossing it. Adding ee to T2T_2 creates a cycle; that cycle must contain another edge e′e' crossing the same cut, with e′∈T2∖T1e' \in T_2 \setminus T_1.

Step 3. Both ee and e′e' cross the cut. By the choice of ee as the minimum differing edge, w(e)<w(e′)w(e) < w(e') (weights are distinct, so the inequality is strict). Then T2∖{e′}∪{e}T_2 \setminus \{e'\} \cup \{e\} is a spanning tree of strictly smaller weight than T2T_2, contradicting that T2T_2 is an MST. Hence no two distinct MSTs exist.

Exercise 3 (code). Verify on a random graph that Kruskal and Prim return the same total weight, and that the MST has exactly V−1V - 1 edges and is connected. Use the implementations from the lesson.

Solution

Equal totals confirm both greedy strategies reach the optimum; the edge count and a union-find connectivity check confirm the result is a spanning tree.

python · numpy
import heapq, random

class UF:
    def __init__(self, n):
        self.parent = list(range(n)); self.rank = [0] * n
    def find(self, x):
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]
            x = self.parent[x]
        return x
    def union(self, x, y):
        rx, ry = self.find(x), self.find(y)
        if rx == ry: return False
        if self.rank[rx] < self.rank[ry]: rx, ry = ry, rx
        self.parent[ry] = rx
        if self.rank[rx] == self.rank[ry]: self.rank[rx] += 1
        return True

def kruskal(n, edges):
    uf = UF(n); mst, cost = [], 0
    for w, u, v in sorted(edges):
        if uf.union(u, v):
            mst.append((u, v, w)); cost += w
            if len(mst) == n - 1: break
    return mst, cost

def prim(n, adj, start=0):
    in_tree = [False] * n
    pq = [(0, start, -1)]; mst, cost = [], 0
    while pq and len(mst) < n - 1:
        w, u, p = heapq.heappop(pq)
        if in_tree[u]: continue
        in_tree[u] = True
        if p != -1: mst.append((p, u, w)); cost += w
        for v, wv in adj[u]:
            if not in_tree[v]: heapq.heappush(pq, (wv, v, u))
    return mst, cost

rng = random.Random(0)
n = 12
# Build a connected graph: a random spanning path plus extra random edges.
edges = [(rng.randint(1, 20), i, i + 1) for i in range(n - 1)]
for _ in range(20):
    u, v = rng.randrange(n), rng.randrange(n)
    if u != v: edges.append((rng.randint(1, 20), u, v))

mst_k, cost_k = kruskal(n, edges)
adj = {i: [] for i in range(n)}
for w, u, v in edges:
    adj[u].append((v, w)); adj[v].append((u, w))
mst_p, cost_p = prim(n, adj)

# Same optimal cost from both algorithms.
assert cost_k == cost_p
# Exactly V - 1 edges, and they connect all vertices.
assert len(mst_k) == n - 1
uf = UF(n)
for u, v, w in mst_k: uf.union(u, v)
assert len({uf.find(i) for i in range(n)}) == 1
print(cost_k, cost_p, len(mst_k))

The final A5 lesson trades exactness for memory: probabilistic data structures.


References

  1. [1]Kruskal, J. B. (1956). "On the shortest spanning subtree of a graph and the traveling salesman problem". Proceedings of the AMS, 7, 48-50. doi:10.1090/S0002-9939-1956-0078686-7↩
  2. [2]Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press. Minimum spanning trees, the cut property, and the Kruskal and Prim algorithms (Ch. 23).↩