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:

  1. 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.
  2. 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ấtcó 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ụng unordered_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

There are no comments at the moment.