Chương 0·Bài 09

C++ STL

Cho một tập số luôn thay đổi và qq thao tác: thêm một số, xóa một số, hoặc hỏi số nhỏ nhất không bé hơn yy. Giữ các số trong mảng không 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 hay xóa phải dịch cả phần đuôi. Với n=q=2⋅105n = q = 2 \cdot 10^5 hai cách đều cỡ 4⋅10104 \cdot 10^{10} bước. Thư viện chuẩn C++ (STL) có sẵn cấu trúc làm cả ba việc trong O(log⁡n)O(\log n), và tương tự cho hầu hết thứ bài thi cần: mảng co giãn, ngăn xếp, hàng đợi, đống để lấy cực trị, bảng đếm theo khóa.

Dùng STL không khó; khó là biết giá của từng thao tác. Mỗi container cam kết vài thao tác thật nhanh và bỏ rơi những thao tác khác, nên chọn nhầm thì chương trình vẫn đúng mà chạy quá giờ: erase giữa vector, lower_bound toàn cục trên set, bảng băm bị chống test. Bài này đi qua từng họ container theo ba câu hỏi: làm được thao tác nào, mất bao lâu, và vì sao.

Chọn container theo thao tác; vector và các hàm có sẵn

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ể: với 10610^6 số, push_back cỡ vài mili giây, priority_queue::push cỡ vài trăm, set::insert gần một giây.

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;
using 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, hàng đợi và deque

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().

Ví dụ kinh điển 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;
using 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

Hàng đợi ưu tiên và đố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;
using 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: tập có thứ tự 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?

Ví dụMảnh Thanh Chứa Điểm

Đề. Thanh dài L≤109L \le 10^9, Q≤2⋅105Q \le 2 \cdot 10^5 thao tác: cắt tại xx, hoặc hỏi độ dài mảnh đang chứa xx (bảo đảm xx chưa từng là vết cắt).

Lời giải. Giữ set các vết cắt, thêm sẵn hai đầu 00 và LL. Mảnh chứa xx nằm giữa vết cắt lớn nhất nhỏ hơn xx và vết cắt nhỏ nhất lớn hơn xx: it = upper_bound(x), mảnh là [∗prev(it),∗it][*prev(it), *it]. Hai đầu thanh có sẵn trong set nên it không bao giờ là end() hay begin(). Mỗi thao tác O(log⁡Q)O(\log Q).

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll len, q;
set<ll> cut;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> len >> q;
    cut.insert(0);
    cut.insert(len);
    while (q--) {
        ll c, x;
        cin >> c >> x;
        if (c == 1) cut.insert(x);
        else {
            auto it = cut.upper_bound(x);
            cout << *it - *prev(it) << '\n';
        }
    }
}
12 7
2 5
1 4
2 2
1 9
2 7
2 11
1 6

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;
using 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

Ví dụĐèn Trên Đại Lộ

Đề. Đại lộ từ 0 đến x≤109x \le 10^9, có đèn ở hai đầu; n≤2⋅105n \le 2 \cdot 10^5 đèn mới lần lượt đặt tại các vị trí phân biệt. Sau mỗi lần đặt, in độ dài lớn nhất của một đoạn giữa hai đèn liên tiếp.

Lời giải. Đặt đèn tại pp chỉ cắt đúng đoạn [l,r][l, r] chứa pp thành [l,p][l, p] và [p,r][p, r]. Giữ set vị trí đèn để tìm ll, rr bằng upper_bound, và multiset độ dài các đoạn: bỏ một bản r−lr - l, thêm p−lp - l và r−pr - p, đọc lớn nhất bằng *rbegin(). Phải là multiset vì hai đoạn có thể cùng độ dài, và bỏ đúng một bản bằng erase(find(...)).

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll x, n;
set<ll> pos;
multiset<ll> gap;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> x >> n;
    pos.insert(0);
    pos.insert(x);
    gap.insert(x);
    for (ll i = 1; i <= n; i++) {
        ll p;
        cin >> p;
        auto it = pos.upper_bound(p);
        ll r = *it, l = *prev(it);
        gap.erase(gap.find(r - l));
        gap.insert(p - l);
        gap.insert(r - p);
        pos.insert(p);
        cout << *gap.rbegin() << " \n"[i == n];
    }
}
10 3
4 7 2

Bảng băm: nhanh trung bình, chậm nhất khi bị chống

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;
using 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

1
Điểm Chênh Lệch Từ Khóacanuc80k0905map<string, ll>: danh sách cộng thêm 1, danh sách trừ bớt 1, lấy giá trị lớn nhất và chặn dưới bằng 0 vì được chọn chuỗi chưa từng xuất hiện.
LQDOJ
2
Hai Đầu Hàng Đợicanuc80k0901deque: số mới nhỏ hơn phần tử đầu hiện tại thì push_front, ngược lại push_back.
LQDOJ
3
Danh Sách Đang Chờcanuc80k0919set các khách đang chờ cùng một biến cho người kế tiếp chưa gọi; truy vấn loại 3 in *begin().
LQDOJ
4
Kho Số Độngcanuc80k0907map<giá trị, số bản sao>: xóa khóa khi số bản sao về 0, đáp án là rbegin()->first - begin()->first.
LQDOJ
5
Ghép Từ Trong Khocanuc80k0902Đưa mọi chuỗi vào một tập; thử mọi chỗ cắt và kiểm tra cả hai nửa có trong tập.
LQDOJ
6
Cặp Chuỗi Cùng Túi Chữcanuc80k0911Sắp các chữ cái của mỗi chuỗi để được khóa; mỗi chuỗi tạo thêm đúng cnt[khóa] cặp với các chuỗi trước nó.
LQDOJ
7
Chỉ Số Nhỏ Hơn Gần Nhấtcanuc80k0929Ngăn xếp các vị trí có giá trị tăng dần: bỏ khỏi đỉnh mọi vị trí có giá trị không nhỏ hơn aia_i, đỉnh còn lại là đáp án.
LQDOJ
8
Túi Số Với Độ Dờicanuc80k0933Đừng cộng vào từng số: giữ một biến offset, lưu xx trừ offset vào min-heap và cộng offset lại khi lấy ra.
LQDOJ
9
Xếp Hình Theo Tầngcanuc80k0916multiset các chiều rộng; mỗi tầng lần lượt lấy hình lớn nhất còn vừa bằng upper_bound rồi lùi một bước.
LQDOJ
10
Mảng Phần Dư Nhỏ Nhấtcanuc80k0934multiset các số của bb; với aia_i lấy lower_bound(n - a_i), không có thì lấy phần tử nhỏ nhất; xóa bằng iterator.
LQDOJ
11
Tổng K Giá Trị Nhỏ Nhấtcanuc80k0938Hai multiset: một giữ đúng KK số nhỏ nhất của cửa sổ kèm tổng, một giữ phần còn lại; mỗi lần trượt chèn, xóa rồi cân bằng.
LQDOJ

Đọc thêm

  • Kinh nghiệm thi VOIVNOI WikiCác lỗi STL hay gặp trong kỳ thi thật: lower_bound trên set, multiset::erase, size() - 1, đặt lại mảng mỗi test.
  • Binary HeapVNOI WikiChèn và xóa gốc của đống bằng tiếng Việt.
  • 关联式容器OI WikiĐầy đủ hàm của set, multiset, map, và cảnh báo về lower_bound toàn cục.
  • 无序关联式容器OI WikiCách tấn công bảng băm của g++ và hàm băm splitmix64 chống tấn công.
  • Priority QueuesUSACO GuideKhi nào đống thay được set, kèm lộ trình bài tập.