Chương 0·Bài 09

C++ STL

Một tập số luôn thay đổi: thêm một số, xóa một số, hỏi số nhỏ nhất không bé hơn yy. Giữ trong mảng chưa sắp thì mỗi câu hỏi phải quét cả mảng; giữ mảng đã sắp thì hỏi nhanh nhưng mỗi lần thêm, xóa phải dịch cả phần đuôi. Thư viện chuẩn C++ (STL) có sẵn set làm cả ba việc trong O(log⁡n)O(\log n), cùng nhiều cấu trúc khác: mảng co giãn, ngăn xếp, hàng đợi, đống, bảng đếm theo khóa.

Dùng STL không khó; khó là biết giá của từng thao tác. Chọn nhầm container thì chương trình vẫn đúng mà chạy quá giờ. Bài này đi qua từng họ container theo ba câu hỏi: làm được gì, mất bao lâu, và vì sao.

Chọn container

Cách làm đúng là liệt kê thao tác trước, chọn cấu trúc sau. Bảng dưới ghi giá của các thao tác hay gặp, với nn là số phần tử đang chứa.

Thao tác vector chưa sắp vector đã sắp set, map priority_queue unordered_map
Thêm một phần tử O(1)O(1) ở cuối O(n)O(n) O(log⁡n)O(\log n) O(log⁡n)O(\log n) O(1)O(1) trung bình
Tìm theo giá trị O(n)O(n) O(log⁡n)O(\log n) O(log⁡n)O(\log n) không có O(1)O(1) trung bình
Xóa theo giá trị O(n)O(n) O(n)O(n) O(log⁡n)O(\log n) không có O(1)O(1) trung bình
Đọc nhỏ nhất hoặc lớn nhất O(n)O(n) O(1)O(1) O(1)O(1) O(1)O(1) cho một phía O(n)O(n)
Số kế tiếp hoặc đứng trước O(n)O(n) O(log⁡n)O(\log n) O(log⁡n)O(\log n) không có không có

Chỉ thêm ở cuối và đọc theo chỉ số: vector. Chỉ lấy cực trị: priority_queue. Cần thứ tự (số kế tiếp, duyệt từ nhỏ đến lớn) hoặc vừa xóa bất kỳ vừa hỏi cực trị: set, multiset, map. Chỉ cần “có hay không” hoặc đếm theo khóa: bảng băm, hoặc nếu đọc hết dữ liệu trước được thì sắp rồi gom (bài 03), thường gọn và an toàn hơn. Hằng số cũng đáng kể, kể cả giữa các thao tác cùng O(log⁡n)O(\log n):

Thêm một triệu số khác nhau, trung vị 5 lần chạy, i7-10850H, g++ -O2.set::insert663 ms · gấp 135 lầnunordered_set::insert352 ms · gấp 72 lầnpriority_queue::push15 ms · gấp 3,1 lầnvector::push_back4,9 ms
Thêm một triệu số khác nhau vào bốn loại container, đo trên i7-10850H, g++ -O2.

set chậm hơn vector hơn trăm lần: mỗi phần tử là một nút cấp phát riêng và cây phải giữ cân bằng. priority_queue cũng O(log⁡n)O(\log n) nhưng nằm trong một mảng liền, nên gần nhanh bằng vector.

vector là mảng nằm liền trong bộ nhớ nhưng đổi được độ dài. push_back tính trung bình chỉ tốn O(1)O(1): khi hết chỗ, vector xin vùng nhớ gấp đôi rồi chép sang, và tổng số lần chép sau nn lần thêm là 1+2+4+⋯<2n1 + 2 + 4 + \dots < 2n. insert và erase tại vị trí ii tốn O(n−i)O(n - i) vì phải dịch phần đuôi. Hai bẫy hay gặp:

  • size() trả về số không dấu: với vector rỗng, v.size() - 1 là 264−12^{64} - 1, không phải −1-1.
  • Xóa hàng loạt bằng erase trong vòng lặp vừa sai (nhảy qua phần tử vừa dồn về) vừa chậm O(n2)O(n^2). Cách đúng là v.erase(remove(v.begin(), v.end(), x), v.end()), một lượt O(n)O(n).

Vài hàm có sẵn thay được vòng lặp viết tay: sort, reverse, unique (gộp phần tử bằng nhau đứng liền kề, nên sắp trước), lower_bound và upper_bound trên mảng đã sắp, max_element (trả iterator, chỉ số là hiệu với đầu mảng), accumulate, count, fill, iota. Bẫy của accumulate: kiểu kết quả là kiểu của giá trị khởi đầu, nên accumulate(a, a + n, 0) cộng trong int dù a là long long; viết 0LL.

Hỏi nhanh

Vector v đang rỗng. Đoạn for (size_t i = 0; i <= v.size() - 1; i++) cout << v[i]; chạy ra sao?

Ví dụBao Nhiêu Giá Trị?

Đề. Dãy n≤2⋅105n \le 2 \cdot 10^5 số ai≤109a_i \le 10^9. Đếm số giá trị khác nhau.

Lời giải. Hai cách cùng O(nlog⁡n)O(n \log n): đưa mọi số vào set rồi in size(), hoặc sắp mảng rồi unique. Đọc hết dữ liệu trước được nên cách thứ hai đủ, và nhanh hơn nhiều lần vì không cấp phát nút cây nào. Giá trị tới 10910^9 nên không dùng được mảng đếm.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, a[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    sort(a + 1, a + n + 1);
    cout << unique(a + 1, a + n + 1) - a - 1 << '\n';
}
8
5 2 5 7 2 9 9 1

Ngăn xếp và hàng đợi

Ba cấu trúc này chỉ cho động vào hai đầu. Ngăn xếp (stack) vào sau ra trước: push, top, pop. Hàng đợi (queue) vào trước ra trước: push ở cuối, front và pop ở đầu. Hàng đợi hai đầu (deque) cho thêm bớt ở cả hai đầu và còn truy cập d[i]. Mọi thao tác ở đầu hay cuối đều O(1)O(1). Khi cần nhanh, dùng vector hay string làm ngăn xếp (push_back, back, pop_back).

top(), front(), pop() trên container rỗng không báo lỗi mà gây hành vi không xác định, nên luôn kiểm empty() trước. pop() trả về void: đọc top() rồi mới pop().

Bài quen thuộc nhất của ngăn xếp: một xâu ngoặc ()[]{} có đúng không. Quét từ trái sang, giữ ngăn xếp các ngoặc mở chưa đóng. Gặp ngoặc đóng thì ngoặc mở trong cùng, tức đỉnh, phải đúng loại; hết xâu mà ngăn xếp còn phần tử thì vẫn sai. Cùng khuôn “giữ những gì còn mở” xuất hiện ở bài phần tử gần nhất bên trái nhỏ hơn, xóa các cặp kề nhau liên tiếp. Hàng đợi là xương sống của BFS (bài 30).

Ví dụXóa Mẫu ABC

Đề. Xâu SS dài 2⋅1052 \cdot 10^5 trên bảng chữ cái A, B, C. Liên tục xóa lần xuất hiện trái nhất của ABC cho tới khi không còn, in xâu cuối.

Lời giải. Tìm lại từ đầu sau mỗi lần xóa là O(n2)O(n^2). Dùng ngăn xếp các ký tự giữ lại: đẩy từng ký tự vào, sau mỗi lần đẩy nếu ba ký tự trên cùng là ABC thì bỏ cả ba. Một lần xóa chỉ làm phần đứng trước nối với phần đứng sau, nên mẫu mới chỉ có thể xuất hiện ở đỉnh ngăn xếp; xóa ngay khi thấy cũng chính là xóa lần xuất hiện trái nhất. Mỗi ký tự vào ra một lần, O(n)O(n).

Ô đỏ là ba ký tự ABC sắp bị bỏ khỏi đỉnh ngăn xếp.
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
string s, st;

signed main() {
    cin >> s;
    for (char c : s) {
        st += c;
        ll m = st.size();
        if (m >= 3 && st[m - 3] == 'A' && st[m - 2] == 'B' && st[m - 1] == 'C') st.resize(m - 3);
    }
    cout << st << '\n';
}
AABCABCBA

Đống

Một túi số, lần lượt thêm một số hoặc lấy ra số lớn nhất. Ta chỉ cần biết số lớn nhất, không cần cả túi có thứ tự; đống (heap) giữ ít thứ tự hơn nên rẻ hơn. Đống nhị phân xếp các số thành từng tầng như một cây gia phả: tầng trên cùng chỉ có một nút gọi là gốc, mỗi nút có tối đa hai nút con ở tầng ngay dưới, và các tầng được lấp đầy từ trái sang. Mỗi nút lớn hơn hoặc bằng hai con của nó, nên gốc luôn là số lớn nhất. Xếp các nút theo tầng vào mảng đánh số từ 1, nút ii có cha ⌊i/2⌋\lfloor i/2 \rfloor và hai con 2i2i, 2i+12i + 1.

Chèn: đặt số mới ở ô cuối rồi chừng nào nó lớn hơn cha thì đổi chỗ với cha. Lấy lớn nhất: chép phần tử cuối lên gốc rồi đẩy xuống, đổi chỗ với con lớn hơn chừng nào còn nhỏ hơn nó. Cây cao ⌊log⁡2n⌋\lfloor \log_2 n \rfloor nên cả hai là O(log⁡n)O(\log n).

Thêm một số hoặc lấy số lớn nhất rồi chạy từng bước; số nhỏ cạnh nút là chỉ số trong mảng.

priority_queue là đống cài sẵn: push O(log⁡n)O(\log n), top O(1)O(1), pop O(log⁡n)O(\log n). Mặc định nhả số lớn nhất; muốn nhả số nhỏ nhất thì khai báo priority_queue<ll, vector<ll>, greater<ll>>. Hướng so sánh ngược với sort và đó là chỗ nhiều người nhầm: priority_queue nhả phần tử sẽ đứng cuối nếu ta sắp dãy bằng chính phép so sánh đó.

Hỏi nhanh

Đẩy lần lượt (3, 5), (3, 9), (1, 7) vào priority_queue<pair<ll, ll>> pq; (khai báo mặc định). pq.top() là cặp nào?

Đống không có thao tác bỏ một phần tử bất kỳ. Cách chữa thông dụng là xóa lười (lazy deletion): chưa bỏ ngay, chỉ ghi nhận “phần tử này đã chết”, và dọn khi nó nổi lên đỉnh. Thuật toán Dijkstra (bài 34) dùng đúng cách này.

Ví dụPhiếu Giảm Một Nửa

Đề. N≤105N \le 10^5 món giá Ai≤109A_i \le 10^9 và M≤105M \le 10^5 phiếu; mỗi phiếu thay giá hiện tại xx của một món bằng ⌊x/2⌋\lfloor x/2 \rfloor. Dùng đủ MM phiếu để tổng giá nhỏ nhất.

Lời giải. Một phiếu dùng lên món giá xx tiết kiệm x−⌊x/2⌋x - \lfloor x/2 \rfloor, lớn nhất khi xx lớn nhất. Và dùng phiếu lên món đắt nhất không làm hỏng các lựa chọn sau, vì mức tiết kiệm của các món khác không đổi. Vậy mỗi phiếu lấy món đang đắt nhất: max-heap, lấy đỉnh, đẩy lại ⌊x/2⌋\lfloor x/2 \rfloor. MM lần, mỗi lần O(log⁡N)O(\log N). Tổng tới 101410^{14}, dùng long long.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, m;
priority_queue<ll> pq;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    for (ll i = 1; i <= n; i++) {
        ll x;
        cin >> x;
        pq.push(x);
    }
    while (m--) {
        ll x = pq.top();
        pq.pop();
        pq.push(x / 2);
    }
    ll ans = 0;
    while (!pq.empty()) {
        ans += pq.top();
        pq.pop();
    }
    cout << ans << '\n';
}
4 5
20 7 3 10

set và hàng xóm

set giữ các khóa phân biệt luôn theo thứ tự tăng, bên trong là một cây nhị phân tìm kiếm luôn được giữ thấp, chỉ cao cỡ log⁡2n\log_2 n tầng, nên tìm, chèn, xóa đều O(log⁡n)O(\log n). Các thao tác chính: insert, erase (theo giá trị hoặc theo iterator), count, find, *s.begin() là số nhỏ nhất, *s.rbegin() là số lớn nhất, s.lower_bound(x) là phần tử đầu tiên ≥x\ge x, s.upper_bound(x) là phần tử đầu tiên >x> x. Iterator đi được hai chiều: next(it), prev(it).

Hai câu hỏi hàng xóm hay gặp:

  • Nhỏ nhất ≥x\ge x: it = s.lower_bound(x), có đáp án khi it != s.end().
  • Lớn nhất ≤x\le x: it = s.upper_bound(x), có đáp án khi it != s.begin(), và đáp án là *prev(it).

upper_bound(x) đứng ngay sau mọi phần tử ≤x\le x, nên phần tử liền trước nó là lớn nhất trong nhóm đó. Lấy * của end() hay lùi từ begin() là hành vi không xác định, thường hiện thành Runtime Error.

Đổi x để xem hai hàng xóm; insert và erase giữ set luôn tăng dần.

Bẫy lớn nhất là hàm lower_bound toàn cục: lower_bound(s.begin(), s.end(), x) vẫn biên dịch và cho đáp án đúng, nhưng iterator của set chỉ đi từng bước, nên nó tốn cỡ nn bước mỗi lần. Hàm thành viên s.lower_bound(x) đi theo cây, O(log⁡n)O(\log n). Tương tự, set không biết thứ hạng của phần tử: cần phần tử nhỏ thứ kk khi tập còn thay đổi thì dùng cây Fenwick (bài 16) hay cây phân đoạn (bài 17).

Hỏi nhanh

Một set<ll> s có 2⋅1052 \cdot 10^5 phần tử, và cần trả lời 2⋅1052 \cdot 10^5 câu hỏi “số nhỏ nhất không bé hơn xx”. Một bạn viết auto it = lower_bound(s.begin(), s.end(), x); cho mỗi câu. Điều gì xảy ra?

multiset và map

multiset giống set nhưng cho phép nhiều bản sao. Hai điểm khác dễ vấp:

  • ms.erase(x) xóa mọi bản sao bằng xx. Muốn xóa đúng một bản, viết ms.erase(ms.find(x)), chỉ khi chắc chắn có xx.
  • ms.count(x) tốn O(log⁡n+k)O(\log n + k) với kk là số bản sao. Cần đếm nhanh thì giữ một map số lần xuất hiện.

map<K, V> là một set các cặp (khóa, giá trị) xếp theo khóa. m[k] cho tham chiếu tới giá trị của khóa kk; nếu kk chưa có, nó tạo ra khóa với giá trị mặc định 0. Nhờ vậy m[x]++ đếm tần suất mà không cần kiểm gì; nhưng cũng vì vậy, chỉ đọc if (m[x] > 0) với xx chưa có là đã chèn thêm một khóa. Hỏi mà không chèn thì dùng m.count(x). Duyệt for (auto [k, v] : m) đi theo khóa tăng dần.

Hỏi nhanh

multiset<ll> ms = {4, 4, 7}; Cách nào xóa đúng một số 4 và giữ lại số 4 còn lại?

Ví dụVé Trong Tầm Giá

Đề. Kho có nn vé giá hih_i; mm khách đến lần lượt, khách ngân sách tjt_j mua vé đắt nhất có giá không vượt tjt_j nếu có, vé đã bán bị bỏ khỏi kho. In giá vé mỗi khách mua, hoặc −1-1. n,m≤2⋅105n, m \le 2 \cdot 10^5.

Lời giải. Kho phải trả lời “số lớn nhất ≤t\le t” rồi xóa đúng một bản sao, và giá có thể trùng, nên dùng multiset. Với ngân sách tt, it = upper_bound(t); nếu it == begin() thì không vé nào vừa, ngược lại lùi một bước, in và erase(it). Phải xóa theo iterator, không theo giá trị, kẻo xóa luôn các bản sao còn lại. Cả bài O((n+m)log⁡n)O((n + m) \log n).

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, m;
multiset<ll> tk;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    for (ll i = 1; i <= n; i++) {
        ll h;
        cin >> h;
        tk.insert(h);
    }
    while (m--) {
        ll t;
        cin >> t;
        auto it = tk.upper_bound(t);
        if (it == tk.begin()) cout << -1 << '\n';
        else {
            it--;
            cout << *it << '\n';
            tk.erase(it);
        }
    }
}
6 5
4 8 8 3 10 6
5 8 2 10 8

Bảng băm

Khi chỉ cần “có hay không” hoặc đếm theo khóa mà không cần thứ tự, bảng băm (hash table) hứa O(1)O(1) thay cho O(log⁡n)O(\log n). Hàm băm hh biến khóa thành số nguyên; bảng có BB ô, khóa xx được cất ở ô h(x) mod Bh(x) \bmod B, mỗi ô giữ danh sách các khóa rơi vào nó. Nếu hh rải đều thì danh sách trung bình dưới một phần tử. Trong C++ đó là unordered_set và unordered_map, dùng giống set và map nhưng không có lower_bound, và thứ tự duyệt không xác định.

Chữ “trung bình” là điểm yếu. Với g++, hàm băm của số nguyên chính là giá trị khóa, còn số ô BB lấy từ một bảng số nguyên tố cố định. Biết BB, người ra test chọn các khóa B,2B,3B,…B, 2B, 3B, \dots: tất cả cùng dư 0 nên cùng vào ô 0, mỗi thao tác phải duyệt một chuỗi dài nn, cả bài O(n2)O(n^2). Đây là kiểu hack có sẵn trên các trang thi trực tuyến.

Khóa ngẫu nhiên cho chuỗi ngắn; khóa toàn bội của B dồn hết vào ô 0.

Hỏi nhanh

Một người ra test chọn 10510^5 khóa nguyên có dạng B,2B,3B,…B, 2B, 3B, \dots, với BB là số ô mà unordered_set mặc định của g++ sẽ có khi đã chứa đủ 10510^5 khóa. Vì sao chương trình chậm đi nhiều?

Có ba cách tránh. Không dùng bảng băm: sắp rồi gom, hay nén tọa độ (bài 03). Dùng map, O(log⁡n)O(\log n) không có điểm yếu nào. Hoặc tự đặt hàm băm trộn đều các bit, có hạt giống lấy từ đồng hồ để không ai dựng test trước được:

C++
struct Hash {
    size_t operator()(ull x) const {
        static const ull seed = chrono::steady_clock::now().time_since_epoch().count();
        x += seed + 0x9e3779b97f4a7c15ULL;
        x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
        x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
        return x ^ (x >> 31);
    }
};

Đây là hàm splitmix64: nó xáo khóa thật kỹ, hai khóa chỉ khác nhau một chút cũng cho kết quả khác hẳn, nên các bội của BB không còn cùng ô. Dùng như unordered_map<ll, ll, Hash>. Khóa là cặp thì ghép thành một số 64 bit trước.

Ví dụHiệu Bằng Khoảng Cách

Đề. Mảng a1..ana_1..a_n với ai≤na_i \le n, tổng nn qua các test không quá 2⋅1052 \cdot 10^5. Đếm cặp i<ji < j có aj−ai=j−ia_j - a_i = j - i.

Lời giải. Chuyển vế: aj−j=ai−ia_j - j = a_i - i. Đặt khóa ki=ai−ik_i = a_i - i, bài thành đếm cặp cùng khóa: duyệt từ trái, mỗi jj tạo thêm đúng cnt[k_j] cặp với các vị trí trước nó, rồi tăng đếm. Ở đây khóa chỉ nằm trong [1−n,n−1][1 - n, n - 1] nên một mảng đếm có dịch chỉ số còn nhanh hơn; dùng bảng băm có hàm trộn cũng an toàn, và là cách viết khi khóa lớn tùy ý. Số cặp tới 2⋅10102 \cdot 10^{10}.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
using ull = unsigned long long;

struct Hash {
    size_t operator()(ull x) const {
        static const ull seed = chrono::steady_clock::now().time_since_epoch().count();
        x += seed + 0x9e3779b97f4a7c15ULL;
        x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
        x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
        return x ^ (x >> 31);
    }
};

ll t, n;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> t;
    while (t--) {
        cin >> n;
        unordered_map<ll, ll, Hash> cnt;
        ll ans = 0;
        for (ll i = 1; i <= n; i++) {
            ll a;
            cin >> a;
            ans += cnt[a - i]++;
        }
        cout << ans << '\n';
    }
}
3
5
1 2 3 4 5
5
5 4 3 2 1
6
2 3 1 4 6 5

Bảng băm được khai báo mới trong mỗi test, nên chi phí đặt lại tỉ lệ với nn của test chứ không phải với giới hạn lớn nhất: bài nhiều test mà đặt lại cả mảng cỡ tối đa mỗi lần là một cái bẫy thời gian quen thuộc khác.

Khi đi thi

  • Trước khi chọn container, ghi ra các thao tác bài cần rồi tra giá: cần số kế tiếp hay xóa phần tử bất kỳ thì set, multiset, map; chỉ lấy cực trị thì priority_queue; đếm theo khóa thì map hoặc sắp rồi gom. Nhân giá với số thao tác và so với 10810^8.
  • Với set và multiset luôn gọi hàm thành viên (s.lower_bound), xóa một bản sao bằng erase(find(x)), và kiểm end(), begin(), empty() trước khi *, top() hay pop().
  • Chỉ dùng unordered_map khi đã có hàm băm ngẫu nhiên; còn thấy size() - 1, accumulate(..., 0), vector truyền theo giá trị, hay đặt lại cả mảng cỡ tối đa mỗi test thì sửa ngay.

Tóm tắt

  • Chọn container theo thao tác: vector cho cuối và chỉ số, priority_queue cho cực trị, set, multiset, map cho thứ tự và hàng xóm, bảng băm cho “có hay không”.
  • push_back trung bình O(1)O(1); insert và erase giữa tốn O(n)O(n), nên bỏ phần tử hàng loạt bằng erase cộng remove; size() không dấu.
  • stack, queue, deque cho mọi thao tác ở đầu trong O(1)O(1) nhưng không được top, front, pop khi rỗng.
  • priority_queue là đống nhị phân: push, pop O(log⁡n)O(\log n), mặc định nhả lớn nhất, chiều so sánh ngược với sort; cần xóa tùy ý thì xóa lười hoặc dùng set.
  • set, multiset, map đều O(log⁡n)O(\log n): nhỏ nhất ≥x\ge x là lower_bound, lớn nhất ≤x\le x là prev(upper_bound), multiset xóa một bản bằng erase(find(x)), m[x] tự chèn khóa.
  • Bảng băm trung bình O(1)O(1) nhưng bị chống bằng cách chọn khóa; phòng bằng hàm băm splitmix64 có hạt giống, hoặc dùng map hay sắp xếp.

Bài tập

Đọc thêm