Minimum Spanning Trees: Kruskal and Prim
Interviewer cue: “connect all these points/cities/computers as cheaply as possible” is a minimum spanning tree problem in disguise. Given a connected, weighted, undirected graph, an MST is the subset of edges that connects every node using the least total weight possible, with no cycles. There are two classic greedy algorithms for building one — Kruskal’s and Prim’s — and both always find the same minimum total weight.
What you’ll learn
- What a minimum spanning tree is, and why greedy works here (unlike most shortest-path problems).
- Kruskal’s algorithm: sort every edge cheapest-first, then use Union-Find to skip any edge that would create a cycle.
- Prim’s algorithm: grow a single tree from a start node, always adding the cheapest edge that reaches a brand-new node, using a min-heap.
- Time/space complexity for both, and when to reach for which.
What is a minimum spanning tree?
Take this weighted, undirected graph — 5 nodes, 7 edges:
graph LR
N0((0)) ---|"4"| N1((1))
N0 ---|"1"| N2((2))
N1 ---|"2"| N2
N1 ---|"5"| N3((3))
N2 ---|"7"| N4((4))
N2 ---|"8"| N3
N3 ---|"3"| N4
A spanning tree connects all 5 nodes using exactly n - 1 = 4n - 1 = 4 edges and
no cycles. Many spanning trees exist for this graph — the minimum
spanning tree is the one whose edges sum to the smallest possible total:
graph LR
N0((0)) ---|"1"| N2((2))
N2 ---|"2"| N1((1))
N1 ---|"5"| N3((3))
N3 ---|"3"| N4((4))
Both algorithms below find exactly this tree, total weight 1 + 2 + 5 + 3 = 111 + 2 + 5 + 3 = 11
— they just build it up in different orders.
Kruskal’s algorithm: sort edges, then union-find
Kruskal’s is a direct application of Union-Find (covered in Phase 3): sort every edge from cheapest to most expensive, then walk the sorted list and add an edge only if its two endpoints are in different components — otherwise it would create a cycle, so skip it.
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
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, a, b):
root_a, root_b = self.find(a), self.find(b)
if root_a == root_b:
return False # already connected -- this edge would create a cycle
if self.rank[root_a] < self.rank[root_b]:
root_a, root_b = root_b, root_a
self.parent[root_b] = root_a
if self.rank[root_a] == self.rank[root_b]:
self.rank[root_a] += 1
return True
def kruskal_mst(n, edges):
# edges: list of (weight, u, v)
uf = UnionFind(n)
mst_edges = []
total_weight = 0
for weight, u, v in sorted(edges): # cheapest edges first
if uf.union(u, v): # keep it only if it joins two DIFFERENT components
mst_edges.append((u, v, weight))
total_weight += weight
return mst_edges, total_weight
edges = [
(4, 0, 1), (1, 0, 2), (2, 1, 2),
(5, 1, 3), (7, 2, 4), (8, 2, 3), (3, 3, 4),
] # (weight, u, v)
mst_edges, total_weight = kruskal_mst(5, edges)
print("MST edges:", mst_edges)
print("total weight:", total_weight)class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
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, a, b):
root_a, root_b = self.find(a), self.find(b)
if root_a == root_b:
return False # already connected -- this edge would create a cycle
if self.rank[root_a] < self.rank[root_b]:
root_a, root_b = root_b, root_a
self.parent[root_b] = root_a
if self.rank[root_a] == self.rank[root_b]:
self.rank[root_a] += 1
return True
def kruskal_mst(n, edges):
# edges: list of (weight, u, v)
uf = UnionFind(n)
mst_edges = []
total_weight = 0
for weight, u, v in sorted(edges): # cheapest edges first
if uf.union(u, v): # keep it only if it joins two DIFFERENT components
mst_edges.append((u, v, weight))
total_weight += weight
return mst_edges, total_weight
edges = [
(4, 0, 1), (1, 0, 2), (2, 1, 2),
(5, 1, 3), (7, 2, 4), (8, 2, 3), (3, 3, 4),
] # (weight, u, v)
mst_edges, total_weight = kruskal_mst(5, edges)
print("MST edges:", mst_edges)
print("total weight:", total_weight)Prim’s algorithm: grow one tree with a heap
Prim’s takes a different route to the same answer: start from any single
node, and repeatedly grow the tree by adding the cheapest edge that leads
to a node not yet in the tree. A min-heap (heapqheapq) keeps “what’s the
cheapest next step?” fast.
import heapq
from collections import defaultdict
def prim_mst(n, weighted_edges, start=0):
graph = defaultdict(list)
for u, v, w in weighted_edges:
graph[u].append((w, v))
graph[v].append((w, u)) # undirected: store the edge both ways
visited = {start}
min_heap = graph[start][:] # every edge leaving the start node
heapq.heapify(min_heap)
mst_edges = []
total_weight = 0
while min_heap and len(visited) < n:
weight, node = heapq.heappop(min_heap) # cheapest edge reaching an UNVISITED node
if node in visited:
continue # stale entry -- both endpoints already joined the tree some other way
visited.add(node)
mst_edges.append((node, weight))
total_weight += weight
for next_weight, neighbor in graph[node]:
if neighbor not in visited:
heapq.heappush(min_heap, (next_weight, neighbor))
return mst_edges, total_weight
weighted_edges = [
(0, 1, 4), (0, 2, 1), (1, 2, 2),
(1, 3, 5), (2, 4, 7), (2, 3, 8), (3, 4, 3),
]
mst_edges, total_weight = prim_mst(5, weighted_edges)
print("nodes added (node, edge weight used):", mst_edges)
print("total weight:", total_weight)import heapq
from collections import defaultdict
def prim_mst(n, weighted_edges, start=0):
graph = defaultdict(list)
for u, v, w in weighted_edges:
graph[u].append((w, v))
graph[v].append((w, u)) # undirected: store the edge both ways
visited = {start}
min_heap = graph[start][:] # every edge leaving the start node
heapq.heapify(min_heap)
mst_edges = []
total_weight = 0
while min_heap and len(visited) < n:
weight, node = heapq.heappop(min_heap) # cheapest edge reaching an UNVISITED node
if node in visited:
continue # stale entry -- both endpoints already joined the tree some other way
visited.add(node)
mst_edges.append((node, weight))
total_weight += weight
for next_weight, neighbor in graph[node]:
if neighbor not in visited:
heapq.heappush(min_heap, (next_weight, neighbor))
return mst_edges, total_weight
weighted_edges = [
(0, 1, 4), (0, 2, 1), (1, 2, 2),
(1, 3, 5), (2, 4, 7), (2, 3, 8), (3, 4, 3),
]
mst_edges, total_weight = prim_mst(5, weighted_edges)
print("nodes added (node, edge weight used):", mst_edges)
print("total weight:", total_weight)Notice Prim’s total_weighttotal_weight comes out 1111 too — the exact same minimum, just
discovered by growing outward from one node instead of scanning every edge
globally.
Complexity
| Algorithm | Time | Space | Grows from |
|---|---|---|---|
| Kruskal | (sorting dominates; Union-Find adds a near- factor) | The globally cheapest remaining edge, anywhere in the graph | |
| Prim (binary heap) | A single growing tree, one node at a time |
Kruskal vs. Prim: which one to reach for
- Kruskal is simplest when the input already arrives as an edge list
(a flat list of
[u, v, weight][u, v, weight]triples, which is exactly how most LeetCode MST problems hand you the graph) and the graph is sparse ( close to ) — sorting edges is cheap. - Prim wins when the graph is dense ( close to , e.g. “every pair of points has an edge” problems like Min Cost to Connect All Points) and you already have — or can cheaply build — an adjacency list, since it never needs to look at every edge up front.
- Both always produce a spanning tree of the same minimum total weight (though the exact edges chosen can differ if multiple edges share a weight) — pick based on the input shape and graph density, not correctness.
Practice — real LeetCode problems
Prim on a dense implicit graph, Kruskal used as an offline query engine, and then the classification of every edge as critical or merely optional. The third is the one that proves you understand what an MST actually is.
LC 1584 — Min Cost to Connect All Points · Medium
Problem. Given points on a plane, the cost of connecting two points is their Manhattan distance. Return the minimum cost to connect all points so that there is exactly one simple path between any two.
Constraints. 1 <= len(points) <= 10001 <= len(points) <= 1000,
-10**6 <= xi, yi <= 10**6-10**6 <= xi, yi <= 10**6, all points distinct.
Examples. [[0,0],[2,2],[3,10],[5,2],[7,0]][[0,0],[2,2],[3,10],[5,2],[7,0]] gives 2020 ·
[[3,12],[-2,5],[-4,1]][[3,12],[-2,5],[-4,1]] gives 1818 · [[0,0]][[0,0]] gives 00
Editorial · approach, complexity, follow-ups
An MST question in disguise — “exactly one simple path between any two points” is the definition of a spanning tree, and minimum cost makes it minimum.
Which algorithm, and why. The graph is complete: means about edges. Kruskal would sort all of them, , which passes but is wasteful. Prim relaxes lazily and never materialises the list. This is the standard dense-versus-sparse call, and it is what the problem is really asking you to make.
Time as written, from the heap. The classic dense-Prim
— scan the bestbest array for its minimum each round instead of using a heap — is
actually faster here and worth naming. Space .
- Add the cost when you pop, not when you push. Pushed entries are candidates; popped-and-not-stale ones are real tree edges.
- The stale check
if in_tree[u]: continueif in_tree[u]: continueis mandatory with a lazy heap, because a point can be pushed several times at decreasing costs. - A single point costs 0 and the loop adds one zero. Two points cost their distance.
- Manhattan, not Euclidean. No square roots, so everything stays integral and exact.
best[0] = 0best[0] = 0seeds the tree at point 0; any start gives the same total, which is worth saying — MST weight is independent of the root.
Follow-ups you should expect: “Euclidean distance instead?” — the same
algorithm, and for points on a plane the MST is a subgraph of the Delaunay
triangulation, which gets you to . ”?” — is out;
you need that geometric structure, or the Manhattan-MST sweep that keeps only
candidate edges. “Which edges?” — record the predecessor whenever you
improve best[v]best[v]. “Second-best MST?” — for each tree edge, forbid it and rerun, or
use the max-edge-on-path trick. “Some points already connected?” — seed the DSU or
mark them all in-tree at cost 0.
LC 1697 — Checking Existence of Edge Length Limited Paths · Hard
Problem. Given an undirected weighted graph and queries [p, q, limit][p, q, limit],
answer for each query whether there is a path from pp to qq using only edges of
weight strictly less than limitlimit.
Constraints. 2 <= n <= 10**52 <= n <= 10**5, 1 <= len(edgeList), len(queries) <= 10**51 <= len(edgeList), len(queries) <= 10**5,
multiple edges between the same pair are possible.
Examples. n = 3n = 3, edgeList = [[0,1,2],[1,2,4],[2,0,8],[1,0,16]]edgeList = [[0,1,2],[1,2,4],[2,0,8],[1,0,16]],
queries = [[0,1,2],[0,2,5]]queries = [[0,1,2],[0,2,5]] gives [false,true][false,true]
Editorial · approach, complexity, follow-ups
The technique is offline query processing: nothing says you must answer queries in the order given, so sort them into the order that makes the work monotone.
Union-find can add edges but never remove them. Processing limits in ascending order means the edge set only ever grows, which is exactly what the structure supports. Each edge is unioned once in total, not once per query — that is where the efficiency comes from.
Time . Space .
- Strictly less than the limit.
edgeList[e][2] < limitedgeList[e][2] < limit, not<=<=. The third test case — one edge of weight 5, with limits 5 and 6 — exists only to catch this, and it is the single most common wrong submission. - Sort the indices, not the queries. If you sort the query list itself you lose
the mapping back to output positions. Sorting
range(len(queries))range(len(queries))by limit keeps it. eeis never reset. It is a single monotone pointer across the whole sweep. Resetting it inside the loop turns the solution quadratic.- Duplicate edges between the same pair are explicitly allowed; the second union is a harmless no-op.
- Disconnected graphs are fine —
find(p) != find(q)find(p) != find(q)simply reportsFalseFalse.
Follow-ups you should expect: “Online, answering each query as it arrives?” —
build a maximum-spanning-tree-style structure and answer with the minimax path
weight: binary lifting on the MST, or a Kruskal reconstruction tree, both giving
per query. That is the real answer to “what if you cannot reorder”, and
it is the follow-up that separates candidates. “Weight at most the limit?” —
change the comparison to <=<=. “Minimise the maximum edge on a path (LC 1631)?” —
the same minimax-path idea. “Edges being added and removed?” — offline dynamic
connectivity, considerably harder.
LC 1489 — Find Critical and Pseudo-Critical Edges in MST · Hard
Problem. Given a weighted undirected graph, find all critical edges (in
every MST) and all pseudo-critical edges (in at least one MST but not all).
Return [critical, pseudo_critical][critical, pseudo_critical] as lists of edge indices.
Constraints. 2 <= n <= 1002 <= n <= 100, 1 <= len(edges) <= min(200, n*(n-1)/2)1 <= len(edges) <= min(200, n*(n-1)/2),
1 <= weight <= 10001 <= weight <= 1000, no repeated edges.
Examples. n = 5n = 5,
edges = [[0,1,1],[1,2,1],[2,3,2],[0,3,2],[0,4,3],[3,4,3],[1,4,6]]edges = [[0,1,1],[1,2,1],[2,3,2],[0,3,2],[0,4,3],[3,4,3],[1,4,6]] gives
[[0,1],[2,3,4,5]][[0,1],[2,3,4,5]] · a 4-cycle of equal weights gives [[],[0,1,2,3]][[],[0,1,2,3]]
Editorial · approach, complexity, follow-ups
Read the definitions as experiments you can run, and the solution writes itself:
- Critical = in every MST = removing it makes the best achievable weight worse (or disconnects the graph entirely).
- Pseudo-critical = in some MST = forcing it in still achieves the baseline weight, and it is not critical.
The n <= 100n <= 100, edges <= 200edges <= 200 constraints are the permission slip: two extra
Kruskal runs per edge is at most 400 runs of a near-linear algorithm.
Time — about union operations. Space .
- Carry the original index. You must sort by weight for Kruskal but report by
input position. Losing the index is the most common way to get this wrong, and
the sorted-tuple trick
(w, u, v, i)(w, u, v, i)handles it in one line. - Disconnection must count as worse, hence returning infinity when fewer than
n - 1n - 1edges were used. Without that, removing a bridge looks free and the edge is misclassified. - Every edge is critical or pseudo-critical or neither. The
elifelifmatters: an edge already found critical must not also be listed as pseudo-critical. - All-equal weights on a cycle make nothing critical and everything
pseudo-critical — that is the second test case, and a good sanity check on the
elifelif. - A bridge is always critical, whatever its weight, since without it the graph cannot be spanned.
Follow-ups you should expect: “Faster than ?” — yes, and this is the real question hiding behind the problem. Run Kruskal weight class by weight class: within a group of equal-weight edges, those that join two different components after all lighter edges are merged are exactly the candidates, and among them the bridges of the contracted multigraph are the critical ones. That gets you to roughly . “Why does grouping by weight work?” — all MSTs use the same number of edges from each weight class, which is the key MST exchange property. “Count the distinct MSTs?” — multiply the spanning-tree counts of the contracted graph per weight class, via Kirchhoff’s matrix-tree theorem. “Is a given edge set extendable to an MST?” — force them all in and compare against the baseline.
LeetCode problem set
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| 1584 | Min Cost to Connect All Points | Medium | A dense graph (every pair of points has an edge, weighted by Manhattan distance): Prim’s shines here |
| 1135 | Connecting Cities With Minimum Cost | Medium · Premium | The MST problem stated almost word for word; either algorithm works directly on the given edge list |
| 1168 | Optimize Water Distribution in a Village | Hard · Premium | Add a virtual node 0 representing “the well,” turn each house’s well cost into an edge from node 0, then run ordinary Kruskal/Prim on the combined edge set |
Recap
- An MST connects every node in a weighted graph using
n - 1n - 1edges at the minimum possible total weight. - Kruskal: sort all edges cheapest-first, use Union-Find to skip any edge that would close a cycle. , best for sparse graphs given as an edge list.
- Prim: grow one tree from a start node, always adding the cheapest edge to an unvisited node via a min-heap. , best for dense graphs or when you already have an adjacency list.
- Both are greedy and both are always correct for MST — unlike shortest-path problems, MST’s “cut property” guarantees the locally cheapest choice is always part of some optimal solution.
Next: Topological Sort — ordering the nodes of a directed acyclic graph so every edge points from earlier to later, the pattern behind build systems and course scheduling.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
