Chương 0·Bài 03

Sorting

Cho nn số nguyên, hãy tìm hiệu nhỏ nhất giữa hai số ở hai vị trí khác nhau. Thử mọi cặp cần n(n−1)2\frac{n(n-1)}{2} phép trừ; với n=2⋅105n = 2 \cdot 10^5 đó là 2⋅10102 \cdot 10^{10}, quá chậm. Nhưng khi 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 n−1n - 1 cặp kề để xét. Sắp xếp (sort) tốn O(nlog⁡n)O(n \log n), trong đó log⁡n\log n viết đầy đủ là log⁡2n\log_2 n (đọc là “lô-ga-rít cơ số 2 của nn”): số lần chia đôi nn cho tới khi còn 1, chẳng hạn log⁡28=3\log_2 8 = 3 vì 8→4→2→18 \to 4 \to 2 \to 1. Với n=2⋅105n = 2 \cdot 10^5 con số này chỉ khoảng 18, nên sắp xếp tốn khoảng 3,5⋅1063{,}5 \cdot 10^6 phép so sánh, và đổi cả một lớp bài lớn thành một lượt quét: đếm giá trị khác nhau, tìm trung vị, ghép cặp, mọi lời giải tham lam duyệt theo thứ tự.

Sắp xếp rồi quét hàng xóm

Bài toán: cho dãy x1,…,xnx_1, \dots, x_n, tìm min⁡∣xi−xj∣\min |x_i - x_j| với i≠ji \ne j. Gọi aa là dãy sau khi sắp tăng dần. Hiệu nhỏ nhất luôn đạt ở một cặp kề (ai−1,ai)(a_{i-1}, a_i), vì với i<ji < j bất kỳ,

aj−ai=(ai+1−ai)+(ai+2−ai+1)+⋯+(aj−aj−1),a_j - a_i = (a_{i+1} - a_i) + (a_{i+2} - a_{i+1}) + \dots + (a_j - a_{j-1}),

là tổng các hiệu kề không âm, nên aj−ai≥ai+1−aia_j - a_i \ge a_{i+1} - a_i. Với x=(17,4,9,25,12,31)x = (17, 4, 9, 25, 12, 31), dãy đã sắp là (4,9,12,17,25,31)(4, 9, 12, 17, 25, 31), các hiệu kề là 5,3,5,8,65, 3, 5, 8, 6, và đáp án 33 ứng với cặp (9,12)(9, 12).

Sau khi sắp xếp chỉ còn n − 1 hiệu kề phải xét.
C++
sort(a + 1, a + n + 1);
ll ans = 1e18;
for (ll i = 2; i <= n; i++) ans = min(ans, a[i] - a[i - 1]);

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ó ai≠ai−1a_i \ne a_{i-1} cộng một. Phần tử nhỏ thứ kk là aka_k, 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 06) hoặc chặt nhị phân (bài 05).

Ví dụBộ Xếp Hình Đồng Đều

Đề. Có m≤50m \le 50 bộ xếp hình, bộ ii có fif_i mảnh. Chọn đúng nn 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 ff tăng dần. Một cách chọn tốt nhất luôn là nn bộ liên tiếp trong dãy đã sắp: nếu cách chọn có min là flf_l và max là frf_r, thay nó bằng nn bộ fl,…,fl+n−1f_l, \dots, f_{l+n-1} không làm max tăng, vì fl+n−1≤frf_{l+n-1} \le f_r. Vậy chỉ cần thử mọi cửa sổ [l,l+n−1][l, l + n - 1] và lấy fl+n−1−flf_{l+n-1} - f_l nhỏ nhất. Cả bài O(mlog⁡m)O(m \log m).

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

Dùng std::sort và hà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 O(nlog⁡n)O(n \log n) phép so sánh kể cả trường hợp xấu nhất.

C++
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 xx phải đứng trước yy 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 kinh điển 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ì?

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

Đề. N≤100N \le 100 hồ sơ, hồ sơ ii có tên thành phố SiS_i và điểm PiP_i (đô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.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

Sắp xếp trộn, nghịch thế và cận dưới

Chọn, chèn, nổi bọt đều có hai vòng lặp lồng nhau và tốn O(n2)O(n^2). Với nổi bọt và chèn, lý do nằm ở nghịch thế. Nghịch thế (inversion) là cặp i<ji < j mà ai>aja_i > a_j; dãy giảm dần có n(n−1)2\frac{n(n-1)}{2} nghịch thế. Đổi chỗ hai phần tử kề nhau đang sai thứ tự xóa đúng một nghịch thế, nên thuật toán chỉ đổi chỗ phần tử kề nhau phải đổi ít nhất bằng số nghịch thế. Muốn nhanh hơn phải cho phần tử nhảy xa.

Sắp xếp trộn (merge sort) làm điều đó bằng cách chia đôi. Để sắp đoạn [l,r][l, r]: nếu chỉ có một phần tử thì xong; ngược lại đặt mid = (l + r) / 2 (chia nguyên), sắp đệ quy [l,mid][l, mid] và [mid+1,r][mid + 1, r], rồi trộn hai nửa bằng hai con trỏ, mỗi bước lấy ra số nhỏ hơn trong hai số đang trỏ: (1,4,6,9)(1, 4, 6, 9) trộn với (2,3,7)(2, 3, 7) cho 1,2,3,4,6,7,91, 2, 3, 4, 6, 7, 9. Cách này đúng vì phần tử nhỏ nhất còn lại luôn nằm ở đầu một trong hai nửa.

Trộn từ dưới lên: mỗi tầng chạm mỗi phần tử một lần; lấy số bên phải ra trước thì cộng số phần tử trái còn lại.

Độ dài đoạn giảm một nửa sau mỗi tầng nên có khoảng log⁡2n\log_2 n tầng; ở mỗi tầng các đoạn rời nhau và phủ cả mảng, trộn tốn thời gian tuyến tính, nên cả thuật toán tốn O(nlog⁡n)O(n \log n). Với n=2⋅105n = 2 \cdot 10^5 có 18 tầng.

Trong lúc trộn ta còn đếm được nghịch thế. Một nghịch thế nằm trọn trong nửa trái, trọn trong nửa phải, hoặc bắc qua hai nửa; hai loại đầu do hai lời gọi đệ quy đếm. Loại bắc qua đếm ngay lúc trộn: khi lấy aja_j của nửa phải ra, các phần tử nửa trái chưa lấy đều lớn hơn aja_j và đứng trước nó, nên có đúng mid - i + 1 nghịch thế mới, với i là con trỏ nửa trái. Hai số bằng nhau thì lấy bên trái trước, nên chúng không bị tính.

Hỏi nhanh

Khi trộn, nửa trái là a[l..mid], nửa phải là a[mid+1..r], con trỏ nửa trái đang ở i. Ta vừa lấy a[j] của nửa phải ra trong khi nửa trái chưa hết. Cần cộng bao nhiêu vào số nghịch thế vì a[j]?

Còn câu hỏi cuối: có thuật toán nào nhanh hơn nlog⁡nn \log n không? Với các thuật toán chỉ dựa vào so sánh thì không. Dãy nn phần tử khác nhau có n!n! thứ tự ban đầu, và mỗi thứ tự cần một cách sắp riêng. Mỗi phép so sánh chỉ trả lời “có” hoặc “không”, nên nhiều nhất chia đôi số khả năng còn lại; muốn phân biệt hết n!n! khả năng thì cần khoảng log⁡2n!\log_2 n! phép so sánh, mà con số này cỡ nlog⁡2nn \log_2 n. Muốn nhanh hơn phải thôi so sánh, tức dùng thêm thông tin về giá trị.

Ví dụĐếm Cặp Ngược Thứ Tự

Đề. Dãy N≤2⋅105N \le 2 \cdot 10^5 số nguyên, ∣ai∣≤109|a_i| \le 10^9. Đếm số cặp i<ji < j với ai>aja_i > a_j (bằng nhau không tính).

Lời giải. Sắp xếp trộn và cộng mid - i + 1 mỗi lần lấy phần tử nửa phải ra trước. Điều kiện lấy bên trái là a[i] <= a[j]: hai số bằng nhau lấy bên trái trước nên không bị đếm, đúng với dấu so sánh chặt của đề. Số nghịch thế tới N(N−1)2≈2⋅1010\frac{N(N-1)}{2} \approx 2 \cdot 10^{10}, cần long long.

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

ll solve(ll l, ll r) {
    if (l == r) return 0;
    ll mid = (l + r) >> 1;
    ll res = solve(l, mid) + solve(mid + 1, r);
    ll i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        if (a[i] <= a[j]) tmp[k++] = a[i++];
        else {
            res += mid - i + 1;
            tmp[k++] = a[j++];
        }
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r) tmp[k++] = a[j++];
    for (ll x = l; x <= r; x++) a[x] = tmp[x];
    return res;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    cout << solve(1, n) << '\n';
}
4
3 1 2 1

Sắp xếp đếm khi giá trị nằm trong miền nhỏ

Có n=107n = 10^7 số nguyên trong [1,104][1, 10^4]. std::sort cần cỡ nlog⁡2n≈2,3⋅108n \log_2 n \approx 2{,}3 \cdot 10^8 phép so sánh, trong khi chỉ có 10410^4 giá trị khác nhau. Ta chỉ cần biết mỗi giá trị xuất hiện mấy lần: bảng đếm cnt[v], rồi viết vv ra đúng cnt[v] lần theo thứ tự tăng của vv. Đó là sắp xếp đếm (counting sort), tốn O(n+V)O(n + V) khi giá trị nằm trong [0,V][0, V], không so sánh phần tử nào nên không vướng cận nlog⁡nn \log n.

Khi mỗi phần tử còn mang dữ liệu khác (tên, chỉ số) và ta sắp theo một khóa nguyên nhỏ, cần bản ổn định. Lấy tổng tiền tố của bảng đếm (bài 02) để biết khối của mỗi khóa bắt đầu ở đâu, rồi duyệt đầu vào từ trái sang phải, đặt mỗi phần tử vào chỗ trống đầu tiên của khối khóa của nó. Phần tử đến trước nhận vị trí nhỏ hơn nên thứ tự giữa các phần tử cùng khóa được giữ nguyên.

Mỗi phần tử là khóa kèm tên; tiền tố của cnt cho ô đầu của mỗi khối.
C++
for (ll i = 1; i <= n; i++) st[key[i] + 1]++;
st[0] = 1;
for (ll v = 1; v <= V + 1; v++) st[v] += st[v - 1];
for (ll i = 1; i <= n; i++) ord[st[key[i]]++] = i;

Sau đoạn trên ord[1..n] là các chỉ số theo khóa tăng dần, cùng khóa thì giữ thứ tự nhập. Tính ổn định còn cho phép sắp nhiều khóa bằng nhiều lượt: sắp theo khóa phụ trước, rồi sắp ổn định theo khóa chính; áp dụng với các “chữ số” từ thấp lên cao ta được sắp xếp theo cơ số (radix sort).

Sắp xếp đếm đáng dùng khi VV cỡ nn trở xuống. Với giá trị tới 10910^9, bảng đếm cần 10910^9 ô; khi đó dùng std::sort, hoặc nén tọa độ ở mục cuối nếu chỉ cần thứ tự.

Hỏi nhanh

Có n=2⋅105n = 2 \cdot 10^5 số nguyên, mỗi số trong [0,109][0, 10^9]. Một bạn nói: “Sắp xếp đếm tốn O(n+V)O(n + V), tuyến tính, nên nhanh hơn std::sort.” Nhận định này sai ở đâu?

Ví dụDãy Số Theo Thứ Tự Tăng

Đề. n≤107n \le 10^7 số nguyên dương ai≤104a_i \le 10^4, mỗi số một dòng. In dãy sau khi sắp không giảm, mỗi số một dòng.

Lời giải. Giá trị nhỏ còn nn rất lớn: đếm cnt[v] rồi in vv đúng cnt[v] lần, O(n+104)O(n + 10^4). Phần tính toán chỉ là vài dòng; phần chậm nhất lại là đọc và ghi, vì đầu vào cỡ 60 MB và đầu ra cũng vậy. Ngay cả cin đã tắt đồng bộ cũng mất cỡ một giây cho 10710^7 số, nên ta tự làm việc đọc ghi theo từng khối lớn. Lời giải gồm bốn phần.

Bước 1: đọc theo khối. fread chép một lần 2162^{16} byte từ đầu vào vào mảng ibuf; gc() trả về từng ký tự trong khối đó và chỉ gọi fread lại khi đã dùng hết. Hết dữ liệu thì fread trả về 0 và gc() trả về ký tự 0.

C++
char gc() {
    if (ip == il) {
        il = fread(ibuf, 1, sizeof ibuf, stdin);
        ip = 0;
        if (il == 0) return 0;
    }
    return ibuf[ip++];
}

Bước 2: tách số. read() bỏ qua mọi ký tự đứng trước '0' trong bảng mã, tức dấu cách và xuống dòng, rồi ghép các chữ số thành một số như ở bài B1 (nhân 10 rồi cộng chữ số mới). Đề chỉ có số dương nên không cần lo dấu trừ.

C++
ll read() {
    char c = gc();
    while (c < '0') c = gc();
    ll x = 0;
    while (c >= '0') {
        x = x * 10 + c - '0';
        c = gc();
    }
    return x;
}

Bước 3: ghi qua bộ đệm. put(c) thêm một ký tự vào obuf; đầy thì đẩy cả khối ra bằng fwrite. Cuối main còn một lần fwrite cho phần dư.

C++
void put(char c) {
    if (op == sizeof obuf) {
        fwrite(obuf, 1, op, stdout);
        op = 0;
    }
    obuf[op++] = c;
}

Bước 4: đếm rồi in. Đọc nn số vào bảng đếm. Duyệt vv từ 1 tới 10410^4, đổi vv thành xâu một lần bằng to_string, rồi ghi xâu đó cnt[v] lần. Thứ tự tăng của vv cho thứ tự không giảm của dãy.

C++
n = read();
for (ll i = 1; i <= n; i++) cnt[read()]++;
for (ll v = 1; v <= 10000; v++) {
    string s = to_string(v);
    for (ll k = 1; k <= cnt[v]; k++) {
        for (char c : s) put(c);
        put('\n');
    }
}
fwrite(obuf, 1, op, stdout);

Ghép bốn phần lại:

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll n, cnt[10005];
char ibuf[1 << 16], obuf[1 << 16];
ll ip, il, op;

char gc() {
    if (ip == il) {
        il = fread(ibuf, 1, sizeof ibuf, stdin);
        ip = 0;
        if (il == 0) return 0;
    }
    return ibuf[ip++];
}

ll read() {
    char c = gc();
    while (c < '0') c = gc();
    ll x = 0;
    while (c >= '0') {
        x = x * 10 + c - '0';
        c = gc();
    }
    return x;
}

void put(char c) {
    if (op == sizeof obuf) {
        fwrite(obuf, 1, op, stdout);
        op = 0;
    }
    obuf[op++] = c;
}

signed main() {
    n = read();
    for (ll i = 1; i <= n; i++) cnt[read()]++;
    for (ll v = 1; v <= 10000; v++) {
        string s = to_string(v);
        for (ll k = 1; k <= cnt[v]; k++) {
            for (char c : s) put(c);
            put('\n');
        }
    }
    fwrite(obuf, 1, op, stdout);
}
8
4
1
4
2
10000
2
1
7

Mỗi số được đọc một lần và in một lần, cộng 10410^4 bước duyệt bảng đếm: O(n+V)O(n + V) thời gian, O(V)O(V) bộ nhớ ngoài hai bộ đệm.

Tự đặt thứ tự: khóa, so sánh cặp và đổi chỗ kề

Đề 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 “xx đứng trước yy khi …”. Ví dụ ưu tiên tỉ lệ p/tp / t 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: p,t≤109p, t \le 10^9 cho tích tới 101810^{18}, 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 ff sao cho cmp(x, y) đúng khi và chỉ khi f(x)<f(y)f(x) < f(y) (hoặc >>). ff 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ụ: nn việc, việc ii cần tit_i 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 xx rồi yy có tx>tyt_x > t_y và cùng bắt đầu lúc SS, tổng thời điểm xong của chúng là 2S+2tx+ty2S + 2t_x + t_y; đổi chỗ thì là 2S+2ty+tx2S + 2t_y + t_x, nhỏ hơn đúng tx−tyt_x - t_y, 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ế: tt tăng dần. Khuôn lập luận này quay lại ở bài 04.

Ví dụGhép Thành Số Lớn Nhất

Đề. N≤104N \le 10^4 thẻ, mỗi thẻ ghi số nguyên dương ai≤99a_i \le 99. 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 xy‾\overline{xy} là số thu được khi viết xx rồi yy. 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 yx‾>xy‾\overline{yx} > \overline{xy}. Sắp theo quy tắc “xx đứng trước yy khi xy‾>yx‾\overline{xy} > \overline{yx}”.

Quy tắc này hợp lệ vì có hàm khóa. Gọi pp, qq là số chữ số của xx, yy; khi đó xy‾=x⋅10q+y\overline{xy} = x \cdot 10^q + y và yx‾=y⋅10p+x\overline{yx} = y \cdot 10^p + x, nên

xy‾>yx‾  ⟺  x (10q−1)>y (10p−1)  ⟺  x10p−1>y10q−1.\overline{xy} > \overline{yx} \iff x\,(10^q - 1) > y\,(10^p - 1) \iff \frac{x}{10^p - 1} > \frac{y}{10^q - 1}.

Vậy quy tắc chính là “khóa f(x)=x10p−1f(x) = \frac{x}{10^p - 1} lớn hơn thì đứng trước”, với 10p−110^p - 1 là số gồm pp 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.

Khóa của x là x chia cho số gồm toàn chữ 9 cùng độ dài; sắp khóa giảm dần.
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

Nén tọa độ và nhóm theo khóa

Nhiều bài cho giá trị cỡ 10910^9 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 16). 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 xx là vị trí của nó trong dãy đã bỏ trùng, tìm bằng lower_bound trong O(log⁡n)O(\log n) (bài 05).

C++
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 aa nằm trong [1,m][1, m]. Với a=(109,5,5,42,7,109)a = (10^9, 5, 5, 42, 7, 10^9), các giá trị phân biệt 5,7,42,1095, 7, 42, 10^9 nhận hạng 1,2,3,41, 2, 3, 4 và aa thành (4,1,1,3,2,4)(4, 1, 1, 3, 2, 4).

Mỗi giá trị nối tới vị trí của nó trong dãy phân biệt đã sắp; vị trí đó là hạng.

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?

Dãy đã sắp còn cho phép nhóm theo khóa: các phần tử cùng khóa thành khối liền, ta duyệt từng khối và xử lý nhóm trong thời gian tỉ lệ với kích thước của nó.

Ví dụMã Hồ Sơ Theo Nhóm

Đề. M≤105M \le 10^5 hồ sơ, hồ sơ ii thuộc nhóm PiP_i và có mốc thời gian YiY_i (đôi một khác nhau). Trong từng nhóm, hồ sơ sớm nhất có hạng 1, tiếp theo hạng 2… Mã hồ sơ gồm 12 chữ số: PiP_i rồi hạng, mỗi phần đệm số 0 cho đủ sáu chữ số. In mã theo thứ tự đầu vào.

Lời giải. Sắp các bản ghi (nhóm, thời gian, chỉ số gốc) theo thứ tự từ điển: mỗi nhóm thành một khối liền, thời gian tăng dần trong khối, nên hạng chính là vị trí trong khối. Duyệt với một bộ đếm, gặp nhóm mới thì đặt lại 1, ghi hạng về rnk[chỉ số gốc] để in theo thứ tự đầu vào. Cả bài O(Mlog⁡M)O(M \log M); đếm trực tiếp theo từng hồ sơ tốn O(M2)O(M^2).

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 1e5 + 5;
ll n, m, p[N], rnk[N];
tuple<ll, ll, ll> rec[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    for (ll i = 1; i <= m; i++) {
        ll y;
        cin >> p[i] >> y;
        rec[i] = {p[i], y, i};
    }
    sort(rec + 1, rec + m + 1);
    ll cur = 0;
    for (ll i = 1; i <= m; i++) {
        if (i > 1 && get<0>(rec[i]) == get<0>(rec[i - 1])) cur++;
        else cur = 1;
        rnk[get<2>(rec[i])] = cur;
    }
    cout << setfill('0');
    for (ll i = 1; i <= m; i++) cout << setw(6) << p[i] << setw(6) << rnk[i] << '\n';
}
4 5
2 2005
1 2010
2 1990
4 2001
1 1980

setw chỉ tác dụng cho lần in kế tiếp, còn setfill giữ hiệu lực cho tới khi đổi.

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ên long 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 2⋅1052 \cdot 10^5 phần tử tới 2⋅10102 \cdot 10^{10} nên dùng long long.

Tóm tắt

  • Sắp xếp tốn O(nlog⁡n)O(n \log n) và biến nhiều bài O(n2)O(n^2) 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::sort phả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_sort khi cần giữ thứ tự nhập.
  • Sắp xếp trộn tốn O(nlog⁡n)O(n \log n) và đếm nghịch thế bằng mid - i + 1 mỗi lần lấy phần tử nửa phải; mọi thuật toán chỉ so sánh cần ít nhất log⁡2n!\log_2 n! lần so sánh.
  • Sắp xếp đếm tốn O(n+V)O(n + V) khi giá trị trong [0,V][0, V]; 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

1
Các Cột Sau Khi Nghiêngcanuc80k0300Chiều cao sau khi nghiêng chính là dãy ban đầu xếp tăng dần.
LQDOJ
2
Đếm Giá Trị Phân Biệtcanuc80k0302Sau khi sắp, đếm các vị trí có giá trị khác phần tử ngay trước.
LQDOJ
3
Thứ Tự Đến Trườngcanuc80k0307Sắp các cặp (thứ tự đến, số hiệu) rồi in số hiệu.
LQDOJ
4
Nhóm Đồng Hạngcanuc80k0308Hai khóa ngược chiều: số bài giảm dần, thời gian phạt tăng dần; rồi đếm các đội trùng thành tích với đội thứ kk.
LQDOJ
5
Hai Người Chọn Thẻcanuc80k0305Sắp giảm dần; người đi trước lấy các vị trí lẻ, người đi sau lấy vị trí chẵn.
LQDOJ
6
Sắp Xếp Theo Bảng Chữ Cái Mớicanuc80k0330Đổi mỗi chữ cái thành hạng của nó trong bảng chữ cái mới rồi so sánh từ điển như thường.
LQDOJ
7
Thu Gọn Dữ Liệucanuc80k0322Nén tệp nào tiết kiệm nhiều nhất trước: sắp mức tiết kiệm giảm dần, trừ dần cho tới khi vừa sức chứa.
LQDOJ
8
Xếp Hạng Theo Tỷ Lệ Chính Xáccanuc80k0349So hai tỷ lệ bằng nhân chéo thay vì chia, bằng nhau thì chỉ số nhỏ trước; kiểm tra tích có vừa long long.
LQDOJ
9
Khuyến Mãi Mua Bacanuc80k0333Sắp giảm dần rồi chia thành các bộ ba liên tiếp; mỗi bộ ba được miễn một cuốn.
LQDOJ
10
Đưa Các Que Về Cùng Độ Dàicanuc80k0317Độ dài đích tối ưu liên quan tới trung vị của dãy đã sắp; tổng chi phí dùng long long.
LQDOJ
11
Đổi Chỗ Khác Tính Chẵn Lẻcanuc80k0313Hai phần tử cùng tính chẵn lẻ có đổi chỗ được không, nếu dùng một phần tử khác tính chẵn lẻ làm trung gian?
LQDOJ
12
Cặp Chuỗi Cùng Túi Chữcanuc80k0911Hai chuỗi cùng túi chữ khi sắp chữ cái của chúng ra cùng một chuỗi; sắp danh sách chuỗi chuẩn rồi đếm theo khối bằng nhau.
LQDOJ
13
Những Người Có Thể Vô Địchcanuc80k0336Sắp tăng kèm chỉ số gốc, tính tổng tiền tố; đem tổng của các vị trí đầu so với giá trị ngay sau.
LQDOJ
14
Nhật Ký Sắp Xếp Nổi Bọtcanuc80k0315Mô phỏng nổi bọt trên năm số, in dãy sau mỗi lần đổi chỗ.
LQDOJ

Đọc thêm

  • Thuật toán sắp xếpVNOI WikiCài từng thuật toán: nổi bọt, chèn, trộn, vun đống, nhanh, theo cơ số, kèm tính ổn định.
  • Competitive Programmer’s Handbook, chương 3Nghịch thế, sắp xếp trộn, cận dưới nlog⁡nn \log n bằng cây quyết định và sắp xếp đếm, rất cô đọng.
  • Custom Comparators and Coordinate CompressionUSACO GuideHàm so sánh nhiều khóa, điều kiện bắt buộc của thứ tự và nén tọa độ, kèm bài tập có lời giải.
  • std::sortcppreferenceBảo đảm độ phức tạp, tính không ổn định và yêu cầu với hàm so sánh.
  • 归并排序OI WikiSắp xếp trộn đệ quy và từ dưới lên, đếm nghịch thế lúc trộn.