Chương 2·Bài 29

Constructive Algorithms

Hãy xếp các số 1,2,3,41, 2, 3, 4 thành một hàng sao cho hai số kề nhau không hơn kém đúng 1. Bạn thử vài lần là ra, ví dụ 3 1 4 2. Bài xây dựng (constructive) là loại bài như thế: không hỏi một con số mà đòi in ra một đối tượng (dãy, hoán vị, ma trận, xâu) thỏa các điều kiện, hoặc báo không tồn tại.

Thường có rất nhiều đáp án đúng và trình chấm kiểm từng điều kiện. Không có thuật toán chung, nhưng có một quy trình: tìm điều kiện cần để biết khi nào vô nghiệm, đoán một mẫu đơn giản cho các trường hợp còn lại, rồi chứng minh mẫu đó thỏa từng điều kiện.

Điều kiện cần

Trước khi cố xây, hỏi: nếu có đáp án thì nó phải thỏa gì? Có thể là tính chẵn lẻ của một tổng, đại lượng không đổi qua các phép, giới hạn trên của một đại lượng, hay đơn giản là thử n=1,2,3n = 1, 2, 3 bằng tay.

Điều kiện cần tìm được thường chính là điều kiện đủ. Khi đó nửa còn lại của bài là xây một đáp án cho mọi trường hợp thỏa điều kiện. Nếu không xây được cho một trường hợp, hoặc điều kiện chưa đủ, hoặc mẫu chưa đúng: thử trường hợp đó bằng vét cạn.

Hỏi nhanh

Cần n/2n/2 số chẵn và n/2n/2 số lẻ có tổng hai nhóm bằng nhau. Với n=6n = 6, vì sao chắc chắn không có đáp án?

Ví dụHai Nửa Cân Bằng

Đề. Với nn chẵn (n≤2⋅105n \le 2 \cdot 10^5), in nn số dương khác nhau: n/2n/2 số chẵn trước, n/2n/2 số lẻ sau, hai nửa có tổng bằng nhau; không được thì in NO.

Lời giải. Đặt h=n/2h = n/2. hh lẻ thì vô nghiệm như câu hỏi trên. hh chẵn: lấy các số chẵn 2,4,…,2h2, 4, \dots, 2h, tổng h(h+1)h(h + 1), và các số lẻ 1,3,…,2h−31, 3, \dots, 2h - 3, tổng (h−1)2(h - 1)^2. Số lẻ cuối cùng bù phần thiếu: h(h+1)−(h−1)2=3h−1h(h + 1) - (h - 1)^2 = 3h - 1. Nó lẻ vì hh chẵn, và lớn hơn 2h−32h - 3 nên không trùng số nào. Với n=12n = 12, h=6h = 6, số cuối là 17.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    ll T;
    cin >> T;
    while (T--) {
        ll n;
        cin >> n;
        ll h = n / 2;
        if (h % 2) {
            cout << "NO\n";
            continue;
        }
        cout << "YES\n";
        for (ll i = 1; i <= h; i++) cout << 2 * i << ' ';
        for (ll i = 1; i < h; i++) cout << 2 * i - 1 << ' ';
        cout << 3 * h - 1 << '\n';
    }
}
3
12
14
16

Một công thức

Khi đáp án là vài con số, hãy thử biểu diễn chúng qua dữ liệu vào bằng một công thức, thường là những bội đơn giản. Viết công thức ra rồi kiểm từng điều kiện của đề một cách riêng rẽ: đẳng thức, chia hết, khác nhau, nằm trong miền. Điều kiện nào hỏng ở trường hợp biên (một tham số bằng 1, hai số trùng nhau) thì hoặc đó là trường hợp vô nghiệm, hoặc phải tách riêng.

Kiểm riêng từng điều kiện của công thức; đổi B về 1 để thấy trường hợp biên hỏng.

Ví dụBộ Ba Chia Hết

Đề. Số “loại I” chia hết cho ABAB, số “loại II” chia hết cho AA nhưng không chia hết cho ABAB. Tìm ba số dương khác nhau x+y=zx + y = z, đúng một số loại I và hai số loại II; không có thì in NO. A,B≤106A, B \le 10^6.

Lời giải. B=1B = 1 thì mọi bội của AA đều chia hết cho AB=AAB = A, không có số loại II: vô nghiệm. B≥2B \ge 2: lấy x=ABx = AB, y=A(B−1)y = A(B - 1), z=A(2B−1)z = A(2B - 1). Đẳng thức đúng. xx loại I. yy và zz chia hết cho AA; B−1B - 1 và 2B−1≡−12B - 1 \equiv -1 không chia hết cho BB khi B≥2B \ge 2, nên chúng là loại II. Ba số khác nhau vì B−1<B<2B−1B - 1 < B < 2B - 1. Mọi số không quá 2⋅10122 \cdot 10^{12}.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    ll T;
    cin >> T;
    while (T--) {
        ll a, b;
        cin >> a >> b;
        if (b == 1) cout << "NO\n";
        else cout << "YES\n" << a * b << ' ' << a * (b - 1) << ' ' << a * (2 * b - 1) << '\n';
    }
}
3
9 1
6 4
11 7

Xếp theo lớp

Điều kiện kiểu “hai phần tử kề nhau không hơn kém đúng 1” hay “hiệu hai số kề nằm trong [2,4][2, 4]” gợi ý chia các số thành lớp theo chẵn lẻ (hay theo số dư): trong cùng một lớp, hai số liên tiếp đã cách nhau 2.

Xếp hết một lớp rồi tới lớp kia. Việc còn lại là kiểm chỗ nối giữa hai lớp, thường cần chọn khéo hai đầu nối. Trường hợp nhỏ không đủ chỗ cho chỗ nối thì kiểm bằng tay hay vét cạn: chúng thường là các trường hợp vô nghiệm.

Ví dụMa Trận Không Kề Nhau

Đề. Điền 1..n21..n^2 vào ma trận n×nn \times n (n≤100n \le 100) sao cho hai ô chung cạnh không chứa hai số liên tiếp; không được thì in −1-1.

Lời giải. n=2n = 2: mỗi ô kề hai ô khác, số 2 có hai láng giềng phải tránh 1 và 3 nhưng chỉ còn ô chéo, vô nghiệm. Với n≠2n \ne 2, điền theo từng hàng các số lẻ 1,3,5,…1, 3, 5, \dots rồi tiếp các số chẵn 2,4,…2, 4, \dots. Hai ô kề ngang cùng lớp hơn kém 2; hai ô kề dọc cùng lớp hơn kém 2n2n. Ô lẻ cạnh ô chẵn chỉ xảy ra ở chỗ nối, nơi số lẻ gần n2n^2 còn số chẵn gần 2, hơn kém nhiều khi n≥3n \ge 3.

Kéo n: số lẻ điền trước, số chẵn điền sau; chỉ n = 2 có cặp ô kề liên tiếp.
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    ll T;
    cin >> T;
    while (T--) {
        ll n;
        cin >> n;
        if (n == 2) {
            cout << -1 << '\n';
            continue;
        }
        ll cur = 1;
        for (ll i = 0; i < n; i++) {
            for (ll j = 0; j < n; j++) {
                cout << cur << ' ';
                cur += 2;
                if (cur > n * n) cur = 2;
            }
            cout << '\n';
        }
    }
}
4
1
2
3
4

Bất biến

Có điều kiện nhìn phức tạp nhưng tương đương một điều kiện toàn cục đơn giản. “XOR các vị trí lẻ bằng XOR các vị trí chẵn” tương đương “XOR cả mảng bằng 0”, vì u=vu = v khi và chỉ khi u⊕v=0u \oplus v = 0. “Tổng hai nửa bằng nhau” là “tổng có dấu bằng 0”.

Khi đó chọn tự do gần hết các phần tử, rồi dùng vài phần tử cuối để bù cho bất biến đúng. Phần khó là bảo đảm phần tử bù không trùng các phần tử đã chọn, thường bằng cách cho nó mang những bit cao mà các phần tử khác không có.

Hỏi nhanh

Vì sao “XOR các phần tử ở vị trí lẻ bằng XOR các phần tử ở vị trí chẵn” tương đương “XOR cả mảng bằng 0”?

Ví dụCân Bằng XOR Theo Vị Trí

Đề. Với n≤2⋅105n \le 2 \cdot 10^5, in nn số không âm khác nhau nhỏ hơn 2312^{31} có XOR các vị trí lẻ bằng XOR các vị trí chẵn.

Lời giải. Cần XOR cả mảng bằng 0. Lấy 1,2,…,n−31, 2, \dots, n - 3, tất cả nhỏ hơn 2202^{20}; thêm 2202^{20}, 2212^{21}, và phần tử cuối là XOR của mọi số đã có. Phần tử cuối có cả bit 20 lẫn bit 21, nên khác mọi số trước: các số nhỏ không có bit 20, còn 2202^{20} và 2212^{21} chỉ có một trong hai bit. XOR cả mảng bằng 0 theo cách chọn phần tử cuối.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    ll T;
    cin >> T;
    while (T--) {
        ll n, x = 0;
        cin >> n;
        for (ll i = 1; i <= n - 3; i++) {
            cout << i << ' ';
            x ^= i;
        }
        ll p = 1 << 20, q = 1 << 21;
        cout << p << ' ' << q << ' ' << (x ^ p ^ q) << '\n';
    }
}
3
3
4
6

Đẩy về cực đại

Khi đề cho phép tăng (hay thêm, hay bật) tùy ý, hãy xét đáp án cực đại: tăng mọi thứ tới mức lớn nhất còn có thể hợp lệ. Thường đáp án cực đại hoặc đúng, hoặc nếu nó sai thì mọi đáp án khác cũng sai.

Chẳng hạn trên lưới, ô có giá trị k>0k > 0 cần kk láng giềng dương, nên kk không vượt số ô chung cạnh của nó. Có ô vượt thì vô nghiệm. Không có ô nào vượt thì đặt mọi ô bằng đúng số ô chung cạnh: mọi ô đều dương nên điều kiện đúng với dấu bằng.

Vét cạn tìm quy luật

Khi không đoán được mẫu, viết một chương trình vét cạn trên kích thước nhỏ (n≤8n \le 8, xâu dài ≤12\le 12), in ra mọi đáp án hay đáp án tốt nhất, rồi nhìn. Các đáp án có chung hình dạng gì? Đại lượng tối ưu tăng theo quy luật nào?

Từ đó phát biểu một mẫu, chứng minh nó, và giữ chương trình vét cạn để so với lời giải trên nhiều test ngẫu nhiên. Một quy luật hay gặp: tích của nhiều thừa số lớn nhất khi tổng cố định thì các thừa số đều nhau, hơn kém nhau không quá 1.

Ví dụXâu Ngắn Nhất Nhiều Dãy Con

Đề. Cho k≤1016k \le 10^{16}. In xâu ngắn nhất có ít nhất kk dãy con bằng codeforces.

Lời giải. Vét cạn trên kk nhỏ cho thấy đáp án tốt nhất luôn có dạng: chữ thứ ii của codeforces lặp cic_i lần liền nhau, theo đúng thứ tự. Số dãy con khi đó là c1c2⋯c10c_1 c_2 \cdots c_{10}, độ dài là tổng các cic_i. Với tổng cố định, tích lớn nhất khi các cic_i hơn kém nhau không quá 1; nên bắt đầu với mọi ci=1c_i = 1 và lần lượt tăng c1,c2,…c_1, c_2, \dots vòng tròn tới khi tích đạt kk. Mỗi lần tăng thêm 1 ký tự và tích tăng nhanh nhất có thể. Với k=12k = 12, tăng bốn chữ đầu cho tích 16.

Mỗi cột là số lần lặp một chữ của codeforces; tăng vòng tròn để các cột đều nhau.
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

ll k, c[10];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> k;
    string s = "codeforces";
    for (ll i = 0; i < 10; i++) c[i] = 1;
    for (ll i = 0;; i = (i + 1) % 10) {
        ll prod = 1;
        for (ll j = 0; j < 10; j++) prod *= c[j];
        if (prod >= k) break;
        c[i]++;
    }
    for (ll i = 0; i < 10; i++) cout << string(c[i], s[i]);
    cout << '\n';
}
12

Khi đi thi

  • Thử tay n=1,2,3,4n = 1, 2, 3, 4 trước tiên: các trường hợp vô nghiệm thường nằm ở đó, và đáp án của chúng gợi ý mẫu chung.
  • Viết một hàm kiểm đáp án theo đúng điều kiện của đề và một chương trình vét cạn nhỏ; chạy mẫu xây dựng trên mọi nn nhỏ trước khi nộp.
  • In đáp án nhanh: tổng kích thước đầu ra có thể tới 10510^5 hay 10610^6 số, dùng '\n' thay cho endl và tắt đồng bộ luồng.

Tóm tắt

  • Điều kiện cần (chẵn lẻ, tổng, kích thước nhỏ) loại các trường hợp vô nghiệm và thường cũng là điều kiện đủ.
  • Một công thức hay một mẫu xếp duy nhất cho mọi trường hợp còn lại, kiểm từng điều kiện riêng rẽ.
  • Chia theo lớp chẵn lẻ hay số dư, rồi chỉ lo chỗ nối giữa các lớp.
  • Đưa điều kiện về một bất biến toàn cục rồi dùng vài phần tử cuối để bù.
  • Đáp án cực đại thường đúng khi đề cho phép tăng tùy ý; vét cạn nhỏ giúp tìm và kiểm quy luật.

Bài tập

Đọc thêm