Unit 12: Cheapest Network
Joining everything up for the least money.
Unit 12 of 25 in Competitive programming for kids. Its 4 lessons are Wiring Up the Village, Are We in the Same Group?, Kruskal and Prim, and Going the Other Way — below is everything each one explains, and a question or two from it to try.
Every sample on this page was run through real Python before it shipped, and prints exactly what it says it prints.
This unit opens with a fortnight’s trial of everything — no card needed — or with a family plan, bought in the iPhone app. The first two units of every track are free for ever. Try it in the app.
🔌 Wiring Up the Village
Six houses, eight possible cables, one budget
Each cable has a price. Lay enough of them that every house is joined to every other, directly or through neighbours, for as little money as possible.
The answer always uses exactly n - 1 cables — any more and one of them is closing a loop, which joins nothing new.
Python
edges = [(0, 1, 3), (0, 4, 5), (1, 2, 5), (1, 4, 6), (2, 3, 9), (2, 5, 3), (3, 5, 7), (4, 5, 2)]
for a, b, w in sorted(edges, key=lambda e: e[2]):
print(w, a, b)
It prints
2 4 5 3 0 1 3 2 5 5 0 4 5 1 2 6 1 4 7 3 5 9 2 3
Take the cheapest cable that joins two separate groups
Go down the sorted list. If a cable joins two houses that are already connected, it can only be closing a loop — skip it. Otherwise take it.
That is Kruskal's algorithm, and everything hard about it is in the words "already connected".
Why this greedy one is safe
Split the houses into any two piles. The cheapest cable crossing that split must be in some best answer: take any best answer that lacks it, add it — you get a loop — and remove another cable of that loop that crosses the split. The result still joins everything, and cost no more.
Kruskal only ever takes a cheapest crossing cable, so it stays inside a best answer the whole way. That is the argument unit 4 said to look for.
Try it yourself
A network joining 6 houses with no loops uses how many cables?
- 6
- 5
- 8
- It depends on the prices
What does this print?
Python
edges = [(0, 1, 4), (1, 2, 1), (0, 2, 7)]
print(sorted(edges, key=lambda e: e[2])[0])
Answer them in the app
🤝 Are We in the Same Group?
The structure that answers it
Union-find keeps a forest of groups. Each item points at another item in its group, and following the chain far enough reaches the root — the one pointing at itself.
Two items are in the same group exactly when they have the same root. Joining two groups is one assignment: point one root at the other.
Python
parent = list(range(6))
def find(x):
while parent[x] != x:
x = parent[x]
return x
def union(a, b):
a = find(a)
b = find(b)
if a == b:
return False
parent[a] = b
return True
print(union(0, 1), union(1, 2), union(0, 2))
print([find(i) for i in range(6)])
It prints
True True False [2, 2, 2, 3, 4, 5]
False means "already together"
union returning False is the answer Kruskal needs: the cable was pointless. That is why it returns something at all.
After joining 0-1 and 1-2, the roots of 0, 1 and 2 are all 2, while 3, 4 and 5 are still alone.
Flatten the chain while you walk it
Long chains make find slow. Fix it as you go: point each node you pass straight at its grandparent, and the chain halves every time it is used.
With that one extra line, a million operations cost about a million steps. It is called path compression and it is never worth leaving out.
Python
parent = [1, 2, 3, 4, 4]
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
print(find(0))
print(parent)
It prints
4 [2, 2, 4, 4, 4]
What else it is good for
Anything shaped like "these two are now connected, are these two connected?". Friend groups, islands joining as water drains, whether adding a road makes the map one piece.
It cannot un-join, though. If a problem removes edges, the usual trick is to run time backwards.
Try it yourself
What does find return?
- The root of the group the item is in
- The item the parent points at
- How many items are in the group
- True or False
What does this print?
Python
parent = list(range(5))
def find(x):
while parent[x] != x:
x = parent[x]
return x
def union(a, b):
a = find(a)
b = find(b)
if a == b:
return False
parent[a] = b
return True
print(union(0, 1), union(2, 3), union(1, 3), union(0, 2))
Answer them in the app
🌉 Kruskal
Sort, then join what is not joined
The two halves are now written. Sort the cables by price, walk down the list, and take a cable whenever union says the two ends were in different groups.
Five cables, twenty pounds.
Python
n = 6
edges = [(0, 1, 3), (0, 4, 5), (1, 2, 5), (1, 4, 6), (2, 3, 9), (2, 5, 3), (3, 5, 7), (4, 5, 2)]
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
total = 0
used = []
for a, b, w in sorted(edges, key=lambda e: e[2]):
ra = find(a)
rb = find(b)
if ra != rb:
parent[ra] = rb
total += w
used.append((a, b, w))
print(used)
print(total)
It prints
[(4, 5, 2), (0, 1, 3), (2, 5, 3), (0, 4, 5), (3, 5, 7)] 20
Where the time goes
Sorting the cables is m log m and dominates everything. The union-find work is so close to m that it is usually written as if it were.
So Kruskal is "the cost of sorting", which for 200000 cables is a few million steps.
When the graph is in pieces
If the houses are in two groups with no cable between them, nothing can join them and the loop simply ends with fewer than n - 1 cables taken.
Count them. Fewer than n - 1 means "impossible", which is exactly what a problem wants told.
Try it yourself
What decides how long Kruskal takes?
- The union-find
- Sorting the edges
- The number of nodes
- The biggest edge weight
What does this print?
Python
n = 4
edges = [(0, 1, 1), (1, 2, 2), (2, 3, 3), (0, 3, 10)]
parent = list(range(n))
def find(x):
while parent[x] != x:
x = parent[x]
return x
total = 0
for a, b, w in sorted(edges, key=lambda e: e[2]):
ra = find(a)
rb = find(b)
if ra != rb:
parent[ra] = rb
total += w
print(total)
Answer them in the app
🏆 Prim, and Going the Other Way
Grow one blob instead of joining many
Prim's algorithm starts at one house and repeatedly buys the cheapest cable leading out of the blob to a house not yet in it.
That is Dijkstra with one line changed: the heap holds the price of the cable, not the distance from the start.
Python
import heapq
n = 6
edges = [(0, 1, 3), (0, 4, 5), (1, 2, 5), (1, 4, 6), (2, 3, 9), (2, 5, 3), (3, 5, 7), (4, 5, 2)]
adj = [[] for i in range(n)]
for a, b, w in edges:
adj[a].append((b, w))
adj[b].append((a, w))
seen = [False] * n
heap = [(0, 0)]
total = 0
order = []
while heap:
w, node = heapq.heappop(heap)
if seen[node]:
continue
seen[node] = True
total += w
order.append(node)
for nxt, weight in adj[node]:
if not seen[nxt]:
heapq.heappush(heap, (weight, nxt))
print(order)
print(total)
It prints
[0, 1, 2, 5, 4, 3] 20
Twenty again, by a different route
Prim bought its cables in a different order from Kruskal and spent exactly the same. Both are best answers, and a graph can have several.
Use Kruskal when the edges arrive as a list — which is nearly always. Use Prim when the graph is dense, or when the edges are easier to generate than to store.
Now read the proof again
The cut property never said "cheapest". It said the best cable crossing a split is usable — and best is whatever you sort by.
So sort the other way and the very same code returns the maximum spanning tree: 32 instead of 20.
Python
n = 6
edges = [(0, 1, 3), (0, 4, 5), (1, 2, 5), (1, 4, 6), (2, 3, 9), (2, 5, 3), (3, 5, 7), (4, 5, 2)]
def spanning(reverse):
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
total = 0
for a, b, w in sorted(edges, key=lambda e: e[2], reverse=reverse):
ra = find(a)
rb = find(b)
if ra != rb:
parent[ra] = rb
total += w
return total
print(spanning(False), spanning(True))
It prints
20 32
Where this turns up in disguise
A problem about joining islands so the worst cable on the route is as small as possible has the same answer: the minimum spanning tree. Any other network has a heavier crossing edge somewhere.
Spotting that a problem is really a spanning tree is most of the work; the code is ten lines you already have.
Try it yourself
Kruskal gave 20 and Prim gave 20 on the same graph but chose different cables. What does that mean?
- One of them is wrong
- The graph has more than one best answer, and both found one
- They will disagree on bigger graphs
- Prim is only an estimate
What does this print?
Python
n = 3
edges = [(0, 1, 1), (1, 2, 2), (0, 2, 3)]
def spanning(reverse):
parent = list(range(n))
def find(x):
while parent[x] != x:
x = parent[x]
return x
total = 0
for a, b, w in sorted(edges, key=lambda e: e[2], reverse=reverse):
ra = find(a)
rb = find(b)
if ra != rb:
parent[ra] = rb
total += w
return total
print(spanning(False), spanning(True))
Answer them in the app