Bài 18.3: Các bài toán sử dụng Set trong C++

Bài 18.3: Các bài toán sử dụng Set trong C++
Chào mừng các bạn quay trở lại với chuỗi bài viết về C++ của FullhouseDev!
Trong thế giới lập trình, chúng ta thường xuyên gặp phải các bài toán cần xử lý một tập hợp các phần tử mà có hai yêu cầu quan trọng:
- Các phần tử phải là duy nhất - không có bất kỳ phần tử nào bị lặp lại.
- Các phần tử cần được sắp xếp theo một thứ tự nào đó.
Nghe có vẻ quen thuộc phải không? Đó chính là khái niệm của một tập hợp trong toán học! Và may mắn thay, Thư viện Chuẩn C++ (Standard Library) đã cung cấp cho chúng ta một công cụ cực kỳ mạnh mẽ để xử lý những trường hợp này: đó chính là container set
.
Bài viết này sẽ đi sâu vào cách set
hoạt động, tại sao nó lại là lựa chọn tuyệt vời cho các bài toán tập hợp, và quan trọng hơn hết, chúng ta sẽ cùng nhau giải quyết một số bài toán thực tế bằng cách vận dụng sức mạnh của set
.
set
là gì? Tại sao nó lại hữu ích?
set
là một container liên kết (associative container) trong C++. Điều đặc biệt về set
là:
- Chứa các phần tử duy nhất: Khi bạn cố gắng thêm một phần tử đã tồn tại vào
set
, thao tác thêm sẽ bị bỏ qua.set
đảm bảo rằng mỗi giá trị chỉ xuất hiện một lần. - Các phần tử luôn được sắp xếp:
set
duy trì các phần tử của nó theo thứ tự được sắp xếp (mặc định là tăng dần). Việc sắp xếp này được tự động thực hiện mỗi khi bạn thêm hoặc xóa phần tử. - Hiệu quả cho các thao tác tập hợp: Nhờ cấu trúc dữ liệu bên dưới (thường là cây tìm kiếm nhị phân cân bằng, ví dụ như cây Đỏ-Đen), các thao tác như thêm, xóa, và tìm kiếm một phần tử trong
set
có độ phức tạp thời gian là O(log N), trong đó N là số lượng phần tử trong set. Điều này cực kỳ hiệu quả so với việc tìm kiếm tuyến tính trong mảng hay vector (O(N)).
Với những đặc điểm này, set
trở thành công cụ lý tưởng cho các bài toán yêu cầu quản lý dữ liệu duy nhất và có thứ tự.
Các Thao Tác Cơ Bản với set
Trước khi đi vào các bài toán, hãy cùng điểm qua một vài thao tác cơ bản nhất với set
. Để sử dụng set
, bạn cần bao gồm header <set>
.
#include <iostream>
#include <set>
int main() {
// 1. Khai báo một set chứa các số nguyên
set<int> mySet;
// 2. Thêm phần tử vào set
mySet.insert(10);
mySet.insert(5);
mySet.insert(20);
mySet.insert(5); // Phần tử này sẽ bị bỏ qua vì đã tồn tại
// 3. Kích thước của set
cout << "Kich thuoc cua set: " << mySet.size() << endl; // Output: 3
// 4. Duyệt qua các phần tử (Chúng sẽ được sắp xếp!)
cout << "Cac phan tu trong set: ";
for (int element : mySet) {
cout << element << " "; // Output: 5 10 20
}
cout << endl;
// 5. Tìm kiếm một phần tử
int valueToFind = 10;
auto it = mySet.find(valueToFind); // find tra ve iterator hoac mySet.end() neu khong tim thay
if (it != mySet.end()) {
cout << valueToFind << " co ton tai trong set." << endl;
} else {
cout << valueToFind << " khong ton tai trong set." << endl;
}
int anotherValue = 15;
if (mySet.count(anotherValue)) { // count() tra ve 1 neu ton tai, 0 neu khong
cout << anotherValue << " co ton tai trong set (dung count)." << endl;
} else {
cout << anotherValue << " khong ton tai trong set (dung count)." << endl;
}
// 6. Xóa một phần tử
mySet.erase(10);
cout << "Kich thuoc cua set sau khi xoa 10: " << mySet.size() << endl; // Output: 2
// Duyet lai de kiem tra
cout << "Cac phan tu trong set sau khi xoa: ";
for (int element : mySet) {
cout << element << " "; // Output: 5 20
}
cout << endl;
return 0;
}
Giải thích code:
Đoạn code trên minh họa các thao tác cơ bản: khai báo set<int>
, thêm các số nguyên vào đó (chú ý số 5 được thêm hai lần nhưng chỉ xuất hiện một lần), lấy kích thước (size()
), duyệt các phần tử (luôn theo thứ tự tăng dần), tìm kiếm một giá trị cụ thể (find()
hoặc count()
), và xóa một phần tử (erase()
).
Áp Dụng set
vào Bài Toán Thực Tế
Giờ là lúc vận dụng set
để giải quyết một số bài toán phổ biến.
Ví dụ 1: Đếm số lượng phần tử duy nhất trong một mảng/vector.
Bài toán: Cho một tập hợp các số (có thể có số lặp lại) dưới dạng mảng hoặc vector. Hãy tìm số lượng các phần tử duy nhất trong tập hợp đó.
Tại sao dùng set
?: Đây là bài toán kinh điển cho set
. Bằng cách đơn giản là đưa tất cả các phần tử từ mảng/vector vào một set
, set
sẽ tự động loại bỏ các phần tử trùng lặp. Kích thước cuối cùng của set
chính là số lượng phần tử duy nhất.
#include <iostream>
#include <vector>
#include <set>
int main() {
vector<int> numbers = {1, 2, 5, 3, 2, 4, 1, 5, 5, 3, 10, 8, 8, 12};
// Khai bao mot set
set<int> uniqueNumbers;
// Them tat ca cac phan tu tu vector vao set
for (int num : numbers) {
uniqueNumbers.insert(num);
}
// Alternatively, initialize set directly from the vector range:
// set<int> uniqueNumbers(numbers.begin(), numbers.end());
// Kich thuoc cua set chinh la so luong phan tu duy nhat
cout << "Vector ban dau: ";
for(int num : numbers) cout << num << " ";
cout << endl;
cout << "So luong phan tu duy nhat: " << uniqueNumbers.size() << endl;
// Bonus: In ra cac phan tu duy nhat (chung da duoc sap xep)
cout << "Cac phan tu duy nhat (da sap xep): ";
for (int num : uniqueNumbers) {
cout << num << " ";
}
cout << endl;
return 0;
}
Giải thích code:
Chúng ta tạo một vector numbers
với các số có thể lặp lại. Sau đó, tạo một set<int>
có tên uniqueNumbers
. Vòng lặp for
duyệt qua vector và thêm từng phần tử vào set. Do tính chất của set
, các số lặp lại sẽ không được thêm vào lần thứ hai. Cuối cùng, uniqueNumbers.size()
cho chúng ta số lượng phần tử duy nhất. Bạn cũng thấy các phần tử khi in ra từ set đã được tự động sắp xếp. Cách khởi tạo set trực tiếp từ range của vector (set<int> uniqueNumbers(numbers.begin(), numbers.end());
) là một cách viết gọn và hiệu quả hơn.
Ví dụ 2: Kiểm tra sự tồn tại của một phần tử một cách hiệu quả.
Bài toán: Cho một tập dữ liệu lớn và một giá trị cụ thể. Hãy kiểm tra xem giá trị đó có tồn tại trong tập dữ liệu hay không một cách nhanh chóng.
Tại sao dùng set
?: Nếu bạn lưu trữ tập dữ liệu trong một vector
hoặc mảng thông thường và thực hiện tìm kiếm tuyến tính, độ phức tạp sẽ là O(N) (trong trường hợp xấu nhất). Tuy nhiên, nếu bạn lưu trữ dữ liệu trong set
, thao tác tìm kiếm (find()
hoặc count()
) chỉ mất O(log N). Điều này tạo ra sự khác biệt lớn khi làm việc với tập dữ liệu có kích thước hàng ngàn, hàng triệu phần tử.
#include <iostream>
#include <vector>
#include <set>
#include <chrono> // De do thoi gian
int main() {
// Tao mot tap du lieu lon
vector<int> largeData;
for (int i = 0; i < 100000; ++i) {
largeData.push_back(i * 2); // Cac so chan
}
// Them vai so le
largeData.push_back(199999);
largeData.push_back(55555);
// Chuyen du lieu vao set de tim kiem nhanh
set<int> dataSet(largeData.begin(), largeData.end());
int valueToFind1 = 50000; // Co trong tap (so chan)
int valueToFind2 = 50001; // Khong co trong tap (so le khac)
int valueToFind3 = 199999; // Co trong tap (so le da them)
// Kiem tra su ton tai dung set (O(log N))
auto start_set = chrono::high_resolution_clock::now();
if (dataSet.count(valueToFind1)) {
cout << valueToFind1 << " co ton tai trong set." << endl;
}
if (!dataSet.count(valueToFind2)) {
cout << valueToFind2 << " khong ton tai trong set." << endl;
}
if (dataSet.count(valueToFind3)) {
cout << valueToFind3 << " co ton tai trong set." << endl;
}
auto end_set = chrono::high_resolution_clock::now();
chrono::duration<double> elapsed_set = end_set - start_set;
cout << "Thoi gian tim kiem dung set: " << elapsed_set.count() << " giay." << endl;
// So sanh voi tim kiem tuyen tinh trong vector (O(N))
// (Can include <algorithm>)
/*
auto start_vector = chrono::high_resolution_clock::now();
bool found1 = false;
for(int val : largeData) { if(val == valueToFind1) { found1 = true; break; } }
if (found1) { cout << valueToFind1 << " co ton tai trong vector (tim tuyen tinh)." << endl; }
// ... lam tuong tu cho cac gia tri khac ...
auto end_vector = chrono::high_resolution_clock::now();
chrono::duration<double> elapsed_vector = end_vector - start_vector;
cout << "Thoi gian tim kiem dung vector (tuyen tinh): " << elapsed_vector.count() << " giay." << endl;
*/
// Code tim kiem vector de so sanh nhanh se chay cham hon dang ke voi du lieu lon
return 0;
}
Giải thích code:
Chúng ta tạo một vector largeData
với 100,000 phần tử. Việc chuyển nó sang set
(dataSet
) mất một ít thời gian ban đầu (O(N log N)), nhưng sau đó, việc kiểm tra sự tồn tại của các giá trị (dataSet.count(...)
) được thực hiện cực kỳ nhanh chóng với độ phức tạp O(log N). Với dữ liệu lớn, sự khác biệt về thời gian thực thi giữa tìm kiếm O(log N) và O(N) là rất đáng kể.
Ví dụ 3: Tìm hợp (Union) của hai tập hợp.
Bài toán: Cho hai tập hợp A và B. Tìm tập hợp chứa tất cả các phần tử có trong A hoặc B (hoặc cả hai), mà không có phần tử nào bị lặp lại.
Tại sao dùng set
?: set
tự nhiên biểu diễn một tập hợp toán học. Để tìm hợp của hai tập hợp, bạn chỉ cần đưa tất cả các phần tử từ cả hai tập hợp vào một set
mới. set
sẽ tự động loại bỏ các phần tử trùng lặp, và kết quả chính là tập hợp hợp.
#include <iostream>
#include <vector>
#include <set>
#include <algorithm> // Can thiet neu dung set_union
int main() {
vector<int> vec1 = {1, 3, 5, 7, 9, 11};
vector<int> vec2 = {2, 3, 4, 5, 6, 7, 8};
// Cách 1: Dùng set de gom tat ca phan tu
set<int> unionSet;
// Them tat ca phan tu tu vec1
for (int num : vec1) {
unionSet.insert(num);
}
// Them tat ca phan tu tu vec2 (set tu loai bo trung lap)
for (int num : vec2) {
unionSet.insert(num);
}
cout << "Vector 1: "; for(int n : vec1) cout << n << " "; cout << endl;
cout << "Vector 2: "; for(int n : vec2) cout << n << " "; cout << endl;
cout << "Hop cua hai tap hop (dung set insert): ";
for (int num : unionSet) {
cout << num << " "; // Output: 1 2 3 4 5 6 7 8 9 11 (da sap xep)
}
cout << endl;
// Cách 2: Dung thuat toan set_union (yeu cau du lieu dau vao phai duoc sap xep)
// Chuyen vector sang set truoc de dam bao sap xep
set<int> set1(vec1.begin(), vec1.end());
set<int> set2(vec2.begin(), vec2.end());
set<int> unionSetAlt;
// set_union ghi ket qua vao iterator dau ra
set_union(set1.begin(), set1.end(),
set2.begin(), set2.end(),
inserter(unionSetAlt, unionSetAlt.begin())); // Su dung inserter de them vao set
cout << "Hop (dung set_union): ";
for (int num : unionSetAlt) {
cout << num << " "; // Output: 1 2 3 4 5 6 7 8 9 11
}
cout << endl;
return 0;
}
Giải thích code:
Cách đơn giản nhất là tạo một set mới và thêm tất cả phần tử từ cả hai vector vào đó. Set tự động xử lý việc trùng lặp và sắp xếp. set_union
là một thuật toán trong <algorithm>
có thể thực hiện phép hợp trên hai dãy đã được sắp xếp. Vì set
luôn giữ các phần tử đã sắp xếp, chúng ta có thể dễ dàng dùng iterator của set với set_union
. inserter
là một adapter iterator giúp thuật toán set_union
thêm các phần tử vào set kết quả thay vì ghi đè lên một vùng nhớ có sẵn.
Ví dụ 4: Tìm giao (Intersection) của hai tập hợp.
Bài toán: Cho hai tập hợp A và B. Tìm tập hợp chứa tất cả các phần tử chỉ có trong cả hai tập hợp A và B.
Tại sao dùng set
?: Tương tự phép hợp, chúng ta có thể tận dụng khả năng tìm kiếm nhanh của set
. Chuyển một trong hai tập hợp sang dạng set
để việc tìm kiếm hiệu quả (O(log N)). Sau đó, duyệt qua tập hợp còn lại và kiểm tra xem mỗi phần tử có tồn tại trong set kia không. Nếu có, đó là phần tử thuộc giao, và chúng ta thêm nó vào một set kết quả.
#include <iostream>
#include <vector>
#include <set>
#include <algorithm> // Can thiet neu dung set_intersection
int main() {
vector<int> vec1 = {1, 3, 5, 7, 9};
vector<int> vec2 = {3, 6, 9, 12, 15};
// Cách 1: Dung set de tim kiem hieu qua
set<int> set1(vec1.begin(), vec1.end()); // Chuyen vec1 sang set de tim kiem nhanh O(log N)
set<int> intersectionSet; // Set ket qua
cout << "Vector 1: "; for(int n : vec1) cout << n << " "; cout << endl;
cout << "Vector 2: "; for(int n : vec2) cout << n << " "; cout << endl;
// Duyet qua vec2, kiem tra tung phan tu xem co trong set1 khong
for (int num : vec2) {
if (set1.count(num)) { // set1.count(num) tra ve 1 neu num co trong set1, 0 neu khong
intersectionSet.insert(num); // Neu co, them vao set giao (set tu loai bo trung lap)
}
}
cout << "Giao cua hai tap hop (dung set count): ";
for (int num : intersectionSet) {
cout << num << " "; // Output: 3 9 (da sap xep)
}
cout << endl;
// Cách 2: Dung thuat toan set_intersection (yeu cau du lieu dau vao phai duoc sap xep)
// Chuyen vector sang set truoc de dam bao sap xep
set<int> set2(vec2.begin(), vec2.end());
set<int> intersectionSetAlt;
// set_intersection ghi ket qua vao iterator dau ra
set_intersection(set1.begin(), set1.end(), // set1 da sap xep
set2.begin(), set2.end(), // set2 da sap xep
inserter(intersectionSetAlt, intersectionSetAlt.begin())); // Them vao set ket qua
cout << "Giao (dung set_intersection): ";
for (int num : intersectionSetAlt) {
cout << num << " "; // Output: 3 9
}
cout << endl;
return 0;
}
Giải thích code:
Chúng ta chuyển vec1
thành set1
để có thể tìm kiếm nhanh. Sau đó, duyệt qua vec2
. Đối với mỗi phần tử trong vec2
, chúng ta dùng set1.count(num)
để kiểm tra xem nó có tồn tại trong set1
không. Nếu có, chúng ta biết rằng phần tử này có mặt trong cả hai tập hợp, nên thêm nó vào intersectionSet
. intersectionSet
cũng là một set
nên nó tự động đảm bảo các phần tử giao là duy nhất và được sắp xếp. Tương tự phép hợp, bạn có thể sử dụng thuật toán set_intersection
trong <algorithm>
với các range đã được sắp xếp.
Ví dụ 5: Sắp xếp và loại bỏ trùng lặp một dãy.
Bài toán: Cho một dãy các phần tử. Cần thu được một dãy mới chỉ chứa các phần tử duy nhất từ dãy ban đầu, và các phần tử này phải được sắp xếp.
Tại sao dùng set
?: Đây chính xác là những gì set
làm! Khi bạn đưa các phần tử vào set
, nó sẽ tự động sắp xếp chúng và chỉ giữ lại các bản sao duy nhất.
#include <iostream>
#include <vector>
#include <set>
#include <algorithm> // Neu muon copy tu set sang vector
int main() {
vector<int> data = {10, 4, 8, 4, 12, 6, 8, 10, 2, 10, 6};
cout << "Vector ban dau: ";
for(int n : data) cout << n << " ";
cout << endl;
// Chuyen vector sang set
set<int> uniqueSortedData(data.begin(), data.end());
cout << "Cac phan tu duy nhat va da sap xep (tu set): ";
for (int num : uniqueSortedData) {
cout << num << " "; // Output: 2 4 6 8 10 12
}
cout << endl;
// Neu ban muon ket qua tra ve la mot vector:
vector<int> resultVector(uniqueSortedData.begin(), uniqueSortedData.end());
cout << "Ket qua trong vector (duy nhat, da sap xep): ";
for (int num : resultVector) {
cout << num << " "; // Output: 2 4 6 8 10 12
}
cout << endl;
return 0;
}
Giải thích code:
Chúng ta khởi tạo set
trực tiếp từ range của vector data
. Thao tác này hiệu quả và gọn gàng. Set uniqueSortedData
lúc này chứa các phần tử duy nhất từ data
, và chúng đã được sắp xếp. Việc duyệt qua set sẽ cho các phần tử theo thứ tự tăng dần. Nếu cần kết quả cuối cùng dưới dạng vector, bạn có thể dễ dàng copy các phần tử từ set sang vector bằng cách sử dụng range constructor của vector (vector<int> resultVector(uniqueSortedData.begin(), uniqueSortedData.end());
).
Các Biến Thể Của Set: Khi nào cần dùng?
Ngoài set
"truyền thống", C++ còn cung cấp các biến thể khác trong Standard Library:
multiset
: Giống nhưset
, nhưng cho phép chứa các phần tử trùng lặp. Các phần tử vẫn được sắp xếp. Thao tác thêm, xóa, tìm kiếm cũng có độ phức tạp O(log N).unordered_set
: Chứa các phần tử duy nhất, nhưng không đảm bảo thứ tự của các phần tử. Thay vì sử dụng cây tìm kiếm nhị phân,unordered_set
sử dụng bảng băm (hash table). Nhờ đó, các thao tác thêm, xóa, tìm kiếm có độ phức tạp trung bình là O(1) (rất nhanh!), nhưng trong trường hợp xấu nhất (va chạm băm nhiều) có thể lên tới O(N). Bạn cần bao gồm header<unordered_set>
. Sử dụngunordered_set
khi bạn chỉ cần tính duy nhất và tốc độ tìm kiếm là ưu tiên hàng đầu, còn thứ tự không quan trọng.
Hiểu rõ sự khác biệt giữa set
, multiset
, và unordered_set
sẽ giúp bạn lựa chọn container phù hợp và hiệu quả nhất cho từng bài toán cụ thể.
Comments