← Applied algorithms
Routes and connections From possible cable runs to the least cable

Kruskal’s minimum spanning tree

Shortest run first. Skip the loops.

Ask NetworkX, Python’s graph library, for the cheapest set of links that connects everything, and the answer comes from minimum_spanning_tree(G, weight="weight", algorithm="kruskal", ignore_nan=False). Its documentation is plain about it: “The default is 'kruskal'.” SciPy’s version says, “This is computed using the Kruskal algorithm.”

We’ll use it on a festival site: eight spots that need cable, 14 places a run could go, the least cable that connects them all, and what to say when the car park is across a river that no cable crosses.

TypeScriptGoOne festival site in each language

01 / The idea

Connect every tent with the least cable.

The site has eight spots that need cable: the generator, the gate, the bar, the main stage, the food court, first aid, the campsite, and an acoustic tent. The crew has measured 14 places a run could go, from 12 to 26 meters. Laying all of them would take 224 m, and most of it would be wasted: each spot only has to be connected to the rest somehow.

Kruskal’s algorithm finds the least cable that does it: look at the runs from shortest to longest, and lay each one whose ends aren’t already connected. The result is a minimum spanning tree: every spot connected, no loops, and no other network with less cable.

It never looks ahead. A run is laid or skipped the moment it comes up and is never reconsidered. Watch it cross the site, including the run it refuses.

Kruskal

Shortest run first. Skip the loops.

FESTIVAL SITE · CABLE IN METERS 8 spots · 14 possible runs
1412221615121417161513201226AGeneratorBGateABarDMain stageEFood courtFFirst aidGCampingHAcoustic tent

Generator–Bar, 12 m: taken. 12 m laid, 7 groups.

taken skipped not looked at yet · letters name the groups

01/ 03
Sort, then take the shortest

Sort the runs. Take the shortest.

14 possible runs, shortest first. The three 12-meter runs, Generator–Bar, Bar–Food court, and Acoustic tent–First aid, each join two separate groups, so all three are taken. So is First aid–Camping at 13 m: 4 runs, 49 m, and 4 groups left.

Reduced motion: choose a scene to see its completed state.

Read this scene

14 possible runs, shortest first. The three 12-meter runs, Generator–Bar, Bar–Food court, and Acoustic tent–First aid, each join two separate groups, so all three are taken. So is First aid–Camping at 13 m: 4 runs, 49 m, and 4 groups left.

Looked at, shortest first: Generator–Bar 12 m, taken. 12 m laid, 7 groups.

Watch and Step through replay the search over the festival site. Try it runs the same TypeScript on runs you take away, lengthen, or reorder, one run at a time, and starts fresh each time you open it.

Bar–Main stage was the shortest run left when it came up, and it was still skipped. Being short isn’t enough; a run has to join something that isn’t joined yet.

02 / Name the rule

Shortest first. Lay it only if it joins two groups.

Every spot starts in a group of its own. Then one step repeats for each run, from shortest to longest:

Sort

Shortest run next

Every run, shortest to longest. Equal lengths in the order they are listed.

Check

Are both ends in one group?

Two finds: do both ends lead up to the same root?

Lay or skip

Join, or leave it

Different groups: lay the run and merge them. Same group: skip it.

The groups are the ones from the Union-find lesson: every spot points at a parent, the root names the group, find follows parents up to the root, and union points the smaller group’s root at the larger’s. Kruskal asks it one question per run.

Stop when n − 1 runs are laid: a network that connects n spots without a loop has exactly n − 1 runs. If the list runs out first, what’s left is a spanning forest, the cheapest tree inside each group, and the groups themselves say what no run can reach.

Two facts make it safe to decide each run on the spot.

The longest run on a loop is never needed. Take it out and every spot is still connected the other way round the loop, with less cable. A skipped run is never shorter than any other run on the loop it would close, because every other run on that loop was laid before it came up.

The shortest run leaving a group is always safe to lay. Every network that connects everything has to leave each group somewhere, and nothing is lost by leaving it on the shortest run.

Equal lengths can change which runs are laid, but never how much cable. In the lab’s Equal runs preset, Generator–Food court is 12 m too, so three 12-meter runs form a loop and one of them must go. List the runs in reverse and a different one goes; both networks are 92 m. The tests shuffle the runs of hundreds of generated sites in both languages and check that the total and the groups never change.

Why is the shortest run leaving a group always safe?A swap that never adds cable

When Kruskal lays a run, one end is in some group, call it G, and the other end is outside it. No run leaving G is shorter. A shorter one would have come up earlier, found its ends in different groups, and been laid, and its far end would be in G by now.

Now take any cheapest network that doesn’t use the new run, and add it. That closes a loop. A loop that leaves G has to come back in, so another run on it leaves G too, and that run is at least as long. Take it out: everything is still connected, and the cable didn’t grow. Do that for each run Kruskal lays, and you arrive at Kruskal’s network without the total ever going up, so it is as cheap as the cheapest.

Joseph B. Kruskal’s paper, “On the shortest spanning subtree of a graph and the traveling salesman problem,” appeared in the Proceedings of the American Mathematical Society in 1956, on pages 48 to 50.

03 / Read the shape

A sort, and union-find over spot numbers.

Basic form is the whole algorithm: numberSpots checks the site and numbers each spot, Groups is union-find with union by size and path compression, and cheapestNetwork walks the sorted runs. In the wild keeps last year’s cable and reports the groups no run can join. At the call site plans the festival three ways. Both languages print the same four lines.

The whole algorithm: numberSpots checks the site, Groups is union-find over spot numbers, byLength sorts shortest first with equal lengths in listed order, and cheapestNetwork takes every run that joins two groups until n − 1 runs are taken.

TypeScriptReading
site.ts
// Kruskal's minimum spanning tree: connect every spot on a festival site with the least cable.
// Every cable run is two-way and measured in whole meters.
export const MAX_SPOTS = 64;
export const MAX_RUNS = 512;
export const MAX_METRES = 1000;

export type Run = { a: string; b: string; metres: number };
export type Site = { spots: string[]; runs: Run[] };
// The runs to lay, their total length, and the groups of spots they join. One group means every
// spot is connected; more than one means the runs ran out first.
export type Network = { runs: Run[]; metres: number; groups: string[][] };

export type CableErrorCode = 'too-big' | 'bad-spot' | 'unknown-spot' | 'bad-metres';

export class CableError extends Error {
	readonly code: CableErrorCode;
	constructor(code: CableErrorCode, message: string) {
		super(message);
		this.name = 'CableError';
		this.code = code;
	}
}

// Union-find over spot numbers, with union by size and path compression.
export class Groups {
	#parent: number[];
	#size: number[];
	count: number;

	constructor(spots: number) {
		this.#parent = Array.from({ length: spots }, (_, i) => i);
		this.#size = Array.from({ length: spots }, () => 1);
		this.count = spots;
	}

	find(spot: number): number {
		let root = spot;
		while (this.#parent[root] !== root) root = this.#parent[root];
		while (this.#parent[spot] !== root) {
			const next = this.#parent[spot];
			this.#parent[spot] = root;
			spot = next;
		}
		return root;
	}

	// Returns false when the two spots are already in one group.
	union(a: number, b: number): boolean {
		let big = this.find(a);
		let small = this.find(b);
		if (big === small) return false;
		if (this.#size[big] < this.#size[small]) [big, small] = [small, big];
		this.#parent[small] = big;
		this.#size[big] += this.#size[small];
		this.count--;
		return true;
	}

	// Every group, spots in site order, groups in the order of their first spot.
	list(spots: string[]): string[][] {
		const at = new Map<number, string[]>();
		spots.forEach((spot, i) => {
			const root = this.find(i);
			if (!at.has(root)) at.set(root, []);
			at.get(root)!.push(spot);
		});
		return [...at.values()];
	}
}

// Check the site and number its spots in the order they are listed.
export function numberSpots(site: Site, laid: Run[] = []): Map<string, number> {
	if (site.spots.length > MAX_SPOTS || site.runs.length + laid.length > MAX_RUNS)
		throw new CableError('too-big', `up to ${MAX_SPOTS} spots and ${MAX_RUNS} runs`);
	const index = new Map<string, number>();
	for (const spot of site.spots) {
		if (!/^[a-z][a-z0-9-]{0,23}$/.test(spot) || index.has(spot))
			throw new CableError('bad-spot', `spot ids are unique lowercase slugs: ${spot}`);
		index.set(spot, index.size);
	}
	for (const { a, b, metres } of [...site.runs, ...laid]) {
		if (!index.has(a) || !index.has(b))
			throw new CableError('unknown-spot', `a run joins spots that don't exist: ${a}, ${b}`);
		if (!Number.isInteger(metres) || metres < 1 || metres > MAX_METRES)
			throw new CableError('bad-metres', `metres are whole numbers from 1 to ${MAX_METRES}`);
	}
	return index;
}

// Shortest first. Equal lengths keep their listed order, so both languages pick the same runs.
export const byLength = (runs: Run[]): Run[] => [...runs].sort((x, y) => x.metres - y.metres);

export function cheapestNetwork(site: Site): Network {
	const index = numberSpots(site);
	const groups = new Groups(site.spots.length);
	const runs: Run[] = [];
	let metres = 0;
	for (const run of byLength(site.runs)) {
		if (runs.length === site.spots.length - 1) break; // n spots are joined by n − 1 runs
		// Both ends already in one group: this run would close a loop, and it is the longest run
		// on that loop, because every other run on it was taken earlier.
		if (!groups.union(index.get(run.a)!, index.get(run.b)!)) continue;
		runs.push(run);
		metres += run.metres;
	}
	return { runs, metres, groups: groups.list(site.spots) };
}
GoAlongside
site.go
// Kruskal's minimum spanning tree: connect every spot on a festival site with the least cable.
// Every cable run is two-way and measured in whole meters.
const (
	MaxSpots  = 64
	MaxRuns   = 512
	MaxMetres = 1000
)

type Run struct {
	A, B   string
	Metres int
}

type Site struct {
	Spots []string
	Runs  []Run
}

// Network is the runs to lay, their total length, and the groups of spots they join. One group
// means every spot is connected; more than one means the runs ran out first.
type Network struct {
	Runs   []Run
	Metres int
	Groups [][]string
}

type CableError struct{ Code, Message string }

func (e *CableError) Error() string { return e.Message }

// groups is union-find over spot numbers, with union by size and path compression.
type groups struct {
	parent, size []int
	count        int
}

func newGroups(spots int) *groups {
	g := &groups{parent: make([]int, spots), size: make([]int, spots), count: spots}
	for i := range g.parent {
		g.parent[i] = i
		g.size[i] = 1
	}
	return g
}

func (g *groups) find(spot int) int {
	root := spot
	for g.parent[root] != root {
		root = g.parent[root]
	}
	for g.parent[spot] != root {
		next := g.parent[spot]
		g.parent[spot] = root
		spot = next
	}
	return root
}

// union returns false when the two spots are already in one group.
func (g *groups) union(a, b int) bool {
	big, small := g.find(a), g.find(b)
	if big == small {
		return false
	}
	if g.size[big] < g.size[small] {
		big, small = small, big
	}
	g.parent[small] = big
	g.size[big] += g.size[small]
	g.count--
	return true
}

// list returns every group, spots in site order, groups in the order of their first spot.
func (g *groups) list(spots []string) [][]string {
	out := [][]string{}
	at := map[int]int{}
	for i, spot := range spots {
		root := g.find(i)
		k, seen := at[root]
		if !seen {
			k = len(out)
			at[root] = k
			out = append(out, nil)
		}
		out[k] = append(out[k], spot)
	}
	return out
}

var spotID = regexp.MustCompile(`^[a-z][a-z0-9-]{0,23}$`)

// NumberSpots checks the site and numbers its spots in the order they are listed.
func NumberSpots(site Site, laid []Run) (map[string]int, error) {
	if len(site.Spots) > MaxSpots || len(site.Runs)+len(laid) > MaxRuns {
		return nil, &CableError{"too-big", fmt.Sprintf("up to %d spots and %d runs", MaxSpots, MaxRuns)}
	}
	index := make(map[string]int, len(site.Spots))
	for _, spot := range site.Spots {
		if _, seen := index[spot]; seen || !spotID.MatchString(spot) {
			return nil, &CableError{"bad-spot", "spot ids are unique lowercase slugs: " + spot}
		}
		index[spot] = len(index)
	}
	for _, run := range slices.Concat(site.Runs, laid) {
		_, knownA := index[run.A]
		_, knownB := index[run.B]
		if !knownA || !knownB {
			return nil, &CableError{"unknown-spot", fmt.Sprintf("a run joins spots that don't exist: %s, %s", run.A, run.B)}
		}
		if run.Metres < 1 || run.Metres > MaxMetres {
			return nil, &CableError{"bad-metres", fmt.Sprintf("metres are whole numbers from 1 to %d", MaxMetres)}
		}
	}
	return index, nil
}

// byLength sorts shortest first. Equal lengths keep their listed order, so both languages pick
// the same runs.
func byLength(runs []Run) []Run {
	sorted := slices.Clone(runs)
	slices.SortStableFunc(sorted, func(x, y Run) int { return x.Metres - y.Metres })
	return sorted
}

func CheapestNetwork(site Site) (Network, error) {
	index, err := NumberSpots(site, nil)
	if err != nil {
		return Network{}, err
	}
	g := newGroups(len(site.Spots))
	network := Network{Runs: []Run{}}
	for _, run := range byLength(site.Runs) {
		if len(network.Runs) == len(site.Spots)-1 {
			break // n spots are joined by n − 1 runs
		}
		// Both ends already in one group: this run would close a loop, and it is the longest run
		// on that loop, because every other run on it was taken earlier.
		if !g.union(index[run.A], index[run.B]) {
			continue
		}
		network.Runs = append(network.Runs, run)
		network.Metres += run.Metres
	}
	network.Groups = g.list(site.Spots)
	return network, nil
}
Reading the TypeScriptArrays by spot number, a stable sort

Groups keeps #parent and #size as arrays indexed by spot number, which is why numberSpots hands each spot one. count drops by one on every merge, so planCable can stop when it reaches 1.

byLength copies before sorting, because sort sorts in place. Ties stay in listed order because, as MDN puts it, since ECMAScript 2019 “the specification dictates that Array.prototype.sort is stable.” Errors are a CableError whose code matches the Go version.

Reading the GoSortStableFunc and an empty list

byLength clones the runs and sorts them with slices.SortStableFunc, which “sorts the slice x while keeping the original order of equal elements.” NumberSpots checks the site’s runs and the laid runs in one loop over slices.Concat.

list starts from [][]string{} rather than nil, and so do the runs, so an empty site encodes as [], the same as the TypeScript.

What is refusedIds, sizes, and meters

More than 64 spots, or more than 512 runs counting laid cable, is too-big. A spot id must be a unique lowercase slug (bad-spot). A run to a spot that isn’t on the site is unknown-spot, and lengths must be whole meters from 1 to 1,000 (bad-metres).

A run from a spot to itself is allowed and always skipped. Two runs between the same spots are both allowed; the shorter comes up first. Both languages run the same cases, and on 400 generated sites both compare the total with every possible choice of runs and check the groups against a grouping that doesn’t use union-find.

04 / Try a decision

Take it or skip it?

The shortest run left comes up. Decide what happens to it before the feedback tells you.

Six runs are taken: Generator–Bar 12 m, Bar–Food court 12, Acoustic tent–First aid 12, First aid–Camping 13, Generator–Gate 14, and Main stage–Food court 14. The next run on the list is Bar–Main stage, 15 m. What should happen to it?

05 / Follow the cost

The sort is the expensive part.

Kruskal: time and extra space, with S spots, R runs, and L laid runs
OperationTimeExtra spaceWhat it assumes
Sort the runsO(R log R)O(R)A sorted copy, shortest first. Equal lengths keep their listed order.
Look at one runO(α(S)) amortizedO(1)Two finds. If the roots differ, one link changes and the run is taken.
Build the networkO(R log R)O(S + R)The sort is most of it. At most R runs are looked at, and the loop stops at S − 1 taken.
Keep laid cable firstO(L α(S) + R log R)O(S + R)L laid runs are joined before the sorted list is walked.
List the groupsO(S α(S))O(S)One find per spot.
Check loops by searchingO(R × S)O(S)Without union-find: walk the runs taken so far from one end to see if the other is reached. Up to S − 1 runs each time.

Sorting R runs costs O(R log R). After that each run costs two finds, and with union by size and path compression a find is effectively constant, so the walk down the list is O(R α(S)) and the sort dominates. When no two runs join the same pair of spots, R is below S², so log R is at most 2 log S and the bound is also written O(R log S).

Stopping at S − 1 runs saves finds, not the sort: the festival looks at 8 of its 14 runs but sorts all of them. If sorting is the problem, a heap can hand runs out shortest first, one at a time, and the search can stop before the long ones are ever ordered.

The last row is what union-find saves. Without it, “are these ends already connected?” means searching the network laid so far, up to S − 1 runs, for every run on the list.

06 / Give it a real job

Keep last year’s cable. Name what can’t be reached.

A festival isn’t built on an empty field every year. Two runs are still in the ground: Generator–Gate, 14 m, and Main stage–Acoustic tent, 17 m. planCable joins those first, at no new cost, then walks the sorted list as before. It adds 5 runs and 63 m of new cable, 94 m in all.

That is 2 m more in the ground than the cheapest network, because the 17-meter Main stage–Acoustic tent run isn’t one the cheapest network would lay. It is also 29 m less cable to buy and lay. Whether to reuse is the crew’s call; the plan gives them both numbers.

Then add the car park, across the river. No run reaches it, and that isn’t an error. The network is the same 7 runs and 92 m, and groups comes back with two entries: the eight spots on the site, and the car park on its own. The lab’s Car park across the river preset shows it, and Cut in two removes four runs so the site itself splits into two groups.

Build UIs?A graph library in your browser already runs it. A maze is where you own it.

Where it already is in your components

If your pages draw networks with Cytoscape.js, it is already there. Its README describes a library for “server-side analysis in a Node.js app or for a rich user interface,” and its documentation says cy.elements().kruskal() runs “Kruskal's algorithm on the subset of the graph in the calling collection.” It returns the nodes together with the chosen edges, as a collection you can style.

In version 3.34.3’s source, it sorts the edges by the weight function you pass, which counts every edge as 1 if you pass none, and keeps an edge when its ends are in different sets. To find a node’s set it checks the sets one at a time. That is fine at diagram size; union-find is what turns the check into a step or two when the graph grows.

When you have to own it

A daily puzzle page needs a maze, and every player should get the same one. A maze without loops is a spanning tree of its cells: each cell is a spot, and each wall between two neighboring cells is a run. Shuffle the walls instead of sorting them, and knock a wall down only when the cells on either side are in different groups. What’s left has exactly one way between any two cells.

carveMaze does that with this lesson’s Groups and a Fisher–Yates shuffle. seeded makes the shuffle repeatable, so seeding it with the date carves the same maze for everyone, and drawMaze strokes the walls still standing.

maze.ts
import { Groups } from '../site'; // this lesson's union-find

// A passage knocked through the wall on the right of a cell, or the wall below it.
export type Opening = { cell: number; side: 'right' | 'down' };

// Kruskal with a shuffle instead of a sort: every cell is a spot, every wall between two
// neighboring cells is a run. Knock a wall down only when it separates two groups, so the maze
// has no loops and exactly one way between any two cells.
export function carveMaze(width: number, height: number, random: () => number): Opening[] {
	if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1)
		throw new RangeError('a maze is whole cells wide and high, at least 1 × 1');
	if (width * height > 10_000) throw new RangeError('up to 10,000 cells');
	const walls: Opening[] = [];
	for (let cell = 0; cell < width * height; cell++) {
		if (cell % width < width - 1) walls.push({ cell, side: 'right' });
		if (cell + width < width * height) walls.push({ cell, side: 'down' });
	}
	for (let i = walls.length - 1; i > 0; i--) {
		const j = Math.floor(random() * (i + 1)); // Fisher–Yates
		[walls[i], walls[j]] = [walls[j], walls[i]];
	}
	const groups = new Groups(width * height);
	return walls.filter(({ cell, side }) =>
		groups.union(cell, side === 'right' ? cell + 1 : cell + width)
	);
}

// The same seed carves the same maze, so a daily puzzle can seed it with the date: 20260914.
export function seeded(seed: number): () => number {
	let state = seed >>> 0;
	return () => {
		state = (state + 0x6d2b79f5) >>> 0; // mulberry32
		let t = Math.imul(state ^ (state >>> 15), state | 1);
		t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
		return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
	};
}

// Draw the outer wall and every inner wall still standing, one stroke. Lines sit on half pixels so
// a 1-pixel wall stays crisp.
export function drawMaze(
	context: CanvasRenderingContext2D,
	width: number,
	height: number,
	openings: Opening[],
	size: number
) {
	const open = new Set(openings.map(({ cell, side }) => `${cell}:${side}`));
	context.beginPath();
	context.rect(0.5, 0.5, width * size, height * size);
	for (let cell = 0; cell < width * height; cell++) {
		const x = (cell % width) * size + 0.5;
		const y = Math.floor(cell / width) * size + 0.5;
		if (cell % width < width - 1 && !open.has(`${cell}:right`)) {
			context.moveTo(x + size, y);
			context.lineTo(x + size, y + size);
		}
		if (cell + width < width * height && !open.has(`${cell}:down`)) {
			context.moveTo(x, y + size);
			context.lineTo(x + size, y + size);
		}
	}
	context.stroke();
}

Nothing here belongs to React or Svelte. Carve once per seed, keep the openings in state, and draw in an effect with a ref to the canvas. The test checks that every cell is reachable with exactly one opening fewer than there are cells, so there are no loops, and that a seed always carves the same maze.

07 / Make the call

The least in total, not the shortest route.

Reach for Kruskal when everything has to be connected, links work both ways, and what counts is the total: cable, pipe, or fiber. It also groups. Stop before the end and the groups left are clusters of things close together. SciPy’s linkage notes that for method 'single', “an optimized algorithm based on minimum spanning tree is implemented,” and scikit-image’s felzenszwalb segments a picture with “a fast, minimum spanning tree based clustering on the image grid.”

It doesn’t find routes. Dijkstra’s algorithm gives each spot its shortest route from one place, and from the generator those routes use 107 m of cable in all. Kruskal’s network uses 92 m, but the acoustic tent ends up 51 m of cable from the generator instead of 38. If the length to each spot matters, say because a long run loses voltage, that is Dijkstra’s question. If only the total matters, it is Kruskal’s.

It connects only the spots you give it. Three tents 100 m apart in a triangle need 200 m, but a junction box in the middle joins them with about 173 m; choosing extra points like that is a different problem, the Steiner tree problem. It knows nothing about limits either, like a distribution box with six sockets. And links must work both ways: for one-way links NetworkX lists minimum_spanning_arborescence, which “Returns a minimum spanning arborescence from G.”

NetworkX also accepts 'prim' and 'boruvka'. Prim’s algorithm grows a single tree outward from one spot and reaches the same total; its simplest form, which checks every spot at each step instead of sorting runs, suits sites where nearly every pair of spots has a run.

SourcesDocumentation, source, and the paper’s record, checked 14 September 2026

08 / Take the idea with you

Explain a network without saying “Kruskal.”

“Sort the possible links from cheapest to dearest. Go down the list and keep a link only if it connects two things that aren’t connected yet. Stop when everything is connected, or when the list runs out, and then say which pieces are still apart.”

Before moving on, find something in your work that gets connected at a cost: offices on a network, sensors on a farm, rooms on a floor plan. Ask whether the question is the total, which is Kruskal’s, or the route from one place, which is Dijkstra’s.

Connections to follow nextRelated lessons

Copy the complete example, add a 13-meter run from the gate to the bar, and predict which run it replaces and the new total before you run it.

Back to applied algorithms →