Cây khung nhỏ nhất (MST)
Cây khung nhỏ nhất (Minimum Spanning Tree, MST) là tập cạnh nối tất cả đỉnh của một đồ thị vô hướng có trọng số sao cho tổng trọng số nhỏ nhất. Thử mọi cây khung là bất khả thi (đồ thị đầy đủ đỉnh có cây khung), nhưng nhờ hai tính chất tham lam đơn giản, thuật toán Kruskal giải trong và Prim trong (hoặc cho đồ thị dày). MST còn là chìa khóa cho cả một họ bài "đường đi có cạnh lớn nhất nhỏ nhất" (minimax), "cạnh nào có thể thuộc MST", "mạng lưới rẻ nhất"... rất hay gặp trong đề VOI.
Định nghĩa
Cho đồ thị vô hướng liên thông , , , mỗi cạnh có trọng số .
- Cây khung (spanning tree): tập con gồm đúng cạnh, nối mọi đỉnh và không có chu trình.
- Cây khung nhỏ nhất: cây khung có nhỏ nhất. Có thể có nhiều MST khác nhau (khi có cạnh bằng trọng số), nhưng tổng trọng số nhỏ nhất thì duy nhất.
- Nếu đồ thị không liên thông, không tồn tại cây khung; khi đó ta nói về rừng khung nhỏ nhất (mỗi thành phần một cây).
Ba sự thật về cây sẽ được dùng liên tục:
- Thêm một cạnh bất kỳ vào cây khung tạo ra đúng một chu trình.
- Xóa một cạnh bất kỳ khỏi cây khung chia cây thành đúng hai phần; đó là một lát cắt (cut) .
- Trong cây, giữa hai đỉnh có đúng một đường đi.
Ý tưởng / Trực giác: hai tính chất nền tảng
Mọi thuật toán MST đều là tham lam, và tính đúng của chúng dựa trên hai tính chất đối ngẫu dưới đây. Cả hai đều chứng minh bằng lập luận trao đổi (exchange argument): lấy một MST, đổi một cạnh lấy cạnh khác mà tổng không tăng.
Tính chất lát cắt (cut property)
Với mọi lát cắt , gọi là cạnh nhẹ nhất trong các cạnh có một đầu thuộc , một đầu thuộc . Khi đó tồn tại một MST chứa . Nếu nhẹ nhất hẳn (không có cạnh cắt nào bằng nó), thì mọi MST đều chứa .
Chứng minh. Lấy MST bất kỳ không chứa . Thêm vào tạo ra một chu trình. Chu trình này đi từ sang bằng rồi phải quay về , nên nó còn ít nhất một cạnh khác cắt lát cắt. Vì nhẹ nhất trong các cạnh cắt, . Cây vẫn là cây khung và , nên cũng là MST và chứa . Nếu hẳn thì , mâu thuẫn, nên mọi MST phải chứa .
Dạng mạnh hơn (dùng cho thuật toán tham lam). Thuật toán không chỉ cần "có một MST chứa ", mà cần "có một MST chứa mọi cạnh đã chọn và cả ":
Nếu tập cạnh đã chọn nằm trong một MST , và không cạnh nào của cắt lát cắt , thì với là cạnh nhẹ nhất cắt lát cắt, cũng nằm trong một MST.
Chứng minh y hệt trên: cạnh bị bỏ ra khỏi cắt lát cắt nên , do đó vẫn chứa toàn bộ . Áp dụng lặp lại từ , mỗi bước thêm một cạnh như vậy, cuối cùng ta được đúng một MST.
Tính chất chu trình (cycle property)
Với mọi chu trình , nếu là cạnh nặng nhất hẳn trên thì không thuộc MST nào. (Nếu chỉ nặng nhất không hẳn, tức có cạnh khác cùng trọng số lớn nhất, thì luôn có một MST không chứa .)
Chứng minh. Giả sử MST chứa . Xóa khỏi chia cây thành hai phần. Phần còn lại của chu trình là một đường đi nối hai đầu của , nên nó có một cạnh nối hai phần đó. nằm trên nên . Khi đó là cây khung nhẹ hơn , mâu thuẫn.
Hai thuật toán từ hai góc nhìn
- Kruskal duyệt cạnh theo trọng số tăng dần; cạnh nào nối hai thành phần khác nhau thì lấy, cạnh nào tạo chu trình thì bỏ. Cạnh bị bỏ là cạnh nặng nhất trên chu trình nó tạo ra (các cạnh khác trên chu trình đều đã được xét trước nên không nặng hơn), nên bỏ nó là an toàn theo tính chất chu trình. Cạnh được lấy là cạnh nhẹ nhất cắt lát cắt "thành phần chứa | phần còn lại" (mọi cạnh nhẹ hơn đã được xét và nằm gọn trong các thành phần). Lát cắt này không cắt cạnh nào đã chọn (mọi cạnh đã chọn nằm gọn trong một thành phần), nên theo dạng mạnh của tính chất lát cắt, tập cạnh đã chọn cộng vẫn nằm trong một MST. Kiểm tra "cùng thành phần" dùng DSU.
- Prim mọc một cây từ một đỉnh gốc. Ở mỗi bước, lát cắt là (tập đỉnh đã vào cây , phần còn lại), và Prim lấy cạnh nhẹ nhất cắt lát cắt đó. Mọi cạnh đã chọn đều có hai đầu trong , nên lát cắt này không cắt cạnh nào đã chọn, và dạng mạnh của tính chất lát cắt đảm bảo cây đang mọc luôn nằm trong một MST. Việc tìm cạnh nhẹ nhất dùng heap (giống Dijkstra, nhưng khóa là trọng số cạnh chứ không phải khoảng cách cộng dồn).
Ví dụ chạy tay
Đồ thị 5 đỉnh, 7 cạnh:
(1)-----4-----(2)
| / |
1 3 2
| / |
(3)-----5-----(4)
\ /
6 7
\ /
(5)
cạnh: 1-3:1 2-4:2 2-3:3 1-2:4 3-4:5 3-5:6 4-5:7
Kruskal
Sắp cạnh tăng dần, duyệt lần lượt. Cột "thành phần" là trạng thái DSU sau khi xét cạnh.
cạnh w find(u) vs find(v) quyết định thành phần
1-3 1 khác LẤY {1,3} {2} {4} {5}
2-4 2 khác LẤY {1,3} {2,4} {5}
2-3 3 khác LẤY {1,2,3,4} {5}
1-2 4 CÙNG (1-3-2) bỏ chu trình 1-2-3: cạnh 4 nặng nhất
3-4 5 CÙNG (3-2-4) bỏ chu trình 3-4-2: cạnh 5 nặng nhất
3-5 6 khác LẤY {1,2,3,4,5} -> đủ n-1 = 4 cạnh, dừng
4-5 7 (không cần xét)
MST = {1-3, 2-4, 2-3, 3-5}, tổng = 1 + 2 + 3 + 6 = 12
(1) (2)
| / |
1 3 2
| / |
(3) (4)
\
6
\
(5)
Prim (xuất phát từ đỉnh 1)
key[v] là trọng số cạnh nhẹ nhất đã biết nối vào cây hiện tại; mỗi bước lấy đỉnh ngoài cây có key nhỏ nhất.
bước lấy đỉnh (key) cập nhật key các đỉnh kề ngoài cây key[1..5]
0 - khởi tạo key[1] = 0 0 ∞ ∞ ∞ ∞
1 1 (0) 2: 4, 3: 1 . 4 1 ∞ ∞
2 3 (1) 2: min(4,3)=3, 4: 5, 5: 6 . 3 . 5 6
3 2 (3) 4: min(5,2)=2 . . . 2 6
4 4 (2) 5: min(6,7)=6 . . . . 6
5 5 (6) - . . . . .
tổng = 0 + 1 + 3 + 2 + 6 = 12 (cùng cây với Kruskal)
Để ý bước 2: khi đỉnh 3 vào cây, key[2] giảm từ 4 xuống 3. Vì vậy ở bước 3, đỉnh 2 được kéo vào cây bằng cạnh 2-3 (trọng số 3) chứ không phải cạnh 1-2 (trọng số 4).
Cài đặt
Kruskal + DSU (C++)
#include <bits/stdc++.h>
using namespace std;
struct DSU {
vector<int> parent, sz;
DSU(int n) : parent(n + 1), sz(n + 1, 1) { iota(parent.begin(), parent.end(), 0); }
int find(int x) {
while (parent[x] != x) x = parent[x] = parent[parent[x]]; // path halving
return x;
}
bool unite(int a, int b) {
a = find(a); b = find(b);
if (a == b) return false; // already connected: edge would form a cycle
if (sz[a] < sz[b]) swap(a, b); // union by size
parent[b] = a; sz[a] += sz[b];
return true;
}
};
struct Edge { int u, v; long long w; };
// Returns MST weight, or -1 if the graph is disconnected
long long kruskal(int n, vector<Edge> edges) {
sort(edges.begin(), edges.end(), [](const Edge& a, const Edge& b) { return a.w < b.w; });
DSU dsu(n);
long long total = 0;
int taken = 0;
for (const Edge& e : edges) {
if (dsu.unite(e.u, e.v)) {
total += e.w;
if (++taken == n - 1) break; // tree is complete
}
}
return taken == n - 1 ? total : -1;
}
int main() {
int n, m;
scanf("%d %d", &n, &m);
vector<Edge> edges(m);
for (auto& e : edges) scanf("%d %d %lld", &e.u, &e.v, &e.w);
printf("%lld\n", kruskal(n, edges));
}
Kruskal (Python)
import sys
def main():
data = sys.stdin.buffer.read().split()
n, m = int(data[0]), int(data[1])
edges = []
for i in range(m):
a, b, c = int(data[2 + 3*i]), int(data[3 + 3*i]), int(data[4 + 3*i])
edges.append((c, a, b))
edges.sort()
parent = list(range(n + 1))
size = [1] * (n + 1)
def find(x):
root = x
while parent[root] != root:
root = parent[root]
while parent[x] != root: # path compression, iterative
parent[x], x = root, parent[x]
return root
total = taken = 0
for c, a, b in edges:
ra, rb = find(a), find(b)
if ra == rb:
continue
if size[ra] < size[rb]:
ra, rb = rb, ra
parent[rb] = ra
size[ra] += size[rb]
total += c
taken += 1
if taken == n - 1:
break
print(total if taken == n - 1 else -1)
main()
Prim với heap (C++)
Phù hợp khi đồ thị cho sẵn danh sách kề. Đây là bản "lười" (lazy): một đỉnh có thể nằm trong heap nhiều lần, bản cũ bị bỏ qua khi lấy ra.
// adj[u] = list of (v, w); vertices 1..n
long long primHeap(int n, const vector<vector<pair<int, long long>>>& adj) {
const long long INF = LLONG_MAX;
vector<long long> key(n + 1, INF);
vector<bool> inTree(n + 1, false);
priority_queue<pair<long long, int>, vector<pair<long long, int>>, greater<>> pq;
key[1] = 0;
pq.push({0, 1});
long long total = 0;
int taken = 0;
while (!pq.empty()) {
auto [w, u] = pq.top(); pq.pop();
if (inTree[u]) continue; // stale entry
inTree[u] = true;
total += w;
taken++;
for (auto [v, c] : adj[u])
if (!inTree[v] && c < key[v]) { // only push real improvements
key[v] = c;
pq.push({c, v});
}
}
return taken == n ? total : -1; // taken counts vertices, not edges
}
Prim cho đồ thị dày
Khi đồ thị đầy đủ (ví dụ điểm trên mặt phẳng, cạnh giữa mọi cặp) thì : sắp xếp cạnh cho Kruskal tốn cả bộ nhớ lẫn thời gian. Prim bản mảng không cần lưu cạnh, chỉ cần hàm tính trọng số, chạy thời gian và bộ nhớ.
// weight(i, j): cost of edge i-j (0-indexed), return LLONG_MAX if the edge is forbidden
template <class F>
long long primDense(int n, F weight) {
const long long INF = LLONG_MAX;
vector<long long> key(n, INF);
vector<bool> inTree(n, false);
key[0] = 0;
long long total = 0;
for (int it = 0; it < n; it++) {
int u = -1;
for (int v = 0; v < n; v++) // closest vertex outside the tree
if (!inTree[v] && (u == -1 || key[v] < key[u])) u = v;
if (key[u] == INF) return -1; // remaining vertices unreachable
inTree[u] = true;
total += key[u];
for (int v = 0; v < n; v++)
if (!inTree[v]) key[v] = min(key[v], weight(u, v));
}
return total;
}
// Example: points (x[i], y[i]), cost = squared Euclidean distance
// long long ans = primDense(n, [&](int i, int j) {
// long long dx = x[i] - x[j], dy = y[i] - y[j];
// return dx * dx + dy * dy;
// });
Độ phức tạp
| Thuật toán | Thời gian | Bộ nhớ | Khi nào dùng |
|---|---|---|---|
| Kruskal + DSU | đồ thị thưa, có sẵn danh sách cạnh | ||
| Prim + heap | đồ thị thưa, có sẵn danh sách kề | ||
| Prim mảng | (+ dữ liệu điểm) | đồ thị dày / đầy đủ, vài nghìn |
Lý giải:
- Kruskal: bước sắp xếp chiếm . Sau đó mỗi cạnh gọi
unitemột lần, mỗi lần nhờ nén đường + gộp theo kích thước, tổng , nhỏ hơn phần sắp xếp. Bộ nhớ: mảng cạnh và DSU . - Prim + heap: mỗi cạnh có thể đẩy một phần tử vào heap (chỉ khi làm giảm
key), nên heap có phần tử, mỗi thao tác (vì nên ). - Prim mảng: vòng, mỗi vòng quét đỉnh để chọn min và đỉnh để cập nhật: . Với , khoảng phép tính, trong khi Kruskal phải sắp cạnh và lưu chúng.
Các dạng mở rộng quan trọng
Cây khung lớn nhất
Muốn tổng lớn nhất: chạy Kruskal với cạnh sắp giảm dần (hoặc đổi dấu trọng số rồi chạy MST thường). Hai tính chất lát cắt/chu trình vẫn đúng khi đổi chiều so sánh. Một hệ quả tiện dụng: MST chỉ phụ thuộc vào thứ tự các trọng số, nên cộng một hằng số vào mọi cạnh, hay áp một hàm tăng ngặt bất kỳ, không làm đổi tập các MST (hàm chỉ tăng không ngặt có thể biến hai trọng số khác nhau thành bằng nhau, sinh thêm MST mới); trọng số âm cũng không sao (khác với Dijkstra).
Đường đi minimax nằm trên MST
Bài toán: giữa và , tìm đường đi sao cho cạnh lớn nhất trên đường là nhỏ nhất (bottleneck path). Đáp án: đường đi giữa và trên MST, và giá trị là cạnh lớn nhất trên đường đó.
Vì sao? Gọi là cạnh lớn nhất trên đường trong MST , . Xóa khỏi tách và về hai phía của một lát cắt. Mọi đường trong đều phải có cạnh cắt lát cắt này. Nếu thì nhẹ hơn , mâu thuẫn; vậy , tức mọi đường đều có cạnh lớn nhất . Đường trên cây đạt đúng .
Trong ví dụ trên: minimax từ 1 đến 4 là đường cây 1-3-2-4 với cạnh lớn nhất 3 (các đường khác: 1-2-4 cho 4, 1-3-4 cho 5). Minimax từ 1 đến 5 là 6.
Đối xứng: bài "cạnh nhỏ nhất trên đường là lớn nhất" (maximin, ví dụ đường truyền có thông lượng lớn nhất) dùng cây khung lớn nhất và lấy cạnh nhỏ nhất trên đường cây.
Nhìn theo Kruskal: minimax chính là trọng số của cạnh mà tại đó và lần đầu cùng thành phần. Do đó với truy vấn offline có thể trả lời ngay trong lúc chạy Kruskal; với truy vấn online, dựng MST rồi dùng binary lifting lưu max cạnh trên mỗi bước nhảy:
const int LOG = 17; // 2^17 > 1e5
vector<vector<pair<int, int>>> tree; // MST adjacency: (neighbor, weight)
vector<array<int, LOG>> up, mx; // 2^k-th ancestor, max edge on that jump
vector<int> dep;
void build(int n, int root) {
up.assign(n + 1, {}); mx.assign(n + 1, {}); dep.assign(n + 1, 0);
vector<bool> seen(n + 1, false);
up[root].fill(root); // mx[root] stays 0 (weights >= 0)
seen[root] = true;
vector<int> st = {root};
while (!st.empty()) { // iterative DFS, no stack overflow
int u = st.back(); st.pop_back();
for (auto [v, w] : tree[u]) if (!seen[v]) {
seen[v] = true; dep[v] = dep[u] + 1;
up[v][0] = u; mx[v][0] = w;
for (int k = 1; k < LOG; k++) { // ancestors are already built
int mid = up[v][k - 1];
up[v][k] = up[mid][k - 1];
mx[v][k] = max(mx[v][k - 1], mx[mid][k - 1]);
}
st.push_back(v);
}
}
}
int pathMax(int u, int v) { // max edge on the tree path u..v
int res = 0;
if (dep[u] < dep[v]) swap(u, v);
for (int k = LOG - 1; k >= 0; k--)
if (dep[u] - (1 << k) >= dep[v]) { res = max(res, mx[u][k]); u = up[u][k]; }
if (u == v) return res;
for (int k = LOG - 1; k >= 0; k--)
if (up[u][k] != up[v][k]) {
res = max({res, mx[u][k], mx[v][k]});
u = up[u][k]; v = up[v][k];
}
return max({res, mx[u][0], mx[v][0]});
}
Bản maximin (trên cây khung lớn nhất). Không chỉ đổi max thành min: giá trị đơn vị của phép min là "vô cùng", không phải 0. Phải đổi cả hai chỗ khởi tạo, nếu không kết quả luôn là 0:
// In build(): replace the max line and initialize the root's table
// mx[root].fill(INT_MAX); // identity of min, NOT 0
// mx[v][k] = min(mx[v][k - 1], mx[mid][k - 1]);
int pathMin(int u, int v) { // min edge on the tree path u..v
int res = INT_MAX; // identity of min, NOT 0
if (dep[u] < dep[v]) swap(u, v);
for (int k = LOG - 1; k >= 0; k--)
if (dep[u] - (1 << k) >= dep[v]) { res = min(res, mx[u][k]); u = up[u][k]; }
if (u == v) return res;
for (int k = LOG - 1; k >= 0; k--)
if (up[u][k] != up[v][k]) {
res = min({res, mx[u][k], mx[v][k]});
u = up[u][k]; v = up[v][k];
}
return min({res, mx[u][0], mx[v][0]});
}
Đồ thị không liên thông. build chỉ dựng bảng cho thành phần chứa root. Khi đồ thị có thể không liên thông (ví dụ bài trucktsp), gọi build từ mọi đỉnh chưa thăm (mỗi đỉnh chưa thăm làm gốc một cây mới, chỉ assign mảng một lần), và trước mỗi truy vấn kiểm tra find(u) != find(v) bằng DSU của Kruskal để in ngay.
Cạnh tiềm năng: cạnh có thuộc MST nào đó không?
Từ hai tính chất trên suy ra tiêu chí gọn:
Cạnh trọng số thuộc ít nhất một MST khi và chỉ khi và không nối được với nhau chỉ bằng các cạnh có trọng số nhỏ hơn hẳn .
- Nếu có đường như vậy, là cạnh nặng nhất hẳn trên chu trình (đường đó + ), loại theo tính chất chu trình.
- Nếu không, cho đứng đầu nhóm cạnh cùng trọng số khi chạy Kruskal: lúc xét , và vẫn khác thành phần nên được lấy.
Tương đương: minimax tính trên đồ thị bỏ cạnh . Với một cạnh: chạy DSU trên các cạnh nhẹ hơn hẳn , . Với mọi cạnh cùng lúc: xử lý theo nhóm cùng trọng số, kiểm tra cả nhóm trước, rồi mới hợp cả nhóm:
// canBeInMST[i] = true if edge i belongs to at least one MST
vector<bool> potentialEdges(int n, const vector<Edge>& edges) {
int m = edges.size();
vector<int> order(m);
iota(order.begin(), order.end(), 0);
sort(order.begin(), order.end(), [&](int a, int b) { return edges[a].w < edges[b].w; });
DSU dsu(n);
vector<bool> canBeInMST(m, false);
for (int i = 0; i < m; ) {
int j = i;
while (j < m && edges[order[j]].w == edges[order[i]].w) j++;
for (int k = i; k < j; k++) { // 1) check the whole group first
const Edge& e = edges[order[k]];
canBeInMST[order[k]] = dsu.find(e.u) != dsu.find(e.v);
}
for (int k = i; k < j; k++) // 2) then merge the whole group
dsu.unite(edges[order[k]].u, edges[order[k]].v);
i = j;
}
return canBeInMST;
}
Trong ví dụ: cạnh 1-2 (trọng số 4) không tiềm năng vì 1 và 2 đã nối bằng 1-3-2 với các cạnh 1 và 3 đều . Nếu đổi trọng số 1-2 thành 3, các cạnh chỉ có 1-3 và 2-4, chưa nối 1 với 2, nên 1-2 trở thành tiềm năng (nó "hòa" với cạnh 2-3).
MST khi có trọng số bằng nhau
- Tổng trọng số MST là duy nhất, nhưng cây có thể khác nhau. Mọi MST có cùng số cạnh mỗi trọng số, và sau khi xử lý xong nhóm trọng số , các thành phần liên thông giống hệt nhau bất kể chọn cạnh nào trong nhóm. Đây là lý do cách "xử lý theo nhóm" ở trên đúng.
- MST có duy nhất không? Duy nhất khi và chỉ khi ở mọi nhóm, số cạnh tiềm năng (bước 1) bằng số lần
unitethành công (bước 2). Nếu nhiều hơn, có cạnh tiềm năng bị bỏ, tức tồn tại MST khác. - Cạnh thuộc mọi MST: một cạnh tiềm năng thuộc mọi MST khi và chỉ khi nó là cầu trong đồ thị nhỏ gồm các cạnh tiềm năng của nhóm đó (mỗi thành phần DSU co thành một đỉnh). Tìm cầu xem trang cầu và khớp. Đồ thị co này thường có đa cạnh (hai cạnh tiềm năng cùng nối một cặp thành phần DSU, và khi đó không cạnh nào là cầu), nên khi DFS tìm cầu nhớ bỏ qua cạnh cha theo id cạnh, không theo đỉnh cha.
- Nếu trọng số đôi một khác nhau, MST là duy nhất.
Cây khung với đúng cạnh loại A
Mỗi cạnh có một loại (A hoặc B), và câu hỏi là: cây khung có thể chứa bao nhiêu cạnh loại A? Dạng này xuất hiện khi chi phí chỉ phụ thuộc vào số cạnh mỗi loại (ví dụ hai công ty có bảng giá riêng), hoặc đề ép "đúng cạnh màu trắng".
Kết quả: các giá trị đạt được là một đoạn liên tục .
- : ưu tiên cạnh A trước. Chạy Kruskal chỉ với cạnh A, số cạnh lấy được là kích thước rừng khung lớn nhất của đồ thị A. Không cây nào chứa nhiều cạnh A hơn (các cạnh A trong cây là một rừng), và rừng này mở rộng thành cây bằng cạnh B nếu đồ thị liên thông.
- : ưu tiên cạnh B trước. Hợp mọi cạnh B bằng DSU, được thành phần; rồi duyệt cạnh A, cạnh nào nối hai thành phần khác nhau thì là cạnh bắt buộc. Có đúng cạnh bắt buộc, và đó là : mọi cây khung phải nối thành phần B mà cạnh B không nối được, nên cần ít nhất cạnh A. Chữ "bắt buộc" ở đây nghĩa là phải lấy trước khi dựng cây (theo thứ tự duyệt đã chọn), không phải "có mặt trong mọi cây khung": tập cạnh này là một rừng khung của đồ thị co theo các thành phần B, và thứ tự duyệt khác có thể cho tập khác. Ngay ví dụ bên dưới, cây có đúng 1 cạnh A nhưng không chứa .
- Mọi giá trị ở giữa đều đạt được nhờ tính chất trao đổi (cây khung là cơ sở của matroid đồ thị): cho hai cây và , lấy cạnh ; thêm vào tạo một chu trình, chu trình đó chứa một cạnh , và vẫn là cây khung. Mỗi bước như vậy làm số cạnh A thay đổi nhiều nhất 1 (bỏ một cạnh, thêm một cạnh) và làm giống hơn. Đi từ cây có cạnh A tới cây có cạnh A theo từng bước hoặc , ta đi qua mọi giá trị ở giữa (giống định lý giá trị trung gian).
Dựng cây với đúng cạnh A (), ba bước DSU:
- Thêm toàn bộ cạnh bắt buộc ( cạnh).
- Thêm các cạnh A khác không tạo chu trình cho tới khi đủ cạnh A. Luôn đủ, vì các cạnh bắt buộc là một rừng con trong đồ thị A, và tham lam mở rộng một rừng A luôn tới được cạnh (tính chất tăng cường của matroid).
- Hoàn tất cây bằng cạnh B. Luôn hoàn tất được, vì cạnh bắt buộc cùng mọi cạnh B đã làm đồ thị liên thông.
Ví dụ: 4 đỉnh, cạnh A: , , ; cạnh B: , .
B trước: {1,2,3} {4} -> e0=(1,3) bỏ, e1=(3,4) BẮT BUỘC, e2=(2,4) bỏ => kMin = 1
A trước: e0, e1, e2 đều nhận (1-3-4-2 là cây) => kMax = 3
k = 1: e1 | (không thêm A) | e3=(1,2), e4=(2,3) -> {3-4, 1-2, 2-3}
k = 2: e1 | e0=(1,3) | e3=(1,2) -> {3-4, 1-3, 1-2}
k = 3: e1 | e0, e2 | (không cần B) -> {3-4, 1-3, 2-4}
Cài đặt (dùng lại DSU ở phần Kruskal):
struct TEdge { int u, v; bool isA; };
// Returns {kMin, kMax}; {-1, -1} if the graph is disconnected.
// forced[i] = true if A-edge i is mandatory (bridges two B-components).
pair<int, int> rangeA(int n, const vector<TEdge>& e, vector<bool>& forced) {
int m = e.size();
forced.assign(m, false);
DSU d1(n); // B first, then A
for (auto& x : e) if (!x.isA) d1.unite(x.u, x.v);
int kMin = 0, comps = 0;
for (int i = 0; i < m; i++)
if (e[i].isA && d1.unite(e[i].u, e[i].v)) forced[i] = true, kMin++;
for (int v = 1; v <= n; v++) comps += d1.find(v) == v;
if (comps != 1) return {-1, -1};
DSU d2(n); // A only
int kMax = 0;
for (auto& x : e) if (x.isA && d2.unite(x.u, x.v)) kMax++;
return {kMin, kMax};
}
// Edge ids of a spanning tree with exactly k A-edges (kMin <= k <= kMax).
vector<int> treeWithK(int n, const vector<TEdge>& e, const vector<bool>& forced, int k) {
int m = e.size();
DSU d(n);
vector<int> res;
for (int i = 0; i < m; i++) // 1) forced A-edges
if (forced[i]) d.unite(e[i].u, e[i].v), res.push_back(i);
int cntA = res.size();
for (int i = 0; i < m && cntA < k; i++) // 2) extra A-edges up to k
if (e[i].isA && !forced[i] && d.unite(e[i].u, e[i].v)) res.push_back(i), cntA++;
for (int i = 0; i < m; i++) // 3) finish with B-edges
if (!e[i].isA && d.unite(e[i].u, e[i].v)) res.push_back(i);
return res;
}
Độ phức tạp: ba DSU (d1, d2, d), mỗi DSU duyệt danh sách cạnh một vài lượt, tổng . Khi chi phí là tùy ý (không đơn điệu, không lồi), cứ thử mọi rồi dựng cây một lần cho tốt nhất.
Có trọng số? Nếu cần MST với đúng cạnh A (tổng trọng số nhỏ nhất), đoạn vẫn đúng nhưng cách dựng trên không còn tối ưu; khi đó dùng kỹ thuật cộng một hằng số phạt vào mọi cạnh A rồi tìm kiếm nhị phân trên (Aliens trick; ý tưởng chung nhắc tới ở DP tối ưu). Khi nhiều cạnh A và B hòa trọng số, cần quy ước phá hòa nhất quán (ví dụ ưu tiên cạnh A khi bằng nhau) và dựng lại cây theo yêu cầu; phần này trang kia không trình bày chi tiết.
⚠️ Lỗi thường gặp
- Tràn số tổng trọng số. đỉnh nên cây có cạnh, mỗi cạnh , tổng tới . Dùng
long longchototalvà cho trọng số nếu bình phương khoảng cách ( đã trànint). - Đồ thị không liên thông mà vẫn in tổng. Kruskal vẫn chạy hết và trả về tổng của một rừng. Luôn đếm số cạnh đã lấy (
taken == n - 1) hoặc số đỉnh đã vào cây (Prim), rồi in-1/IMPOSSIBLEtheo đề. Trường hợp : đáp án là , vòng lặp không lấy cạnh nào, điều kiệntaken == 0 == n - 1vẫn đúng. - Binary lifting trên rừng chỉ dựng một cây. Gọi
build(n, 1)một lần thì các đỉnh ngoài thành phần của đỉnh 1 có bảng rỗng, truy vấn trả về giá trị rác. Dựng từ mọi đỉnh chưa thăm, và trả lời trước bằng DSU khifind(u) != find(v). - Maximin quên đổi giá trị khởi tạo. Chuyển
pathMaxsangminmà vẫn đểres = 0(vàmx[root]bằng 0) thì mọi truy vấn trả về 0. Giá trị khởi tạo củaminphải làINT_MAX. - DSU không tối ưu. Thiếu cả nén đường lẫn gộp theo kích thước thì
findcó thể , tổng và TLE. Chỉ bỏ nén đường khi cố ý dùng cấu trúc cây DSU (như cách leo cây trong lời giải một số bài VOI), khi đó bắt buộc giữ gộp theo kích thước để độ sâu . - Nhầm MST với cây đường đi ngắn nhất. MST tối thiểu tổng cạnh của cây, không tối thiểu khoảng cách từ một gốc. Đường đi giữa hai đỉnh trên MST không phải đường ngắn nhất (nó chỉ tối ưu theo nghĩa minimax). Muốn khoảng cách ngắn nhất thì dùng Dijkstra.
- Prim: nhầm khóa của heap. Khóa là trọng số cạnh nối vào cây (
c), không phảidist[u] + cnhư Dijkstra. Viết nhầm vẫn ra một cây khung nhưng sai tổng. - Prim lười quên bỏ bản cũ. Thiếu
if (inTree[u]) continue;thì một đỉnh bị cộng trọng số nhiều lần. - Dùng Kruskal cho đồ thị đầy đủ quá lớn. điểm cho cạnh: vẫn chạy được nhưng tốn bộ nhớ, Prim gọn hơn. điểm thì cả hai đều bất khả thi; phải dùng cấu trúc hình học để giảm số cạnh ứng viên xuống (ví dụ MST Manhattan).
- Kiểm tra cạnh tiềm năng sai dấu hoặc sai thứ tự. Phải dùng cạnh nhẹ hơn hẳn (), không phải ; và khi làm cho mọi cạnh, phải kiểm tra cả nhóm cùng trọng số trước khi hợp bất kỳ cạnh nào trong nhóm. Hợp xen kẽ sẽ đánh dấu sai các cạnh "hòa" là không tiềm năng. Nhớ bỏ chính cạnh đang xét khỏi DSU.
- Maximin dùng nhầm cây. Bài "thông lượng lớn nhất" (cạnh nhỏ nhất trên đường càng lớn càng tốt) cần cây khung lớn nhất và lấy min trên đường; dùng MST thường hoặc lấy max là sai.
- Dựng cây đúng cạnh A mà không lấy cạnh bắt buộc trước. Thêm cạnh A tùy ý rồi mới thêm cạnh B có thể không hoàn tất được cây. Ví dụ ở mục trên với : lấy thay vì cạnh bắt buộc , sau đó cạnh B chỉ nối được , đỉnh 4 bị cô lập. Luôn thêm toàn bộ cạnh bắt buộc trước.
- Đa cạnh, khuyên. Kruskal tự xử lý (khuyên có
find(u) == find(v)nên bị bỏ, đa cạnh chỉ lấy cái nhẹ nhất). Với Prim trên ma trận kề, nhớ giữminkhi đọc nhiều cạnh cùng cặp đỉnh.
Biến thể / Mở rộng khác
- Cây khung nhỏ thứ hai: với mỗi cạnh ngoài MST , thử thay cạnh lớn nhất trên đường cây (tìm bằng
pathMaxở trên); lấy chênh lệch nhỏ nhất. - Cây Kruskal (Kruskal reconstruction tree): mỗi lần hợp hai thành phần, tạo một đỉnh mới làm cha của hai gốc với giá trị bằng trọng số cạnh. Minimax là giá trị tại LCA của ; các đỉnh đi tới được từ chỉ bằng cạnh là một cây con.
- Borůvka: mỗi vòng, mọi thành phần chọn cạnh nhẹ nhất đi ra ngoài; sau vòng là xong. Hữu ích khi đồ thị ẩn (ví dụ trọng số XOR) và có cách tìm "cạnh nhẹ nhất ra ngoài" nhanh.
- Tính chất trao đổi (exchange): từ một cây khung có thể đổi một cạnh lấy một cạnh khác để thu được cây khung khác. Nhờ đó, các đại lượng như "số cạnh loại A trong cây" chạy liên tục trên một đoạn, dùng trong các bài ràng buộc số cạnh mỗi loại (xem mục "Cây khung với đúng cạnh loại A" ở trên).
- DSU có rollback cho MST khi cạnh thay đổi theo thời gian (offline): xem DSU rollback.
Bài tập luyện
Luyện kỹ thuật
- Sửa Chữa Đường (roadrepair) (Nghiệp dư) MST cơ bản nhất: cài Kruskal + DSU, nhớ kiểm tra đồ thị không liên thông và dùng
long longcho tổng. - Tưới Ruộng (waterfield) (Trung cấp) đồ thị đầy đủ điểm với trọng số bình phương khoảng cách, chỉ được dùng cạnh : luyện Prim với hàm trọng số trả về vô cùng cho cạnh bị cấm.
- Giải đấu Siêu Bò (Superbull) (Nâng cao) cần nhận ra một giải đấu loại trực tiếp chính là một cây khung; tìm cây khung lớn nhất trên đồ thị đầy đủ trọng số XOR, dùng Prim .
- Vận Chuyển Phép Thuật (trucktsp) (Nâng cao) bài maximin kinh điển: cây khung lớn nhất rồi trả lời nhiều truy vấn "cạnh nhỏ nhất trên đường cây" bằng binary lifting, chú ý đồ thị có thể không liên thông.
- Cạnh đầu trong cây khung nhỏ nhất (daleks) (Nâng cao) áp dụng trực tiếp tiêu chí cạnh tiềm năng: trọng số lớn nhất để cạnh vẫn thuộc một MST nào đó chính là minimax giữa hai đầu khi bỏ chính cạnh đó.
Đề VOI
- Nâng cấp mạng (voi11_ugnet) (Nâng cao) VOI 2011: thông lượng tốt nhất giữa hai máy là bài maximin, trả lời bằng cạnh nhỏ nhất trên đường đi trong cây khung lớn nhất; chỉ các cáp ngoài cây cần nâng.
- Mạng truyền thông (voi13_comnet) (Nâng cao) VOI 2013. Gợi ý: mỗi giả định chính là câu hỏi "cạnh tiềm năng" với chi phí mới. Chú ý dấu nhỏ hơn hẳn và bỏ chính cạnh .
- Mạng truyền thông (voi14_aznet) (Kỳ cựu) VOI 2014: chi phí chỉ phụ thuộc số kênh công ty A trong cây; nhờ tính chất trao đổi, số đó chạy trên một đoạn liên tục, tìm hai đầu đoạn bằng hai lượt Kruskal ưu tiên loại khác nhau (xem mục Cây khung với đúng cạnh loại A).
- Trò chơi (voi17_bgame) (Nâng cao) VOI 2017: thêm cạnh theo số chuối giảm dần như Kruskal cây khung lớn nhất; mỗi cạnh ngoài cây đóng một chu trình mà mọi cạnh trên đó đều không nhẹ hơn nó, kết hợp với ý thành phần song liên thông cạnh.
- Kết nối internet (voi22_inet) (Kỳ cựu) VOI 2022: các nhóm ban đầu là thành phần khi chỉ giữ cạnh , và dây cáp rẻ nhất là cạnh tiếp theo Kruskal nhận; khó ở chỗ phải dựng MST Manhattan chỉ với cạnh ứng viên.
Bình luận