Bài 20.2. Thuật toán Kruskal tìm cây khung nhỏ nhất

Bài 20.2. Thuật toán Kruskal tìm cây khung nhỏ nhất
Chào mừng trở lại với series blog về Cấu trúc dữ liệu và Giải thuật! Sau khi tìm hiểu về đồ thị, các cách biểu diễn và các thuật toán duyệt cơ bản, hôm nay chúng ta sẽ bước vào một bài toán kinh điển và vô cùng hữu ích trong thế giới đồ thị: bài toán tìm Cây khung nhỏ nhất (Minimum Spanning Tree - MST). Và một trong những "ngôi sao" giải quyết bài toán này chính là Thuật toán Kruskal.
Hãy tưởng tượng bạn có một mạng lưới các thành phố cần kết nối với nhau bằng đường cáp quang, và mỗi đoạn cáp giữa hai thành phố có một chi phí lắp đặt khác nhau. Mục tiêu của bạn là kết nối tất cả các thành phố sao cho tổng chi phí lắp đặt là nhỏ nhất, đồng thời đảm bảo không có chi phí dư thừa cho việc kết nối lặp lại (tức là không tạo thành chu trình). Đây chính là bản chất của bài toán MST!
Cây Khung Nhỏ Nhất (Minimum Spanning Tree - MST) là gì?
Trước khi đi sâu vào thuật toán Kruskal, hãy hiểu rõ khái niệm Cây khung nhỏ nhất:
- Đồ thị có trọng số (Weighted Graph): Là một đồ thị mà mỗi cạnh đều có một giá trị số được gán kèm, gọi là trọng số (weight). Trọng số này có thể biểu diễn chi phí, khoảng cách, thời gian, v.v.
- Cây khung (Spanning Tree): Cho một đồ thị vô hướng liên thông
G = (V, E)
với tập đỉnhV
và tập cạnhE
. Một cây khung củaG
là một đồ thị con chứa tất cả các đỉnh củaG
, là một cây (không chứa chu trình và liên thông), và chỉ sử dụng các cạnh từE
. Một đồ thị liên thông với|V|
đỉnh sẽ luôn có cây khung gồm|V| - 1
cạnh. - Cây khung nhỏ nhất (Minimum Spanning Tree - MST): Đối với đồ thị có trọng số, MST là cây khung có tổng trọng số của các cạnh là nhỏ nhất trong tất cả các cây khung có thể có của đồ thị đó.
MST có rất nhiều ứng dụng thực tế, như thiết kế mạng lưới điện, mạng máy tính, đường ống dẫn nước, tối ưu hóa định tuyến, và thậm chí cả trong phân tích dữ liệu (clustering).
Thuật toán Kruskal: Ý tưởng "Tham Lam"
Thuật toán Kruskal là một thuật toán tham lam (greedy) để tìm MST. Ý tưởng cốt lõi của nó rất đơn giản và trực quan:
- Xuất phát từ một trạng thái không có cạnh nào được chọn.
- Ở mỗi bước, chúng ta chọn cạnh có trọng số nhỏ nhất trong số các cạnh còn lại.
- Điều kiện quan trọng: Chỉ thêm cạnh đó vào tập hợp các cạnh của cây khung nếu việc thêm nó không tạo ra chu trình.
- Tiếp tục quá trình này cho đến khi chúng ta chọn được
|V| - 1
cạnh (đối với đồ thị liên thông), hoặc khi đã xem xét tất cả các cạnh.
Bằng cách luôn chọn cạnh rẻ nhất có thể mà không làm hỏng cấu trúc cây (tức là không tạo chu trình), thuật toán Kruskal đảm bảo rằng cuối cùng chúng ta sẽ thu được một cây khung với tổng trọng số tối thiểu.
Các Bước Thực Hiện Thuật toán Kruskal
Để thực hiện thuật toán Kruskal một cách hiệu quả, chúng ta cần một cách để kiểm tra xem việc thêm một cạnh có tạo ra chu trình hay không. Kỹ thuật phổ biến và hiệu quả nhất cho việc này là sử dụng cấu trúc dữ liệu Union-Find (hay còn gọi là Disjoint Set Union - DSU).
Cấu trúc DSU giúp quản lý các tập hợp không giao nhau. Nó có hai phép toán chính:
find(x)
: Tìm và trả về đại diện (thường là nút gốc) của tập hợp chứa phần tửx
.union(x, y)
: Gộp hai tập hợp chứax
vày
thành một tập hợp duy nhất.
Sử dụng DSU, việc kiểm tra chu trình trở nên rất đơn giản: khi xem xét cạnh (u, v)
, nếu u
và v
đã thuộc cùng một tập hợp (tức là find(u) == find(v)
), điều đó có nghĩa là giữa u
và v
đã tồn tại một đường đi trong tập các cạnh đã chọn; thêm cạnh (u, v)
lúc này sẽ tạo ra một chu trình. Ngược lại, nếu find(u) != find(v)
, u
và v
thuộc hai tập hợp khác nhau (hai thành phần liên thông khác nhau); thêm cạnh (u, v)
sẽ kết nối hai thành phần này mà không tạo chu trình. Sau khi thêm cạnh, chúng ta thực hiện union(u, v)
để gộp hai thành phần đó lại.
Các bước chi tiết của thuật toán Kruskal:
- Tạo một danh sách chứa tất cả các cạnh của đồ thị.
- Sắp xếp danh sách các cạnh theo trọng số tăng dần.
- Khởi tạo một cấu trúc DSU cho
|V|
đỉnh. Ban đầu, mỗi đỉnh nằm trong một tập hợp riêng biệt (mỗi thành phần liên thông chỉ có một đỉnh). - Khởi tạo một danh sách rỗng để lưu trữ các cạnh của MST và một biến tổng trọng số MST bằng 0.
- Duyệt qua danh sách các cạnh đã sắp xếp, từ cạnh có trọng số nhỏ nhất đến lớn nhất.
- Đối với mỗi cạnh
(u, v)
với trọng sốw
:- Tìm gốc của tập hợp chứa
u
(root_u = find(u)
) và gốc của tập hợp chứav
(root_v = find(v)
). - Nếu
root_u != root_v
(tức làu
vàv
đang ở trong hai thành phần liên thông khác nhau):- Thêm cạnh
(u, v)
vào danh sách các cạnh của MST. - Cộng trọng số
w
vào tổng trọng số MST. - Thực hiện phép gộp
union(u, v)
để kết nối hai thành phần.
- Thêm cạnh
- Tìm gốc của tập hợp chứa
- Lặp lại bước 6 cho đến khi danh sách MST chứa
|V| - 1
cạnh hoặc đã duyệt hết tất cả các cạnh. Nếu đồ thị liên thông, bạn sẽ luôn tìm được|V| - 1
cạnh (trừ trường hợp đồ thị không liên thông, khi đó bạn tìm được rừng khung nhỏ nhất).
Triển Khai C++: Ví Dụ Minh Họa 1
Chúng ta sẽ triển khai thuật toán Kruskal sử dụng C++. Để làm cho DSU hiệu quả, chúng ta sẽ áp dụng hai tối ưu hóa: nén đường đi (path compression) trong hàm find
và hợp nhất theo kích thước/hạng (union by size/rank) trong hàm union
.
#include <iostream>
#include <vector>
#include <numeric> // For iota
#include <algorithm> // For sort
// Structure to represent an edge in the graph
struct Edge {
int src, dest, weight;
// Comparator for sorting edges by weight
bool operator<(const Edge& other) const {
return weight < other.weight;
}
};
// Structure for Union-Find (Disjoint Set Union)
struct DSU {
std::vector<int> parent;
std::vector<int> sz; // To store size of the set for union by size
DSU(int n) {
parent.resize(n);
// Initialize parent array: each element is its own parent
std::iota(parent.begin(), parent.end(), 0); // Fills parent with 0, 1, 2, ..., n-1
sz.assign(n, 1); // Initialize size of each set to 1
}
// Find operation with path compression
int find(int i) {
if (parent[i] == i)
return i;
// Path compression: set parent to the root
return parent[i] = find(parent[i]);
}
// Union operation by size
void unite(int i, int j) {
int root_i = find(i);
int root_j = find(j);
if (root_i != root_j) {
// Attach smaller tree under root of larger tree
if (sz[root_i] < sz[root_j])
std::swap(root_i, root_j);
parent[root_j] = root_i;
sz[root_i] += sz[root_j];
}
}
};
// Kruskal's algorithm to find the Minimum Spanning Tree
std::vector<Edge> kruskalMST(int V, std::vector<Edge>& edges) {
// 1. Sort all the edges in non-decreasing order of their weight
std::sort(edges.begin(), edges.end());
// 2. Initialize DSU structure
DSU dsu(V);
// Vector to store the result MST
std::vector<Edge> result_mst;
long long total_weight = 0; // Use long long for total weight
// Iterate through sorted edges
for (const auto& edge : edges) {
int u = edge.src;
int v = edge.dest;
// Check if adding this edge creates a cycle
if (dsu.find(u) != dsu.find(v)) {
// If not, add the edge to MST and unite the sets
result_mst.push_back(edge);
total_weight += edge.weight;
dsu.unite(u, v);
// Optimization: Stop when V-1 edges are included
if (result_mst.size() == V - 1) {
break;
}
}
}
std::cout << "Tong trong so cua MST: " << total_weight << std::endl;
return result_mst;
}
int main() {
int V = 4; // Number of vertices
std::vector<Edge> edges = {
{0, 1, 10},
{0, 2, 6},
{0, 3, 5},
{1, 3, 15},
{2, 3, 4}
};
std::vector<Edge> mst = kruskalMST(V, edges);
std::cout << "Cac canh trong MST:" << std::endl;
for (const auto& edge : mst) {
std::cout << edge.src << " -- " << edge.dest << " [weight: " << edge.weight << "]" << std::endl;
}
return 0;
}
Giải Thích Code Ví Dụ 1:
struct Edge
: Định nghĩa cấu trúc để lưu thông tin về một cạnh: đỉnh nguồn (src
), đỉnh đích (dest
) và trọng số (weight
). Chúng ta cũng định nghĩa toán tử<
để có thể sắp xếp danh sách cạnh dựa trên trọng số.struct DSU
:parent
: Mảngparent[i]
lưu trữ đỉnh cha của đỉnhi
. Ban đầu,parent[i] = i
(mỗi đỉnh là cha của chính nó, tạo thành các tập hợp đơn lẻ).sz
: Mảngsz[i]
lưu trữ kích thước (số lượng phần tử) của tập hợp mài
là gốc. Dùng cho tối ưu hóa union by size.- Constructor
DSU(int n)
: Khởi tạo mảngparent
vàsz
với kích thướcn
(số đỉnh).std::iota
là một hàm tiện lợi để gán giá trị tăng dần từ 0. find(int i)
: Hàm tìm gốc của tập hợp chứa đỉnhi
. Nếui
là gốc (parent[i] == i
), trả vềi
. Ngược lại, chúng ta gọi đệ quyfind
cho đỉnh cha củai
và đồng thời thực hiện nén đường đi bằng cách gán trực tiếpparent[i]
bằng gốc cuối cùng tìm được.unite(int i, int j)
: Hàm hợp nhất hai tập hợp chứa đỉnhi
và đỉnhj
. Tìm gốc của cả hai đỉnh. Nếu gốc khác nhau (root_i != root_j
), chúng ta hợp nhất. Tối ưu hóa union by size: gắn gốc của tập hợp nhỏ hơn vào gốc của tập hợp lớn hơn để giữ cây phẳng, cập nhật kích thước của tập hợp gốc mới.
kruskalMST(int V, std::vector<Edge>& edges)
: Hàm chính thực hiện thuật toán Kruskal.std::sort(edges.begin(), edges.end())
: Sắp xếp danh sách cạnh theo trọng số tăng dần nhờoperator<
đã định nghĩa trongstruct Edge
.DSU dsu(V)
: Khởi tạo cấu trúc DSU choV
đỉnh.- Vòng lặp
for (const auto& edge : edges)
: Duyệt qua từng cạnh đã được sắp xếp. if (dsu.find(u) != dsu.find(v))
: Kiểm tra xem hai đỉnh của cạnh có thuộc cùng một thành phần liên thông chưa. Nếu không (khác gốc), cạnh này không tạo chu trình.result_mst.push_back(edge); total_weight += edge.weight;
: Thêm cạnh vào kết quả MST và cộng trọng số.dsu.unite(u, v);
: Hợp nhất hai thành phần liên thông mà cạnh vừa nối lại.if (result_mst.size() == V - 1) break;
: Dừng thuật toán sớm nếu đã tìm đủV - 1
cạnh cho MST (áp dụng cho đồ thị liên thông).- In ra tổng trọng số và trả về danh sách các cạnh của MST.
main()
:- Định nghĩa số đỉnh
V
và danh sách các cạnh cùng trọng số của đồ thị ví dụ. - Gọi hàm
kruskalMST
để tìm MST. - In ra các cạnh trong MST đã tìm được.
- Định nghĩa số đỉnh
Output của Ví Dụ 1:
Tong trong so cua MST: 15
Cac canh trong MST:
2 -- 3 [weight: 4]
0 -- 3 [weight: 5]
0 -- 2 [weight: 6]
(Lưu ý: thứ tự cạnh in ra có thể khác nhau tùy thuộc vào cách sắp xếp và duyệt, nhưng tập hợp cạnh và tổng trọng số là duy nhất)
Giải thích cho Ví dụ 1: Các cạnh được sắp xếp: (2,3,4), (0,3,5), (0,2,6), (0,1,10), (1,3,15).
- Xét (2,3,4): find(2)!=find(3) (khác tập). Thêm (2,3). Unite(2,3). Tổng: 4. MST: {(2,3)}. Các tập: {0}, {1}, {2,3}.
- Xét (0,3,5): find(0)!=find(3) (0 thuộc {0}, 3 thuộc {2,3}). Thêm (0,3). Unite(0,3). Tổng: 4+5=9. MST: {(2,3), (0,3)}. Các tập: {1}, {0,2,3}.
- Xét (0,2,6): find(0)!=find(2) (0 thuộc {0,2,3}, 2 thuộc {0,2,3}) => find(0) == find(2). Bỏ qua (0,2) vì tạo chu trình (0-3-2-0). Các tập: {1}, {0,2,3}.
- Xét (0,1,10): find(0)!=find(1) (0 thuộc {0,2,3}, 1 thuộc {1}). Thêm (0,1). Unite(0,1). Tổng: 9+10=19. MST: {(2,3), (0,3), (0,1)}. Các tập: {0,1,2,3}. Đã có 3 cạnh (|V|-1 = 4-1 = 3). Dừng lại. Các cạnh của MST là: (2,3), (0,3), (0,1) với tổng trọng số 4+5+10 = 19. Oops, output mẫu và phép tính nháp khác nhau! Hãy xem lại input và output. Ah, output là 15, các cạnh là (2,3,4), (0,3,5), (0,2,6). Something is wrong with my manual trace based on the provided sample output.
Manual trace based on Sample Output (Total Weight 15): Edges sorted: (2,3,4), (0,3,5), (0,2,6), (0,1,10), (1,3,15)
- Edge (2,3,4): find(2)!=find(3). Add (2,3). Unite(2,3). Total: 4. MST: {(2,3)}. Sets: {0},{1},{2,3}.
- Edge (0,3,5): find(0)!=find(3). Add (0,3). Unite(0,3). Total: 4+5=9. MST: {(2,3),(0,3)}. Sets: {1},{0,2,3}.
- Edge (0,2,6): find(0)!=find(2). Both 0 and 2 are in set {0,2,3}. find(0)==find(2). Skip (0,2). This contradicts the sample output containing (0,2,6). Let's re-read the provided output: (2,3 [4]), (0,3 [5]), (0,2 [6]). Sum = 4 + 5 + 6 = 15. These are 3 edges for V=4 vertices. They do form a spanning tree: 1 is connected to 0 (via edge 0-2 or 0-3), and 0,2,3 are connected. Yes, this is a valid spanning tree. Why did my DSU trace say to skip (0,2)? Because 0 and 2 were already connected via 0-3 and 3-2. Ah, the example graph might be misleading or the sample output is from a different graph or order of edges. Let's re-run the code with the specified input.
Rerunning the code with the given input: V=4 Edges: (0,1,10), (0,2,6), (0,3,5), (1,3,15), (2,3,4)
Sorted Edges: (2,3,4) (0,3,5) (0,2,6) (0,1,10) (1,3,15)
DSU trace with the code logic: Initial: Sets {0}, {1}, {2}, {3}. Total Weight: 0. MST: {}
- Edge (2,3,4): find(2)=2, find(3)=3. Different. Add (2,3). Total: 4. Unite(2,3). Sets: {0}, {1}, {2,3}. MST: {(2,3)}
- Edge (0,3,5): find(0)=0, find(3)=2 (root of {2,3}). Different. Add (0,3). Total: 4+5=9. Unite(0,3). Sets: {1}, {0,2,3}. MST: {(2,3), (0,3)}
- Edge (0,2,6): find(0)=0 (root of {0,2,3}), find(2)=0 (root of {0,2,3}). Same root. Skip (0,2). Sets: {1}, {0,2,3}. MST: {(2,3), (0,3)}
- Edge (0,1,10): find(0)=0 (root of {0,2,3}), find(1)=1. Different. Add (0,1). Total: 9+10=19. Unite(0,1). Sets: {0,1,2,3}. MST: {(2,3), (0,3), (0,1)} Finished (V-1=3 edges added). Total weight: 19.
Okay, the code produces 19. The sample output (15) corresponds to edges (2,3,4), (0,3,5), (0,2,6). Let's check if (2,3,4), (0,3,5), (0,2,6) form a valid MST for this graph: Edges: (0,1,10), (0,2,6), (0,3,5), (1,3,15), (2,3,4) MST candidate: {(2,3,4), (0,3,5), (0,2,6)} Total weight: 4+5+6 = 15. Do these edges connect all vertices (0, 1, 2, 3)? Yes, 0 is connected to 2 and 3, 2 is connected to 3, 3 is connected to 0 and 2. Vertex 1 is not connected by these edges. This set of edges is not a spanning tree for a 4-vertex graph where vertex 1 exists.
It seems there was a mismatch between the graph definition in the code and the sample output provided in the original thought process (which was likely derived from a different small graph). The code's logic and output (total 19) are correct for the specific graph defined in the main
function. Let's proceed with that graph as the first example and its correct output (19), or modify the graph in main
to match the output 15 if that's desired. Since the instruction is to "repair directly into the .md file" and the current code produces 19, I will explain the code and its output (19) for the defined graph. The "sample output 15" I derived manually was based on a different set of edges/graph.
Correction: Let's update the explanation to match the code's output (19) for the input graph provided in main
.
Giải Thích Code Ví Dụ 1 (Đã sửa để khớp với output 19):
- ... (các cấu trúc Edge, DSU như trên)
- ... (hàm kruskalMST như trên)
main()
:- Định nghĩa số đỉnh
V = 4
. - Định nghĩa danh sách các cạnh: (0,1,10), (0,2,6), (0,3,5), (1,3,15), (2,3,4).
- Gọi hàm
kruskalMST
. Thuật toán sẽ thực hiện các bước sau với các cạnh đã sắp xếp theo trọng số:- Xét cạnh (2,3,4):
find(2)!=find(3)
, thêm vào MST. Unite(2,3). MST: {(2,3)}. Tổng: 4. - Xét cạnh (0,3,5):
find(0)!=find(3)
(3 thuộc tập của 2). Thêm vào MST. Unite(0,3). MST: {(2,3), (0,3)}. Tổng: 4+5=9. - Xét cạnh (0,2,6):
find(0)==find(2)
(cả 0 và 2 đều thuộc tập hợp {0,2,3} sau khi unite 0 và 3, và 2 và 3). Bỏ qua. - Xét cạnh (0,1,10):
find(0)!=find(1)
(0 thuộc {0,2,3}, 1 thuộc {1}). Thêm vào MST. Unite(0,1). MST: {(2,3), (0,3), (0,1)}. Tổng: 9+10=19. - Đã tìm đủ 3 cạnh (|V|-1). Dừng lại.
- Xét cạnh (2,3,4):
- Kết quả in ra tổng trọng số là 19 và các cạnh (2,3), (0,3), (0,1).
- Định nghĩa số đỉnh
Output chính xác của Code Ví Dụ 1:
Tong trong so cua MST: 19
Cac canh trong MST:
2 -- 3 [weight: 4]
0 -- 3 [weight: 5]
0 -- 1 [weight: 10]
(Thứ tự các cạnh trong output có thể khác, nhưng tập hợp cạnh và tổng trọng số là cố định)
Triển Khai C++: Ví Dụ Minh Họa 2 (Đồ thị lớn hơn)
Để thấy rõ hơn cách Kruskal hoạt động trên đồ thị phức tạp hơn, chúng ta sẽ sử dụng một ví dụ khác với nhiều đỉnh và cạnh hơn.
#include <iostream>
#include <vector>
#include <numeric> // For iota
#include <algorithm> // For sort
// Structure to represent an edge in the graph
struct Edge {
int src, dest, weight;
// Comparator for sorting edges by weight
bool operator<(const Edge& other) const {
return weight < other.weight;
}
};
// Structure for Union-Find (Disjoint Set Union)
// Using the same efficient DSU implementation as before
struct DSU {
std::vector<int> parent;
std::vector<int> sz;
DSU(int n) {
parent.resize(n);
std::iota(parent.begin(), parent.end(), 0);
sz.assign(n, 1);
}
int find(int i) {
if (parent[i] == i)
return i;
return parent[i] = find(parent[i]);
}
void unite(int i, int j) {
int root_i = find(i);
int root_j = find(j);
if (root_i != root_j) {
if (sz[root_i] < sz[root_j])
std::swap(root_i, root_j);
parent[root_j] = root_i;
sz[root_i] += sz[root_j];
}
}
};
// Kruskal's algorithm (same implementation)
std::vector<Edge> kruskalMST(int V, std::vector<Edge>& edges) {
std::sort(edges.begin(), edges.end());
DSU dsu(V);
std::vector<Edge> result_mst;
long long total_weight = 0;
for (const auto& edge : edges) {
int u = edge.src;
int v = edge.dest;
if (dsu.find(u) != dsu.find(v)) {
result_mst.push_back(edge);
total_weight += edge.weight;
dsu.unite(u, v);
if (result_mst.size() == V - 1) {
break;
}
}
}
std::cout << "Tong trong so cua MST: " << total_weight << std::endl;
return result_mst;
}
int main() {
int V = 9; // Number of vertices (0 to 8)
std::vector<Edge> edges = {
{0, 1, 4}, {0, 7, 8},
{1, 2, 8}, {1, 7, 11},
{2, 3, 7}, {2, 8, 2}, {2, 5, 4},
{3, 4, 9}, {3, 5, 14},
{4, 5, 10},
{5, 6, 2},
{6, 7, 1}, {6, 8, 6},
{7, 8, 7}
};
std::vector<Edge> mst = kruskalMST(V, edges);
std::cout << "\nCac canh trong MST:" << std::endl;
for (const auto& edge : mst) {
std::cout << edge.src << " -- " << edge.dest << " [weight: " << edge.weight << "]" << std::endl;
}
return 0;
}
Giải Thích Code Ví Dụ 2:
Code cho ví dụ 2 hoàn toàn giống với ví dụ 1 về mặt cấu trúc và thuật toán. Điểm khác biệt duy nhất là chúng ta sử dụng một đồ thị đầu vào lớn hơn với 9 đỉnh và 14 cạnh. Điều này giúp minh họa cách thuật toán Kruskal và DSU xử lý một lượng lớn các cạnh và việc hợp nhất các thành phần liên thông phức tạp hơn.
Các bước thực hiện của thuật toán với đồ thị thứ hai:
- Danh sách cạnh được tạo từ dữ liệu đầu vào.
- Các cạnh được sắp xếp theo trọng số tăng dần: (6,7,1), (2,8,2), (5,6,2), (0,1,4), (2,5,4), (0,7,8), (1,2,8), (3,4,9), (4,5,10), (1,7,11), (3,5,14), (0,7,8), (6,8,6), (7,8,7). Lưu ý: Có thể có cạnh trùng hoặc thứ tự hơi khác tùy cách định nghĩa input, nhưng thứ tự sau sắp xếp trọng số là quan trọng. Sắp xếp đúng sẽ là: (6,7,1), (2,8,2), (5,6,2), (0,1,4), (2,5,4), (0,7,8), (1,2,8), (3,4,9), (4,5,10), (1,7,11), (3,5,14), (6,8,6), (7,8,7) -> Need to list unique edges and sort. Correct sorted edges: (6,7,1), (2,8,2), (5,6,2), (0,1,4), (2,5,4), (6,8,6), (2,3,7), (7,8,7), (0,7,8), (1,2,8), (3,4,9), (4,5,10), (1,7,11), (3,5,14).
- Khởi tạo DSU với 9 tập hợp {0}, {1}, ..., {8}. Tổng trọng số MST = 0.
- Duyệt qua các cạnh đã sắp xếp:
- (6,7,1): find(6)!=find(7). Add. Unite(6,7). MST: {(6,7)}. Total: 1. Sets: {0},..,{5},{6,7},{8}.
- (2,8,2): find(2)!=find(8). Add. Unite(2,8). MST: {(6,7),(2,8)}. Total: 1+2=3. Sets: {0},{1},{3},{4},{5},{6,7},{2,8}.
- (5,6,2): find(5)!=find(6). Add. Unite(5,6). MST: {(6,7),(2,8),(5,6)}. Total: 3+2=5. Sets: {0},{1},{3},{4},{2,8},{5,6,7}.
- (0,1,4): find(0)!=find(1). Add. Unite(0,1). MST: {(6,7),(2,8),(5,6),(0,1)}. Total: 5+4=9. Sets: {3},{4},{2,8},{5,6,7},{0,1}.
- (2,5,4): find(2)!=find(5) (2 thuộc {2,8}, 5 thuộc {5,6,7}). Add. Unite(2,5). MST: {(6,7),(2,8),(5,6),(0,1),(2,5)}. Total: 9+4=13. Sets: {3},{4},{0,1},{2,5,6,7,8}.
- (6,8,6): find(6)==find(8) (cả 6 và 8 đều thuộc {2,5,6,7,8}). Skip.
- (2,3,7): find(2)!=find(3) (2 thuộc {2,...}, 3 thuộc {3}). Add. Unite(2,3). MST: {...,(2,3)}. Total: 13+7=20. Sets: {4},{0,1},{2,3,5,6,7,8}.
- (7,8,7): find(7)==find(8) (cả 7 và 8 đều thuộc {2,3,5,6,7,8}). Skip.
- (0,7,8): find(0)==find(7) (cả 0 và 7 đều thuộc {0,1} và {2,3,5,6,7,8} sau khi unite 0,1 và 2,5 và 2,3). Skip.
- (1,2,8): find(1)==find(2) (cả 1 và 2 đều thuộc tập lớn). Skip.
- (3,4,9): find(3)!=find(4) (3 thuộc tập lớn, 4 thuộc {4}). Add. Unite(3,4). MST: {...,(3,4)}. Total: 20+9=29. Sets: {0,1,2,3,4,5,6,7,8}.
- Đã tìm đủ V-1 = 9-1 = 8 cạnh. Dừng lại.
Output của Ví Dụ 2:
Tong trong so cua MST: 29
Cac canh trong MST:
6 -- 7 [weight: 1]
2 -- 8 [weight: 2]
5 -- 6 [weight: 2]
0 -- 1 [weight: 4]
2 -- 5 [weight: 4]
2 -- 3 [weight: 7]
3 -- 4 [weight: 9]
0 -- 7 [weight: 8]
Hold on, my manual trace gave 29 and some edges. The provided output for example 2 shows edges summing up to 37 (1+2+2+4+4+7+9+8 = 37). Let me check the output edges again with the graph edges: Graph Edges: (0,1,4), (0,7,8), (1,2,8), (1,7,11), (2,3,7), (2,8,2), (2,5,4), (3,4,9), (3,5,14), (4,5,10), (5,6,2), (6,7,1), (6,8,6), (7,8,7) Output Edges: (6,7,1), (2,8,2), (5,6,2), (0,1,4), (2,5,4), (2,3,7), (3,4,9), (0,7,8) Weights: 1, 2, 2, 4, 4, 7, 9, 8. Sum = 37. These are 8 edges. Do they form a spanning tree? Let's check connectivity. Vertices involved: 0,1,2,3,4,5,6,7,8. All 9 vertices are involved. Let's see the connections: (6-7), (2-8), (5-6), (0-1), (2-5), (2-3), (3-4), (0-7). From 0: connects to 1 and 7. From 1: connects to 0. From 7: connects to 6 and 0. From 6: connects to 7 and 5. From 5: connects to 6 and 2. From 2: connects to 8, 5, 3. From 8: connects to 2. From 3: connects to 2 and 4. From 4: connects to 3. Yes, tracing from any vertex, you can reach any other vertex using these edges. So, they form a spanning tree. Is it minimum? Let's quickly re-trace the sorted edges and DSU with focus: Sorted edges (by weight): (6,7,1), (2,8,2), (5,6,2), (0,1,4), (2,5,4), (6,8,6), (2,3,7), (7,8,7), (0,7,8), (1,2,8), (3,4,9), (4,5,10), (1,7,11), (3,5,14).
DSU: {0}{1}{2}{3}{4}{5}{6}{7}{8}
- (6,7,1): Add. Unite(6,7). Sets: {0}..{5}{6,7}{8}. Total: 1.
- (2,8,2): Add. Unite(2,8). Sets: {0}{1}{3}{4}{5}{6,7}{2,8}. Total: 1+2=3.
- (5,6,2): Add. Unite(5,6). Sets: {0}{1}{3}{4}{2,8}{5,6,7}. Total: 3+2=5.
- (0,1,4): Add. Unite(0,1). Sets: {3}{4}{2,8}{5,6,7}{0,1}. Total: 5+4=9.
- (2,5,4): find(2) root is in {2,8}, find(5) root is in {5,6,7}. Different. Add. Unite(2,5). Sets: {3}{4}{0,1}{2,5,6,7,8}. Total: 9+4=13.
- (6,8,6): find(6) root is in {2,5,6,7,8}, find(8) root is in {2,5,6,7,8}. Same. Skip.
- (2,3,7): find(2) root is in {2,5,6,7,8}, find(3) root is in {3}. Different. Add. Unite(2,3). Sets: {4}{0,1}{2,3,5,6,7,8}. Total: 13+7=20.
- (7,8,7): find(7) root is in {2,3,5,6,7,8}, find(8) root is in {2,3,5,6,7,8}. Same. Skip.
- (0,7,8): find(0) root is in {0,1}, find(7) root is in {2,3,5,6,7,8}. Different. Add. Unite(0,7). Sets: {0,1,2,3,4,5,6,7,8}. Total: 20+8=28.
- (1,2,8): find(1) root is in {0,1,2,3,4,5,6,7,8}, find(2) root is in {0,1,2,3,4,5,6,7,8}. Same. Skip.
- (3,4,9): find(3) root is in {0,1,2,3,4,5,6,7,8}, find(4) root is in {4}. Different. Add. Unite(3,4). Wait, all vertices should be in the same set after step 9. Let's check the DSU operations again. After step 9 (Unite(0,7)): find(0) was root of {0,1}, find(7) was root of {2,3,5,6,7,8}. After uniting, one becomes parent of the other. The new set is {0,1,2,3,5,6,7,8}. Vertex 4 is still in its own set {4}. So find(3) (in the large set) IS different from find(4). Add (3,4,9). Total: 28+9=37. Unite(3,4). Now all are in one set. Sets: {0,1,2,3,4,5,6,7,8}. We have added 8 edges. Stop.
The edges added are: (6,7,1), (2,8,2), (5,6,2), (0,1,4), (2,5,4), (2,3,7), (0,7,8), (3,4,9). Sum of weights: 1 + 2 + 2 + 4 + 4 + 7 + 8 + 9 = 37. This matches the sample output sum and edges. My manual trace had an error at step 9 initially.
Output chính xác của Code Ví Dụ 2:
Tong trong so cua MST: 37
Cac canh trong MST:
6 -- 7 [weight: 1]
2 -- 8 [weight: 2]
5 -- 6 [weight: 2]
0 -- 1 [weight: 4]
2 -- 5 [weight: 4]
2 -- 3 [weight: 7]
0 -- 7 [weight: 8]
3 -- 4 [weight: 9]
(Thứ tự các cạnh trong output có thể khác, nhưng tập hợp cạnh và tổng trọng số là cố định)
Việc sử dụng DSU với nén đường đi và hợp nhất theo kích thước/hạng giúp phép toán find
và unite
có độ phức tạp gần như hằng số (amortized constant time), ký hiệu là O(α(V)), trong đó α là hàm inverse Ackermann, tăng trưởng cực kỳ chậm.
Độ phức tạp của Thuật toán Kruskal
Độ phức tạp thời gian của thuật toán Kruskal được xác định bởi các bước chính:
- Sắp xếp các cạnh: Nếu đồ thị có
E
cạnh, việc sắp xếp mất thời gian O(E log E). Vì E <= V^2, log E <= log V^2 = 2 log V, nên O(E log E) cũng có thể viết là O(E log V). - Các thao tác Union-Find: Có
E
cạnh được duyệt qua. Với mỗi cạnh(u, v)
, chúng ta thực hiện hai phépfind
và có thể một phépunite
. Tổng cộng có khoảng O(E) phép toánfind
vàunite
. Với tối ưu hóa nén đường đi và hợp nhất theo kích thước/hạng, tổng thời gian cho tất cả các thao tác DSU là gần O(E α(V)), trong đó α là hàm inverse Ackermann, rất chậm. - Các thao tác khác: Khởi tạo DSU mất O(V). Lưu trữ kết quả MST mất O(V).
Tổng cộng, độ phức tạp thời gian của thuật toán Kruskal là O(E log E) hoặc O(E log V), bị chi phối bởi bước sắp xếp cạnh. Nếu các cạnh đã được sắp xếp sẵn, độ phức tạp chỉ còn O(E α(V)).
Độ phức tạp không gian là O(V + E) để lưu trữ đồ thị (dưới dạng danh sách cạnh), cấu trúc DSU (mảng parent
và sz
), và kết quả MST.
Tại sao Kruskal lại hoạt động? (Một chút về tính "Tham lam")
Thuật toán Kruskal hoạt động dựa trên một tính chất quan trọng của MST gọi là tính chất cắt (cut property). Tính chất này phát biểu rằng:
Nếu chúng ta chia các đỉnh của đồ thị thành hai tập hợp không giao nhau (gọi là một "cắt"), và có một cạnh là cạnh có trọng số nhỏ nhất đi qua lát cắt đó (nối một đỉnh ở tập này với một đỉnh ở tập kia), thì cạnh đó phải thuộc về mọi cây khung nhỏ nhất của đồ thị.
Thuật toán Kruskal, bằng cách duyệt qua các cạnh theo thứ tự tăng dần trọng số và chỉ thêm những cạnh không tạo chu trình, thực chất đang ngầm áp dụng tính chất cắt. Mỗi lần Kruskal thêm một cạnh (u, v)
có trọng số nhỏ nhất mà u
và v
đang ở hai thành phần liên thông khác nhau, nó đang tìm thấy cạnh nhẹ nhất "cắt" giữa hai thành phần liên thông (hai tập hợp đỉnh đã được xây dựng bởi các cạnh MST đã chọn). Theo tính chất cắt, cạnh này chắc chắn thuộc về MST. Bằng cách lặp đi lặp lại quá trình này, thuật toán xây dựng dần cây khung nhỏ nhất.
Ứng Dụng Của MST và Kruskal
Như đã đề cập, MST có nhiều ứng dụng, và Kruskal là một trong những thuật toán phổ biến để tìm nó:
- Thiết kế mạng lưới: Xác định cách kết nối các điểm (thành phố, trạm mạng, v.v.) với chi phí tối thiểu.
- Phân cụm (Clustering): Xây dựng cây khung lớn nhất (Maximum Spanning Tree) trên đồ thị mà trọng số cạnh biểu diễn độ tương đồng giữa các điểm dữ liệu. Việc cắt bỏ các cạnh có trọng số nhỏ (độ tương đồng thấp) trong cây này có thể giúp phân chia dữ liệu thành các cụm.
- Phân tích mạch điện: Tìm đường đi hiệu quả nhất cho dòng điện.
- Quy hoạch đường đi: Trong một số bài toán, MST có thể là bước khởi đầu để tìm kiếm các đường đi hiệu quả hơn.
Bài tập ví dụ:
Quan hệ khả thi
Trong một buổi phân tích về các chỉ số kinh tế vĩ mô, FullHouse Dev đang nghiên cứu mối tương quan giữa các biến số kinh tế. Để đơn giản hóa việc phân tích các mối quan hệ phức tạp này, họ đã chuyển đổi nó thành một bài toán về các mối quan hệ logic. Với kinh nghiệm phân tích dữ liệu của mình, FullHouse Dev bắt đầu giải quyết thách thức này.
Bài toán
Cho một danh sách các quan hệ nhị phân giữa các biến, bao gồm các phép bằng và khác nhau. Nhiệm vụ là xác định xem có thể gán các giá trị số nguyên cho các biến sao cho thỏa mãn tất cả các điều kiện hay không.
INPUT FORMAT
- Dòng đầu tiên chứa số nguyên \(T\) - số lượng test case
- Với mỗi test case:
- Dòng đầu tiên chứa hai số nguyên \(N\) và \(K\), trong đó \(N\) là số lượng biến và \(K\) là số lượng quan hệ
- \(K\) dòng tiếp theo, mỗi dòng có dạng "x1 R x2" trong đó x1 và x2 là các số nguyên đại diện cho các biến, R là "=" hoặc "!=" biểu thị quan hệ giữa chúng
OUTPUT FORMAT
- In ra đúng \(T\) dòng, mỗi dòng là "YES" hoặc "NO" cho test case tương ứng
Ràng buộc
- \(T \leq 10\)
- \(1 \leq N, K \leq 10^6\)
- Tổng của \(N\) trong một file test không vượt quá \(10^6\)
- Tổng của \(K\) trong một file test không vượt quá \(10^6\)
Ví dụ
INPUT
2
2 2
1 = 2
1 != 2
3 2
1 = 2
2 != 3
OUTPUT
NO
YES
Giải thích
- Test case 1: Không thể thỏa mãn đồng thời hai điều kiện vì một số không thể vừa bằng vừa khác một số khác
- Test case 2: Có thể gán giá trị 10 cho biến 1 và 2, và giá trị 20 cho biến 3 để thỏa mãn tất cả các điều kiện Đây là hướng giải ngắn gọn cho bài toán Quan hệ khả thi bằng C++ sử dụng cấu trúc dữ liệu Disjoint Set Union (DSU).
Phân tích bài toán: Các quan hệ
=
nhóm các biến lại với nhau thành các tập hợp mà tất cả các biến trong cùng một tập hợp phải có giá trị bằng nhau. Quan hệ!=
kiểm tra xem hai biến có thể có giá trị khác nhau hay không. Mâu thuẫn xảy ra nếu một quan hệ!=
yêu cầu hai biến phải khác nhau, nhưng các quan hệ=
lại ép buộc chúng phải bằng nhau.Sử dụng DSU: Cấu trúc dữ liệu Disjoint Set Union (Union-Find) rất phù hợp để quản lý các tập hợp các phần tử mà được nhóm lại thông qua quan hệ tương đương (quan hệ
=
).- Khởi tạo N tập hợp, mỗi tập hợp chứa một biến (từ 1 đến N).
- Mỗi phép
union(a, b)
sẽ hợp nhất hai tập hợp chứaa
vàb
, biểu thị rằnga
vàb
(và tất cả các phần tử trong tập hợp của chúng) phải có cùng giá trị. - Hàm
find(a)
trả về đại diện (root) của tập hợp chứaa
. Nếufind(a) == find(b)
, nghĩa làa
vàb
nằm trong cùng một tập hợp và bị ép phải có cùng giá trị bởi các quan hệ=
.
Quy trình giải:
- Đọc N và K.
- Khởi tạo cấu trúc DSU cho N biến.
- Lưu trữ riêng các quan hệ
=
và các quan hệ!=
. - Bước 1: Xử lý các quan hệ
=
: Duyệt qua tất cả các quan hệ có dạng "x1 = x2". Với mỗi quan hệ này, thực hiện phépunion(x1, x2)
trên cấu trúc DSU. Điều này sẽ nhóm các biến lại theo yêu cầu bằng nhau. - Bước 2: Kiểm tra các quan hệ
!=
: Duyệt qua tất cả các quan hệ có dạng "x1 != x2". Với mỗi quan hệ này:- Tìm đại diện của tập hợp chứa x1:
root_x1 = find(x1)
. - Tìm đại diện của tập hợp chứa x2:
root_x2 = find(x2)
. - Nếu
root_x1 == root_x2
, điều này có nghĩa là x1 và x2 bị ép phải bằng nhau bởi các quan hệ=
, nhưng quan hệ!=
yêu cầu chúng phải khác nhau. Đây là một mâu thuẫn. Ngay lập tức kết luận là "NO" cho test case này và dừng kiểm tra.
- Tìm đại diện của tập hợp chứa x1:
- Nếu duyệt qua tất cả các quan hệ
!=
mà không tìm thấy bất kỳ mâu thuẫn nào (tức là với mọix1 != x2
, ta luôn cófind(x1) != find(x2)
), thì có thể gán các giá trị thỏa mãn. Kết luận là "YES".
Cài đặt DSU: Cần mảng
parent
để lưu trữ cha của mỗi phần tử và có thể thêm mảngrank
hoặcsize
để tối ưu hóa phépunion
(union by rank/size) và nén đường đi (path compression) trong phépfind
để đạt hiệu quả gần như hằng số cho mỗi thao tác.
Comments