Bài 18.2: Multiset và Unordered_set trong C++

Chào mừng trở lại với chuỗi bài blog về C++! Hôm nay, chúng ta sẽ "lặn sâu" vào thế giới của hai cấu trúc dữ liệu cực kỳ hữu ích trong Thư viện Chuẩn C++ (STL): multisetunordered_set. Nếu bạn đã quen thuộc với set, thì multisetunordered_set là những "anh em" của nó, mỗi loại mang một đặc tính riêng biệt để giải quyết các bài toán cụ thể.

Chúng ta sẽ tìm hiểu xem chúng là gì, cách sử dụng chúng, và quan trọng nhất là sự khác biệt cốt lõi giúp bạn đưa ra lựa chọn phù hợp cho từng tình huống lập trình.

multiset: Khi thứ tự quan trọng và trùng lặp được chấp nhận

multiset là một container (vùng chứa) trong STL, lưu trữ các phần tử theo một thứ tự được sắp xếp. Điểm đặc biệt làm nên tên tuổi của nó (multi) chính là khả năng cho phép các phần tử trùng lặp.

Nghĩ về nó như một tập hợp các số mà bạn luôn muốn giữ chúng theo thứ tự từ nhỏ đến lớn, và nếu có nhiều số giống nhau, bạn muốn giữ lại tất cả chúng.

Về mặt kỹ thuật, multiset thường được triển khai dựa trên cấu trúc cây cân bằng (như Red-Black Tree), đảm bảo các thao tác cơ bản như thêm, xóa, tìm kiếm có độ phức tạp thời gian là O(log N), trong đó N là số lượng phần tử trong multiset.

Các thao tác cơ bản với multiset

Hãy cùng xem một số ví dụ minh họa cách sử dụng multiset.

1. Khởi tạo và thêm phần tử (insert)
#include <iostream>
#include <multiset>
#include <string>

int main() {
    // Khởi tạo một multiset rỗng chứa số nguyên
    multiset<int> myMultiset;

    // Thêm các phần tử
    myMultiset.insert(10);
    myMultiset.insert(5);
    myMultiset.insert(15);
    myMultiset.insert(5); // Chèn thêm một giá trị 5 (trùng lặp)
    myMultiset.insert(10); // Chèn thêm một giá trị 10 (trùng lặp)

    // In ra kích thước
    cout << "Kich thuoc myMultiset: " << myMultiset.size() << endl; // Output: 5

    // Duyệt qua các phần tử (sẽ theo thứ tự tăng dần)
    cout << "Cac phan tu trong myMultiset: ";
    for (int num : myMultiset) {
        cout << num << " "; // Output: 5 5 10 10 15
    }
    cout << endl;

    return 0;
}

Giải thích: Đoạn code này tạo một multiset<int>, chèn các số vào. Bạn có thể thấy rằng các giá trị 510 được thêm nhiều lần và đều được lưu giữ. Khi duyệt qua myMultiset, các phần tử được in ra theo thứ tự tăng dần, bất kể thứ tự chèn vào ban đầu.

2. Tìm kiếm phần tử (find, count)

Bạn có thể tìm kiếm một phần tử cụ thể và đếm số lần xuất hiện của nó.

#include <iostream>
#include <multiset>

int main() {
    multiset<int> numbers = {10, 5, 15, 5, 20, 10}; // {5, 5, 10, 10, 15, 20}

    // Tìm kiếm giá trị 10
    auto it_find = numbers.find(10);
    if (it_find != numbers.end()) {
        cout << "Tim thay so 10 trong multiset." << endl; // Tìm thấy
        // Lưu ý: find() tra ve iterator toi LAN XUAT HIEN DAU TIEN theo thu tu sap xep
        cout << "Gia tri tai iterator find: " << *it_find << endl;
    } else {
        cout << "Khong tim thay so 10." << endl;
    }

     // Tìm kiếm giá trị 100 (không tồn tại)
    it_find = numbers.find(100);
    if (it_find != numbers.end()) {
        cout << "Tim thay so 100 trong multiset." << endl;
    } else {
        cout << "Khong tim thay so 100." << endl; // Không tìm thấy
    }

    // Dem so lan xuat hien cua mot gia tri
    cout << "So lan xuat hien cua 5: " << numbers.count(5) << endl;   // Output: 2
    cout << "So lan xuat hien cua 10: " << numbers.count(10) << endl; // Output: 2
    cout << "So lan xuat hien cua 20: " << numbers.count(20) << endl; // Output: 1
    cout << "So lan xuat hien cua 100: " << numbers.count(100) << endl; // Output: 0

    return 0;
}

Giải thích: Phương thức find(value) trả về một iterator trỏ đến lần xuất hiện đầu tiên của value trong multiset (theo thứ tự đã sắp xếp). Nếu không tìm thấy, nó trả về numbers.end(). Phương thức count(value) thì cực kỳ tiện lợi, nó trả về tổng số lần value xuất hiện trong multiset.

3. Xóa phần tử (erase)

Xóa phần tử trong multiset có thể hơi phức tạp hơn so với set vì có thể có trùng lặp.

#include <iostream>
#include <multiset>

int main() {
    multiset<int> numbers = {10, 5, 15, 5, 20, 10}; // {5, 5, 10, 10, 15, 20}

    cout << "Multiset ban dau: ";
    for (int num : numbers) cout << num << " ";
    cout << endl;

    // Xoa mot LAN xuat hien cua gia tri 5 bang iterator
    auto it_erase = numbers.find(5); // Tìm lần xuất hiện đầu tiên
    if (it_erase != numbers.end()) {
        numbers.erase(it_erase);
    }

    cout << "Sau khi xoa mot so 5: ";
    for (int num : numbers) cout << num << " "; // Output: 5 10 10 15 20
    cout << endl;

    // Xoa TAT CA cac lan xuat hien cua gia tri 10 bang gia tri
    // Phuong thuc erase(value) tra ve so phan tu bi xoa
    size_t removed_count = numbers.erase(10);
    cout << "So phan tu bi xoa (gia tri 10): " << removed_count << endl; // Output: 2

    cout << "Sau khi xoa tat ca so 10: ";
    for (int num : numbers) cout << num << " "; // Output: 5 15 20
    cout << endl;

    // Xoa tat ca phan tu trong mot khoang (vi du: tat ca phan tu > 10)
    // lower_bound(value) tra ve iterator toi phan tu >= value dau tien
    // upper_bound(value) tra ve iterator toi phan tu > value dau tien
    numbers.erase(numbers.upper_bound(15), numbers.end()); // Xoa 20

    cout << "Sau khi xoa phan tu > 15: ";
    for (int num : numbers) cout << num << " "; // Output: 5 15
    cout << endl;


    return 0;
}

Giải thích:

  • erase(iterator): Xóa phần tử mà iterator đó đang trỏ tới. Nếu bạn chỉ muốn xóa một trong số các phần tử trùng lặp, bạn cần tìm đúng iterator của phần tử đó (thường là dùng find hoặc duyệt).
  • erase(value): Xóa tất cả các lần xuất hiện của value trong multiset và trả về số lượng phần tử đã bị xóa.
  • erase(first_iterator, last_iterator): Xóa tất cả các phần tử trong phạm vi từ first_iterator đến last_iterator (không bao gồm last_iterator). Kết hợp với lower_boundupper_bound rất hữu ích.

unordered_set: Khi tốc độ là ưu tiên và chỉ cần duy nhất

Trái ngược với multiset (và set), unordered_set là một container lưu trữ các phần tử theo một thứ tự không được đảm bảo (unordered). Nó dựa trên cấu trúc bảng băm (hash table).

Điểm mấu chốt của unordered_set là:

  1. không cho phép các phần tử trùng lặp.
  2. Các thao tác cơ bản như thêm, xóa, tìm kiếm có độ phức tạp thời gian trung bình là O(1) (thời gian hằng số). Trong trường hợp xấu nhất (xảy ra va chạm băm nhiều), độ phức tạp có thể lên tới O(N), nhưng điều này khá hiếm với hàm băm tốt.

Nghĩ về nó như một cái "túi" chứa các vật duy nhất. Bạn bỏ vật vào, chúng nằm lộn xộn trong túi, nhưng bạn có thể nhanh chóng kiểm tra xem một vật cụ thể có trong túi hay không.

Các thao tác cơ bản với unordered_set

Hãy xem unordered_set hoạt động như thế nào.

1. Khởi tạo và thêm phần tử (insert)
#include <iostream>
#include <unordered_set>
#include <string>

int main() {
    // Khởi tạo một unordered_set rỗng chứa số nguyên
    unordered_set<int> myUnorderedSet;

    // Thêm các phần tử
    myUnorderedSet.insert(10);
    myUnorderedSet.insert(5);
    myUnorderedSet.insert(15);
    auto result_insert_5 = myUnorderedSet.insert(5); // Thêm giá trị 5 (đã có)
    auto result_insert_10 = myUnorderedSet.insert(10); // Thêm giá trị 10 (đã có)

    // Kết quả insert tra ve pair<iterator, bool>, bool = true neu chen thanh cong, false neu phan tu da ton tai
    cout << "Insert 5 lan 2 " << (result_insert_5.second ? "thanh cong" : "that bai") << endl; // Output: that bai
    cout << "Insert 10 lan 2 " << (result_insert_10.second ? "thanh cong" : "that bai") << endl; // Output: that bai

    // In ra kích thước
    cout << "Kich thuoc myUnorderedSet: " << myUnorderedSet.size() << endl; // Output: 3

    // Duyệt qua các phần tử (thứ tự không đảm bảo)
    cout << "Cac phan tu trong myUnorderedSet: ";
    for (int num : myUnorderedSet) {
        cout << num << " "; // Output order is unpredictable (e.g., 15 5 10)
    }
    cout << endl;

    return 0;
}

Giải thích: Khi bạn cố gắng chèn một phần tử đã tồn tại vào unordered_set, thao tác insert sẽ trả về một pair với phần tử secondfalse, và phần tử đó sẽ không được thêm vào lại. Kích thước của tập hợp vẫn là 3 (5, 10, 15). Khi duyệt, thứ tự in ra có thể khác nhau mỗi lần chạy.

2. Tìm kiếm phần tử (find, count)

Tìm kiếm trong unordered_set rất nhanh, trung bình là O(1).

#include <iostream>
#include <unordered_set>

int main() {
    unordered_set<int> numbers = {10, 5, 15, 20};

    // Tìm kiếm giá trị 10
    auto it_find = numbers.find(10);
    if (it_find != numbers.end()) {
        cout << "Tim thay so 10 trong unordered_set." << endl; // Tim thay
    } else {
        cout << "Khong tim thay so 10." << endl;
    }

     // Tìm kiếm giá trị 100 (không tồn tại)
    it_find = numbers.find(100);
    if (it_find != numbers.end()) {
        cout << "Tim thay so 100 trong unordered_set." << endl;
    } else {
        cout << "Khong tim thay so 100." << endl; // Khong tim thay
    }

    // Dem so lan xuat hien cua mot gia tri
    // Luu y: Voi unordered_set (khong co trung lap), count chi co the tra ve 0 hoac 1
    cout << "So lan xuat hien cua 5: " << numbers.count(5) << endl;   // Output: 1
    cout << "So lan xuat hien cua 100: " << numbers.count(100) << endl; // Output: 0

    return 0;
}

Giải thích: Phương thức find(value) hoạt động tương tự như trong multisetset, trả về iterator trỏ đến phần tử nếu tìm thấy, hoặc end() nếu không. Phương thức count(value) trả về số lần xuất hiện. Tuy nhiên, vì unordered_set không cho phép trùng lặp, count luôn chỉ trả về 0 (nếu không tìm thấy) hoặc 1 (nếu tìm thấy).

3. Xóa phần tử (erase)

Xóa phần tử trong unordered_set cũng rất nhanh.

#include <iostream>
#include <unordered_set>

int main() {
    unordered_set<int> numbers = {10, 5, 15, 20};

    cout << "Unordered_set ban dau: ";
    for (int num : numbers) cout << num << " ";
    cout << endl;

    // Xoa phan tu co gia tri 15 bang gia tri
    // Phuong thuc erase(value) tra ve so phan tu bi xoa (0 hoac 1)
    size_t removed_count = numbers.erase(15);
    cout << "So phan tu bi xoa (gia tri 15): " << removed_count << endl; // Output: 1

    cout << "Sau khi xoa 15: ";
     for (int num : numbers) cout << num << " "; // Thu tu co the thay doi
    cout << endl;

    // Xoa phan tu khong ton tai
    removed_count = numbers.erase(100);
    cout << "So phan tu bi xoa (gia tri 100): " << removed_count << endl; // Output: 0

    // Xoa phan tu bang iterator
    auto it_erase = numbers.find(10);
    if(it_erase != numbers.end()) {
        numbers.erase(it_erase);
    }

    cout << "Sau khi xoa 10: ";
     for (int num : numbers) cout << num << " "; // Thu tu co the thay doi
    cout << endl;

    return 0;
}

Giải thích: Phương thức erase(value) sẽ tìm và xóa phần tử có giá trị đó (nếu tồn tại) và trả về 1 nếu xóa thành công, 0 nếu không tìm thấy. Phương thức erase(iterator) xóa phần tử mà iterator đó trỏ tới. Cả hai đều có độ phức tạp trung bình O(1).

Multiset vs. Unordered_set: Lựa chọn nào cho bài toán của bạn?

Sau khi tìm hiểu về cả hai, đây là bảng tóm tắt sự khác biệt chính để giúp bạn quyết định nên dùng container nào:

Đặc điểm multiset unordered_set
Trùng lặp Cho phép các phần tử trùng lặp Không cho phép các phần tử trùng lặp
Thứ tự Các phần tử được sắp xếp theo thứ tự (tăng dần theo mặc định) Thứ tự các phần tử không được đảm bảo (dựa vào hàm băm)
Cấu trúc dữ liệu Thường là cây cân bằng (như Red-Black Tree) Thường là bảng băm (Hash Table)
Độ phức tạp TB O(log N) cho insert, find, erase, count O(1) cho insert, find, erase, count (count trả về 0 hoặc 1)
Độ phức tạp WW O(log N) O(N) (trong trường hợp va chạm băm nghiêm trọng)

Khi nào nên sử dụng multiset?

  • Khi bạn cần lưu trữ một tập hợp mà thứ tự của các phần tử là quan trọng.
  • Khi bạn cần lưu trữ các phần tử có thể trùng lặp.
  • Khi bạn cần thực hiện các thao tác như tìm kiếm phạm vi (lower_bound, upper_bound).
  • Khi độ phức tạp O(log N) là chấp nhận được.

Ví dụ: Theo dõi danh sách điểm thi của học sinh (có thể có điểm trùng nhau) và muốn xem danh sách điểm theo thứ tự tăng dần.

Khi nào nên sử dụng unordered_set?

  • Khi bạn chỉ cần lưu trữ các phần tử duy nhất.
  • Khi thứ tự của các phần tử không quan trọng.
  • Khi bạn ưu tiên tốc độ truy cập trung bình O(1) cho các thao tác thêm, xóa, tìm kiếm.

Ví dụ: Lưu trữ danh sách các từ duy nhất xuất hiện trong một văn bản, hoặc kiểm tra sự tồn tại nhanh chóng của một phần tử trong một tập lớn.

Comments

There are no comments at the moment.