Chương 0·Bài 07

Backtracking

Có những bài không hỏi một giá trị mà hỏi ta liệt kê hoặc đếm mọi cách: có bao nhiêu cách đặt tám quân hậu lên bàn cờ để không quân nào ăn được quân khác, có bao nhiêu tập con có tổng bằng SS, hãy in mọi hoán vị thỏa một điều kiện. Một cấu hình là kết quả của một dãy quyết định, và số dãy quyết định tăng như 2n2^n hay n!n!. Riêng tám hậu, chỉ việc chọn tám ô bất kỳ trong 64 ô đã có khoảng 4,44{,}4 tỉ cách.

Quay lui (backtracking) xây nghiệm từng quyết định một, đi sâu cho tới khi đủ nghiệm rồi lùi lại thử lựa chọn khác; và ngay khi một dãy quyết định dở dang đã chắc chắn không thể thành nghiệm, nó bỏ cả nhánh đó. Với tám hậu, thuật toán chỉ chạm tới 2057 trạng thái dở dang. Đây là vét cạn (bài 00) có biết dừng đúng lúc, và là bước đệm trước quy hoạch động (bài 12).

Cây quyết định: xây nghiệm từng vị trí

Bài toán nhỏ: in mọi xâu nhị phân độ dài nn không có hai chữ số 1 đứng kề nhau. Duyệt cả 2n2^n xâu rồi lọc thì đúng nhưng phí: với n=30n = 30 có 10910^9 xâu, xâu hợp lệ chỉ khoảng 0,2%0{,}2\%.

Với n=3n = 3 ta viết ba vòng for lồng nhau, mỗi vòng chọn một chữ số. Nhưng nn đọc từ input thì không viết được “nn vòng for”. Đệ quy chính là vòng lặp lồng nhau với độ sâu tùy ý: hàm go(pos) nhận tiền tố đã chốt gồm các vị trí trước pos, thử từng chữ số cho vị trí pos, và với mỗi chữ số dùng được thì gọi go(pos + 1). Mỗi lời gọi là một nút của cây quyết định; khi một nhánh duyệt xong, hàm trả về nút cha (“quay lui”) và nút cha thử lựa chọn kế tiếp. Khác với vét cạn ở một dòng continue: chữ số vừa thử làm hai số 1 kề nhau thì không gọi đệ quy, cả cây con phía dưới biến mất.

Cây được dựng theo đúng thứ tự gọi đệ quy: nút × bị cắt, lá xanh là nghiệm.
C++
void go(ll pos) {
    if (pos > n) {
        cout << s + 1 << '\n';
        return;
    }
    for (char c = '0'; c <= '1'; c++) {
        if (c == '1' && s[pos - 1] == '1') continue;
        s[pos] = c;
        go(pos + 1);
    }
}

Ở đây s là mảng ký tự toàn cục dùng từ s[1]: s[0] để trống làm lính canh nên vị trí đầu không phải xét riêng, và s[n + 1] có sẵn ký tự kết thúc xâu. Không có dòng hoàn tác: lần thử sau ghi đè s[pos], và go(pos + 1) chỉ đọc các vị trí trước nó.

Phép cắt đúng vì điều kiện của đề là bền: tiền tố đã chứa 11 thì mọi xâu bắt đầu bằng nó đều chứa 11. Chỉ với điều kiện bền ta mới cắt mà không mất nghiệm. Điều kiện như “tổng cuối cùng bằng SS” thì chưa thể kết luận giữa chừng, và muốn cắt theo nó phải có thêm lập luận.

Ví dụBa Ký Tự

Đề. Liệt kê mọi xâu dài đúng n≤8n \le 8 gồm các ký tự a, b, c, theo thứ tự từ điển.

Lời giải. Mỗi vị trí có ba lựa chọn, cây đầy đủ có 3n≤65613^n \le 6561 lá, không có gì để cắt. Thử ký tự theo thứ tự a, b, c thì các lá được thăm đúng theo thứ tự từ điển: hai xâu khác nhau ở vị trí đầu tiên pp thì xâu có ký tự nhỏ hơn ở pp nằm trong nhánh được thử trước.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll n;
char s[10];

void go(ll pos) {
    if (pos > n) {
        cout << s + 1 << '\n';
        return;
    }
    for (char c = 'a'; c <= 'c'; c++) {
        s[pos] = c;
        go(pos + 1);
    }
}

signed main() {
    cin >> n;
    go(1);
}
2

Chọn hoặc bỏ: các tập con

Một họ bài lớn hỏi về tập con. Mỗi phần tử có hai khả năng, chọn hoặc bỏ, nên cây là cây nhị phân đầy đủ độ sâu nn: 2n2^n lá và 2n+1−12^{n+1} - 1 nút. Quay lui đi cùng cây, mang theo trạng thái như tổng đang có hay danh sách đã chọn:

C++
void go(ll i, ll sum) {
    if (i > n) {
        if (sum == s) ans++;
        return;
    }
    go(i + 1, sum);
    go(i + 1, sum + a[i]);
}

Có hai kiểu trạng thái, và đây là chỗ nhiều người vấp. sum truyền theo giá trị: mỗi lời gọi có bản sao riêng, hàm trả về thì bản của nút cha vẫn nguyên, không cần hoàn tác. Một biến chung như danh sách phần tử đã chọn thì khác: nhánh chọn sửa nó trước khi đệ quy, nên phải trả lại ngay sau đó. Quy tắc dùng cho cả bài: bất cứ gì sửa trước lời gọi đệ quy phải được khôi phục ngay sau nó, như push_back đi với pop_back, đánh dấu đi với bỏ đánh dấu. Khung chung:

go(trạng thái):
    nếu đã đủ nghiệm: ghi nhận, thoát
    với mỗi lựa chọn hợp lệ:
        áp dụng lựa chọn
        go(trạng thái mới)
        hoàn tác lựa chọn

Nếu mọi aia_i dương thì tổng chỉ tăng, nên thêm if (sum > s) return; là cắt đúng. Với số âm, dòng đó làm mất nghiệm. Cách đúng cho số bất kỳ là đóng khung kết quả: tổng cuối nằm trong đoạn từ sum + neg[i] đến sum + pos[i], với pos[i], neg[i] là tổng các số dương, tổng các số âm từ ii trở đi; mục tiêu nằm ngoài đoạn thì không còn hy vọng.

Hỏi nhanh

Đếm các tập con có tổng bằng SS, trong đó aia_i có thể âm. Bạn viết if (sum > S) return; ở đầu hàm go(i, sum) để cắt bớt nhánh. Chương trình sai ở đâu?

Ví dụĐếm Tập Con Theo Tổng

Đề. Dãy N≤20N \le 20 số, ∣ai∣≤105|a_i| \le 10^5, và số SS. Đếm số tập con không rỗng (theo vị trí) có tổng đúng bằng SS.

Lời giải. Cây chọn hoặc bỏ, 220≈1062^{20} \approx 10^6 lá. Có số âm nên không cắt kiểu “tổng đã vượt”. Chỗ dễ sai là tập rỗng: khi S=0S = 0, lá “bỏ hết” có tổng 0 nhưng không được tính. Mang thêm số phần tử đã chọn, hoặc trừ 1 ở cuối khi S=0S = 0.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll n, s, a[25], ans;

void go(ll i, ll sum, ll cnt) {
    if (i > n) {
        if (sum == s && cnt > 0) ans++;
        return;
    }
    go(i + 1, sum, cnt);
    go(i + 1, sum + a[i], cnt + 1);
}

signed main() {
    cin >> n >> s;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    go(1, 0, 0);
    cout << ans << '\n';
}
3 0
-1 0 1

Hoán vị: đánh dấu rồi hoàn tác

Khi thứ tự quan trọng và mỗi giá trị chỉ dùng một lần, mỗi vị trí chọn một giá trị chưa dùng; mảng used cho biết giá trị nào đã dùng. Đánh dấu trước lời gọi, bỏ đánh dấu sau lời gọi: hai dòng hoàn tác là hình ảnh phản chiếu của hai dòng áp dụng. Thiếu một trong hai là lỗi quay lui kinh điển, và nó không báo lỗi gì: chương trình vẫn chạy, chỉ in thiếu.

Mỗi bước là một lần đánh dấu hoặc một lần hoàn tác; ô cam ở hàng used là dấu vừa được xóa.

Hỏi nhanh

Trong chương trình liệt kê hoán vị ở ví dụ dưới, bạn lỡ xóa dòng used[v] = false;. Với input 3 3, chương trình in ra gì?

Số nút của cây hoán vị tính được ngay: tầng 1 có nn nút, tầng 2 có n(n−1)n(n-1) nút, tầng 3 có n(n−1)(n−2)n(n-1)(n-2) nút, và tầng cuối có n!n! lá. Cộng mọi tầng lại được khoảng 2,7⋅n!2{,}7 \cdot n! nút, chỉ gấp khoảng 2,72{,}7 lần số lá. Với n=10n = 10 có 3,63{,}6 triệu lá và 9,99{,}9 triệu nút, mỗi nút thử nn giá trị: khoảng 10810^8 phép, trần thực tế của việc duyệt hoán vị. Nếu chỉ cần mọi hoán vị mà không có điều kiện để cắt, next_permutation (bài 00) gọn hơn; quay lui đáng dùng khi có ràng buộc cắt được giữa chừng.

Ví dụDãy Chọn Không Lặp

Đề. Liệt kê mọi dãy (x1,…,xm)(x_1, \dots, x_m) với xi∈[1,n]x_i \in [1, n] đôi một khác nhau, theo thứ tự từ điển. m≤n≤8m \le n \le 8.

Lời giải. Khung hoán vị dừng ở độ sâu mm. Thử vv tăng dần cho đúng thứ tự từ điển. Có n!(n−m)!≤40 320\frac{n!}{(n-m)!} \le 40\,320 dãy.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll n, m, x[10];
bool used[10];

void go(ll i) {
    if (i > m) {
        for (ll j = 1; j <= m; j++) cout << x[j] << " \n"[j == m];
        return;
    }
    for (ll v = 1; v <= n; v++) {
        if (used[v]) continue;
        used[v] = true;
        x[i] = v;
        go(i + 1);
        used[v] = false;
    }
}

signed main() {
    cin >> n >> m;
    go(1);
}
3 2

Tổ hợp: ép thứ tự để không đếm trùng

In mọi cách chọn kk số từ 1..n1..n, coi {1,3}\{1, 3\} và {3,1}\{3, 1\} là một. Dùng khung hoán vị thì mỗi tập xuất hiện k!k! lần. Cách sửa là ép mỗi tập chỉ có một cách viết: theo thứ tự tăng dần. Phần tử đặt sau phải lớn hơn phần tử đặt trước, nên hàm nhận thêm giá trị nhỏ nhất còn được đặt.

Cắt thêm một nhát: còn phải chọn left số mà chọn vv làm số tiếp theo thì sau vv phải còn ít nhất left - 1 giá trị, tức v≤n−left+1v \le n - \text{left} + 1. Thử vv lớn hơn chắc chắn không đủ chỗ. Sau khi cắt, mọi nút đều nằm trên đường tới một lá; số lá đúng bằng số cách chọn kk số từ nn số, ký hiệu (nk)\binom{n}{k} (đọc là “tổ hợp chập kk của nn”, bằng n!k! (n−k)!\frac{n!}{k!\,(n-k)!}).

Hỏi nhanh

Muốn liệt kê mọi cách chọn 3 số khác nhau từ {1,…,6}\{1, \dots, 6\} (không phân biệt thứ tự). Nếu bạn bỏ ràng buộc “số sau lớn hơn số trước” mà chỉ giữ “không dùng lại số đã dùng”, cây có bao nhiêu lá?

Ví dụChọn Dãy Tăng

Đề. Liệt kê mọi dãy tăng ngặt x1<⋯<xmx_1 < \dots < x_m với xi∈[1,n]x_i \in [1, n], theo thứ tự từ điển. m≤n≤8m \le n \le 8.

Lời giải. Mỗi dãy tăng là một tập viết theo thứ tự tăng, nên đúng là khung tổ hợp: vị trí ii thử vv từ xi−1+1x_{i-1} + 1 tới n−(m−i)n - (m - i). Gán x0=0x_0 = 0 để vị trí đầu bắt đầu từ 1.

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

void go(ll i) {
    if (i > m) {
        for (ll j = 1; j <= m; j++) cout << x[j] << " \n"[j == m];
        return;
    }
    for (ll v = x[i - 1] + 1; v <= n - (m - i); v++) {
        x[i] = v;
        go(i + 1);
    }
}

signed main() {
    cin >> n >> m;
    go(1);
}
4 2

Cắt nhánh: tám hậu

Tám hậu: đặt tám hậu lên bàn 8×88 \times 8 sao cho không hai quân cùng hàng, cùng cột hay cùng đường chéo. Hãy thu hẹp không gian từng bước. Mỗi hàng đúng một hậu: còn 88≈1,7⋅1078^8 \approx 1{,}7 \cdot 10^7 cách. Khác cột: 8!=40 3208! = 40\,320. Quay lui đi thêm một bước: kiểm đường chéo ngay khi vừa đặt từng hậu, và chỉ còn 2057 nút.

Đặt hậu từng hàng từ trên xuống. Hai ô (r,c)(r, c) và (r′,c′)(r', c') cùng đường chéo khi r−c=r′−c′r - c = r' - c' hoặc r+c=r′+c′r + c = r' + c', nên ba mảng đánh dấu trả lời “ô này có bị tấn công không” trong O(1)O(1): col[c], d1[r + c] và d2[r - c + n] (cộng nn để chỉ số không âm).

Ô vàng bị tấn công; hàng đỏ là hàng hết ô an toàn, phải quay lui. Bấm ô để cấm đặt hậu ở đó.
C++
void place(ll r) {
    if (r > n) {
        ans++;
        return;
    }
    for (ll c = 1; c <= n; c++) {
        if (col[c] || d1[r + c] || d2[r - c + n]) continue;
        col[c] = d1[r + c] = d2[r - c + n] = true;
        place(r + 1);
        col[c] = d1[r + c] = d2[r - c + n] = false;
    }
}

Ba nguyên tắc nằm sau chương trình. Chỉ kiểm cái mới: các hậu cũ đã không tấn công nhau, chỉ cần kiểm hậu mới, và mảng đánh dấu làm việc đó trong O(1)O(1). Cắt càng gần gốc càng lợi: một nhát cắt ở độ sâu nhỏ xóa cả cây con rất lớn. Chỉ cắt khi chứng minh được nhánh không thể hoàn thành: hàm cắt được phép bỏ sót nhánh vô vọng, nhưng không được kết luận nhầm một nhánh còn có nghiệm.

Cây bị cắt thì không có công thức đẹp cho số nút, và cách đáng tin nhất là đếm: thêm nodes++ ở đầu hàm, chạy với nn nhỏ rồi nhìn tốc độ tăng. Tám hậu có 20572057 nút (n=8n = 8), 35 53935\,539 (n=10n = 10), 856 189856\,189 (n=12n = 12), 27 358 55327\,358\,553 (n=14n = 14): mỗi lần nn tăng 1, số nút nhân lên 4 tới 6 lần. Khi không chạy thử được, có thể ước lượng bằng cách thả ngẫu nhiên từ gốc xuống (cách của Knuth): ở mỗi nút đếm số con bb rồi chọn một con ngẫu nhiên, ước lượng là 1+b0+b0b1+…1 + b_0 + b_0 b_1 + \dots dọc đường đi, lấy trung bình nhiều lần.

Ví dụTám Hậu Trên Ô Trống

Đề. Bàn 8×88 \times 8, ô . được đặt hậu, ô * bị cấm. Đếm số cách đặt đúng tám hậu vào ô được phép, không hai hậu tấn công nhau. Ô cấm chỉ ngăn đặt hậu tại chính nó, không chặn đường tấn công.

Lời giải. Đúng như trên, thêm một phép lọc: ô (r,c)(r, c) phải là .. Ô cấm chỉ làm cây ít nút hơn, không quá 2057 nút. Chỗ dễ sai là coi ô * như chướng ngại chặn đường tấn công; đề nói rõ nó không chặn.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll ans;
string b[10];
bool col[10], d1[20], d2[20];

void place(ll r) {
    if (r == 8) {
        ans++;
        return;
    }
    for (ll c = 0; c < 8; c++) {
        if (b[r][c] == '*' || col[c] || d1[r + c] || d2[r - c + 8]) continue;
        col[c] = d1[r + c] = d2[r - c + 8] = true;
        place(r + 1);
        col[c] = d1[r + c] = d2[r - c + 8] = false;
    }
}

signed main() {
    for (ll r = 0; r < 8; r++) cin >> b[r];
    place(0);
    cout << ans << '\n';
}
........
..*.....
........
....*...
........
......*.
.*......
........

Mỗi nghiệm đúng một lần

Khi đề yêu cầu đếm, mỗi nghiệm phải ứng với đúng một đường từ gốc tới lá: đủ (ít nhất một đường) và không thừa (không quá một đường). Thừa thì đếm trùng, thường gấp lên một hệ số kiểu k!k!. Có bốn cách chuẩn.

Thứ tự tăng cho tập, đã gặp ở mục tổ hợp.

Phần tử lặp. Hoán vị các chữ của aab: đánh dấu theo vị trí thì hai chữ a bị coi là khác nhau và aab sinh ra hai lần. Cách sửa là đếm tần suất cnt[ch] và thử từng chữ cái khác nhau còn lại thay vì từng vị trí. Một xâu đích xác định duy nhất chữ cái ở mỗi vị trí, nên có đúng một đường; số lá là n!c1! c2!⋯\frac{n!}{c_1!\,c_2!\cdots}.

Đối xứng. Chia NN người thành hai đội bằng nhau không tên: duyệt mọi tập N/2N/2 người thì mỗi cách chia bị tính hai lần. Cố định người số 1 vào đội đầu thì chỉ còn một nửa. Xếp số quanh vòng tròn mà các phép quay coi là một: cố định số 1 ở đầu, còn (n−1)!(n-1)!.

Ô trống đầu tiên. Đếm cách lát bảng bằng domino 1×21 \times 2: thay vì “đặt domino ở đâu cũng được”, luôn xét ô trống đầu tiên pp theo thứ tự hàng rồi cột. Mọi ô trước pp đã phủ, nên domino phủ pp chỉ có thể nằm ngang (sang phải) hoặc đứng (xuống dưới). Cách lát nào cũng là một dãy domino sắp theo ô đầu tăng dần, dãy đó duy nhất.

Ô viền cam là ô trống đầu tiên; mỗi cách lát được sinh đúng một lần.

Hỏi nhanh

Một chương trình đếm cách lát bảng 2×32 \times 3 bằng ba domino, nhưng mỗi bước nó thử mọi vị trí còn trống để đặt một domino (không cố định ô nào) và cộng 1 khi bảng kín. Bảng 2×32 \times 3 có đúng 3 cách lát. Chương trình in ra bao nhiêu?

Ví dụHoán Vị Phân Biệt

Đề. Xâu ss dài không quá 8 chữ cái thường. In số xâu khác nhau tạo được bằng cách xếp lại các ký tự của ss, rồi in các xâu đó theo thứ tự từ điển.

Lời giải. Đếm tần suất từng chữ cái. Ở mỗi vị trí thử từng chữ cái còn số lượng, theo thứ tự a tới z, nên các xâu sinh ra không trùng và đúng thứ tự từ điển. Đề đòi in số lượng trước, nên gom các xâu vào một danh sách rồi in. Có không quá 8!=40 3208! = 40\,320 xâu.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll n, cnt[26];
string s, cur;
vector<string> res;

void go() {
    if ((ll)cur.size() == n) {
        res.push_back(cur);
        return;
    }
    for (ll ch = 0; ch < 26; ch++) {
        if (cnt[ch] == 0) continue;
        cnt[ch]--;
        cur.push_back('a' + ch);
        go();
        cur.pop_back();
        cnt[ch]++;
    }
}

signed main() {
    cin >> s;
    n = s.size();
    for (char c : s) cnt[c - 'a']++;
    go();
    cout << res.size() << '\n';
    for (string x : res) cout << x << '\n';
}
aab

Dừng sớm và cắt theo cận

Không phải bài nào cũng cần mọi nghiệm.

Chỉ cần một nghiệm, hoặc có/không. Cho hàm trả về bool: true nghĩa là đã tìm thấy. Nút nào thấy một con trả true thì trả true ngay, và không hoàn tác, vì trạng thái lúc đó chính là nghiệm cần giữ để in. Thử lựa chọn theo thứ tự tăng thì nghiệm đầu tiên tìm được là nghiệm nhỏ nhất theo thứ tự từ điển; với tám hậu nó ra sau 114 nút trong khi cây đầy đủ có 2057 nút.

Bài tối ưu. Giữ best là giá trị tốt nhất đã thấy. Với bài tìm cực tiểu mà chi phí chỉ tăng khi đi sâu, nhánh nào đã có cost >= best thì khỏi đi tiếp. Tổng quát hơn, nếu có một cận dưới lb của chi phí phần còn lại (ước lượng lạc quan, không bao giờ lớn hơn chi phí thật) thì cắt khi cost + lb >= best: mọi lá dưới nút này có chi phí ít nhất cost + lb. Kỹ thuật này gọi là nhánh và cận (branch and bound). lb thổi phồng sẽ cắt nhầm nhánh chứa nghiệm tối ưu; dấu >= loại cả phương án bằng best, ổn khi chỉ cần giá trị tối ưu nhưng sai khi phải đếm số nghiệm tối ưu.

Thứ tự thử các con. Đổi thứ tự thử không đổi tập nghiệm, chỉ đổi thời gian. Thử trước lựa chọn hứa hẹn nhất giúp best tốt lên sớm, nên các nhánh sau bị cắt nhiều hơn.

Khi nào nên bỏ quay lui? Khi các nhánh khác nhau dẫn tới cùng một trạng thái còn lại (tập ô đã phủ, tập số đã dùng): ghi nhớ kết quả, tức quy hoạch động (bài 12, 15). Khi nn cỡ 30 tới 40 với bài chọn tập con: chia đôi rồi ghép (bài 10).

Ví dụPhủ Các Ô Được Chọn

Đề. Bảng 10×1010 \times 10 gồm ô 0 và 1. Có năm tờ giấy vuông cho mỗi cỡ 1×1,…,5×51 \times 1, \dots, 5 \times 5. Mỗi tờ nằm trọn trong bảng, chỉ phủ ô 1, không chồng nhau. Phủ toàn bộ ô 1 bằng ít tờ nhất, hoặc in −1-1.

Lời giải. Luôn xét ô 1 chưa phủ đầu tiên pp theo thứ tự hàng rồi cột. Tờ phủ pp phải có góc trên trái đúng tại pp: góc trên trái của một hình vuông chứa pp đứng trước hoặc trùng pp, mà mọi ô trước pp là ô 0 hoặc đã phủ. Vậy ở mỗi nút chỉ cần thử cỡ tờ, không cần thử vị trí, và mỗi cách phủ được sinh đúng một lần. Cắt khi số tờ đã dùng >= best, thử tờ to trước để best giảm nhanh. Ở bảng toàn số 1, không cắt thì cây có 424 304 nút, có cắt thì chỉ 378 nút.

Bước 1: bảng đánh dấu. a[r][c] = 1 nghĩa là ô (r,c)(r, c) là ô 1 chưa phủ. Đọc bảng xong thì mọi ô 1 đều chưa phủ. paper[s] đếm số tờ cỡ ss còn lại, best = 26 là giá trị “chưa có nghiệm”, vì tổng số tờ chỉ là 25.

C++
for (ll r = 0; r < 10; r++)
    for (ll c = 0; c < 10; c++) cin >> a[r][c];

Bước 2: tờ có đặt được không. Tờ cạnh ss, góc trên trái (r,c)(r, c) chiếm các hàng r..r+s−1r..r + s - 1 và các cột c..c+s−1c..c + s - 1. Tờ đặt được khi nằm trong bảng và mọi ô nó che đều là ô 1 chưa phủ.

C++
bool fits(ll r, ll c, ll s) {
    if (r + s > 10 || c + s > 10) return false;
    for (ll i = r; i < r + s; i++)
        for (ll j = c; j < c + s; j++)
            if (!a[i][j]) return false;
    return true;
}

Bước 3: đặt và nhấc tờ. Đặt tờ là gán 0 cho mọi ô nó che, nhấc tờ là gán lại 1. Một hàm nhận giá trị cần gán làm được cả hai việc.

C++
void put(ll r, ll c, ll s, ll v) {
    for (ll i = r; i < r + s; i++)
        for (ll j = c; j < c + s; j++) a[i][j] = v;
}

Bước 4: quay lui. dfs(used) cắt ngay nếu đã dùng không ít hơn best tờ. Đánh số ô từ 0 tới 99 theo hàng rồi cột, ô số pp nằm ở hàng p/10p / 10, cột p mod 10p \bmod 10; quét số pp tăng dần để tìm ô chưa phủ đầu tiên. Không còn ô nào thì đã phủ xong, cập nhật best. Còn lại, thử cỡ ss từ 5 xuống 1, mỗi cỡ còn giấy và đặt được thì áp dụng (giảm paper, đặt tờ), gọi tiếp, rồi hoàn tác theo thứ tự ngược lại.

C++
void dfs(ll used) {
    if (used >= best) return;
    ll p = 0;
    while (p < 100 && !a[p / 10][p % 10]) p++;
    if (p == 100) {
        best = used;
        return;
    }
    ll r = p / 10, c = p % 10;
    for (ll s = 5; s >= 1; s--) {
        if (paper[s] == 0 || !fits(r, c, s)) continue;
        paper[s]--;
        put(r, c, s, 0);
        dfs(used + 1);
        put(r, c, s, 1);
        paper[s]++;
    }
}

Ghép lại, main đọc bảng, gọi dfs(0) và in −1-1 nếu best vẫn là 26:

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll a[10][10], paper[6] = {0, 5, 5, 5, 5, 5}, best = 26;

bool fits(ll r, ll c, ll s) {
    if (r + s > 10 || c + s > 10) return false;
    for (ll i = r; i < r + s; i++)
        for (ll j = c; j < c + s; j++)
            if (!a[i][j]) return false;
    return true;
}

void put(ll r, ll c, ll s, ll v) {
    for (ll i = r; i < r + s; i++)
        for (ll j = c; j < c + s; j++) a[i][j] = v;
}

void dfs(ll used) {
    if (used >= best) return;
    ll p = 0;
    while (p < 100 && !a[p / 10][p % 10]) p++;
    if (p == 100) {
        best = used;
        return;
    }
    ll r = p / 10, c = p % 10;
    for (ll s = 5; s >= 1; s--) {
        if (paper[s] == 0 || !fits(r, c, s)) continue;
        paper[s]--;
        put(r, c, s, 0);
        dfs(used + 1);
        put(r, c, s, 1);
        paper[s]++;
    }
}

signed main() {
    for (ll r = 0; r < 10; r++)
        for (ll c = 0; c < 10; c++) cin >> a[r][c];
    dfs(0);
    cout << (best == 26 ? -1 : best) << '\n';
}
1 1 0 0 0 0 0 0 0 0
1 1 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0

Mỗi nút quét tối đa 100 ô và thử nhiều nhất 5 cỡ, mỗi cỡ tốn tối đa 25 phép; số nút nhỏ nhờ cắt theo best và nhờ chỉ xét một ô ở mỗi nút.

Khi đi thi

  • Giới hạn nhỏ kiểu n≤20n \le 20, bảng 8×88 \times 8 hay lưới vài chục ô cùng yêu cầu “in mọi cấu hình” hoặc “đếm mọi cách” là dấu hiệu của quay lui. Trước khi viết, ước lượng số nút bằng 2n2^n, n!n! hay mnm^n và tự hỏi ràng buộc nào cắt được ở gần gốc.
  • Quyết định thứ tự xây nghiệm trước khi gõ code: chỉ số tăng, ô trống đầu tiên, cố định phần tử đối xứng, để mỗi nghiệm có đúng một đường. Mọi thứ sửa trước lời gọi đệ quy phải được khôi phục ngay sau nó.
  • Nếu cây vẫn quá lớn sau khi cắt, đếm số trạng thái khác nhau: ít thì ghi nhớ kết quả (quy hoạch động), nn cỡ 40 thì chia đôi. Với bài tối ưu, cận phải lạc quan, và dùng > thay >= khi cần đếm số nghiệm tối ưu.

Tóm tắt

  • Quay lui là duyệt chiều sâu trên cây quyết định: chọn, đệ quy, hoàn tác. Trạng thái truyền theo giá trị tự trở về; biến chung phải được khôi phục ngay sau lời gọi đệ quy.
  • Cắt nhánh chỉ được loại những nhánh chắc chắn không có nghiệm. Kiểm phần mới trong O(1)O(1) bằng mảng đánh dấu, và kiểm càng sớm càng tốt.
  • Mỗi nghiệm một đường: thứ tự tăng cho tập, tần suất cho phần tử lặp, cố định phần tử đối xứng, ô trống đầu tiên cho bài lát.
  • Chỉ cần một nghiệm thì hàm trả bool và không hoàn tác khi thành công. Bài tối ưu cắt khi cost + lb >= best, trong đó lb lạc quan.
  • Chi phí là số nút nhân việc mỗi nút: tập con 2n+12^{n+1}, hoán vị ≈e⋅n!\approx e \cdot n!, tám hậu 2057 nút. Đo bằng bộ đếm nút khi không có công thức.

Bài tập

1
Vòng Quay Về Gốccanuc80k0706Mỗi lần quay có hai chiều, tổng góc có hướng phải chia hết cho 360; cây có 2n2^n lá.
LQDOJ
2
Chọn Bộ Bài Cân Đốicanuc80k0713Chọn hoặc bỏ từng bài; mang theo số bài, tổng, nhỏ nhất và lớn nhất của tập đang xây.
LQDOJ
3
Liệt Kê Ngoặc Đúngcanuc80k0710Chỉ thêm ) khi đã có nhiều ( hơn ), và chỉ thêm ( khi chưa đủ một nửa.
LQDOJ
4
Dãy Không Giảm Tối Ưucanuc80k0709Truyền giá trị phần tử trước làm cận dưới cho phần tử sau; chấm điểm ở lá.
LQDOJ
5
Mật Khẩu Tăng Dầncanuc80k0711Sắp xếp các chữ cái rồi chọn tổ hợp tăng dần, đếm nguyên âm và phụ âm trên đường đi.
LQDOJ
6
Xếp Toán Tửcanuc80k0715Mỗi khe một toán tử, kèm số lượng còn lại của bốn loại; chia nguyên làm tròn về 0 như / của C++.
LQDOJ
7
Vòng Số Có Tổng Nguyên Tốcanuc80k0727Cố định số 1 ở đầu vòng, chỉ thêm số nếu tổng với số trước là nguyên tố, kiểm cặp cuối với đầu ở lá.
LQDOJ
8
Chia Đội Cân Bằngcanuc80k0730Cố định người số 1 vào đội đầu rồi chọn tổ hợp N/2−1N/2 - 1 người còn lại; chấm điểm ở lá.
LQDOJ
9
Chuỗi Tốt Nhỏ Nhấtcanuc80k0726Đặt từng ký tự và chỉ kiểm các cặp khối bằng nhau kết thúc ở cuối xâu; dừng ở nghiệm đầu tiên.
LQDOJ
10
Chọn Hình Chữ Nhật Lát Bảngcanuc80k0742Xét ô trống đầu tiên rồi thử từng miếng chưa dùng ở hai hướng xoay, miếng đó có góc trên trái tại ô này.
LQDOJ
11
Lộ Trình Qua 49 Ôcanuc80k0737Đếm đường đi qua mọi ô của lưới 7×77 \times 7; cắt ngay khi đường đi chia các ô chưa thăm thành hai miền hoặc về đích quá sớm.
LQDOJ

Đọc thêm

  • Đệ quy và Thuật toán quay luiVNOI WikiTừ đệ quy tới liệt kê cấu hình bằng quay lui, khung cài đặt quen thuộc trong các lớp chuyên.
  • Competitive Programmer’s Handbook, chương 5Tám hậu và bài đường đi trên lưới 7×77 \times 7 được tối ưu qua bốn bước cắt nhánh, có đo số lời gọi.
  • 回溯法OI WikiTám hậu và đếm đường đi trong mê cung với cách giữ cờ và hoàn tác.
  • 搜索优化OI WikiPhân loại cắt nhánh: ghi nhớ, cắt theo tối ưu và cắt theo tính khả thi.
  • Generating all K-combinationscp-algorithmsDuyệt tổ hợp không dùng đệ quy: thứ tự từ điển và mã Gray.