Kruskal's Algorithm
Minimum spanning tree by sorting all edges and greedily adding each edge that joins two different components, tracked with union-find.
Overview
Kruskal's algorithm builds a minimum spanning tree edge-by-edge in global weight order: sort every edge, walk through them from lightest to heaviest, and keep an edge iff its endpoints are currently in different components. A Union-Find (Disjoint Set Union) structure answers "same component?" and merges components in near-constant amortised time, so the whole algorithm is dominated by the sort: O(E log E) = O(E log V).
Where Prim's Algorithm grows a single tree from a seed, Kruskal grows a forest of many small trees that merge. That makes it the natural choice for sparse graphs given as an Edge List, for problems that want a spanning forest of a disconnected graph, and for any question phrased as "process edges by weight and track connectivity" (bottleneck paths, threshold connectivity, clustering).
Intuition
A mental model before the formal terms.
Spread all the cables on a table sorted by price. Pick up the cheapest; if it connects two islands that are not yet connected (by any chain of cables you already chose), keep it, otherwise it would only create a loop — throw it away. Keep going until every island is connected. You never need to know *where* the tree is growing, only *which islands are already joined*, and that is exactly what union-find remembers.
How it works
- Sort the edges by weight, ascending:
O(E log E). - Initialise union-find with each node in its own set.
- For each edge
(u, v, w)in sorted order: iffind(u) ≠ find(v), add the edge to the MST andunion(u, v). Otherwise skip — it would close a cycle. - Stop early once
V - 1edges are chosen. If the loop ends with fewer, the graph is disconnected and you have a minimum spanning forest. - Union by rank/size plus path compression makes each
find/unionO(α(V))≈ constant, so the loop isO(E · α(V)).
Why it works
Cut property applied at each accepted edge: when (u, v) is accepted, consider the cut S = component of u, V \ S = the rest. Every other crossing edge is heavier or equal (edges are processed in sorted order and lighter crossing edges would already have merged the sides). So (u, v) is a lightest crossing edge and belongs to some MST containing the edges chosen so far.
Cycle property for rejected edges: a rejected edge is the heaviest on the cycle it would close (all cycle edges accepted earlier are lighter or equal), and the heaviest edge on a cycle is never needed in an MST.
Since every accepted edge is safe and every rejected edge is unnecessary, the final V - 1 edges form an MST. Correctness of union-find guarantees no cycles are ever created.
Recognition
How to tell a problem wants this.
- "Minimum cost to connect everything" with the input already given as a list of weighted edges.
- Sparse graphs:
Eclose toV— sortingEedges is cheap. - Questions about when two nodes become connected as edges are added in weight order (minimum bottleneck, "smallest threshold so that all queries are connected").
- You need the MST of a possibly disconnected graph (spanning forest) or want to count components as a by-product.
Interactive visualization
Play, step, change the input. ← → and space work too.
| node | parent | root |
|---|---|---|
| A | A | A |
| B | B | B |
| C | C | C |
| D | D | D |
| E | E | E |
| F | F | F |
| G | G | G |
1sort edges by weight ascending2make_set(v) for every node3for (u, v, w) in edges:4 if find(u) != find(v):5 union(u, v); mst.add((u, v, w))6 else: skip — u and v already connected, edge would close a cycle7return mstPseudocode
1sort edges by weight ascending2uf = UnionFind(n); mst = []; total = 03for (u, v, w) in edges:4 if uf.find(u) != uf.find(v):5 uf.union(u, v); mst.append((u, v, w)); total += w6 if len(mst) == n - 1: break7return total, mstImplementations
1from typing import NamedTuple2 3 4class WEdge(NamedTuple):5 w: float6 u: int7 v: int8 9 10class DSU:11 """Disjoint-set with union by size and path halving."""12 131 · Disjoint-set with union by size and path halving14 def __init__(self, n: int) -> None:15 self.parent = list(range(n))16 self.size = [1] * n17 18 def find(self, x: int) -> int:19 while self.parent[x] != x:20 self.parent[x] = self.parent[self.parent[x]] # path halving21 x = self.parent[x]22 return x23 24 def unite(self, a: int, b: int) -> bool:25 a, b = self.find(a), self.find(b)26 if a == b:27 return False # already connected: accepting would close a cycle28 if self.size[a] < self.size[b]:29 a, b = b, a30 self.parent[b] = a31 self.size[a] += self.size[b]32 return True33 34 352 · Sort edges by weight; the greedy scan then never needs to reconsider36def kruskal(n: int, edges: list[WEdge]) -> tuple[float, list[WEdge]]:37 ordered = sorted(edges, key=lambda e: e.w)38 39 dsu = DSU(n)40 chosen: list[WEdge] = []41 total: float = 042 433 · Accept an edge only when it joins two different components44 for e in ordered:45 if dsu.unite(e.u, e.v):46 chosen.append(e)47 total += e.w48 if len(chosen) == n - 1:49 break # tree complete50 514 · Fewer than n-1 accepted edges means the graph was disconnected52 if len(chosen) != n - 1:53 return -1, []54 return total, chosen55 56 575 · Stopping early is what makes the sort the dominant cost, not the scan58def mst_weight(n: int, edges: list[WEdge]) -> float:59 return kruskal(n, edges)[0]list(range(n))is the identity parent list, and[1] * nthe initial sizes.a, b = self.find(a), self.find(b)resolves both roots in one tuple assignment.sorted(edges, key=lambda e: e.w)returns a new list, so the caller list is untouched — the opposite default fromlist.sort().WEdge(NamedTuple)gives an immutable record with named fields; because it is also a tuple,sorted(edges)without a key would order by(w, u, v), which happens to be correct here but is worth being explicit about.- The
len(chosen) == n - 1early break stops the scan once the tree is complete.
sorted runs TimSort in C, so on large edge lists the Python version is far closer to C++ than the union-find loop alone would suggest.
sorted()returns a new list whilelist.sort()sorts in place — choosing the former is what keeps this function free of side effects.key=evaluates the projection once per element, unlike a comparator, andoperator.attrgetter("w")is marginally faster than the lambda.NamedTuplefields are immutable;e._replace(w=...)produces a modified copy if needed.networkx.minimum_spanning_edges(G, algorithm="kruskal")is the library version, andscipy.sparse.csgraph.minimum_spanning_treeworks on a sparse matrix.
- Using
edges.sort(key=...)and mutating the callers list whensorted()was intended. - Writing
findrecursively and hittingRecursionErroron a long parent chain. - Returning the total without checking
len(chosen) == n - 1, so a disconnected graph reports a forest weight.
- Sorting API and defaults diverge sharply: Python
sorted(key=...)returns a copy, C++std::sortmutates and is unstable, and JS/TSsortmutates and — uniquely — is actively wrong without an explicit comparator because it stringifies. - Stability differs: Python and JS/TS sorts are stable, C++
std::sortis not, which can change *which* minimum spanning tree comes out when weights tie (never the total weight). - Immutable edge records come free in Python (
NamedTuple); C++ uses a plain aggregatestruct, TypeScript aninterfaceplusreadonlyon the parameter, and JavaScript has no way to express it at all. - The recursive
findis a genuine hazard only in Python (RecursionErrornear 1000 frames); the iterative path-halving loop used here sidesteps it in every language.
Complexity
Sorting dominates; the union-find loop is O(E · α(V)), effectively linear. log E ≤ 2 log V so O(E log E) = O(E log V). If edges arrive pre-sorted or weights are small integers (counting sort), the whole algorithm is near-linear.
Compare growth rates in the Complexity Explorer →When to use — and when not to
- Sparse graphs (
EisO(V)orO(V log V)) given as an edge list. - Disconnected graphs where a minimum spanning forest is acceptable or desired.
- Connectivity-threshold questions: "smallest
wsuch thatsandtare connected using only edges ≤w" — stop whenfind(s) == find(t). - Edges already sorted, or sortable in linear time — Kruskal becomes
O(E α(V)). - Offline processing of edge additions in weight order (Kruskal reconstruction tree, single-linkage clustering).
- Dense graphs / complete graphs on points:
E = V²edges must be materialised and sorted,O(V² log V); Prim's Algorithm with a matrix isO(V²). - Edges are only available through per-node adjacency queries — Prim's node-centric loop fits better.
- Directed graphs — no MST; use a minimum arborescence algorithm.
- Shortest paths — same warning as Prim: MST paths are not shortest paths.
Alternatives
Common mistakes
- Using union-find without rank/size or without path compression —
finddegrades toO(V)and the loop becomesO(E · V). - Comparing
parent[u] == parent[v]instead offind(u) == find(v). - Forgetting to sort, or sorting descending (that gives a maximum spanning tree — sometimes wanted, e.g. maximum bottleneck).
- Returning the MST weight for a disconnected graph without checking that
V - 1edges were chosen. - Materialising all
O(V²)edges for a complete geometric graph whenVis large — memory blows up; switch to Prim.
Interview patterns
- Min cost to connect all points / connecting cities — the direct application.
- Redundant connection: the first edge whose endpoints are already connected (Kruskal loop without sorting).
- Minimum bottleneck / "path with minimum maximum edge": process edges by weight until
sandtjoin. - Critical and pseudo-critical MST edges: rerun Kruskal excluding / forcing each edge.
- Number of connected components after adding edges — union-find is the reusable half of Kruskal.
- Number of IslandsIntermediate
- Merge IntervalsIntermediate