Giao diện
Minimum Spanning Tree — Cây khung nhỏ nhất
Bạn là kỹ sư hạ tầng mạng, được giao nhiệm vụ kéo cáp quang nối 20 thành phố. Mỗi tuyến cáp có chi phí khác nhau phụ thuộc địa hình. Yêu cầu: mọi thành phố phải liên thông, tổng chi phí thấp nhất. Bạn không cần nối trực tiếp mọi cặp — chỉ cần đủ cạnh để tất cả liên thông. Đó chính xác là bài toán Minimum Spanning Tree (MST).
MST xuất hiện khắp nơi: thiết kế mạng máy tính, phân cụm dữ liệu (clustering), xấp xỉ TSP, và thậm chí cả phân tích hình ảnh. Hai thuật toán kinh điển — Kruskal (greedy trên cạnh + DSU) và Prim (greedy trên đỉnh + priority queue) — mỗi cái mạnh ở một context khác nhau.
Bức tranh tư duy
Hình dung bạn cần nối N hòn đảo bằng cầu. Mỗi cầu có chi phí xây khác nhau. Bạn muốn mọi đảo liên thông với tổng chi phí cầu thấp nhất.
text
Đảo A ---4--- Đảo B ---8--- Đảo C
| \ | /
2 3 7 5
| \ | /
Đảo D ---6--- Đảo E ---1--- Đảo F
MST (tổng = 2 + 3 + 1 + 5 + 4 = 15):
A--D (2), A--B (hoặc A--E) (3), E--F (1), F--C (5), A--B (4)Hai cách tiếp cận:
- Kruskal: xếp tất cả cầu theo chi phí tăng dần. Duyệt từng cầu — nếu nối hai đảo chưa liên thông, xây nó. Bỏ qua nếu tạo vòng.
- Prim: bắt đầu từ một đảo, liên tục xây cầu rẻ nhất ra đảo mới chưa thăm.
📌 Cut Property — nền tảng lý thuyết
Với mọi cách chia đồ thị thành hai nhóm (cut), cạnh có trọng số nhỏ nhất qua cut chắc chắn thuộc MST. Cả Kruskal và Prim đều dựa trên tính chất này.
Cốt lõi kỹ thuật
Kruskal's Algorithm — Sort edges + DSU
Sắp xếp tất cả cạnh theo trọng số. Duyệt từng cạnh: nếu hai đỉnh chưa cùng component (dùng DSU), thêm cạnh vào MST. Dừng khi có V-1 cạnh.
cpp
#include <vector>
#include <algorithm>
#include <numeric>
using namespace std;
struct Edge { int u, v, w; };
struct DSU {
vector<int> p, r;
DSU(int n) : p(n), r(n, 0) { iota(p.begin(), p.end(), 0); }
int find(int x) { return p[x] == x ? x : p[x] = find(p[x]); }
bool unite(int a, int b) {
a = find(a); b = find(b);
if (a == b) return false;
if (r[a] < r[b]) swap(a, b);
p[b] = a;
if (r[a] == r[b]) r[a]++;
return true;
}
};
pair<long long, vector<Edge>> kruskal(int V, vector<Edge>& edges) {
sort(edges.begin(), edges.end(),
[](auto& a, auto& b) { return a.w < b.w; });
DSU dsu(V);
vector<Edge> mst;
long long total = 0;
for (auto& e : edges) {
if (dsu.unite(e.u, e.v)) {
mst.push_back(e);
total += e.w;
if ((int)mst.size() == V - 1) break;
}
}
return {total, mst};
}python
def kruskal(V: int, edges: list[tuple[int, int, int]]) -> tuple[int, list]:
edges.sort(key=lambda e: e[2])
dsu = DSU(V)
mst = []
total = 0
for u, v, w in edges:
if dsu.unite(u, v):
mst.append((u, v, w))
total += w
if len(mst) == V - 1:
break
return total, mstjava
import java.util.*;
class Kruskal {
static long[] solve(int V, int[][] edges) {
Arrays.sort(edges, (a, b) -> a[2] - b[2]);
DSU dsu = new DSU(V);
long total = 0;
int count = 0;
for (int[] e : edges) {
if (dsu.unite(e[0], e[1])) {
total += e[2];
if (++count == V - 1) break;
}
}
return new long[]{total, count};
}
}go
import "sort"
type Edge struct{ U, V, W int }
func kruskal(V int, edges []Edge) (int, []Edge) {
sort.Slice(edges, func(i, j int) bool {
return edges[i].W < edges[j].W
})
dsu := NewDSU(V)
mst := []Edge{}
total := 0
for _, e := range edges {
if dsu.Unite(e.U, e.V) {
mst = append(mst, e)
total += e.W
if len(mst) == V-1 { break }
}
}
return total, mst
}Prim's Algorithm — Priority queue greedy
Bắt đầu từ đỉnh bất kỳ. Duy trì priority queue (min-heap) chứa các cạnh ra ngoài tập đã thăm. Mỗi bước, lấy cạnh rẻ nhất nối đến đỉnh chưa thăm, thêm vào MST. Tương tự Dijkstra, nhưng key là trọng số cạnh thay vì khoảng cách tích lũy.
cpp
#include <vector>
#include <queue>
using namespace std;
long long prim(int V, vector<vector<pair<int,int>>>& adj) {
vector<bool> inMST(V, false);
// {weight, vertex}
priority_queue<pair<int,int>, vector<pair<int,int>>,
greater<>> pq;
pq.push({0, 0});
long long total = 0;
int count = 0;
while (!pq.empty() && count < V) {
auto [w, u] = pq.top(); pq.pop();
if (inMST[u]) continue;
inMST[u] = true;
total += w;
count++;
for (auto [v, wt] : adj[u]) {
if (!inMST[v]) pq.push({wt, v});
}
}
return total;
}python
import heapq
def prim(V: int, adj: list[list[tuple[int, int]]]) -> int:
in_mst = [False] * V
pq = [(0, 0)] # (weight, vertex)
total = 0
count = 0
while pq and count < V:
w, u = heapq.heappop(pq)
if in_mst[u]:
continue
in_mst[u] = True
total += w
count += 1
for v, wt in adj[u]:
if not in_mst[v]:
heapq.heappush(pq, (wt, v))
return totaljava
import java.util.*;
class Prim {
static long solve(int V, List<List<int[]>> adj) {
boolean[] inMST = new boolean[V];
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[0] - b[0]);
pq.offer(new int[]{0, 0});
long total = 0;
int count = 0;
while (!pq.isEmpty() && count < V) {
int[] cur = pq.poll();
int w = cur[0], u = cur[1];
if (inMST[u]) continue;
inMST[u] = true;
total += w;
count++;
for (int[] e : adj.get(u)) {
if (!inMST[e[0]]) pq.offer(new int[]{e[1], e[0]});
}
}
return total;
}
}go
import "container/heap"
type Item struct{ w, v int }
type PQ []Item
func (pq PQ) Len() int { return len(pq) }
func (pq PQ) Less(i, j int) bool { return pq[i].w < pq[j].w }
func (pq PQ) Swap(i, j int) { pq[i], pq[j] = pq[j], pq[i] }
func (pq *PQ) Push(x interface{}) { *pq = append(*pq, x.(Item)) }
func (pq *PQ) Pop() interface{} {
old := *pq; n := len(old)
x := old[n-1]; *pq = old[:n-1]; return x
}
func primMST(V int, adj [][]Item) int {
inMST := make([]bool, V)
pq := &PQ{{0, 0}}
heap.Init(pq)
total, count := 0, 0
for pq.Len() > 0 && count < V {
cur := heap.Pop(pq).(Item)
if inMST[cur.v] { continue }
inMST[cur.v] = true
total += cur.w
count++
for _, e := range adj[cur.v] {
if !inMST[e.v] { heap.Push(pq, e) }
}
}
return total
}Cut Property — tại sao Kruskal và Prim đúng
Định lý: Cho một cut (phân hoạch đồ thị thành hai tập S và V\S), cạnh nhẹ nhất qua cut thuộc ít nhất một MST.
Kruskal kiểm tra mỗi cạnh theo trọng số tăng dần. Cạnh nối hai component khác nhau chính là cạnh nhẹ nhất qua cut ngăn cách hai component đó → thuộc MST. Prim luôn chọn cạnh nhẹ nhất từ tập đã thăm ra tập chưa thăm — cũng chính là cạnh nhẹ nhất qua cut.
Thực chiến
Tình huống: Thiết kế mạng chi phí tối thiểu
Bối cảnh: N data center cần kết nối. Có M tuyến cáp khả dụng với chi phí khác nhau. Tìm cách kết nối tất cả với chi phí thấp nhất.
python
def design_network(
num_dc: int,
cables: list[tuple[str, str, int]],
dc_names: list[str]
) -> tuple[int, list[tuple[str, str, int]]]:
name_to_id = {name: i for i, name in enumerate(dc_names)}
edges = [(name_to_id[u], name_to_id[v], cost)
for u, v, cost in cables]
total, mst_edges = kruskal(num_dc, edges)
result = [(dc_names[u], dc_names[v], w) for u, v, w in mst_edges]
if len(mst_edges) < num_dc - 1:
raise ValueError("Cannot connect all data centers")
return total, resultPhân tích: Kruskal phù hợp vì input dạng edge list. Nếu đồ thị dense (M ≈ V²), Prim với adjacency matrix O(V²) sẽ nhanh hơn.
Sai lầm điển hình
❌ Sai lầm 1: Quên kiểm tra đồ thị liên thông
Vấn đề: Chạy MST trên đồ thị không liên thông.
python
# SAI: không kiểm tra kết quả
total, mst = kruskal(V, edges)
print(f"MST cost: {total}") # có thể thiếu cạnh!Tại sao sai: Nếu đồ thị không liên thông, MST chỉ nối được các component riêng lẻ. Kết quả thiếu cạnh mà không báo lỗi.
python
# ĐÚNG: verify MST có đúng V-1 cạnh
total, mst = kruskal(V, edges)
if len(mst) != V - 1:
raise ValueError("Graph is not connected — MST doesn't exist")❌ Sai lầm 2: Prim bỏ quên đỉnh đã thăm
Vấn đề: Không kiểm tra in_mst[u] sau khi pop từ priority queue.
python
# SAI: xử lý đỉnh đã thăm → kết quả sai
w, u = heapq.heappop(pq)
total += w # nếu u đã trong MST, cộng thừa!Tại sao sai: Priority queue có thể chứa nhiều entry cho cùng một đỉnh. Không kiểm tra sẽ cộng thừa trọng số và tạo cycle.
python
# ĐÚNG: skip đỉnh đã thăm (lazy deletion)
w, u = heapq.heappop(pq)
if in_mst[u]:
continue
in_mst[u] = True
total += w❌ Sai lầm 3: Nhầm Prim với Dijkstra
Vấn đề: Dùng khoảng cách tích lũy thay vì trọng số cạnh.
python
# SAI: Dijkstra-style — dist[v] = dist[u] + w
heapq.heappush(pq, (dist[u] + w, v))Tại sao sai: Prim chọn cạnh nhẹ nhất ra ngoài MST, không phải đường đi ngắn nhất. Key trong priority queue là trọng số cạnh, không phải tổng khoảng cách.
python
# ĐÚNG: key là trọng số cạnh, không phải distance
heapq.heappush(pq, (w, v))Under the Hood
Hiệu năng
| Thuật toán | Time | Space | Tốt nhất khi |
|---|---|---|---|
| Kruskal | O(E log E) | O(V + E) | Đồ thị thưa (E ≈ V), edge list input |
| Prim (binary heap) | O((V+E) log V) | O(V + E) | Đồ thị dense, adjacency list |
| Prim (Fibonacci heap) | O(E + V log V) | O(V + E) | Lý thuyết tối ưu, hiếm dùng thực tế |
Kruskal: bottleneck ở sort cạnh O(E log E). DSU operations gần O(1). Tối ưu cho sparse graph khi E ≪ V².
Prim: tương tự Dijkstra. Mỗi đỉnh pop 1 lần từ heap, mỗi cạnh push 1 lần. Tối ưu cho dense graph.
Khi nào MST duy nhất?
MST duy nhất khi và chỉ khi không có hai cạnh khác nhau cùng trọng số mà có thể thay thế nhau (cùng là cạnh nhẹ nhất qua một cut). Nếu tất cả trọng số phân biệt, MST luôn duy nhất.
MST và bài toán liên quan
- Minimum bottleneck spanning tree: MST cũng là cây khung có cạnh nặng nhất nhỏ nhất
- TSP approximation: MST × 2 cho đường Hamilton xấp xỉ ≤ 2 × OPT (metric TSP)
- Clustering: Xóa k-1 cạnh nặng nhất của MST → k clusters (single-linkage clustering)
Checklist ghi nhớ
✅ Checklist triển khai
Kruskal
- [ ] Sort edges theo weight → duyệt → DSU unite → thêm vào MST nếu union thành công
- [ ] Dừng sớm khi MST đủ V-1 cạnh
- [ ] Verify kết quả:
len(mst) == V - 1→ đồ thị liên thông
Prim
- [ ] Min-heap lưu
(weight, vertex)— key là trọng số cạnh, KHÔNG phải khoảng cách - [ ] Skip đỉnh đã thăm khi pop (lazy deletion)
- [ ] Tương tự Dijkstra về cấu trúc, nhưng khác ở key
Lựa chọn thuật toán
- [ ] Sparse graph (E ≈ V) → Kruskal
- [ ] Dense graph (E ≈ V²) → Prim
- [ ] Cần edge list cụ thể của MST → Kruskal (tự nhiên hơn)
- [ ] Đồ thị không liên thông → MST không tồn tại, kiểm tra trước
Bài tập luyện tập
Bài 1: Min Cost to Connect All Points — Foundation
Đề bài: Cho mảng points[i] = [xi, yi]. Chi phí nối hai điểm = |xi - xj| + |yi - yj|. Tìm chi phí tối thiểu để nối tất cả điểm. (LeetCode 1584)
🧠 Quiz
Kruskal dừng khi nào?
- [ ] A. Hết cạnh
- [x] B. MST đủ V-1 cạnh
- [ ] C. Tất cả đỉnh đã visit
- [ ] D. Queue rỗng Giải thích: Cây V đỉnh luôn có đúng V-1 cạnh. Kruskal dừng sớm khi MST đủ cạnh — không cần duyệt hết tất cả edges.
💡 Gợi ý
- Tạo tất cả cạnh (V² cạnh cho V điểm), weight = Manhattan distance
- Chạy Kruskal hoặc Prim
- Dense graph (V² edges) → Prim có thể nhanh hơn
✅ Lời giải
python
def minCostConnectPoints(points: list[list[int]]) -> int:
n = len(points)
edges = []
for i in range(n):
for j in range(i + 1, n):
d = abs(points[i][0]-points[j][0]) + abs(points[i][1]-points[j][1])
edges.append((i, j, d))
total, _ = kruskal(n, edges)
return totalPhân tích: O(V² log V) time — V² cạnh, sort O(V² log V²) = O(V² log V). Prim O(V²) với adjacency matrix nhanh hơn cho bài này.
Bài 2: Critical/Pseudo-critical Edges in MST — Advanced
Đề bài: Phân loại mỗi cạnh thành critical (bắt buộc thuộc mọi MST), pseudo-critical (thuộc ít nhất một MST), hoặc không thuộc MST nào. (LeetCode 1489)
💡 Gợi ý
- Critical: xóa cạnh → MST weight tăng (hoặc không liên thông)
- Pseudo-critical: ép cạnh vào MST → MST weight không đổi
- Thử từng cạnh: O(E²α(V))
Liên kết học tiếp
Từ khóa glossary: MST, cây khung nhỏ nhất, Kruskal, Prim, cut property, spanning tree, greedy
Tìm kiếm liên quan: cây khung, kết nối mạng chi phí nhỏ nhất, minimum cost network, clustering MST