Skip to content

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:

diagram Weighted graph: 5 nodes, 7 possible edges mermaid

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:

diagram Minimum spanning tree: the cheapest 4 edges connecting all 5 nodes (total weight 11) mermaid

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.

kruskal_mst.py
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)
kruskal_mst.py
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.

prim_mst.py
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)
prim_mst.py
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.

sketch Prim's algorithm growing the MST, one cheapest edge at a time p5.js
Starting from node 0, each tick adds the cheapest edge that reaches a brand-new node -- the same greedy heap-pop step as the code above.

Complexity

AlgorithmTimeSpaceGrows from
KruskalO(ElogE)O(E \log E) (sorting dominates; Union-Find adds a near-O(1)O(1) α(n)\alpha(n) factor)O(V+E)O(V + E)The globally cheapest remaining edge, anywhere in the graph
Prim (binary heap)O(ElogV)O(E \log V)O(V+E)O(V + E)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 (EE close to VV) — sorting EE edges is cheap.
  • Prim wins when the graph is dense (EE close to V2V^2, 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: n=1000n = 1000 means about 5×1055 \times 10^5 edges. Kruskal would sort all of them, O(n2logn)O(n^2 \log n), 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 O(n2logn)O(n^2 \log n) as written, from the heap. The classic O(n2)O(n^2) dense-Prim — scan the bestbest array for its minimum each round instead of using a heap — is actually faster here and worth naming. Space O(n)O(n).

  • 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]: continue is 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] = 0 seeds 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 O(nlogn)O(n \log n). ”n=105n = 10^5?” — O(n2)O(n^2) is out; you need that geometric structure, or the Manhattan-MST sweep that keeps only O(n)O(n) 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 O(ElogE+QlogQ+(E+Q)α(n))O(E \log E + Q \log Q + (E + Q) \alpha(n)). Space O(n+Q)O(n + Q).

  • 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.
  • ee is 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 reports FalseFalse.

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 O(logn)O(\log n) 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 O(E2α(n))O(E^2 \alpha(n)) — about 200×400200 \times 400 union operations. Space O(n+E)O(n + E).

  • 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 - 1 edges were used. Without that, removing a bridge looks free and the edge is misclassified.
  • Every edge is critical or pseudo-critical or neither. The elifelif matters: 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 O(E2)O(E^2)?” — 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 O(ElogE)O(E \log E). “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

#ProblemDifficultyThe twist
1584Min Cost to Connect All PointsMediumA dense graph (every pair of points has an edge, weighted by Manhattan distance): Prim’s shines here
1135Connecting Cities With Minimum CostMedium · PremiumThe MST problem stated almost word for word; either algorithm works directly on the given edge list
1168Optimize Water Distribution in a VillageHard · PremiumAdd 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 - 1 edges at the minimum possible total weight.
  • Kruskal: sort all edges cheapest-first, use Union-Find to skip any edge that would close a cycle. O(ElogE)O(E \log E), 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. O(ElogV)O(E \log V), 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 coffee

Was this page helpful?

Let us know how we did