In 1926 the Czech mathematician Otakar Borůvka was asked how to wire electricity to the towns of southern Moravia with the least total cable. His answer is the oldest minimum spanning tree algorithm, and the procedure is short: every component, at the same moment, grabs the cheapest edge leaving it; add all of those edges; merge; repeat. Kruskal (1956) and Prim (1957) came later and dominate textbooks because they are easy to run sequentially. Borůvka is the one people reach for when the graph lives on a GPU, is split across machines, or is too large for a global sorted edge list, because each phase is a bulk operation over all edges with no ordering between components.
This article builds the algorithm from the cut property, traces it, proves the phase bound, and gives two tested implementations: union-find, and the contraction form used on GPUs and clusters. It also covers tie-breaking, which is harmless in one form and an infinite loop in the other.
The cut property, applied many times at once
Take a connected, undirected graph with edge weights. A spanning tree touches every vertex with V−1 edges and no cycles; a minimum spanning tree (MST) has the smallest total weight. Every greedy MST algorithm rests on one fact, the cut property: split the vertices into any two non-empty sets, and the lightest edge crossing the split belongs to some MST. If edge weights are all distinct, it belongs to the MST, which is then unique.
The proof is an exchange argument. Suppose an MST T avoids the lightest crossing edge e. Adding e to T creates a cycle, and that cycle must cross the split a second time, through some edge f. Swap f out for e. The result is still a spanning tree, and it weighs no more, since e is no heavier than f. So some MST contains e.
Borůvka applies the cut property to many cuts at once. For a component C of the current forest, the split is C against everything else, and C's cheapest outgoing edge is safe. Every component's pick is safe for its own cut. The step that needs care is adding all picks together, and that holds only if the picks cannot form a cycle. They cannot, provided every comparison uses one strict total order on edges, which the section on ties explains.
A two-phase trace
Use the seven-vertex graph in the figure. It has eleven edges with distinct weights. At the start every vertex is its own component, so phase 1 asks each vertex for its cheapest incident edge:
| Component | Cheapest outgoing edge | Note |
|---|---|---|
| {A} | A–D (4) | picked by A and D: one edge, two votes |
| {B} | B–E (6) | |
| {C} | C–E (5) | picked by C and E |
| {D} | A–D (4) | duplicate of A's pick |
| {E} | C–E (5) | duplicate of C's pick |
| {F} | E–F (10) | |
| {G} | E–G (12) | G's other edge, F–G (13), is heavier |
Seven votes produce five distinct edges, and adding them leaves two components: {A, D} and {B, C, E, F, G}. Phase 2 looks only at edges that cross between them: A–B (7), B–D (9), D–F (11) and D–E (15). Both components pick A–B, and the tree is complete after two phases with weight 4 + 6 + 5 + 10 + 12 + 7 = 44. The tested code below prints exactly this edge set. Note what never happened: no global sort, and no priority queue. Each phase was one pass over the edges.
Why at most log V phases
Every component that has an outgoing edge picks one, and each pick merges it with at least one other component. In the worst case components pair up exactly, so the number of components at least halves each phase. Starting from V singletons, at most ⌈log2 V⌉ phases are needed. Each phase scans all E edges and does near-constant union-find work per edge, so the total is O(E log V), the same as Kruskal with a sort or Prim with a binary heap.
The bound is usually pessimistic, since components often absorb several neighbours in one phase. In the contraction form a second effect helps: after relabelling, internal edges become self-loops and are deleted, so the edge list shrinks too.
Ties: harmless in one form, an infinite loop in the other
Suppose three components X, Y and Z are joined pairwise by edges of weight 5, and each breaks ties toward a different neighbour: X picks X–Y, Y picks Y–Z, and Z picks Z–X. Adding all three makes a cycle. The sequential implementation survives this because it checks find(u) != find(v) before each union, so the third edge is simply skipped. The MST weight is still correct.
The contraction form has no such check. It treats the picks as a pointer graph in which each component points at its partner, and it assumes the only cycles are mutual pairs (X picks Y and Y picks X). It breaks those by making the smaller id the root, then follows pointers until they stop changing. A three-cycle has no root. All three components count their edge, so the total includes a whole cycle. Forcing this on a triangle of weight-5 edges, the code below reports 15 instead of 10. Synchronous pointer jumping, the double-buffered form used on GPUs, never converges at all: the three pointers rotate forever. The fix is to compare edges by the pair (weight, edge id), never by weight alone. With a strict total order, the pointer graph can only have cycles of length two. Along any longer cycle the chosen edges would have to strictly decrease, which is impossible on a cycle. The same key makes the output deterministic across runs and thread schedules, which matters when a pipeline diffs MSTs between builds.
Floating-point weights add one more trap. A NaN compares false against everything, so an edge with a NaN weight can win or lose depending on argument order, and the order is no longer total. Reject NaN at load time.
A sequential implementation with union-find
The sequential version keeps components in a union-find structure. Each phase makes one pass to record the best edge per root, then a second pass to union them. Because the loop stops when no component has a crossing edge, a disconnected input returns a minimum spanning forest instead of hanging, and self-loops and parallel edges need no special handling.
def find(parent, x):
while parent[x] != x:
parent[x] = parent[parent[x]] # path halving
x = parent[x]
return x
def boruvka(n, edges):
"""edges: list of (w, u, v). Returns (total, chosen edge indices, phases).
Keys are (w, index): a strict total order, so the result is unique and stable."""
parent = list(range(n))
chosen, total, phase = [], 0, 0
while True:
best = [None] * n # best[root] = index of cheapest crossing edge
for i, (w, u, v) in enumerate(edges):
ru, rv = find(parent, u), find(parent, v)
if ru == rv:
continue # internal edge or self-loop
for r in (ru, rv):
if best[r] is None or (w, i) < (edges[best[r]][0], best[r]):
best[r] = i
picked = {i for i in best if i is not None}
if not picked:
break # no crossing edge: a spanning forest remains
phase += 1
for i in sorted(picked):
w, u, v = edges[i]
ru, rv = find(parent, u), find(parent, v)
if ru != rv:
parent[ru] = rv
chosen.append(i)
total += w
return total, chosen, phaseRun on the example, it returns weight 44 in 2 phases. Checked against Kruskal on 2,000 random multigraphs with weights drawn from 1–5 (so ties are everywhere), self-loops, parallel edges and disconnected parts, both implementations on this page agree on every total.
The data-parallel shape: contraction
Parallel MST code avoids union-find, because concurrent unions need atomics and get slow under contention. It uses contraction instead. Every phase is five bulk steps, and each maps to a standard data-parallel primitive: a segmented or atomic minimum keyed by component, an array write, a compare, repeated gathers, and a stream compaction. The Python below has that structure. Each loop is a kernel launch on a GPU or a superstep in a vertex-centric system such as Pregel or GraphX.
def boruvka_contract(n, edges):
"""Each loop body is a bulk step: scatter-min, hook, pointer-jump, relabel, filter."""
E = [(w, i, u, v) for i, (w, u, v) in enumerate(edges)]
total, chosen = 0, []
while E:
best = {} # 1. scatter-min per super-vertex
for w, i, u, v in E:
for a in (u, v):
if a not in best or (w, i) < best[a][:2]:
best[a] = (w, i, v if a == u else u)
succ = {a: b[2] for a, b in best.items()} # 2. hook
for a, b in list(succ.items()): # 3. a mutual pair -> smaller id is root
if succ.get(b) == a and a < b:
succ[a] = a
for a, (w, i, _) in best.items(): # each non-root owns one tree edge
if succ[a] != a:
total += w
chosen.append(i)
changed = True # 4. pointer jumping
while changed:
changed = False
for a in succ:
r = succ[succ[a]]
if r != succ[a]:
succ[a], changed = r, True
E = [(w, i, succ.get(u, u), succ.get(v, v)) for w, i, u, v in E]
E = [e for e in E if e[2] != e[3]] # 5. relabel, drop self-loops
return total, sorted(chosen)Why does each non-root own exactly one tree edge? After step 3, every component except the roots points along its own chosen edge. A mutual pair shares one edge, and only the non-root side counts it. Pointer jumping finishes in O(log V) rounds because each round doubles how far a pointer reaches. On a GPU, the scatter-min is usually an atomic minimum on a packed 64-bit key with the weight in the high bits and the edge id in the low bits, which is the (weight, id) order from the previous section in a single instruction. In a distributed system, steps 1 and 5 are shuffles keyed by component id, and the edge list shrinks between rounds, so later rounds are cheap.
Hybrids and relatives
Borůvka's phase structure makes it a building block for other algorithms:
- Borůvka then Prim. A few Borůvka phases shrink V by a constant factor each. Prim with a Fibonacci heap then runs in O(E + V log V) on the contracted graph. Running log log V phases first gives O(E log log V) overall.
- Karger–Klein–Tarjan (1995). This randomized algorithm runs in expected linear time. It alternates two Borůvka steps with random sampling and a linear-time verification that discards edges that cannot be in the MST. Borůvka steps do the contraction.
- GHS (Gallager, Humblet and Spira, 1983). This is the classic message-passing MST algorithm for networks where every node knows only its own links. Fragments find their minimum-weight outgoing edge and merge, which is Borůvka's idea with distributed coordination.
- Geometric MSTs. For low-dimensional points, each component's cheapest edge comes from a kd-tree nearest-neighbour query, with no edge list stored. The hdbscan library offers this as
boruvka_kdtree.
| Borůvka | Kruskal | Prim (binary heap) | |
|---|---|---|---|
| Time | O(E log V) | O(E log E), dominated by the sort | O(E log V) |
| Main data structure | per-component minimum | sorted edges + union-find | priority queue |
| Parallelism | natural: each phase is bulk | sort parallelises; the scan is sequential | poor: one frontier |
| Disconnected input | forest, if the loop stops on no picks | forest | one tree per start vertex |
| Best fit | GPU, distributed, implicit or geometric edges | sparse graphs in memory | dense graphs, adjacency matrix |
Where it is used
Borůvka-style MSTs appear in single-linkage and HDBSCAN clustering, in network backbone design (the original problem), and as a subroutine in approximation algorithms such as the metric TSP 2-approximation. When the edge list fits in RAM and a sort is cheap, Kruskal is simpler and just as fast. Use Borůvka when the graph is partitioned, the edges are implicit, or the hardware is wide.
Failure modes
- Comparing by weight only. This is safe with the union-find check, but it gives non-deterministic output and makes contraction loop forever. Always use (w, id).
- No termination on a disconnected graph. A loop written as
while components > 1never ends when the input has two islands. Stop when a phase picks nothing, and report the number of trees. - Double counting mutual picks. Summing over components instead of over distinct edges counts A–D twice in the example. Deduplicate by edge id, or let only non-roots contribute.
- Not compacting the edges. If self-loops are not filtered after relabelling, every phase rescans dead edges. That keeps the O(E log V) bound but wastes the shrinkage that makes later phases cheap.
- Testing on small, distinct-weight graphs only. The tie bug and the forest bug both hide there. Fuzz against Kruskal with weights from a tiny range.
What to do next
- Run the two implementations above on the seven-vertex example, and confirm weight 44 in two phases.
- Write the fuzz test: random multigraphs with weights in 1–5, compared to Kruskal from the Kruskal walkthrough.
- Force a cyclic tie-break in the contraction version on a triangle of weight-5 edges, with each vertex preferring its next neighbour. Confirm the total comes out as 15 instead of 10, and that synchronous jumping never settles. Then restore the (w, id) key.
- Review union-find for path halving and union by rank, and Prim's algorithm for the Borůvka-then-Prim hybrid.
- For point data, read Euclidean MST and try kd-tree nearest-neighbour queries as the per-component minimum.
- Contrast with reverse-delete, which applies the cycle property instead of the cut property.
- Before you ship parallel MST code, add assertions: pointer jumping must terminate within ⌈log2 V⌉ + 1 rounds, and the output must contain V − (number of trees) edges.