Cho số nguyên, tìm hiệu nhỏ nhất giữa hai số. Thử mọi cặp cần khoảng phép trừ; với là , quá chậm. Nhưng nếu các số đã xếp từ bé tới lớn, hai số gần nhau nhất chắc chắn đứng cạnh nhau, và chỉ còn cặp kề phải xét.
Sắp xếp làm được nhiều việc như thế: một lần sắp biến cả lớp bài thành một lượt quét, từ đếm giá trị khác nhau, tìm trung vị, tới các lời giải tham lam duyệt theo thứ tự. Bài này đi từ std::sort và hàm so sánh tới cách tự chọn một thứ tự đúng và nén tọa độ.
ISắp rồi quét cặp kề
Bài toán: cho dãy , tìm với . Gọi là dãy sau khi sắp tăng dần. Hiệu nhỏ nhất luôn đạt ở một cặp kề , vì với bất kỳ,
là tổng các hiệu kề không âm, nên . Với , dãy đã sắp là , các hiệu kề là , và đáp án ứng với cặp .
sort(a + 1, a + n + 1);
ll ans = 1e18;
for (ll i = 2; i <= n; i++) ans = min(ans, a[i] - a[i - 1]);
sort tốn , với là , số lần chia đôi cho tới khi còn 1 (bài 11 khóa C++). Với con số này chỉ khoảng 18, nên sắp xếp tốn cỡ phép so sánh, nhanh hơn thử mọi cặp hàng nghìn lần.
Dãy đã sắp còn cho thêm vài thứ gần như miễn phí. Các giá trị bằng nhau thành một khối liền, nên số giá trị phân biệt là số vị trí có cộng một. Phần tử nhỏ thứ là , một trung vị là phần tử đứng giữa, a[(n + 1) / 2]. Chỉ cần một lần giá trị nhỏ nhất hay lớn nhất thì không cần sắp, duyệt một lượt là đủ. Sắp xếp đáng giá khi bài cần nhiều thông tin thứ tự cùng lúc, và thường đi kèm hai con trỏ (bài 0.7) hoặc chặt nhị phân (bài 0.6).
Ví dụBộ Xếp Hình Đồng Đều
Đề. Có bộ xếp hình, bộ có mảnh. Chọn đúng bộ khác nhau để số mảnh lớn nhất trừ nhỏ nhất là nhỏ nhất.
Lời giải. Sắp tăng dần. Một cách chọn tốt nhất luôn là bộ liên tiếp trong dãy đã sắp: nếu cách chọn có min là và max là , thay nó bằng bộ không làm max tăng, vì . Vậy chỉ cần thử mọi cửa sổ và lấy nhỏ nhất.
Độ phức tạp: cả bài .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, m, f[55];
signed main() {
cin >> n >> m;
for (ll i = 1; i <= m; i++) cin >> f[i];
sort(f + 1, f + m + 1);
ll ans = 1e18;
for (ll l = 1; l + n - 1 <= m; l++) ans = min(ans, f[l + n - 1] - f[l]);
cout << ans << '\n';
}
5 9
14 4 8 9 10 15 6 7 20
4
IIHàm so sánh
Trong thi đấu ta không tự viết lại sắp xếp thông thường: std::sort đúng và nhanh, chuẩn C++ bảo đảm phép so sánh kể cả trường hợp xấu nhất.
sort(a + 1, a + n + 1);
sort(a + 1, a + n + 1, greater<ll>());
pair, tuple, string có sẵn thứ tự từ điển: so thành phần đầu, bằng nhau thì so tiếp. Với thứ tự khác, ta đưa thêm hàm cmp(x, y), trả về true khi phải đứng trước một cách chặt chẽ. Hàm này phải là thứ tự yếu chặt (strict weak ordering):
cmp(x, x)luôn sai;- nếu
cmp(x, y)đúng thìcmp(y, x)sai; - nếu
cmp(x, y)vàcmp(y, z)đúng thìcmp(x, z)đúng; - quan hệ “ngang hàng” (cả hai chiều đều sai) có tính bắc cầu.
Cách dễ nhất để thỏa cả bốn là gán mỗi phần tử một khóa và chỉ so khóa bằng < hoặc > chặt. Lỗi hay gặp nhất là viết <= hay >= với ý “bằng nhau cũng được”: một phần tử bị coi là đứng trước chính nó có thể khiến vòng quét bên trong sort chạy ra ngoài mảng, nên chương trình đúng ở test nhỏ rồi sập khi dữ liệu có nhiều giá trị bằng nhau.
Hỏi nhanh
Một bạn sắp mảng giảm dần bằng hàm bool cmp(ll x, ll y) { return x >= y; }. Test mẫu chạy đúng, nhưng ở test có rất nhiều số bằng nhau thì chương trình báo Runtime Error. Nguyên nhân là gì?
Hàm so sánh trả lời “ phải đứng trước một cách chặt chẽ”. Với câu trả lời đúng là không, mà >= lại nói có; điều đó vi phạm cmp(x, x) phải sai, nên các vòng quét trong sort có thể không dừng đúng chỗ. Chương trình vẫn biên dịch và chạy qua dữ liệu nhỏ, nên lỗi hay lọt qua test mẫu. Sửa thành x > y.
std::sort không ổn định (stable): thứ tự các phần tử bằng nhau là không xác định. Khi đề cần thứ tự duy nhất, thêm một khóa phân định (chỉ số gốc, tên); khi muốn giữ thứ tự nhập, dùng stable_sort. Khuôn của hàm nhiều khóa: khóa này khác nhau thì trả lời ngay theo nó, bằng thì sang khóa sau, khóa cuối là một phép so sánh chặt. Khi cần in số hiệu thay vì giá trị, ta sắp một mảng chỉ số, hoặc gói chỉ số vào bản ghi như ví dụ dưới.
Ví dụSắp Xếp Hồ Sơ Địa Điểm
Đề. hồ sơ, hồ sơ có tên thành phố và điểm (đôi một khác nhau). Sắp theo tên tăng dần; cùng thành phố thì điểm cao đứng trước. In chỉ số ban đầu theo thứ tự mới.
Lời giải. Mỗi hồ sơ là một bản ghi (tên, điểm, chỉ số). Hai khóa ngược chiều nhau nên viết hàm so sánh: tên khác nhau thì so tên tăng dần, cùng tên thì so điểm giảm dần. Điểm đôi một khác nhau nên không còn hòa, không cần khóa thứ ba.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
struct Rec {
string city;
ll score, id;
};
bool cmp(Rec x, Rec y) {
if (x.city != y.city) return x.city < y.city;
return x.score > y.score;
}
ll n;
Rec a[105];
signed main() {
cin >> n;
for (ll i = 1; i <= n; i++) {
cin >> a[i].city >> a[i].score;
a[i].id = i;
}
sort(a + 1, a + n + 1, cmp);
for (ll i = 1; i <= n; i++) cout << a[i].id << '\n';
}
5
delta 70
alpha 30
alpha 90
delta 50
beta 60
3
2
5
1
4
IIITự chọn thứ tự
Đề hiếm khi nói “hãy sắp”; ta phải tự chọn thứ tự, bằng hai cách.
Quy về khóa. Gán mỗi phần tử một giá trị dễ sắp sao cho sắp theo khóa đúng là thứ tự cần. Muốn sắp tên theo một bảng chữ cái khác, thay mỗi chữ bằng hạng của nó trong bảng mới. Muốn nhóm các chuỗi là hoán vị chữ cái của nhau, sắp chữ cái trong mỗi chuỗi: chúng cho ra cùng một chuỗi chuẩn.
So sánh cặp. Viết thẳng quy tắc “ đứng trước khi …”. Ví dụ ưu tiên tỉ lệ lớn: x.p * y.t > y.p * x.t. Nhân chéo thay cho phép chia nên không có số thực, vì so bằng double có thể coi hai tỉ lệ bằng nhau là khác nhau chút ít và phá tính bắc cầu. Nhớ ước lượng tích lớn nhất: cho tích tới , còn vừa long long.
Để chắc quy tắc so sánh cặp hợp lệ, hãy tìm hàm khóa sao cho cmp(x, y) đúng khi và chỉ khi (hoặc ). có thể là số thực ta không bao giờ tính ra; có nó thì bốn điều kiện tự đúng.
Còn làm sao biết thứ tự nào là đúng? Phương pháp quen thuộc là đổi chỗ hai phần tử kề nhau: lấy một phương án bất kỳ, xét hai phần tử đứng cạnh nhau và hỏi đổi chỗ thì tốt lên hay tệ đi. Ví dụ: việc, việc cần phút, làm lần lượt; chọn thứ tự để tổng thời điểm hoàn thành nhỏ nhất. Nếu hai việc kề nhau rồi có và cùng bắt đầu lúc , tổng thời điểm xong của chúng là ; đổi chỗ thì là , nhỏ hơn đúng , các việc khác không đổi. Vậy phương án tối ưu không có cặp kề nào nghịch thế: tăng dần. Khuôn lập luận này quay lại ở bài 0.5.
Ví dụGhép Thành Số Lớn Nhất
Đề. thẻ, mỗi thẻ ghi số nguyên dương . Xếp thành một hàng rồi đọc liền các số để được số lớn nhất, in số đó.
Lời giải. Mọi cách xếp cho chuỗi cùng độ dài, nên so hai kết quả là so hai chuỗi. Ký hiệu là số thu được khi viết rồi . Với hai thẻ kề nhau, phần đứng trước và sau chúng không đổi khi hoán đổi, nên đổi chỗ có lợi khi và chỉ khi . Sắp theo quy tắc “ đứng trước khi ”.
Quy tắc này hợp lệ vì có hàm khóa. Gọi , là số chữ số của , ; khi đó và , nên
Vậy quy tắc chính là “khóa lớn hơn thì đứng trước”, với là số gồm chữ số 9. Thứ tự đó cho kết quả lớn nhất: một cách xếp tối ưu không thể có cặp kề nào đổi chỗ được lợi, nên khóa không tăng từ trái sang phải.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n;
string s[10005];
bool cmp(string x, string y) {
return x + y > y + x;
}
signed main() {
cin >> n;
for (ll i = 1; i <= n; i++) cin >> s[i];
sort(s + 1, s + n + 1, cmp);
for (ll i = 1; i <= n; i++) cout << s[i];
cout << '\n';
}
5
9 91 34 3 30
99134330
IVNén tọa độ
Nhiều bài cho giá trị cỡ nhưng chỉ cần biết cái nào nhỏ hơn, cái nào bằng nhau, trong khi ta lại muốn dùng giá trị làm chỉ số của mảng đếm hay cây Fenwick (bài 1.7). Khi đó thay mỗi giá trị bằng thứ hạng của nó trong tập giá trị phân biệt: nén tọa độ (coordinate compression). Phép thay giữ nguyên mọi quan hệ và , nên bài chỉ dựa trên so sánh có cùng đáp án.
Ba bước: chép dãy ra, sắp, bỏ trùng bằng unique; thứ hạng của là vị trí của nó trong dãy đã bỏ trùng, tìm bằng lower_bound trong (bài 0.6).
for (ll i = 1; i <= n; i++) v[i] = a[i];
sort(v + 1, v + n + 1);
ll m = unique(v + 1, v + n + 1) - v - 1;
for (ll i = 1; i <= n; i++) a[i] = lower_bound(v + 1, v + m + 1, a[i]) - v;
Sau đoạn trên các giá trị của nằm trong . Với , các giá trị phân biệt nhận hạng và thành .
Hỏi nhanh
Mảng v = {5, 3, 5, 3} được gọi unique mà không sắp trước. Bốn phần tử đầu của v sau đó thế nào?
unique chỉ bỏ các phần tử bằng nhau mà đứng kề nhau: nó so mỗi phần tử với phần tử ngay trước. Ở đây không có hai phần tử kề nào bằng nhau nên dãy giữ nguyên. Muốn bỏ mọi giá trị trùng thì phải sắp trước, để các giá trị bằng nhau tụ thành khối. unique cũng không bao giờ đổi thứ tự phần tử.
Khi đi thi
- Trước khi viết hai vòng lặp trên mọi cặp, hãy hỏi: nếu dãy đã sắp theo một khóa nào đó thì đáp án có nằm ở hai phần tử kề nhau, ở một cửa sổ liên tiếp hay ở ranh giới giữa hai khối không? Nếu có thì sắp rồi quét một lượt.
- Hàm so sánh dùng
<hoặc>chặt, so từng khóa, thêm chỉ số gốc ở cuối khi đề cần thứ tự duy nhất. Runtime Error chỉ ở test có nhiều giá trị bằng nhau thì nghĩ tới<=trước tiên. So tỉ lệ bằng nhân chéo trênlong long. - Đọc miền giá trị trước khi chọn công cụ: miền nhỏ thì mảng đếm, miền lớn mà chỉ cần thứ tự thì nén tọa độ. Số nghịch thế của phần tử tới nên dùng
long long.
Tóm tắt
- Sắp xếp tốn và biến nhiều bài thành một lượt quét: cặp kề, cửa sổ liên tiếp, khối giá trị bằng nhau.
- Hàm so sánh của
std::sortphải là thứ tự yếu chặt:cmp(x, x)sai, so khóa bằng<chặt, thêm chỉ số để phân định,stable_sortkhi cần giữ thứ tự nhập. - Giá trị lớn mà chỉ cần thứ tự thì nén tọa độ bằng
sort,unique,lower_bound. - Một thứ tự đúng được chứng minh bằng đổi chỗ hai phần tử kề nhau, và quan hệ “đứng trước” phải xuất phát từ một hàm khóa thì mới hợp lệ.
Bài tập
Bài tập ngoài
Tự chọn thứ tự
ExamsCodeforcesIIINén tọa độ
Coordinate CompressionAtCoderIVĐọc thêm
- Thuật toán sắp xếpVNOI Wiki
- Competitive Programmer’s Handbook, chương 3
- Custom Comparators and Coordinate CompressionUSACO Guide
- std::sortcppreference
- 归并排序OI Wiki