Chương 0·Bài 0.1

Brute Force

Muốn biết trong một nhóm số có hai số nào cộng lại bằng 10 không, cách chắc chắn nhất là thử hết mọi cặp. Vét cạn (brute force) làm đúng như vậy: liệt kê mọi khả năng có thể là đáp án, kiểm từng cái, rồi giữ cái tốt nhất hoặc đếm những cái thỏa. Liệt kê đủ và kiểm đúng thì kết quả đúng, không cần thêm ý tưởng nào.

Cái khó chỉ nằm ở thời gian: số khả năng tăng rất nhanh theo nn. Bài này dạy cách liệt kê các loại ứng viên hay gặp mà không sót, không trùng, và cách tính trước xem vét cạn có kịp không. Đề nhỏ thì vét cạn chính là lời giải; đề lớn thì nó là bộ kiểm cho lời giải nhanh.

Khi nào đủ nhanh

Mọi lời giải vét cạn có hai phần. Không gian ứng viên là tập những thứ ta sẽ thử, chọn sao cho mỗi đáp án thật ứng với đúng một ứng viên (khi đề yêu cầu đếm) hoặc ít nhất một ứng viên (khi chỉ cần tối ưu). Phép kiểm nhìn một ứng viên và cho biết nó có hợp lệ không, giá trị bao nhiêu. Lời giải đúng khi trả lời được ba câu: ứng viên có phủ hết đáp án không, mỗi đáp án có bị đếm đúng một lần không, phép kiểm có chính xác không.

Phần còn lại là tốc độ. Máy chấm làm được cỡ 10810^8 phép đơn giản mỗi giây: cộng, so sánh, đọc ghi các ô liền nhau của mảng. Phép chia, map, truy cập mảng lớn lộn xộn đều chậm hơn nhiều lần. Quy tắc thực dụng là

soˆˊ ứng vieˆn×chi phıˊ kiểm một ứng vieˆn≤khoảng 108,\text{số ứng viên} \times \text{chi phí kiểm một ứng viên} \le \text{khoảng } 10^8,

và chỉ cỡ vài 10710^7 nếu phép kiểm nặng. Các mốc quen thuộc:

Giới hạn Duyệt được Số ứng viên
n≤10n \le 10 mọi hoán vị 10!≈3,6⋅10610! \approx 3{,}6 \cdot 10^6
n≤20n \le 20 mọi tập con 220≈1062^{20} \approx 10^6
n≤100n \le 100 mọi bộ ba n3=106n^3 = 10^6
n≤5000n \le 5000 mọi cặp, mọi đoạn con n2≈2,5⋅107n^2 \approx 2{,}5 \cdot 10^7
n≥105n \ge 10^5 không duyệt trực tiếp được n2=1010n^2 = 10^{10}
Gõ n của đề để xem cách duyệt nào còn kịp một giây.

Bảng chỉ là mốc thô; luôn thay số của chính đề, và đừng quên chi phí kiểm. Duyệt 2202^{20} tập con chỉ là 10610^6 ứng viên, nhưng nếu mỗi tập phải cộng lại 2020 phần tử thì thành 2⋅1072 \cdot 10^7 phép, và với n=25n = 25 là 225⋅25≈8⋅1082^{25} \cdot 25 \approx 8 \cdot 10^8, hết ngân sách.

Để nói gọn, ta chỉ giữ phần tăng nhanh nhất theo nn và bỏ hằng số nhân: duyệt mọi cặp tốn n(n−1)2\frac{n(n-1)}{2} phép, viết là O(n2)O(n^2), đọc là “bậc n2n^2”; duyệt mọi tập con rồi cộng lại tốn O(n⋅2n)O(n \cdot 2^n); một việc chỉ tốn vài phép, không phụ thuộc nn, viết là O(1)O(1). Ký hiệu này gọi là độ phức tạp thời gian, và từ đây mỗi lời giải đều kèm một câu như vậy.

Hỏi nhanh

Đề cho n≤25n \le 25 số và hỏi có bao nhiêu tập con có tổng bằng SS. Một bạn duyệt mọi tập con, với mỗi tập cộng lại từ đầu các phần tử được chọn (tốn khoảng nn phép). Ước lượng nào đúng?

Ví dụĐiểm Hẹn Ít Mệt Nhất

Đề. N≤100N \le 100 người ở các vị trí nguyên Xi≤100X_i \le 100. Chọn điểm hẹn nguyên PP để tổng (Xi−P)2(X_i - P)^2 nhỏ nhất, in tổng đó.

Lời giải. Ứng viên là điểm hẹn PP. Điểm hẹn ngoài đoạn [1,100][1, 100] không bao giờ tốt hơn điểm biên gần nó, vì kéo PP về phía mọi người làm mọi khoảng cách nhỏ đi, nên chỉ cần thử P=1..100P = 1..100. Phép kiểm cộng NN bình phương. Cả bài 100⋅100=104100 \cdot 100 = 10^4 phép, không cần nghĩ thêm công thức nào.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, x[105];

signed main() {
    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> x[i];
    ll ans = 1e18;
    for (ll p = 1; p <= 100; p++) {
        ll cost = 0;
        for (ll i = 1; i <= n; i++) cost += (x[i] - p) * (x[i] - p);
        ans = min(ans, cost);
    }
    cout << ans << '\n';
}
3
1 4 7

Cặp và bộ ba

Ứng viên đơn giản nhất là một cặp hay một bộ ba, duyệt bằng các vòng for lồng nhau. Đếm các cặp chỉ số i<ji < j có ai+aj=ka_i + a_j = k:

C++
for (ll i = 1; i <= n; i++)
    for (ll j = i + 1; j <= n; j++)
        if (a[i] + a[j] == k) cnt++;

Điểm đáng nói là cận j = i + 1. Cho jj chạy từ đầu thì mỗi cặp bị gặp hai lần dưới dạng (i,j)(i, j) và (j,i)(j, i), lại thêm các cặp i=ji = j. Cận j>ij > i liệt kê đúng một lần mỗi cặp không thứ tự, tức n(n−1)2\frac{n(n-1)}{2} cặp. Nếu đề hỏi cặp có thứ tự thì cho cả hai vòng chạy đủ rồi bỏ i=ji = j. Đọc kỹ xem (i,j)(i, j) và (j,i)(j, i) là một hay hai, đây là lỗi hay gặp nhất ở bài đếm cặp.

Cận đúng không chỉ tránh đếm trùng mà còn nhanh hơn thấy rõ, dù hai cách cùng là O(n2)O(n^2):

Đếm cặp có tổng bằng k, n = 20000.j từ i + 1158 msj từ 1, bỏ i = j, chia đôi330 ms · gấp 2,1 lần
Đếm cặp có tổng bằng k với n bằng 20000 theo hai cách đặt cận.

Vòng lặp đủ làm gấp đôi số cặp và thêm một phép so sánh i != j mỗi bước, nên chậm hơn khoảng gấp đôi. Bớt được nửa số ứng viên là bớt được nửa thời gian.

Khi các biến ràng buộc nhau bằng một đẳng thức, có cách khéo hơn lồng thêm vòng. Đếm bộ (a,b,c)(a, b, c) nguyên dương với a≤b≤c≤Na \le b \le c \le N và a2+b2=c2a^2 + b^2 = c^2: ba vòng lặp có cỡ N3/6N^3/6 ứng viên, nhưng khi đã chọn aa, bb thì cc buộc phải bằng a2+b2\sqrt{a^2 + b^2}. Ta chỉ duyệt aa, bb, suy ra cc rồi kiểm cc có nguyên và không vượt NN không. Quy tắc chung: số vòng lặp bằng số biến thật sự tự do; biến cuối tính từ các biến đã chọn, rồi kiểm đủ điều kiện của nó.

Hỏi nhanh

Sau khi chọn aa, bb, ta tính cc là phần nguyên của a2+b2\sqrt{a^2 + b^2} bằng hàm căn nguyên đúng. Ta cộng 1 vào đáp án khi nào?

Ví dụBa Số Chạm Tổng

Đề. Cho K≤2500K \le 2500 và S≤3KS \le 3K. Đếm bộ ba có thứ tự (x,y,z)(x, y, z) với 0≤x,y,z≤K0 \le x, y, z \le K và x+y+z=Sx + y + z = S.

Lời giải. Ba vòng lặp có 25013≈1,6⋅10102501^3 \approx 1{,}6 \cdot 10^{10} ứng viên, quá chậm. Nhưng z=S−x−yz = S - x - y bị xác định khi đã chọn x,yx, y: chỉ duyệt hai vòng, tính zz, và đếm nếu 0≤z≤K0 \le z \le K. Còn 25012≈6,3⋅1062501^2 \approx 6{,}3 \cdot 10^6 phép. Bộ ba có thứ tự nên mọi cặp (x,y)(x, y) đều được xét, không cần cận y≥xy \ge x.

Mỗi ô là một cặp (x, y) được duyệt; ô xanh ghi z = S − x − y khi nó hợp lệ. Kéo K và S.
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll k, s, ans;

signed main() {
    cin >> k >> s;
    for (ll x = 0; x <= k; x++)
        for (ll y = 0; y <= k; y++) {
            ll z = s - x - y;
            if (z >= 0 && z <= k) ans++;
        }
    cout << ans << '\n';
}
2 2

Đoạn con

Một đoạn con liên tiếp là một cặp (l,r)(l, r) với 1≤l≤r≤n1 \le l \le r \le n, có n(n+1)2\frac{n(n+1)}{2} đoạn. Nếu với mỗi đoạn ta cộng lại từ ll tới rr, số phép cộng là tổng độ dài mọi đoạn, cỡ n3/6n^3/6; với n=3000n = 3000 đó là 4,5⋅1094{,}5 \cdot 10^9, vượt xa 10810^8.

Sự lãng phí lộ ra khi nhìn hai đoạn kề nhau: [l,r−1][l, r-1] và [l,r][l, r] chỉ khác đúng phần tử ara_r, vậy mà ta cộng lại từ đầu. Giữ ll cố định, cho rr tăng dần và mang theo tổng chạy: tổng [l,r][l, r] bằng tổng [l,r−1][l, r-1] cộng ara_r. Mỗi đoạn tốn O(1)O(1), cả bài n(n+1)2\frac{n(n+1)}{2} phép.

Ô hàng l, cột r là tổng đoạn [l, r]; mỗi ô mới bằng ô bên trái cộng một số.

Nguyên tắc này rộng hơn phép cộng: khi hai ứng viên kề nhau trong thứ tự duyệt chỉ khác nhau một chút, hãy cập nhật trạng thái thay vì dựng lại từ đầu. Trạng thái có thể là tổng, tọa độ, một bảng đếm chữ cái. Nếu trạng thái chỉ xấu đi khi đoạn dài thêm (ví dụ số chữ khác nhau vượt ngưỡng), còn được break khỏi vòng rr.

Hỏi nhanh

Dãy có n=3000n = 3000 phần tử. Một chương trình duyệt mọi đoạn [l,r][l, r] và với mỗi đoạn cộng lại từ đầu các phần tử từ ll tới rr. Số phép cộng gần với con số nào nhất?

Tập con: bitmask

Mỗi phần tử có hai lựa chọn, được chọn hoặc không, nên nn phần tử có 2n2^n tập con. Cách duyệt gọn nhất là đếm từ 00 tới 2n−12^n - 1 rồi đọc mỗi số dưới dạng nn chữ số nhị phân: bit ii bằng 1 nghĩa là phần tử ii được chọn. Mỗi tập con ứng với đúng một số, gọi là mask. Mục này đánh số phần tử từ 0 để phần tử ii ứng với bit ii (chữ số nhị phân thứ ii, đếm từ phải sang, bắt đầu từ 0).

Ba phép trên bit đủ cho mục này: 1 << n là 2n2^n; mask >> i dịch các chữ số nhị phân của mask sang phải ii chỗ, đưa bit ii về hàng cuối; x & 1 giữ lại đúng chữ số cuối của x. Chẳng hạn 13=1101213 = 1101_2: 13 >> 2 là 112=311_2 = 3, rồi 3 & 1 bằng 1, nên bit 2 của 13 đang bật.

Mỗi số từ 0 tới 2ⁿ − 1 là một tập con: bit i bật thì a[i] được chọn.
C++
for (ll mask = 0; mask < (1 << n); mask++) {
    ll sum = 0;
    for (ll i = 0; i < n; i++)
        if (mask >> i & 1) sum += a[i];
    if (sum == s) ways++;
}

Có 2n2^n mask, mỗi mask cộng tối đa nn phần tử, tốn O(n⋅2n)O(n \cdot 2^n): với n=20n = 20 là khoảng 2⋅1072 \cdot 10^7 phép. Biểu thức mask >> i & 1 đúng vì >> ưu tiên cao hơn &. Chỗ dễ sai là các cách viết gần giống: == ưu tiên cao hơn &, nên mask & (1 << i) == 1 được hiểu là mask & ((1 << i) == 1).

Hỏi nhanh

Trong C++, biểu thức nào kiểm đúng bit ii của mask đang bật, với mọi i≥0i \ge 0?

Khi nn gần giới hạn, phép kiểm tính từ đầu là phần đắt nhất. Tổng của mask suy ra được từ tổng của mask đã tắt bit 1 thấp nhất, mask đó nhỏ hơn nên đã tính xong. Trong dòng dưới, mask & (mask - 1) chính là mask đã tắt bit 1 thấp nhất, còn __builtin_ctz(mask) cho vị trí của bit đó:

C++
for (ll mask = 1; mask < (1 << n); mask++) sum[mask] = sum[mask & (mask - 1)] + a[__builtin_ctz(mask)];

Mỗi mask còn O(1)O(1), đổi lại 2n2^n ô nhớ. Với n=22n = 22, khác biệt đo được:

Đếm tập con có tổng bằng S, n = 22.suy từ mask nhỏ hơn18 mscộng lại từ đầu217 ms · gấp 12 lần
Đếm tập con có tổng bằng S với n bằng 22, cộng lại từ đầu hay suy từ mask nhỏ hơn.

Suy từ mask nhỏ hơn nhanh khoảng 10 lần, đúng bằng số phép cộng bớt được ở mỗi mask. Phép kiểm càng rẻ, nn càng đẩy lên được cao.

Vài biến thể: mỗi phần tử có ba lựa chọn thì có 3n3^n cấu hình, duyệt bằng số viết ở cơ số 3; muốn tập đúng kk phần tử thì lọc bằng __builtin_popcount(mask) == k (hàm đếm số bit 1); khi 20<n≤4020 < n \le 40 thì chia đôi tập rồi ghép lại (bài 1.1).

Ví dụChia Táo Thành Hai Nhóm

Đề. n≤20n \le 20 quả táo nặng pi≤109p_i \le 10^9. Chia hết táo thành hai nhóm (một nhóm có thể rỗng) sao cho chênh lệch tổng hai nhóm nhỏ nhất.

Lời giải. Mỗi cách chia ứng với tập các quả thuộc nhóm 1, tức một mask. Gọi TT là tổng mọi quả và ss là tổng nhóm 1 thì chênh lệch là ∣T−2s∣|T - 2s|. Hai cách chia (G1,G2)(G_1, G_2) và (G2,G1)(G_2, G_1) cho cùng chênh lệch, nên ghim quả cuối vào nhóm 2 và chỉ duyệt mask của n−1n - 1 quả đầu: 219⋅19≈1072^{19} \cdot 19 \approx 10^7 phép. Tổng tới 2⋅10102 \cdot 10^{10} nên dùng long long.

Tham lam “xếp quả nặng nhất còn lại vào nhóm đang nhẹ hơn” không đúng: với 5,5,4,3,35, 5, 4, 3, 3 nó cho 99 và 1111, trong khi {5,5}\{5, 5\} và {4,3,3}\{4, 3, 3\} cùng tổng 1010. Vét cạn không phụ thuộc vào cảm giác như vậy.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, p[25], tot;

signed main() {
    cin >> n;
    for (ll i = 0; i < n; i++) {
        cin >> p[i];
        tot += p[i];
    }
    ll ans = 1e18;
    for (ll mask = 0; mask < (1 << (n - 1)); mask++) {
        ll s = 0;
        for (ll i = 0; i < n - 1; i++)
            if (mask >> i & 1) s += p[i];
        ans = min(ans, abs(tot - 2 * s));
    }
    cout << ans << '\n';
}
5
3 2 7 4 1

Hoán vị

Khi ứng viên là một thứ tự (thứ tự thăm các thành phố, cách xếp nn người vào nn ghế) thì có n!n! ứng viên, tăng nhanh hơn 2n2^n rất nhiều: 8!=40 3208! = 40\,320, 10!≈3,6⋅10610! \approx 3{,}6 \cdot 10^6, 12!≈4,8⋅10812! \approx 4{,}8 \cdot 10^8. Vét cạn hoán vị chỉ dùng được khi n≤10n \le 10 hoặc 1111, và phép kiểm phải rẻ.

C++ có sẵn next_permutation(first, last): biến dãy thành hoán vị đứng ngay sau nó theo thứ tự từ điển và trả về true; nếu dãy đã giảm dần thì trả về false. Bắt đầu từ dãy tăng dần rồi gọi liên tục sẽ đi qua mọi hoán vị đúng một lần. Dãy có phần tử trùng thì hàm chỉ đi qua các hoán vị phân biệt.

Hàm làm ba việc: tìm vị trí ii lớn nhất có ai<ai+1a_i < a_{i+1} (điểm gãy), đổi aia_i với số nhỏ nhất lớn hơn nó trong phần đuôi, rồi đảo phần đuôi cho tăng dần. Đuôi sau điểm gãy giảm dần, tức đã là cách xếp lớn nhất của nó; muốn lớn hơn ít nhất thì phải tăng aia_i lên giá trị nhỏ nhất có thể rồi xếp phần còn lại nhỏ nhất có thể.

Mỗi lần gọi: tìm điểm gãy, đổi với số nhỏ nhất lớn hơn nó trong đuôi, đảo đuôi.

Hai quy tắc khi dùng: phải sắp tăng dần trước, vì hàm chỉ đi tiếp từ vị trí hiện tại; và viết do { ... } while (next_permutation(...)), vì vòng while đặt điều kiện trước sẽ bỏ qua hoán vị đầu tiên.

Hỏi nhanh

Một bạn khai báo p = {3, 1, 2} rồi chạy do { ... } while (next_permutation(p, p + 3)); mà không sắp xếp. Phần thân vòng lặp chạy mấy lần?

Ví dụHoán Vị Cột Số

Đề. n≤8n \le 8 xâu, mỗi xâu đúng k≤8k \le 8 chữ số. Chọn một hoán vị các vị trí và áp dụng cho mọi xâu, rồi đọc mỗi xâu như một số. Tìm hiệu nhỏ nhất giữa số lớn nhất và số nhỏ nhất.

Lời giải. Ứng viên là hoán vị của kk cột, có 8!=40 3208! = 40\,320 cái. Với mỗi hoán vị, dựng lại nn số rồi lấy lớn nhất trừ nhỏ nhất: n⋅k=64n \cdot k = 64 phép. Cả bài khoảng 2,6⋅1062{,}6 \cdot 10^6 phép. Số có tới 8 chữ số, vẫn vừa int, nhưng dùng ll cho chắc.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, k, p[10];
string s[10];

signed main() {
    cin >> n >> k;
    for (ll i = 0; i < n; i++) cin >> s[i];
    for (ll j = 0; j < k; j++) p[j] = j;
    ll ans = 1e18;
    do {
        ll lo = 1e18, hi = -1e18;
        for (ll i = 0; i < n; i++) {
            ll v = 0;
            for (ll j = 0; j < k; j++) v = v * 10 + s[i][p[j]] - '0';
            lo = min(lo, v);
            hi = max(hi, v);
        }
        ans = min(ans, hi - lo);
    } while (next_permutation(p, p + k));
    cout << ans << '\n';
}
2 2
01
10

Sinh ứng viên

Đôi khi ứng viên tốt nhất lại chính là đáp án. Có hai cách dùng.

Thử đáp án tăng dần. Tìm số nhỏ nhất x≥ax \ge a có tổng chữ số chia hết cho 4: cho xx chạy từ aa lên tới khi thỏa. Cách này chỉ nhanh khi chứng minh được đáp án nằm sát aa. Ở đây mười số cùng phần đầu, khác nhau ở chữ số cuối, có tổng chữ số là mười số nguyên liên tiếp, nên đáp án luôn cách aa không quá 66 đơn vị. Cận này cũng có thể tìm ra bằng cách cho vét cạn chạy trên mọi aa nhỏ rồi lấy khoảng cách lớn nhất.

Sinh ứng viên thay vì lọc. Đếm các số không vượt N≤109N \le 10^9 chỉ gồm chữ số 44 và 77. Duyệt từng số 1..N1..N rồi kiểm chữ số mất cỡ 101010^{10} phép. Nhưng số như vậy rất ít: dưới 10910^9 chỉ có 2+4+⋯+29=10222 + 4 + \dots + 2^9 = 1022 số. Thay vì lọc cả miền giá trị, ta sinh trực tiếp: từ một số đã dựng, nối thêm chữ số 44 hoặc 77. Khi số vừa dựng đã vượt NN thì nối thêm chỉ làm nó lớn hơn, nên bỏ cả nhánh. Số ứng viên giờ bằng số đối tượng hợp lệ, không phải độ lớn của miền giá trị. Hàm tự gọi lại chính nó, đây là hình hài đầu tiên của quay lui (bài 0.8).

Mỗi nút trong hình dưới là một số đã dựng; nối thêm 4 hoặc 7 cho hai nút con.

Nút × đã vượt N nên cả nhánh dưới bị bỏ; số nút chỉ tăng theo số chữ số của N.

Bớt ứng viên

Lời giải vét cạn đúng rồi mới tới chuyện nhanh. Có bốn cách bớt việc, phần lớn đã gặp ở trên:

  • Cận chặt, break đúng chỗ. Cho jj bắt đầu từ i+1i + 1, thoát vòng khi a2+b2>N2a^2 + b^2 > N^2. Mỗi break phải dựa trên một tính đơn điệu có thật; một break sai bỏ sót đáp án một cách lặng lẽ.
  • Dừng sớm. Khi chỉ cần một đáp án, hay đáp án đầu tiên theo một thứ tự, duyệt theo thứ tự ấy và dừng ngay khi gặp.
  • Gộp ứng viên đối xứng. Hai ứng viên luôn cho cùng kết quả thì chỉ duyệt một, như ghim một quả táo vào nhóm 2 để giảm từ 2n2^n xuống 2n−12^{n-1}.
  • Phép kiểm rẻ. Kiểm “tổng hai số có nguyên tố không” bằng chia thử tốn cỡ 2⋅106≈1400\sqrt{2 \cdot 10^6} \approx 1400 phép; một sàng Eratosthenes (bài 0.9) dựng trước cho câu trả lời bằng một lần đọc mảng.

Còn một cách mạnh hơn, không có công thức chung: dùng một nhận xét toán học để chứng minh chỉ một phần nhỏ ứng viên có thể là đáp án, rồi chỉ duyệt phần đó.

Ví dụBa Số Có Tận Cùng Bằng 3

Đề. Mảng n≤2⋅105n \le 2 \cdot 10^5 số nguyên dương. Có chọn được ba phần tử ở ba vị trí khác nhau mà tổng có chữ số hàng đơn vị là 33 không? Tổng nn qua các test không quá 2⋅1052 \cdot 10^5.

Lời giải. Duyệt bộ ba trên cả mảng là n3/6≈1015n^3/6 \approx 10^{15}, không thể. Nhận xét: chữ số cuối của tổng chỉ phụ thuộc chữ số cuối của từng số, và một bộ ba dùng mỗi chữ số cuối nhiều nhất ba lần. Vậy với mỗi chữ số cuối 0..90..9 chỉ cần giữ lại tối đa ba phần tử, còn lại không quá 3030 số. Duyệt mọi bộ ba i<j<ki < j < k của 3030 số là 30⋅29⋅286=4060\frac{30 \cdot 29 \cdot 28}{6} = 4060 phép mỗi test. Đây là vét cạn trên một không gian đã được thu hẹp có chứng minh.

Mỗi cột là một chữ số cuối, giữ tối đa ba số; các số mờ không thể làm đổi đáp án.
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll t, n, cnt[10], v[35], m;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> t;
    while (t--) {
        cin >> n;
        memset(cnt, 0, sizeof cnt);
        m = 0;
        for (ll i = 1; i <= n; i++) {
            ll x;
            cin >> x;
            if (cnt[x % 10] < 3) {
                cnt[x % 10]++;
                v[++m] = x % 10;
            }
        }
        bool ok = false;
        for (ll i = 1; i <= m; i++)
            for (ll j = i + 1; j <= m; j++)
                for (ll k = j + 1; k <= m; k++)
                    if ((v[i] + v[j] + v[k]) % 10 == 3) ok = true;
        cout << (ok ? "YES" : "NO") << '\n';
    }
}
3
5
10 21 32 44 55
4
10 20 40 80
3
1 1 1

Bộ kiểm

Lời giải nhanh thường tinh vi và dễ sai; vét cạn thì đơn giản tới mức khó sai. Hai chương trình cùng giải một đề phải cho cùng đáp án trên mọi đầu vào, nên chỉ cần một bộ sinh test nhỏ ngẫu nhiên và một phép so sánh:

  1. Viết brute đơn giản nhất có thể.
  2. Sinh test với nn và giá trị đều nhỏ, vì lỗi thường lộ ngay ở dữ liệu nhỏ: n=1n = 1, số âm, số bằng nhau.
  3. Chạy hàng nghìn test, dừng ở test đầu tiên hai bên khác nhau.
  4. Đọc test ấy bằng mắt: nó nhỏ nên lỗi thường hiện ra ngay.

Vét cạn còn hai việc nữa. Một là lấy điểm: nhiều đề chia subtask, subtask đầu có giới hạn nhỏ chính là dành cho vét cạn. Hai là tìm quy luật: chạy brute trên nhiều đầu vào nhỏ rồi nhìn kết quả để đoán công thức hay một cận, như cận 66 ở mục trước.

Thử với bài Đoạn Có Tổng Lớn Nhất: tìm tổng lớn nhất của một đoạn con liên tiếp không rỗng, N≤2⋅105N \le 2 \cdot 10^5. Vét cạn mọi đoạn là O(N2)O(N^2), quá chậm. Cách O(N)O(N) duyệt dãy một lần: giữ cur là tổng lớn nhất của đoạn kết thúc tại vị trí hiện tại (cur trước đó âm thì bỏ, bắt đầu lại từ phần tử này), và ans là lớn nhất của các cur. Khởi tạo ans phải là −∞-\infty chứ không phải 00, vì đoạn không được rỗng: dãy toàn số âm có đáp án âm.

Lỗi khởi tạo ans = 0 không lộ ra ở test mẫu, nhưng bộ kiểm tìm ra ngay. Chương trình dưới chạy brute (mọi đoạn) và fast (cách trên) trên 20000 dãy ngắn ngẫu nhiên, lần lượt với hai giá trị khởi tạo:

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, a[10];

ll brute() {
    ll best = -1e18;
    for (ll l = 1; l <= n; l++) {
        ll sum = 0;
        for (ll r = l; r <= n; r++) {
            sum += a[r];
            best = max(best, sum);
        }
    }
    return best;
}

ll fast(ll init) {
    ll cur = 0, best = init;
    for (ll i = 1; i <= n; i++) {
        cur = max(cur, 0LL) + a[i];
        best = max(best, cur);
    }
    return best;
}

signed main() {
    mt19937 rng(2024);
    for (ll init : {0LL, (ll)-1e18}) {
        bool ok = true;
        for (ll t = 1; t <= 20000 && ok; t++) {
            n = rng() % 6 + 1;
            for (ll i = 1; i <= n; i++) a[i] = (ll)(rng() % 11) - 5;
            if (brute() != fast(init)) {
                cout << "ans = " << init << ": sai ở test " << t << ", dãy";
                for (ll i = 1; i <= n; i++) cout << ' ' << a[i];
                cout << ", đúng là " << brute() << ", in ra " << fast(init) << '\n';
                ok = false;
            }
        }
        if (ok) cout << "ans = " << init << ": 20000 test đều khớp\n";
    }
}

Phản ví dụ nhỏ hết mức: dãy một phần tử −3-3. Hạt giống 2024 cố định nên lần chạy nào cũng cho cùng dãy test, lỗi tái hiện được. Khi bài lớn hơn, người ta tách thành ba chương trình (sinh test, brute, fast) và một script so đầu ra, nhưng nguyên tắc vẫn như trên.

Khi đi thi

  • Đọc giới hạn trước khi nghĩ cách làm: n≤10n \le 10 gợi hoán vị, n≤20n \le 20 gợi tập con, vài nghìn gợi cặp và đoạn con. Nhân số ứng viên với chi phí kiểm rồi so với 10810^8 trước khi viết dòng code đầu tiên.
  • Gặp ràng buộc đẳng thức thì chỉ duyệt các biến tự do và suy ra biến còn lại, nhớ kiểm biến suy ra có hợp lệ; tổng và tích dùng long long.
  • Viết vét cạn trước để lấy điểm subtask nhỏ, giữ nó làm bộ kiểm, và chưa nộp lời giải nhanh nào khi chưa so nó với vét cạn trên các test nhỏ ngẫu nhiên.

Tóm tắt

  • Vét cạn là một không gian ứng viên cùng một phép kiểm; đúng khi ứng viên không sót, không đếm trùng và phép kiểm chính xác.
  • Thời gian ước lượng bằng số ứng viên nhân chi phí kiểm, ngưỡng khoảng 10810^8: n!n! tới n≈10n \approx 10, 2n2^n tới n≈20n \approx 20, bộ ba tới vài trăm, cặp tới vài nghìn.
  • Cặp dùng cận j>ij > i để không trùng; ràng buộc đẳng thức cho phép bỏ một vòng lặp bằng cách suy ra biến còn lại.
  • Ứng viên kề nhau chỉ khác một chút thì cập nhật tăng dần: tổng đoạn từ đoạn ngắn hơn một phần tử, tổng mask từ mask đã tắt bit thấp nhất.
  • Tập con duyệt bằng mask từ 00 tới 2n−12^n - 1, hoán vị bằng next_permutation (sắp tăng dần trước, dùng do ... while).
  • Vét cạn là bộ kiểm: so với lời giải nhanh trên nhiều test nhỏ ngẫu nhiên để lấy phản ví dụ nhỏ nhất.

Bài tập

Đọc thêm