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 . 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 , 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 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ử | ở cuối | trung bình | |||
| Tìm theo giá trị | không có | trung bình | |||
| Xóa theo giá trị | không có | trung bình | |||
| Đọc nhỏ nhất hoặc lớn nhất | cho một phía | ||||
| Số kế tiếp hoặc đứng trước | 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 :
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 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 : 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 lần thêm là . insert và erase tại vị trí tốn 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() - 1là , không phải .- Xóa hàng loạt bằng
erasetrong vòng lặp vừa sai (nhảy qua phần tử vừa dồn về) vừa chậm . Cách đúng làv.erase(remove(v.begin(), v.end(), x), v.end()), một lượt .
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?
size() trả về số không dấu; với vector rỗng v.size() - 1 quay vòng thành , lớn hơn mọi i. Phương án đầu là cách nghĩ theo số có dấu; trình biên dịch không báo lỗi gì. Viết i + 1 < v.size(), hoặc gán ll n = v.size(); rồi dùng n - 1.
Ví dụBao Nhiêu Giá Trị?
Đề. Dãy số . Đếm số giá trị khác nhau.
Lời giải. Hai cách cùng : đư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 nên không dùng được mảng đếm.
#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
5
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 . 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 dài 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à . 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, .
#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
ABA
Đố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 có cha và hai con , .
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 nên cả hai là .
priority_queue là đống cài sẵn: push , top , pop . 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?
Mặc định priority_queue nhả phần tử lớn nhất. Cặp so theo thứ tự từ điển: số đầu bằng nhau thì so tiếp số sau, nên . Thứ tự đẩy vào không ảnh hưởng. Phương án đầu nhầm với min-heap.
Đố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
Đề. món giá và phiếu; mỗi phiếu thay giá hiện tại của một món bằng . Dùng đủ phiếu để tổng giá nhỏ nhất.
Lời giải. Một phiếu dùng lên món giá tiết kiệm , lớn nhất khi 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 . lần, mỗi lần . Tổng tới , dùng long long.
#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
13
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ỡ tầng, nên tìm, chèn, xóa đều . 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 , s.upper_bound(x) là phần tử đầu tiên . 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 :
it = s.lower_bound(x), có đáp án khiit != s.end(). - Lớn nhất :
it = s.upper_bound(x), có đáp án khiit != s.begin(), và đáp án là*prev(it).
upper_bound(x) đứng ngay sau mọi phần tử , 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.
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ỡ bước mỗi lần. Hàm thành viên s.lower_bound(x) đi theo cây, . Tương tự, set không biết thứ hạng của phần tử: cần phần tử nhỏ thứ 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ó phần tử, và cần trả lời câu hỏi “số nhỏ nhất không bé hơn ”. 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?
Hàm toàn cục nhận mọi iterator nên biên dịch và đúng; số phép so sánh vẫn , nhưng để tới phần tử giữa nó phải dịch iterator từng bước. Phương án thứ hai nhầm “đã sắp” với “đi tới giữa được ngay”. Dùng hàm thành viên s.lower_bound(x).
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 . Muốn xóa đúng một bản, viếtms.erase(ms.find(x)), chỉ khi chắc chắn có .ms.count(x)tốn với là số bản sao. Cần đếm nhanh thì giữ mộtmapsố 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 ; nếu 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 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?
erase(giá trị) xóa mọi bản sao. Dạng erase(4, 1) không tồn tại, người viết nhầm với string::erase(pos, len). Đoạn [lower_bound(4), upper_bound(4)) là toàn bộ các số 4. Chỉ erase theo iterator mới xóa một phần tử.
Ví dụVé Trong Tầm Giá
Đề. Kho có vé giá ; khách đến lần lượt, khách ngân sách mua vé đắt nhất có giá không vượt nếu có, vé đã bán bị bỏ khỏi kho. In giá vé mỗi khách mua, hoặc . .
Lời giải. Kho phải trả lời “số lớn nhấ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 , 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 .
#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
4
8
-1
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 thay cho . Hàm băm biến khóa thành số nguyên; bảng có ô, khóa được cất ở ô , mỗi ô giữ danh sách các khóa rơi vào nó. Nếu 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ố ô lấy từ một bảng số nguyên tố cố định. Biết , người ra test chọn các khóa : 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 , cả bài . Đây là kiểu hack có sẵn trên các trang thi trực tuyến.
Hỏi nhanh
Một người ra test chọn khóa nguyên có dạng , với là số ô mà unordered_set mặc định của g++ sẽ có khi đã chứa đủ khóa. Vì sao chương trình chậm đi nhiều?
Khóa chia dư 0 với mọi , nên tất cả vào cùng một ô. Chi phí chia lấy dư không đổi theo độ lớn khóa; băm lại chỉ xảy ra khi số khóa vượt số ô; còn việc tự chuyển sang cây là hành vi của ngôn ngữ khác, C++ chuẩn không làm vậy.
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, 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:
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 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 với , tổng qua các test không quá . Đếm cặp có .
Lời giải. Chuyển vế: . Đặt khóa , bài thành đếm cặp cùng khóa: duyệt từ trái, mỗi 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 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 .
#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
10
0
3
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 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ìmaphoặc sắp rồi gom. Nhân giá với số thao tác và so với . - Với
setvàmultisetluôn gọi hàm thành viên (s.lower_bound), xóa một bản sao bằngerase(find(x)), và kiểmend(),begin(),empty()trước khi*,top()haypop(). - Chỉ dùng
unordered_mapkhi đã có hàm băm ngẫu nhiên; còn thấysize() - 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:
vectorcho cuối và chỉ số,priority_queuecho cực trị,set,multiset,mapcho thứ tự và hàng xóm, bảng băm cho “có hay không”. push_backtrung bình ;insertvàerasegiữa tốn , nên bỏ phần tử hàng loạt bằngerasecộngremove;size()không dấu.stack,queue,dequecho mọi thao tác ở đầu trong nhưng không đượctop,front,popkhi rỗng.priority_queuelà đống nhị phân:push,pop, mặc định nhả lớn nhất, chiều so sánh ngược vớisort; cần xóa tùy ý thì xóa lười hoặc dùngset.set,multiset,mapđều : nhỏ nhất làlower_bound, lớn nhất làprev(upper_bound),multisetxóa một bản bằngerase(find(x)),m[x]tự chèn khóa.- Bảng băm trung bình 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
maphay sắp xếp.
Bài tập
Đọc thêm
- Kinh nghiệm thi VOIVNOI Wiki
- Binary HeapVNOI Wiki
- 关联式容器OI Wiki
- 无序关联式容器OI Wiki
- Priority QueuesUSACO Guide