Cây khung nhỏ nhấtMinimum Spanning Trees
Mục lục
Table of contents
Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.
Tổng quan
Bạn có một tập địa điểm và một tập kết nối khả dĩ giữa chúng, mỗi kết nối có một chi phí. Bạn muốn mọi địa điểm đều tới được nhau, và muốn trả càng ít tiền càng tốt. Đó là bài toán minimum spanning tree (MST), và nó là một trong số ít bài toán thực sự quan trọng mà chiến lược greedy hiển nhiên không chỉ là một heuristic tạm được — nó tối ưu có chứng minh.
Các ứng dụng đúng như cách phát biểu gợi ý. Kéo cáp quang nối một nhóm toà nhà. Đi dây một bảng mạch với tổng chiều dài đường mạch nhỏ nhất. Thiết kế topology backbone của một mạng. Nhưng MST còn xuất hiện ở những nơi mà “mạng lưới” chỉ là ẩn dụ: single-linkage clustering theo đúng nghĩa đen là “dựng MST rồi xoá k−1 edge nặng nhất”; các thuật toán phân đoạn ảnh dựng MST trên graph tương đồng pixel; thuật toán 2-approximation chuẩn cho bài toán người bán hàng rong metric bắt đầu bằng việc dựng MST; sinh mê cung là MST trên lưới với trọng số ngẫu nhiên.
Điều khiến MST đáng học kỹ không phải là bản thân các thuật toán — Kruskal và Prim mỗi cái chỉ khoảng mười lăm dòng — mà là vì sao chúng đúng. Cả hai đều greedy, và thuật toán greedy thường sai (xem ./20-brute-force-greedy-and-randomised-algorithms.md). Ở đây chúng đúng, và lý do là hai sự thật cấu trúc về spanning tree: cut property và cycle property. Hiểu chúng thì cả hai thuật toán trở nên hiển nhiên; bỏ qua chúng thì bạn chỉ đang học vẹt code.
MST được định nghĩa trên graph vô hướng, liên thông, có trọng số. Việc thêm hướng biến bài toán thành tìm minimum spanning arborescence, một bài toán khác và khó hơn, giải bằng thuật toán Chu-Liu/Edmonds. Note này giả định bạn đã nắm các cách biểu diễn và phép duyệt trong ./13-graph-data-structures.md.
Kiến thức nền tảng
Spanning tree
Spanning tree của một graph vô hướng liên thông G = (V, E) là một subgraph:
- chứa mọi vertex (“spanning”),
- liên thông, và
- không có cycle (“tree”).
Hai bất kỳ trong ba điều kiện đó, cộng với số edge đúng bằng V − 1, sẽ kéo theo điều còn lại. Từ đó có bốn cách đặc trưng tương đương, đều nên có sẵn trong đầu:
- liên thông + acyclic
- liên thông + đúng
V − 1edge - acyclic + đúng
V − 1edge - giữa mọi cặp vertex có đúng một simple path
Graph G: một hình vuông với một đường chéo.
0 --- 1 trọng số edge: 0-1 : 1
| \ | 0-2 : 4
| \ | 0-3 : 2
2 --- 3 1-3 : 3
2-3 : 5
Spanning tree T1 = {0-1, 0-3, 0-2} Spanning tree T2 = {0-1, 1-3, 2-3}
trọng số 1 + 2 + 4 = 7 (đây là MST) trọng số 1 + 3 + 5 = 9
0 --- 1 0 --- 1
| \ |
| \ |
2 3 2 --- 3
Thêm bất kỳ edge nào không thuộc tree vào một spanning tree sẽ tạo ra đúng một cycle (vì trước đó đã có đúng một path giữa hai đầu mút của nó). Bỏ bất kỳ edge nào của tree sẽ tách tree thành đúng hai component. Hai sự thật này là bộ máy đứng sau mọi chứng minh bên dưới.
Một graph liên thông thường có cực kỳ nhiều spanning tree — complete graph K_n có n^(n−2) cái theo công thức Cayley, tức 10^8 chỉ với mười vertex. Liệt kê hết chúng để tìm cái rẻ nhất là bất khả thi, và đó là lý do các tính chất greedy trở nên quan trọng.
Minimum spanning tree là spanning tree có tổng trọng số nhỏ nhất trong tất cả các spanning tree. Nếu graph không liên thông thì không có spanning tree nào cả; thứ bạn nhận được thay vào đó là minimum spanning forest — mỗi connected component một MST.
Cut property
Một cut (S, V∖S) là một cách phân hoạch tập vertex thành hai tập khác rỗng. Một edge vượt qua cut nếu nó có một đầu mút ở mỗi phía.
Cut property. Với một cut bất kỳ, nếu
elà edge trọng số nhỏ nhất duy nhất vượt qua cut đó, thìethuộc mọi MST của graph.(Khi có hoà, phát biểu yếu hơn vẫn đúng: một edge trọng số nhỏ nhất vượt cut thuộc một MST nào đó.)
Chứng minh (lập luận đổi chỗ). Giả sử T là một MST không chứa e = (u, v), với u ∈ S và v ∈ V∖S. T là spanning nên nó chứa một path từ u tới v. Path đó bắt đầu trong S và kết thúc bên ngoài, nên tại một điểm nào đó nó dùng một edge f cũng vượt qua cut. Vì e là edge vượt cut nhỏ nhất duy nhất, ta có w(e) < w(f). Giờ lập T' = T − f + e. Bỏ f tách T thành hai mảnh, và e nối chúng lại (nó nối đúng hai phía của cut), nên T' lại là một spanning tree — với trọng số w(T) − w(f) + w(e) < w(T). Điều đó mâu thuẫn với việc T là nhỏ nhất. ∎
Cut (S, V\S): S | V\S
|
u --------|-------- v e, trọng số 2 <- edge vượt cut nhỏ nhất duy nhất
|
a --------|-------- b f, trọng số 7
|
c --------|-------- d g, trọng số 5
Mọi MST đều chứa e. Không cần chứng minh bằng quan sát - lập luận đổi chỗ ở
trên đúng với bất kỳ cut nào.
Chỉ riêng tính chất này đã biện minh cho cả hai thuật toán. Prim nuôi lớn một tập S và liên tục thêm edge rẻ nhất rời khỏi S — theo cách xây dựng, đó là một cut. Kruskal thêm edge rẻ nhất nối hai component khác nhau — chính là edge rẻ nhất vượt qua cut tách một trong hai component đó khỏi mọi thứ còn lại. Cả hai chỉ là cut property được áp đi áp lại.
Cycle property
Cycle property. Với một cycle
Cbất kỳ trong graph, nếuflà edge trọng số lớn nhất duy nhất trênC, thìfkhông thuộc bất kỳ MST nào.
Chứng minh. Giả sử một MST T chứa f = (u, v). Bỏ f: T tách thành hai component, với u ở một bên và v ở bên kia. Phần còn lại của cycle C tạo thành một path từ u tới v, và path đó phải quay lại giữa hai component tại một edge g ≠ f nào đó. Khi đó T − f + g là một spanning tree với trọng số w(T) − w(f) + w(g) < w(T) vì w(g) < w(f). Mâu thuẫn. ∎
Cycle property là quy tắc loại trừ soi gương với quy tắc thu nhận của cut property. Kruskal dùng nó một cách ngầm định: khi nó bỏ qua một edge vì hai đầu mút đã liên thông, edge đó chính là edge nặng nhất trên cycle mà nó sẽ khép lại (mọi edge nhẹ hơn của cycle đó đã được xét trước rồi). Đây cũng là cơ sở của thuật toán “reverse-delete” — sort edge giảm dần rồi xoá từng edge nào mà việc xoá nó vẫn giữ graph liên thông — đúng nhưng chậm và hiếm khi được dùng.
Kết hợp lại, hai tính chất này là thứ mà sách giáo khoa gọi là blue rule (tô xanh edge nhỏ nhất vượt cut: nó nằm trong MST) và red rule (tô đỏ edge lớn nhất trên cycle: nó nằm ngoài). Mọi thuật toán MST đều là một chính sách lập lịch trên hai quy tắc này.
Tính duy nhất của MST
Nếu mọi trọng số edge đôi một khác nhau, MST là duy nhất.
Chứng minh. Giả sử T₁ ≠ T₂ đều là MST. Gọi e là edge trọng số nhỏ nhất trong hiệu đối xứng của chúng — chẳng hạn e ∈ T₁ ∖ T₂ (lập luận đối xứng cho trường hợp còn lại). Thêm e vào T₂ tạo ra đúng một cycle C. C không thể nằm trọn trong T₁ (tree không có cycle), nên C chứa một edge f ∉ T₁, và f ≠ e. Khi đó f ∈ T₂ ∖ T₁, nên f cũng nằm trong hiệu đối xứng, và do tính nhỏ nhất của e với trọng số đôi một khác nhau, ta có w(e) < w(f). Vậy T₂ − f + e là một spanning tree có trọng số nhỏ hơn hẳn T₂. Mâu thuẫn. ∎
Chiều ngược lại không đúng: MST vẫn có thể duy nhất dù có trọng số trùng nhau. Thứ mà hoà trọng số thực sự gây ra là sự mơ hồ trong output của thuật toán, chứ không nhất thiết là nhiều MST — hai lần chạy Kruskal với cách phá hoà khác nhau có thể xuất ra hai tập edge khác nhau, dù cả hai có cùng tổng trọng số.
Hệ quả thực dụng: nếu bạn cần một MST tất định, tái lập được, hãy phá hoà một cách tất định. Sort edge theo (weight, u, v) thay vì chỉ theo weight khiến output của Kruskal ổn định qua các lần chạy và qua các phiên bản Python.
Các tính chất khác đáng biết
- Mọi MST đều là minimum bottleneck spanning tree. MST không chỉ tối thiểu hoá tổng trọng số mà còn tối thiểu hoá trọng số edge lớn nhất trên mọi spanning tree. Đó là lý do MST trả lời được câu “nối mọi thứ lại sao cho liên kết tệ nhất vẫn tốt nhất có thể”. Chiều ngược lại sai — một minimum bottleneck spanning tree không nhất thiết là MST.
- MST bất biến qua mọi phép biến đổi đơn điệu tăng của trọng số. Bình phương mọi trọng số, hay lấy log, hay cộng một hằng số, đều không làm MST thay đổi — bởi các thuật toán chỉ bao giờ so sánh trọng số. (Hãy đối chiếu thật kỹ với shortest path, nơi cộng một hằng số vào mọi trọng số sẽ làm đổi đáp án; xem ./14-shortest-path-algorithms.md.)
- Maximum spanning tree tìm được bằng cách đổi dấu mọi trọng số rồi chạy thuật toán MST bất kỳ, hoặc tương đương là sort giảm dần trong Kruskal.
- MST không phải là shortest-path tree. Đây là hai đối tượng khác nhau và nhầm lẫn chúng là lỗi kinh điển. Trong một tam giác với
w(A,B) = 1,w(B,C) = 1,w(A,C) = 1.9, MST là{AB, BC}(tổng 2), nhưng shortest-path tree từAlà{AB, AC}vìd(A,C) = 1.9 < 2. MST tối thiểu hoá tổng trọng số edge; shortest-path tree tối thiểu hoá từng khoảng cách từ gốc tới mỗi vertex. - Tập edge của MST là một cơ sở của matroid (graphic matroid), và đó là lý do trừu tượng vì sao greedy đúng ở đây, cũng là lý thuyết tổng quát cho câu hỏi “khi nào greedy đúng”.
Khái niệm chính
Thuật toán Kruskal — sort edge, O(E log E)
Kruskal là cut property áp dụng ở phạm vi toàn cục: xét các edge từ rẻ nhất tới đắt nhất, và lấy mỗi edge trừ khi nó tạo ra cycle. “Tạo ra cycle” nghĩa là “hai đầu mút đã thuộc cùng một component”, và đó chính xác là truy vấn mà cấu trúc disjoint-set union (union-find) trả lời trong thời gian gần như hằng số — xem ./16-disjoint-set-union-find.md để có phần trình bày đầy đủ về cấu trúc dùng ở đây.
class DSU:
"""Disjoint-set union với union by size và path halving.
find/union là O(alpha(n)) amortized - trên thực tế là hằng số.
Trình bày đầy đủ trong ./16-disjoint-set-union-find.md."""
def __init__(self, n):
self.parent = list(range(n)) # mỗi phần tử khởi đầu trong tập của riêng nó
self.size = [1] * n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a, b):
"""Gộp tập của a và b. Trả về False nếu chúng vốn đã cùng tập."""
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # cùng component: edge này sẽ khép một cycle
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra # union by size: treo cây nhỏ vào cây lớn
self.parent[rb] = ra
self.size[ra] += self.size[rb]
return True
def kruskal(n, edges):
"""Minimum spanning tree của một graph vô hướng.
`edges` là danh sách bộ ba (w, u, v) - biểu diễn edge list.
Trả về (total_weight, mst_edges). Ném lỗi nếu graph không liên thông.
Thời gian O(E log E), chi phối bởi phép sort; bộ nhớ O(V + E).
"""
edges = sorted(edges) # (w, u, v) sort theo w, rồi u, rồi v
dsu = DSU(n)
total = 0
mst = []
for w, u, v in edges:
if dsu.union(u, v): # khác component -> lấy được an toàn
total += w
mst.append((w, u, v))
if len(mst) == n - 1:
break # spanning tree có đúng n-1 edge
if len(mst) != n - 1:
raise ValueError("graph is not connected - no spanning tree exists")
return total, mst
Vì sao nó đúng. Khi Kruskal nhận edge (u, v), hãy xét cut tách component hiện tại của u khỏi mọi thứ còn lại. Mọi edge vượt cut đó với trọng số nhỏ hơn w đều đã được xét và bị loại — nhưng việc bị loại chỉ xảy ra với edge nằm trong một component, nên không có edge vượt cut nào như vậy. Do đó (u, v) là edge trọng số nhỏ nhất vượt cut đó, và cut property nói rằng nó thuộc một MST. Mọi edge mà nó loại bỏ đều là edge trọng số lớn nhất trên cycle mà edge đó sẽ khép lại, bị loại bởi cycle property.
Độ phức tạp: thời gian O(E log E). Phép sort chi phối với O(E log E); E thao tác union-find tốn O(E · α(V)) với α là hàm Ackermann ngược — nhỏ hơn 5 với mọi input vừa trong vũ trụ này. Vì E ≤ V² nên log E ≤ 2 log V, do đó O(E log E) = O(E log V); cả hai dạng đều xuất hiện trong tài liệu và có nghĩa như nhau. Bộ nhớ là O(V) cho DSU cộng O(E) cho danh sách edge đã sort.
Khi phép sort là miễn phí, Kruskal trở thành O(E α(V)) — về cơ bản là tuyến tính. Điều đó xảy ra khi trọng số là số nguyên nhỏ (counting sort hoặc bucket sort, xem ./08-sorting-algorithms.md) hoặc khi edge đã tới sẵn theo thứ tự. Đây là lợi thế thực dụng lớn nhất của Kruskal.
Hai tính chất đẹp nảy ra từ cấu trúc của nó:
- Input không liên thông được xử lý một cách tự nhiên. Bỏ dòng
raiseđi và Kruskal trả về minimum spanning forest — MST của từng component — mà không cần thay đổi gì trong vòng lặp. - Dùng được theo kiểu incremental / online. Vì nó xử lý từng edge độc lập, bạn có thể nạp edge từ một stream, hoặc dừng sớm ngay khi đã lấy đủ
n − 1edge.
Thuật toán Prim — nuôi một cây, O(E log V)
Prim là cut property áp dụng ở phạm vi cục bộ: bắt đầu từ một vertex tuỳ ý, rồi liên tục thêm edge rẻ nhất nối cây đang có với một vertex bên ngoài. Cut ở đây là (cây, mọi thứ còn lại) và nó thay đổi ở mỗi bước.
Graph G ở trên. Bắt đầu từ 0; cây lớn dần từng vertex một và
cut (cây | phần còn lại) di chuyển theo.
bước 0: cây = {0} edge rời cây rẻ nhất: 0-1 (w=1) -> lấy
bước 1: cây = {0,1} ứng viên 0-2 (4), 0-3 (2), 1-3 (3) -> lấy 0-3 (2)
bước 2: cây = {0,1,3} ứng viên 0-2 (4), 2-3 (5) -> lấy 0-2 (4)
bước 3: cây = {0,1,2,3} xong - n-1 = 3 edge, tổng trọng số 7
Truy vấn “edge rẻ nhất rời khỏi cây” chính là công việc của một priority queue — xem ./12-heaps-and-priority-queues.md.
import heapq
def prim(adj, start=0):
"""Minimum spanning tree bằng cách nuôi một cây duy nhất từ `start`.
adj[u] là danh sách các cặp (v, w). Trả về (total_weight, mst_edges).
Thời gian O(E log V), bộ nhớ O(V + E).
Đây là bản "lazy": thay vì decrease-key (thứ heapq không có), nó push bản
sao và loại bỏ phần tử cũ lúc pop.
"""
n = len(adj)
in_tree = [False] * n
pq = [(0, start, -1)] # (trọng số edge, vertex, vertex đi tới từ đó)
total = 0
mst = []
while pq:
w, u, p = heapq.heappop(pq)
if in_tree[u]:
continue # phần tử cũ: u đã vào cây rồi
in_tree[u] = True
if p != -1: # -1 đánh dấu phần tử khởi đầu nhân tạo
total += w
mst.append((w, p, u))
for v, wv in adj[u]:
if not in_tree[v]:
heapq.heappush(pq, (wv, v, u))
if len(mst) != n - 1:
raise ValueError("graph is not connected - no spanning tree exists")
return total, mst
Độ phức tạp: O(E log E) = O(E log V). Mỗi edge được push tối đa một lần từ mỗi đầu mút, nên heap chứa O(E) phần tử; mỗi lần push và pop tốn O(log E). Bộ nhớ là O(E) cho heap — cái giá của cách làm lazy.
Bản eager giữ đúng một phần tử heap cho mỗi vertex (kết nối rẻ nhất đã biết tới cây) và dùng decrease-key, cho bộ nhớ heap O(V) và thời gian O(E log V). Trong Python không có primitive decrease-key nên bản lazy là thứ bạn sẽ viết; trong C++ với std::priority_queue cũng vậy. Với Fibonacci heap, Prim là O(E + V log V), đạt cận tối ưu lý thuyết cho cách tiếp cận dựa trên so sánh.
Trên graph dense, hãy bỏ hẳn heap. Giữ một mảng key[] chứa “edge rẻ nhất từ cây tới vertex này” và chọn giá trị nhỏ nhất bằng quét tuyến tính:
def prim_dense(matrix):
"""Prim cho graph dense cho dưới dạng adjacency matrix (INF = không có edge).
Thời gian O(V^2), bộ nhớ O(V) ngoài matrix. Khi E xấp xỉ V^2 thì cách này
thắng bản dùng heap: O(V^2) < O(V^2 log V).
"""
n = len(matrix)
INF = float("inf")
key = [INF] * n # trọng số edge rẻ nhất đã biết vào mỗi vertex
parent = [-1] * n
in_tree = [False] * n
key[0] = 0
total = 0
for _ in range(n):
u = -1
for v in range(n): # quét O(V) tìm vertex biên rẻ nhất
if not in_tree[v] and (u == -1 or key[v] < key[u]):
u = v
if key[u] == INF:
raise ValueError("graph is not connected - no spanning tree exists")
in_tree[u] = True
total += key[u]
for v in range(n): # relax cut, O(V)
if not in_tree[v] and matrix[u][v] < key[v]:
key[v] = matrix[u][v]
parent[v] = u
return total, parent
Sự giống nhau với Dijkstra không phải ngẫu nhiên, nhưng hai thuật toán không phải một. Cả hai đều nuôi lớn một tập theo kiểu greedy từ một priority queue. Khác biệt nằm ở đúng một dòng: key của Dijkstra là dist[u] + w (chi phí tính từ source), key của Prim chỉ là w (chi phí của riêng edge nối). Đó là lý do Dijkstra sinh ra shortest-path tree còn Prim sinh ra MST, và là lý do Dijkstra hỏng với trọng số âm còn Prim thì không quan tâm — Prim không bao giờ tích luỹ, nên chẳng có gì cho một trọng số âm phá hoại.
Kruskal hay Prim?
| Kruskal | Prim (binary heap) | Prim (mảng, dense) | Borůvka | |
|---|---|---|---|---|
| Thời gian | O(E log E) = O(E log V) | O(E log V) | O(V²) | O(E log V) |
| Bộ nhớ (ngoài input) | O(V) DSU + O(E) sort | O(E) heap lazy / O(V) eager | O(V) | O(V) |
| Biểu diễn input | edge list | adjacency list | adjacency matrix | edge list |
| Tốt nhất khi | sparse; trọng số đã sort hoặc là số nguyên nhỏ | sparse tới trung bình; đã sẵn adjacency list | dense, E ≈ V² | song song / phân tán |
| Input không liên thông | cho spanning forest miễn phí | chỉ phủ component của start | chỉ phủ component của vertex 0 | cho spanning forest miễn phí |
| Cần | sort + union-find | priority queue | không cần gì | union-find |
| Cut property áp lên | component của mỗi edge được nhận | cây đang lớn dần | cây đang lớn dần | mọi component cùng lúc |
Tóm gọn:
- Graph sparse (
E ≈ V) — cả hai đều được; Kruskal thường đơn giản hơn, và nếu trọng số là số nguyên nhỏ thì nó trở thành gần như tuyến tính. - Graph dense (
E ≈ V²) — dùngprim_dense.O(V²)thắngO(V² log V)và nó không cấp phát gì cả. Đây cũng chính là lý lẽ như khi so Dijkstra dùng mảng với Dijkstra dùng heap. - Edge tới dưới dạng stream, hoặc đã được sort — Kruskal, thuật toán không bao giờ cần truy cập ngẫu nhiên vào neighbour.
- Bạn đã có sẵn adjacency list và muốn tránh dựng edge list — Prim.
- Graph không liên thông và bạn muốn forest — Kruskal, làm việc đó miễn phí; Prim cần thêm vòng lặp ngoài trên các vertex khởi đầu chưa thăm.
Thuật toán Borůvka (1926 — thuật toán MST đầu tiên từng được công bố, có trước cả hai thuật toán kia) rất đáng biết: ở mỗi vòng, mọi component đồng thời chọn edge đi ra rẻ nhất của riêng nó, và tất cả các edge đó được thêm vào cùng lúc. Mỗi vòng giảm ít nhất một nửa số component, nên có O(log V) vòng, mỗi vòng tốn O(E) — tổng cộng O(E log V). Vì công việc trong mỗi vòng song song hoá cực dễ, nó là nền tảng của các hiện thực MST song song và phân tán, và của những thuật toán nhanh nhất được biết về lý thuyết (thuật toán ngẫu nhiên O(E) kỳ vọng của Karger-Klein-Tarjan, và thuật toán tất định O(E α(V)) của Chazelle). Borůvka đòi hỏi trọng số edge đôi một khác nhau để đúng như phát biểu — khi có hoà, hai component có thể chọn “edge của nhau” và tạo thành cycle — sửa bằng cách phá hoà theo chỉ số edge.
Ứng dụng
Thiết kế mạng lưới. Chính là bài toán gốc thúc đẩy MST. Nối n văn phòng bằng đường thuê riêng, nối n ngôi nhà vào một đường ống nước chính, nối n linh kiện trên một PCB — tối thiểu hoá tổng chiều dài cáp trong khi vẫn giữ mọi thứ liên thông. Chú ý giả định nằm sẵn bên trong: MST tối ưu chi phí tổng mà không quan tâm gì tới độ dài path bên trong mạng. Nếu latency giữa hai node bất kỳ là quan trọng thì MST là hình dạng sai (nó có thể là một đường dài ngoằng), và bạn cần thứ khác — một shortest-path tree, hoặc một topology có dự phòng. MST cũng là topology có độ dự phòng nhỏ nhất: bỏ bất kỳ edge nào là mạng bị chia cắt. Các backbone thực tế cố ý thêm edge ngoài MST để chịu lỗi.
Single-linkage clustering. Để chia n điểm thành k cụm mà “khoảng cách giữa hai cụm” nghĩa là khoảng cách giữa hai phần tử gần nhau nhất của chúng: dựng MST trên graph khoảng cách đầy đủ, rồi xoá k − 1 edge nặng nhất. k component còn lại chính xác là các cụm single-linkage, và trọng số của edge nhẹ nhất bị xoá chính là spacing — khoảng cách nhỏ nhất giữa hai cụm — mà cách dựng này chứng minh được là lớn nhất có thể.
def single_linkage_clusters(n, edges, k):
"""Chia n điểm thành k cụm bằng cách cắt k-1 edge nặng nhất của MST.
`edges` là danh sách (distance, i, j). Trả về (labels, spacing) với
`spacing` là khoảng cách nhỏ nhất giữa hai cụm khác nhau.
Thời gian O(E log E).
"""
_, mst = kruskal(n, edges)
mst.sort() # tăng dần theo trọng số
keep = mst[:len(mst) - (k - 1)] # bỏ k-1 edge nặng nhất
spacing = mst[len(mst) - (k - 1)][0] if k > 1 else float("inf")
dsu = DSU(n)
for w, u, v in keep:
dsu.union(u, v)
labels = [dsu.find(x) for x in range(n)]
return labels, spacing
Lưu ý một điểm thực dụng: single-linkage bị hiện tượng chaining — một dải mỏng các điểm trung gian sẽ gộp hai cụm vốn tách biệt rõ ràng, bởi chỉ cặp gần nhất mới có tiếng nói. Đó là tính chất của hàm mục tiêu chứ không phải bug của thuật toán, và cũng là lý do complete-linkage hay Ward clustering thường được ưa dùng hơn trên dữ liệu thực.
TSP xấp xỉ. Với graph metric (trọng số thoả bất đẳng thức tam giác), dựng MST, nhân đôi mọi edge, rồi lấy một chu trình Euler với các đường tắt sẽ cho một tour tốn nhiều nhất gấp đôi tối ưu — bởi MST rẻ hơn tour tối ưu (xoá một edge bất kỳ của tour thì bạn có một spanning path, tức là một spanning tree). Thuật toán Christofides tinh chỉnh chính ý tưởng đó thành xấp xỉ 1.5.
Sinh mê cung. Gán trọng số ngẫu nhiên cho các bức tường của một lưới rồi tính MST: kết quả là một mê cung hoàn hảo — có đúng một đường đi giữa hai ô bất kỳ, theo đúng tính chất spanning tree. Kruskal ngẫu nhiên và Prim ngẫu nhiên là hai thuật toán sinh mê cung chuẩn mực, và cho ra kết cấu mê cung khác nhau rõ rệt (Kruskal cho nhiều ngõ cụt ngắn; Prim cho cấu trúc toả tròn hơn).
Phân đoạn ảnh. Thuật toán phân đoạn Felzenszwalb-Huttenlocher dựng graph trên các pixel với edge có trọng số theo độ chênh màu, rồi gộp các vùng theo thứ tự MST, kèm một ngưỡng thích ứng theo kích thước vùng. Union-find lo phần gộp, y hệt như trong Kruskal.
Phân tích mạch và phân loại học. Bài toán liên thông trọng số nhỏ nhất trên graph tương đồng là cùng một phép tính, dù vertex là gene, tài liệu, hay node mạng.
Best Practices
- Xác nhận graph là vô hướng trước khi vớ lấy thuật toán MST. Trên graph có hướng bạn cần minimum spanning arborescence (Chu-Liu/Edmonds), một thuật toán khác chứ không phải một biến thể nhỏ.
- Kiểm tra tính liên thông và quyết định “không liên thông” nghĩa là gì. Kruskal âm thầm trả về forest; Prim âm thầm trả về MST của một component. Cả hai đều thất bại trong im lặng. Hãy assert
len(mst) == n - 1hoặc ghi rõ rằng bạn muốn một forest. - Phá hoà một cách tất định. Sort tuple
(w, u, v)thay vì chỉ theo trọng số khiến output tái lập được. Với trọng số đôi một khác nhau thì MST duy nhất và điều này không quan trọng; với trọng số hoà thì nó rất quan trọng khi test so sánh tập edge chính xác. - Chọn thuật toán theo mật độ.
prim_densechoE ≈ V², Kruskal hoặc Prim dùng heap cho sparse. Trên complete graphV = 2000,O(V²) = 4×10⁶so vớiO(E log V) = 2×10⁶ × 11 ≈ 2×10⁷— chênh 5 lần, chưa kể chi phí cấp phát heap. - Tận dụng một phép sort rẻ. Nếu trọng số là số nguyên nhỏ, counting sort các edge và Kruskal tụt xuống gần như
O(E α(V)). Nếu edge tới đã sort sẵn (từ một câuORDER BYtrong database chẳng hạn), hãy bỏ hẳn bước sort. - Dùng path compression và union by size/rank cùng lúc. Mỗi thứ riêng lẻ cho
O(log n); cả hai cùng nhau cho inverse-Ackermann amortized. Xem ./16-disjoint-set-union-find.md. - Đừng dùng MST khi thứ bạn thật sự cần là đường đi ngắn. MST tối thiểu hoá tổng trọng số edge, không phải khoảng cách giữa một cặp cụ thể — hai thứ có thể lệch nhau tới hệ số
V. Nếu latency giữa các node quan trọng, bạn cần một topology khác. - Đừng cộng hằng số vào trọng số với hy vọng đổi được đáp án — bạn không đổi được. MST bất biến qua mọi phép biến đổi đơn điệu tăng của trọng số, vì các thuật toán chỉ so sánh. (Điều này ngược hẳn với trường hợp shortest path, nơi một phép dịch như vậy âm thầm làm hỏng đáp án.)
- Với maximum spanning tree, hãy đổi dấu trọng số thay vì viết thuật toán thứ hai.
- Dừng sớm ngay khi đã lấy đủ
n − 1edge. Trên graph có nhiều edge nặng dư thừa, một dòng code này cắt đi một phần đáng kể công việc của Kruskal. - Để mắt tới bộ nhớ trong Prim lazy. Heap chứa
O(E)phần tử, không phảiO(V). Trên graph dense đó làV²tuple, và trong Python thì không hề rẻ; ở đóprim_densevừa nhanh hơn vừa nhỏ hơn. - Ưu tiên
heapqthay vì tự viết heap trong production, nhưng nhớ hai giới hạn của nó: không có decrease-key (nên mới có mẫu xử lý phần tử cũ) và nó chỉ là min-heap (đổi dấu nếu cần max).
Tài liệu tham khảo
- roadmap.sh — Data Structures & Algorithms
- Minimum spanning tree — Wikipedia
- Spanning tree — Wikipedia
- Kruskal’s algorithm — Wikipedia
- Prim’s algorithm — Wikipedia
- Borůvka’s algorithm — Wikipedia
- Single-linkage clustering — Wikipedia
- Travelling salesman problem — Wikipedia
- cp-algorithms — Minimum spanning tree: Kruskal
- cp-algorithms — Kruskal with disjoint set union
- cp-algorithms — Minimum spanning tree: Prim
- cp-algorithms — Disjoint set union
- CLRS — Introduction to Algorithms, Chapter 23: Minimum Spanning Trees
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- VisuAlgo — Minimum spanning tree
- Python Documentation —
heapq
Part of the Data Structures & Algorithms Roadmap knowledge base.
Overview
You have a set of locations and a set of possible connections between them, each with a cost. You want every location reachable from every other, and you want to pay as little as possible. That is the minimum spanning tree (MST) problem, and it is one of the few genuinely important problems where the obvious greedy strategy is not merely a decent heuristic — it is provably optimal.
The applications are exactly what the framing suggests. Laying fibre to connect a set of buildings. Wiring a circuit board with the least total trace length. Designing a backbone network topology. But the MST also shows up where the “network” is metaphorical: single-linkage clustering is literally “build the MST and delete the k−1 heaviest edges”; image segmentation algorithms build an MST over a pixel-similarity graph; the standard 2-approximation for the metric travelling salesman problem starts by building an MST; maze generation is an MST over a grid with random edge weights.
What makes MST worth studying carefully is not the algorithms — Kruskal’s and Prim’s are each about fifteen lines — but why they are correct. Both are greedy, and greedy algorithms are usually wrong (see ./20-brute-force-greedy-and-randomised-algorithms.md). Here they are right, and the reason is two structural facts about spanning trees: the cut property and the cycle property. Understand those and both algorithms become obvious; skip them and you are memorizing code.
MSTs are defined on undirected, connected, weighted graphs. Direction changes the problem into finding a minimum spanning arborescence, a different and harder problem solved by the Chu-Liu/Edmonds algorithm. This note assumes the representations and traversals from ./13-graph-data-structures.md.
Fundamentals
Spanning trees
A spanning tree of a connected undirected graph G = (V, E) is a subgraph that:
- includes every vertex (“spanning”),
- is connected, and
- contains no cycle (“tree”).
Any two of those three conditions, plus an edge count of exactly V − 1, imply the third. This gives four equivalent characterizations, all worth having to hand:
- connected + acyclic
- connected + exactly
V − 1edges - acyclic + exactly
V − 1edges - exactly one simple path between every pair of vertices
Graph G: a square with one diagonal.
0 --- 1 edge weights: 0-1 : 1
| \ | 0-2 : 4
| \ | 0-3 : 2
2 --- 3 1-3 : 3
2-3 : 5
Spanning tree T1 = {0-1, 0-3, 0-2} Spanning tree T2 = {0-1, 1-3, 2-3}
weight 1 + 2 + 4 = 7 (this is the MST) weight 1 + 3 + 5 = 9
0 --- 1 0 --- 1
| \ |
| \ |
2 3 2 --- 3
Adding any non-tree edge to a spanning tree creates exactly one cycle (because there was already exactly one path between its endpoints). Removing any tree edge splits the tree into exactly two components. These two facts are the machinery behind every proof below.
A connected graph usually has an enormous number of spanning trees — the complete graph K_n has n^(n−2) of them by Cayley’s formula, which is 10^8 for just ten vertices. Enumerating them to find the cheapest is out of the question, which is why the greedy properties matter.
A minimum spanning tree is a spanning tree whose total edge weight is minimum among all spanning trees. If the graph is disconnected there is no spanning tree at all; what you get instead is a minimum spanning forest — one MST per connected component.
The cut property
A cut (S, V∖S) is any partition of the vertices into two non-empty sets. An edge crosses the cut if it has one endpoint in each side.
Cut property. For any cut, if
eis the unique minimum-weight edge crossing that cut, thenebelongs to every MST of the graph.(With ties, the weaker statement holds: some minimum-weight crossing edge belongs to some MST.)
Proof (exchange argument). Suppose T is an MST that does not contain e = (u, v), with u ∈ S and v ∈ V∖S. T is spanning, so it contains a path from u to v. That path starts in S and ends outside it, so at some point it uses an edge f that also crosses the cut. Since e is the unique minimum crossing edge, w(e) < w(f). Now form T' = T − f + e. Removing f splits T into two pieces, and e reconnects them (it joins the same two sides of the cut), so T' is again a spanning tree — with weight w(T) − w(f) + w(e) < w(T). That contradicts T being minimum. ∎
Cut (S, V\S): S | V\S
|
u --------|-------- v e, weight 2 <- unique minimum crossing edge
|
a --------|-------- b f, weight 7
|
c --------|-------- d g, weight 5
Every MST contains e. No proof by inspection needed - the exchange argument
above works for any cut whatsoever.
This single property justifies both algorithms. Prim’s grows a set S and repeatedly adds the cheapest edge leaving S — a cut, by construction. Kruskal’s adds the cheapest edge joining two different components — which is the cheapest edge crossing the cut that separates one of those components from everything else. Both are just the cut property applied over and over.
The cycle property
Cycle property. For any cycle
Cin the graph, iffis the unique maximum-weight edge onC, thenfbelongs to no MST.
Proof. Suppose an MST T contains f = (u, v). Remove f: T splits into two components, with u in one and v in the other. The rest of the cycle C forms a u-to-v path, which must cross back between the two components at some edge g ≠ f. Then T − f + g is a spanning tree with weight w(T) − w(f) + w(g) < w(T) since w(g) < w(f). Contradiction. ∎
The cycle property is the exclusion rule that mirrors the cut property’s inclusion rule. Kruskal’s uses it implicitly: when it skips an edge because both endpoints are already connected, that edge is the heaviest on the cycle it would close (all lighter edges of that cycle were considered earlier). This is also the basis of the “reverse-delete” algorithm — sort edges descending and delete each one whose removal keeps the graph connected — which is correct but slow and rarely used.
Together the two properties are what algorithm textbooks call the blue rule (colour a minimum crossing edge blue: it is in the MST) and the red rule (colour a maximum cycle edge red: it is out). Every MST algorithm is a scheduling policy over these two rules.
MST uniqueness
If all edge weights are distinct, the MST is unique.
Proof. Suppose T₁ ≠ T₂ are both MSTs. Let e be the minimum-weight edge in their symmetric difference — say e ∈ T₁ ∖ T₂ (the argument is symmetric). Adding e to T₂ creates exactly one cycle C. C cannot lie entirely inside T₁ (a tree has no cycles), so C contains some edge f ∉ T₁, and f ≠ e. Then f ∈ T₂ ∖ T₁, so f is also in the symmetric difference, and by the minimality of e with distinct weights, w(e) < w(f). Then T₂ − f + e is a spanning tree of strictly smaller weight than T₂. Contradiction. ∎
The converse does not hold: an MST can be unique even with repeated weights. What ties actually cause is ambiguity of the algorithm’s output, not necessarily multiple MSTs — two runs of Kruskal’s with different tie-breaking may emit different edge sets, though both will have the same total weight.
Practical consequence: if you need a deterministic, reproducible MST, break ties deterministically. Sorting edges by (weight, u, v) rather than by weight alone makes Kruskal’s output stable across runs and across Python versions.
Other properties worth knowing
- Every MST is a minimum bottleneck spanning tree. The MST minimizes not only the total weight but also the maximum edge weight over all spanning trees. That is why the MST answers “connect everything while keeping the worst single link as good as possible.” The converse is false — a minimum bottleneck spanning tree need not be an MST.
- The MST is invariant under any strictly increasing transform of the weights. Squaring all weights, or taking logs, or adding a constant, leaves the MST unchanged — because the algorithms only ever compare weights. (Contrast this sharply with shortest paths, where adding a constant to every weight changes the answer; see ./14-shortest-path-algorithms.md.)
- A maximum spanning tree is found by negating all weights and running any MST algorithm, or equivalently by sorting descending in Kruskal’s.
- The MST is not a shortest-path tree. These are different objects and confusing them is a classic error. In a triangle with
w(A,B) = 1,w(B,C) = 1,w(A,C) = 1.9, the MST is{AB, BC}(total 2), but the shortest-path tree fromAis{AB, AC}becaused(A,C) = 1.9 < 2. The MST minimizes total edge weight; a shortest-path tree minimizes each root-to-vertex distance. - The MST edge set is a matroid basis (of the graphic matroid), which is the abstract reason greedy works here and the general theory behind “when is greedy correct.”
Key Concepts
Kruskal’s algorithm — sort the edges, O(E log E)
Kruskal’s is the cut property applied globally: consider edges from cheapest to most expensive, and take each one unless it would create a cycle. “Would create a cycle” means “both endpoints are already in the same component”, which is exactly the query a disjoint-set union (union-find) structure answers in near-constant time — see ./16-disjoint-set-union-find.md for the full treatment of the structure used here.
class DSU:
"""Disjoint-set union with union by size and path halving.
find/union are O(alpha(n)) amortized - effectively constant.
Full treatment in ./16-disjoint-set-union-find.md."""
def __init__(self, n):
self.parent = list(range(n)) # every element starts in its own set
self.size = [1] * n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a, b):
"""Merge the sets of a and b. Returns False if they were already merged."""
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # same component: this edge would close a cycle
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra # union by size: hang the small tree off the big
self.parent[rb] = ra
self.size[ra] += self.size[rb]
return True
def kruskal(n, edges):
"""Minimum spanning tree of an undirected graph.
`edges` is a list of (w, u, v) triples - the edge-list representation.
Returns (total_weight, mst_edges). Raises if the graph is disconnected.
Time O(E log E) dominated by the sort; space O(V + E).
"""
edges = sorted(edges) # (w, u, v) sorts by weight, then u, then v
dsu = DSU(n)
total = 0
mst = []
for w, u, v in edges:
if dsu.union(u, v): # different components -> safe to take
total += w
mst.append((w, u, v))
if len(mst) == n - 1:
break # a spanning tree has exactly n-1 edges
if len(mst) != n - 1:
raise ValueError("graph is not connected - no spanning tree exists")
return total, mst
Why it is correct. When Kruskal’s accepts edge (u, v), consider the cut separating u’s current component from everything else. Every edge crossing that cut with weight less than w was already examined and rejected — but rejection only happens for edges inside a component, so no such crossing edge exists. Therefore (u, v) is a minimum-weight edge across that cut, and the cut property says it belongs to an MST. Every edge it rejects is the maximum-weight edge on the cycle it would close, excluded by the cycle property.
Complexity: O(E log E) time. The sort dominates at O(E log E); the E union-find operations cost O(E · α(V)) where α is the inverse Ackermann function — under 5 for any input that fits in the universe. Since E ≤ V², log E ≤ 2 log V, so O(E log E) = O(E log V); both forms appear in the literature and mean the same thing. Space is O(V) for the DSU plus O(E) for the sorted edge list.
When the sort is free, Kruskal’s becomes O(E α(V)) — effectively linear. That happens when weights are small integers (counting sort or bucket sort, see ./08-sorting-algorithms.md) or when edges arrive already sorted. This is Kruskal’s biggest practical advantage.
Two nice properties fall out of the structure:
- Disconnected input is handled naturally. Drop the
raiseand Kruskal’s returns the minimum spanning forest — the MST of each component — without any change to the loop. - Incremental / online use. Because it processes edges independently, you can feed edges from a stream, or stop early once
n − 1edges are taken.
Prim’s algorithm — grow one tree, O(E log V)
Prim’s is the cut property applied locally: start from an arbitrary vertex, and repeatedly add the cheapest edge that connects the tree built so far to a vertex outside it. The cut is (tree, everything else) and it changes at every step.
Graph G from above. Start at 0; the tree grows one vertex at a time and
the cut (tree | rest) moves with it.
step 0: tree = {0} cheapest edge leaving: 0-1 (w=1) -> take it
step 1: tree = {0,1} candidates 0-2 (4), 0-3 (2), 1-3 (3) -> take 0-3 (2)
step 2: tree = {0,1,3} candidates 0-2 (4), 2-3 (5) -> take 0-2 (4)
step 3: tree = {0,1,2,3} done - n-1 = 3 edges, total weight 7
The “cheapest edge leaving the tree” query is what a priority queue is for — see ./12-heaps-and-priority-queues.md.
import heapq
def prim(adj, start=0):
"""Minimum spanning tree by growing a single tree from `start`.
adj[u] is a list of (v, w) pairs. Returns (total_weight, mst_edges).
Time O(E log V), space O(V + E).
This is the "lazy" variant: instead of decrease-key (which heapq lacks) it
pushes duplicates and discards stale entries on pop.
"""
n = len(adj)
in_tree = [False] * n
pq = [(0, start, -1)] # (edge weight, vertex, vertex we came from)
total = 0
mst = []
while pq:
w, u, p = heapq.heappop(pq)
if in_tree[u]:
continue # stale entry: u joined the tree already
in_tree[u] = True
if p != -1: # -1 marks the artificial start entry
total += w
mst.append((w, p, u))
for v, wv in adj[u]:
if not in_tree[v]:
heapq.heappush(pq, (wv, v, u))
if len(mst) != n - 1:
raise ValueError("graph is not connected - no spanning tree exists")
return total, mst
Complexity: O(E log E) = O(E log V). Every edge is pushed at most once from each endpoint, so the heap holds O(E) entries; each push and pop costs O(log E). Space is O(E) for the heap — the price of the lazy approach.
The eager variant keeps one heap entry per vertex (its cheapest known connection to the tree) and uses decrease-key, giving O(V) heap space and O(E log V) time. In Python without a decrease-key primitive, the lazy version is what you write; in C++ with std::priority_queue the same applies. With a Fibonacci heap, Prim’s is O(E + V log V), matching the theoretical optimum for a comparison-based approach.
On a dense graph, drop the heap entirely. Keep a key[] array of “cheapest edge from the tree to this vertex” and pick the minimum by linear scan:
def prim_dense(matrix):
"""Prim's for a dense graph given as an adjacency matrix (INF = no edge).
Time O(V^2), space O(V) beyond the matrix. When E is close to V^2 this beats
the heap version: O(V^2) < O(V^2 log V).
"""
n = len(matrix)
INF = float("inf")
key = [INF] * n # cheapest known edge weight into each vertex
parent = [-1] * n
in_tree = [False] * n
key[0] = 0
total = 0
for _ in range(n):
u = -1
for v in range(n): # O(V) scan for the cheapest fringe vertex
if not in_tree[v] and (u == -1 or key[v] < key[u]):
u = v
if key[u] == INF:
raise ValueError("graph is not connected - no spanning tree exists")
in_tree[u] = True
total += key[u]
for v in range(n): # O(V) relaxation of the cut
if not in_tree[v] and matrix[u][v] < key[v]:
key[v] = matrix[u][v]
parent[v] = u
return total, parent
The resemblance to Dijkstra is not a coincidence, but the two are not the same algorithm. Both grow a set greedily from a priority queue. The difference is one line: Dijkstra’s key is dist[u] + w (cost from the source), Prim’s key is just w (cost of the single connecting edge). That is why Dijkstra produces a shortest-path tree and Prim’s produces an MST, and why Dijkstra breaks on negative weights while Prim’s does not care — Prim’s never accumulates, so there is nothing for a negative weight to corrupt.
Kruskal’s or Prim’s?
| Kruskal’s | Prim’s (binary heap) | Prim’s (dense array) | Borůvka’s | |
|---|---|---|---|---|
| Time | O(E log E) = O(E log V) | O(E log V) | O(V²) | O(E log V) |
| Space (beyond input) | O(V) DSU + O(E) sort | O(E) lazy heap / O(V) eager | O(V) | O(V) |
| Input representation | edge list | adjacency list | adjacency matrix | edge list |
| Best when | sparse; weights pre-sorted or small integers | sparse to medium; you already have an adjacency list | dense, E ≈ V² | parallel / distributed |
| Disconnected input | spanning forest for free | only spans start’s component | only spans vertex 0’s component | spanning forest for free |
| Needs | sorting + union-find | priority queue | nothing | union-find |
| Cut property applied to | the component of each accepted edge | the growing tree | the growing tree | every component at once |
The short version:
- Sparse graph (
E ≈ V) — either works; Kruskal’s is usually simpler, and if weights are small integers it becomes effectively linear. - Dense graph (
E ≈ V²) — useprim_dense.O(V²)beatsO(V² log V)and it allocates nothing. This is the same reasoning as array-based versus heap-based Dijkstra. - Edges arriving as a stream, or already sorted — Kruskal’s, which never needs random access to neighbours.
- You already hold an adjacency list and want to avoid building an edge list — Prim’s.
- Disconnected graph and you want the forest — Kruskal’s, which does it for free; Prim’s needs an outer loop over unvisited start vertices.
Borůvka’s algorithm (1926 — the first MST algorithm ever published, predating both) is worth knowing about: in each round, every component simultaneously picks its own cheapest outgoing edge, and all those edges are added at once. Each round at least halves the number of components, so there are O(log V) rounds, each costing O(E) — total O(E log V). Because the per-round work is embarrassingly parallel, it is the basis of parallel and distributed MST implementations, and of the theoretically fastest known algorithms (the Karger-Klein-Tarjan randomized O(E) expected-time algorithm, and Chazelle’s O(E α(V)) deterministic one). Borůvka’s requires distinct edge weights to be correct as stated — with ties, two components can each pick “the other’s” edge and form a cycle — which is fixed by breaking ties on edge index.
Applications
Network design. The original motivating problem. Connect n offices with leased lines, n houses to a water main, n components on a PCB — minimize total cable while keeping everything connected. Note the assumption baked in: the MST optimizes total cost with no regard for path lengths within the network. If latency between any two nodes matters, an MST is the wrong shape (it can be a long path), and you want something else entirely — a shortest-path tree, or a topology with redundancy. The MST is also the minimum redundancy topology: remove any edge and the network partitions. Real backbones deliberately add edges beyond the MST for fault tolerance.
Single-linkage clustering. To split n points into k clusters where “distance between clusters” means the distance between their two closest members: build the MST over the complete distance graph, then delete the k − 1 heaviest edges. The k remaining components are exactly the single-linkage clusters, and the weight of the lightest deleted edge is the spacing — the minimum inter-cluster distance — which this construction provably maximizes.
def single_linkage_clusters(n, edges, k):
"""Split n points into k clusters by cutting the k-1 heaviest MST edges.
`edges` is a list of (distance, i, j). Returns (labels, spacing) where
`spacing` is the minimum distance between two different clusters.
Time O(E log E).
"""
_, mst = kruskal(n, edges)
mst.sort() # ascending by weight
keep = mst[:len(mst) - (k - 1)] # drop the k-1 heaviest edges
spacing = mst[len(mst) - (k - 1)][0] if k > 1 else float("inf")
dsu = DSU(n)
for w, u, v in keep:
dsu.union(u, v)
labels = [dsu.find(x) for x in range(n)]
return labels, spacing
Note the practical caveat: single-linkage suffers from chaining — a thin bridge of intermediate points merges two otherwise well-separated clusters, because only the single closest pair matters. That is a property of the objective, not a bug in the algorithm, and it is why complete-linkage or Ward clustering are often preferred on real data.
Approximate TSP. For a metric graph (weights satisfy the triangle inequality), building an MST, doubling every edge, and taking an Euler tour with shortcuts yields a tour at most twice the optimal — because the MST is cheaper than the optimal tour (delete any tour edge and you get a spanning path, hence a spanning tree). Christofides’ algorithm refines the same idea to a 1.5-approximation.
Maze generation. Assign random weights to the walls of a grid and compute the MST: the result is a perfect maze — exactly one path between any two cells, by the spanning-tree property. Randomized Kruskal’s and randomized Prim’s are two of the standard maze algorithms and produce visibly different maze textures (Kruskal’s gives many short dead ends; Prim’s gives a more radial structure).
Image segmentation. Felzenszwalb-Huttenlocher segmentation builds a graph over pixels with edges weighted by colour difference and merges regions in MST order, subject to a threshold that adapts to region size. Union-find does the merging, exactly as in Kruskal’s.
Circuit and taxonomy analysis. Minimum-weight connectivity over similarity graphs is the same computation whether the vertices are genes, documents, or network nodes.
Best Practices
- Confirm the graph is undirected before reaching for an MST algorithm. On a directed graph you need a minimum spanning arborescence (Chu-Liu/Edmonds), which is a different algorithm and not a small variation.
- Check connectivity and decide what “disconnected” should mean. Kruskal’s silently returns a forest; Prim’s silently returns the MST of one component. Neither failure is loud. Assert
len(mst) == n - 1or explicitly document that you want a forest. - Break ties deterministically. Sorting
(w, u, v)tuples rather than by weight alone makes the output reproducible. With distinct weights the MST is unique and this does not matter; with ties it very much does when tests compare exact edge sets. - Pick the algorithm by density.
prim_denseforE ≈ V², Kruskal’s or heap-Prim’s for sparse. On aV = 2000complete graph,O(V²) = 4×10⁶versusO(E log V) = 2×10⁶ × 11 ≈ 2×10⁷— a 5x difference, plus the heap allocations. - Exploit a cheap sort. If weights are small integers, counting-sort the edges and Kruskal’s drops to effectively
O(E α(V)). If the edges arrive sorted (from a databaseORDER BY, say), skip the sort entirely. - Use path compression and union by size/rank together. Either alone gives
O(log n); both together give inverse-Ackermann amortized. See ./16-disjoint-set-union-find.md. - Do not use an MST when you actually need short paths. An MST minimizes total edge weight, not the distance between any particular pair — the two can differ by a factor of
V. If node-to-node latency matters, you want a different topology. - Do not add a constant to the weights hoping to change the answer — you cannot. The MST is invariant under any strictly increasing transform of the weights, because the algorithms only compare. (This is the opposite of the shortest-path case, where such a shift silently corrupts the answer.)
- For a maximum spanning tree, negate the weights rather than writing a second algorithm.
- Stop early once
n − 1edges are taken. On a graph with many redundant heavy edges, this cuts a substantial fraction of the work in Kruskal’s for one line of code. - Watch memory in lazy Prim’s. The heap holds
O(E)entries, notO(V). On a dense graph that isV²tuples, which in Python is not cheap;prim_denseis both faster and smaller there. - Prefer
heapqover a hand-rolled heap in production, but know its two limitations: no decrease-key (hence the stale-entry pattern) and it is a min-heap only (negate for max).
References
- roadmap.sh — Data Structures & Algorithms
- Minimum spanning tree — Wikipedia
- Spanning tree — Wikipedia
- Kruskal’s algorithm — Wikipedia
- Prim’s algorithm — Wikipedia
- Borůvka’s algorithm — Wikipedia
- Single-linkage clustering — Wikipedia
- Travelling salesman problem — Wikipedia
- cp-algorithms — Minimum spanning tree: Kruskal
- cp-algorithms — Kruskal with disjoint set union
- cp-algorithms — Minimum spanning tree: Prim
- cp-algorithms — Disjoint set union
- CLRS — Introduction to Algorithms, Chapter 23: Minimum Spanning Trees
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- VisuAlgo — Minimum spanning tree
- Python Documentation —
heapq