Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học

Chia Ruộng

Chia Ruộng
Nguồn: Codeforces Group Độ khó: Khá Sinh dãy tam phân Meet in the middle Hash table

Đề bài

Trong làng Omega có 1 phú ông rất giàu có. Tài sản của ông rất nhiều trâu, bò, ruộng vườn. Một hôm ông gọi 3 người con đã đến tuổi trưởng thành đến ông chia cho một số ruộng để làm ăn. Ông có hàng trăm mẫu ruộng nhưng ông chỉ chọn $n$ thửa ruộng để chia, thửa thứ $i$ có diện tích $a_i$ m². Hỏi rằng phú ông có bao nhiêu cách chia đều $n$ thửa ruộng cho 3 con sao cho các thửa ruộng được chia phải giữ nguyên diện tích.

Input:

  • Dòng 1: ghi số $n$ là số thửa ruộng của phú ông đem chia $(1 \le n \le 20)$.
  • Dòng 2: ghi các số $a_i$ là diện tích của thửa ruộng thứ $i$ $(100 \le a_i \le 5000)$.

Output:

  • Dòng thứ nhất ghi số cách chia, nếu không thể chia được thì ghi $-1$.
  • Các dòng sau trong trường hợp chia được, mỗi dòng là 01 cách chia với quy ước người thứ nhất có tên là "A", người thứ hai có tên là "B", người thứ ba có tên là "C" và phải tương ứng với thứ tự của các thửa ruộng được chia.

Lưu ý: Nếu có cách chia thì in ra các cách chia tăng dần theo thứ tự từ điển. Như trong ví dụ đề cho các chuỗi trên từng dòng theo thứ tự từ điển: AABBC < AACCB < BBAAC < BBCCA < CCAAB < CCBBA.

Subtask:

  • 40% số test đầu tiên có $n \le 15$.
  • 60% số test còn lại không có giới hạn gì thêm.
Ví dụ – input
5
100 200 250 50 300
Ví dụ – output
6
AABBC
AACCB
BBAAC
BBCCA
CCAAB
CCBBA

Giải thích ví dụ: tổng diện tích là $100 + 200 + 250 + 50 + 300$ $= 900$, mỗi người con phải nhận đúng $300$. Ví dụ cách AABBC: A nhận $100 + 200$, B nhận $250 + 50$, C nhận $300$.

Lời mở đầu

Bài này mình được một bạn học viên hỏi nên sẵn tiện mình viết bài giải chi tiết để nhiều bạn được tham khảo luôn. Hy vọng mang đến được giá trị cho các bạn 😇.

Dành cho học viên · Ưu tiênNộp bài trên hệ thống CLUE OJ

Link này chỉ học viên đã tham gia tổ chức mới vào được. Học viên nên ưu tiên nộp ở đây vì sẽ được chấm nhanh hơn.

Nộp bài trên CLUE OJ
Dành cho tất cả mọi ngườiNộp bài trên Codeforces

Bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì dùng link này nhé. Chưa có tài khoản thì tạo tài khoản rồi đăng nhập nộp bài thử hen.

Nộp bài trên Codeforces

Lộ trình lời giải, bấm vào từng phần để nhảy tới:

Vét cạnSinh hết dãy tam phân – O(N · 3^N) là TLE

Với bài chia đất này nếu làm theo kiểu sinh ra các dãy tam phân (do có 3 trường hợp: mỗi thửa ruộng giao cho A, B hoặc C), thì liệt kê ra hết các dãy tam phân rồi cứ 0 là ứng với thằng A, 1 là ứng với thằng B, 2 là ứng với thằng C, như thế tính tổng từng thằng xem trường hợp nào thỏa mãn thì đưa vào đáp án.

Nếu làm như thế độ phức tạp sẽ là $O(N \cdot 3^N)$. Với $N$ tối đa $20$ thì $20 \cdot 3^{20} = 69\,735\,688\,020$, xem như gần 70 tỷ, như thế chắc chắn sẽ TLE rồi vì đã vượt quá ngưỡng $10^8$ (tức là 100 triệu) phép tính của 1 giây.

Thì bài này cũng vẫn làm theo tư tưởng xét dãy tam phân nhưng phải kết hợp thêm với kỹ thuật meet in the middle hay còn gọi là phân đôi tập để tối ưu hóa thêm.

Kiến thứcKỹ thuật Meet in the middle (phân đôi tập)

Định nghĩa sơ lược
Meet in the middle là một kĩ thuật tìm kiếm được sử dụng để giải quyết những vấn đề có đầu vào nhỏ, nhưng không nhỏ đến mức mà ta có thể sử dụng vét cạn để giải quyết. Kĩ thuật này sẽ có điểm khá giống với chia để trị: ta sẽ chia vấn đề thành 2, giải quyết chúng riêng rẽ rồi hợp nhất lại để ra được kết quả. Nhưng mà nó sẽ khác nhau ở chỗ là ở kĩ thuật chia để trị ta sẽ chia bài toán lớn thành các bài toán con, còn ở kĩ thuật meet in the middle thì ta sẽ không làm được như vậy vì sau khi chia vấn đề ra làm 2 thì nó sẽ không còn có cấu trúc giống như bài toán ban đầu.

Em có thể tham khảo thêm bài viết của GeeksforGeeks hoặc bài viết mới của VNOI Wiki về kỹ thuật này.

Em có thể hiểu đại khái như sau: giả sử bài toán cho $N = 20$ thì hãy tách ra làm 2 phần với mỗi phần có $N = 10$, vậy nếu dùng dãy tam phân ta sẽ có $3^{10}$ dãy bên này kết nối với $3^{10}$ dãy bên kia. Em sẽ hiểu rằng cứ mỗi dãy trong $3^{10}$ dãy bên này sẽ xét nó với toàn bộ $3^{10}$ dãy bên kia.

Nhìn lại bài "tiền tệ" quen thuộc

Ví dụ trước đó anh nhớ em đã từng giải bài toán tiền tệ: Bà mẹ có 2 người con là A và B và cần chia $N$ tờ tiền $(N \le 20)$ cho 2 người con sao cho tổng tiền mỗi người con nhận được đều bằng nhau, yêu cầu in ra chi tiết tất cả các cách có thể chia. Test ví dụ đề cho:

Input bài tiền tệ
6
1 2 2 5 10 10
Output bài tiền tệ
4
A A A B A B
A A A B B A
B B B A A B
B B B A B A

Thì có phải là em giải quyết bằng cách sinh ra tất cả các dãy nhị phân có chiều dài $N$, bài cho $N$ tối đa $20$ nên tổng cộng sẽ có $2^{20} = 1\,048\,576$ (hơn 1 triệu) dãy nhị phân, và như thế em sẽ xét vào từng dãy, nếu tổng tiền tại các vị trí số 0 bằng tổng tiền tại các vị trí số 1 thì đó là 1 đáp án thỏa mãn. Tính ra độ phức tạp sẽ là $O(N \cdot 2^N)$, khi $N = 20$ thì sẽ là $20 \cdot 2^{20}$, hơn 20 triệu thôi và vẫn nhỏ hơn ngưỡng $10^8$ của 1 giây nên không sợ bị TLE.

Thì nếu bài tiền tệ này mà em ứng dụng kỹ thuật phân đôi tập vào cũng sẽ giúp chạy nhanh hơn nha. Nhìn lại test ví dụ $N = 6$, chia đôi ra tức là $6 / 2 = 3$, tức là mỗi nửa có $2^3 = 8$ dãy nhị phân: 000, 001, 010, 011, 100, 101, 110, 111. Cứ với 1 dãy bên này sẽ xét với toàn bộ 8 dãy bên kia:

000 ghép với: 000|000 000|001 000|010 000|011 000|100 000|101 000|110 000|111
001 ghép với: 001|000 001|001 001|010 001|011 001|100 001|101 001|110 001|111
010 ghép với: 010|000 010|001 010|010 010|011 010|100 010|101 010|110 010|111

…và cứ thế tới dãy 111 bên này.

Thì khi phân ra làm 2 như vậy, với 1 nửa bên kia em lưu các kết quả vào map và em chỉ việc xét 1 nửa bên này, rồi ứng với từng phần tử của 1 nửa bên này em check vào map để xét với bên kia. Như thế tính ra độ phức tạp thay vì $O(2^N)$ thì chỉ còn $O(2^{N/2} \cdot \log(2^{N/2}))$, tối ưu hơn rất nhiều:

$N = 20$Vét cạnPhân đôi tập
Bài tiền tệ (nhị phân)$2^{20} = 1\,048\,576$$2^{10} \cdot \log_2(2^{10}) = 10\,240$
Bài chia ruộng (tam phân)$20 \cdot 3^{20} \approx 70$ tỷ$10 \cdot 3^{10} \cdot \log_2(3^{10})$ $\approx 9\,359\,045$

Em thấy nhanh hơn cả 100 lần không? Với bài chia ruộng chỉ cần tầm 9 triệu mấy phép tính thì yên tâm AC trong 1 giây. Cái kỹ thuật phân đôi tập này rất hay, em tìm hiểu rồi ứng dụng vào bài chia ruộng nhé.

Ý tưởngÁp dụng vào bài chia ruộng

Để anh nói cụ thể hơn ở bài chia đất này như sau, đầu tiên em chia đôi $n$ ra: $n1 = n / 2$, $n2 = n - n1$. Em tạo ra các dãy tam phân có chiều dài $n1$ và $n2$, quy ước theo ABC cho dễ đi ha. Ví dụ $n = 5$ thì $n1 = 2$, $n2 = 3$: có $3^2 = 9$ dãy cho nửa đầu và $3^3 = 27$ dãy cho nửa sau:

n1 = 2 (9 dãy): AA AB AC BA BB BC CA CB CC
n2 = 3 (27 dãy): AAA AAB AAC ABA ABB ABC ACA ACB ACC BAA BAB BAC BBA BBB BBC BCA BCB BCC CAA CAB CAC CBA CBB CBC CCA CCB CCC

Em tạo ra cấu trúc Key có 3 thành phần là sumA, sumB, sumC. Khi xét các dãy ứng với $n2$ thì em phải hiểu là nó ứng với $n2$ phần tử cuối của mảng. Ví dụ test đề có $n = 5$ thì mảng $a$ có các vị trí $a[0],$ $a[1],$ $a[2],$ $a[3],$ $a[4]$, khi xét dãy $n2 = 3$ phần tử cuối mà giả sử dãy là CAB thì có nghĩa là: chọn $a[2]$ cho thằng C, chọn $a[3]$ cho thằng A, chọn $a[4]$ cho thằng B. Khi đó $sumA = 50$, $sumB = 300$, $sumC = 250$.

Cứ thế em tạo ra cái hash table lưu hết trạng thái Key{sumA, sumB, sumC} của các dãy xét ra, và value của nó chính là (danh sách) chuỗi trạng thái của dãy, ví dụ khi xét "CAB" thì: key = {50, 300, 250} và value chứa "CAB".

Gọi $sum$ là tổng các phần tử của mảng, nếu thấy $sum$ không chia hết cho 3 thì ta kết luận $-1$ ngay, còn nếu chia hết cho 3 thì ta tính $sum$ /= 3 (phần mỗi người con phải nhận). Sau khi xét hết toàn bộ $3^{n2}$ dãy tam phân, em quay lại sinh ra $3^{n1}$ dãy tam phân và cũng tính ra được $\{sumA, sumB, sumC\}$ của từng dãy. Nếu $sumA \le sum$, $sumB \le sum$ và $sumC \le sum$ thì mới thỏa, khi đó ta tìm kiếm trong hash table xem có tồn tại các dãy mà tại đó

Khóa cần tìm
$Key = \{sum - sumA,\ sum - sumB,\ sum - sumC\}$

và lấy value của nó ra ghép với chuỗi dãy tam phân của $n1$ thì đó là 1 đáp án, cứ thế đưa hết các đáp án vào vector, sau cùng in ra nó.

Vì sao đáp án tự động tăng dần theo thứ tự từ điển? Mình sinh các dãy tam phân theo kiểu "đếm" từ AA…A tới CC…C nên chúng xuất hiện theo đúng thứ tự từ điển. Nửa đầu $s1$ được duyệt theo thứ tự từ điển, và với mỗi $s1$ thì các chuỗi nửa sau trong cùng 1 value cũng được thêm vào theo thứ tự từ điển, nên chuỗi ghép $s1 + s2$ đã tăng dần sẵn, không cần sắp xếp lại.

Em nhập diện tích các thửa ruộng rồi bấm Tiếp để xem 2 giai đoạn: lưu nửa sau vào hash table, rồi duyệt nửa đầu và tra khóa còn thiếu:

Ví dụ nhanh:

100%Code Meet in the middle + unordered_map với Key tự định nghĩa

Nó chỉ khó ở chỗ khi em dùng hash table thì dữ liệu hiện tại có Key là struct có 3 thành phần số nguyên, lúc này em phải tự cài đặt cơ chế hash cho nó. Nếu chỉ có 1 số nguyên thì nó còn biết hash, nhưng ở đây nó đang là 1 cấu trúc có 3 thành phần số nguyên thì nó sẽ không biết hash thế nào. Giải pháp ở đây hoặc là em tự tạo hash table, hoặc em dùng hash table có trong thư viện thì phải chỉ định rõ phép so sánh bằng (operator==) và hàm hash (KeyHasher) cho nó, em có thể tham khảo tại câu hỏi này trên StackOverflow.

Đó thì toàn bộ ý tưởng là như vậy, anh gửi luôn source code của anh đã làm đúng theo ý tưởng anh nói ở trên để nếu em không làm được thì tham khảo, đã nộp thành công trên web. Và cũng nói rõ là code này chưa có clean đâu nha em, vì em xem sẽ thấy có rất nhiều phần code bị trùng nhau, nếu chia hàm rồi gọi lại nhìn sẽ gọn gàng hơn nhưng anh tranh thủ code nhanh ra nên để hết code trong hàm main. Bản Python thì dùng luôn dict với key là tuple (sumA, sumB, sumC), Python tự hash được tuple nên đỡ phải cài đặt.

#include <bits/stdc++.h>
using namespace std;

struct Key
{
  int first, second, third;

  bool operator==(const Key &other) const{
      return first == other.first && second == other.second && third == other.third;
  }
};

// https://stackoverflow.com/questions/17016175/c-unordered-map-using-a-custom-class-type-as-the-key
struct KeyHasher
{
  size_t operator()(const Key& k) const
  {
    return ((hash<int>()(k.first) ^ (hash<int>()(k.second) << 1)) >> 1) ^ (hash<int>()(k.third) << 1);
  }
};

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n;
    cin >> n;
    int a[n], sum = 0;
    for(int i = 0; i < n; ++i){
        cin >> a[i];
        sum += a[i];
    }
    if(sum % 3 != 0){
        cout << -1;
        return 0;
    }
    sum /= 3;
    vector<string> res;
    int n1 = n / 2;
    int n2 = n - n1;
    unordered_map<Key, vector<string>, KeyHasher> mp;
    string s(n2, 'A');
    bool flag = true;
    while(flag){
        flag = false;
        int sumA = 0, sumB = 0, sumC = 0;
        for(int i = 0; i < n2; ++i){
            if(s[i] == 'A'){
                sumA += a[n1 + i];
            }
            else if(s[i] == 'B'){
                sumB += a[n1 + i];
            }
            else{
                sumC += a[n1 + i];
            }
        }
        if(sumA <= sum && sumB <= sum && sumC <= sum){
            mp[{sumA, sumB, sumC}].push_back(s);
        }
        for(int i = n2 - 1; i >= 0; --i){
            if(s[i] != 'C'){
                s[i]++;
                flag = true;
                for(int j = i + 1; j < n2; ++j){
                    s[j] = 'A';
                }
                break;
            }
        }
    }

    string s1(n1, 'A');
    flag = true;
    while(flag){
        flag = false;
        int sumA = 0, sumB = 0, sumC = 0;
        for(int i = 0; i < n1; ++i){
            if(s1[i] == 'A'){
                sumA += a[i];
            }
            else if(s1[i] == 'B'){
                sumB += a[i];
            }
            else{
                sumC += a[i];
            }
        }
        if(sumA <= sum && sumB <= sum && sumC <= sum){
            auto it = mp.find({sum - sumA, sum - sumB, sum - sumC});
            if(it != mp.end()){
                for(int i = 0; i < it->second.size(); ++i){
                    res.push_back(s1 + it->second[i]);
                }
            }
        }
        for(int i = n1 - 1; i >= 0; --i){
            if(s1[i] != 'C'){
                s1[i]++;
                flag = true;
                for(int j = i + 1; j < n1; ++j){
                    s1[j] = 'A';
                }
                break;
            }
        }
    }
    if(res.size() == 0){
        cout << -1;
    }
    else{
        cout << res.size();
        for(int i = 0; i < res.size(); ++i){
            cout << '\n' << res[i];
        }
    }
    return 0;
}
import sys
input = sys.stdin.readline

n = int(input())
a = list(map(int, input().split()))
s = sum(a)

if s % 3 != 0:
    print(-1)
    sys.exit(0)

target = s // 3
res = []

n1 = n // 2
n2 = n - n1

# sinh n2 phần tử
mp = {}      # key: (sumA, sumB, sumC) -> list các chuỗi

s2 = ['A'] * n2
flag = True
while flag:
    flag = False
    sumA = sumB = sumC = 0
    for i in range(n2):
        if s2[i] == 'A':
            sumA += a[n1 + i]
        elif s2[i] == 'B':
            sumB += a[n1 + i]
        else:
            sumC += a[n1 + i]
    if sumA <= target and sumB <= target and sumC <= target:
        key = (sumA, sumB, sumC)
        mp.setdefault(key, []).append(''.join(s2))

    # tăng chuỗi base-3 A/B/C
    for i in range(n2 - 1, -1, -1):
        if s2[i] != 'C':
            s2[i] = chr(ord(s2[i]) + 1)  # A→B, B→C
            flag = True
            for j in range(i + 1, n2):
                s2[j] = 'A'
            break

# sinh n1 phần tử
s1 = ['A'] * n1
flag = True
while flag:
    flag = False
    sumA = sumB = sumC = 0
    for i in range(n1):
        if s1[i] == 'A':
            sumA += a[i]
        elif s1[i] == 'B':
            sumB += a[i]
        else:
            sumC += a[i]

    if sumA <= target and sumB <= target and sumC <= target:
        need = (target - sumA, target - sumB, target - sumC)
        if need in mp:
            pre = ''.join(s1)
            for tail in mp[need]:
                res.append(pre + tail)

    # tăng chuỗi base-3 A/B/C
    for i in range(n1 - 1, -1, -1):
        if s1[i] != 'C':
            s1[i] = chr(ord(s1[i]) + 1)
            flag = True
            for j in range(i + 1, n1):
                s1[j] = 'A'
            break

if not res:
    print(-1)
else:
    print(len(res))
    for r in res:
        print(r)
C++▶ Chạy tay từng dòng codeMeet in the middle: lưu 3^n2 dãy nửa sau vào hash table, duyệt 3^n1 dãy nửa đầu và tra khóa còn thiếu.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4struct Key
5{
6 int first, second, third;
7
8 bool operator==(const Key &other) const{
9 return first == other.first && second == other.second && third == other.third;
10 }
11};
12
13// https://stackoverflow.com/questions/17016175/c-unordered-map-using-a-custom-class-type-as-the-key
14struct KeyHasher
15{
16 size_t operator()(const Key& k) const
17 {
18 return ((hash<int>()(k.first) ^ (hash<int>()(k.second) << 1)) >> 1) ^ (hash<int>()(k.third) << 1);
19 }
20};
21
22int main(){
23 ios::sync_with_stdio(0);
24 cin.tie(0);
25 int n;
26 cin >> n;
27 int a[n], sum = 0;
28 for(int i = 0; i < n; ++i){
29 cin >> a[i];
30 sum += a[i];
31 }
32 if(sum % 3 != 0){
33 cout << -1;
34 return 0;
35 }
36 sum /= 3;
37 vector<string> res;
38 int n1 = n / 2;
39 int n2 = n - n1;
40 unordered_map<Key, vector<string>, KeyHasher> mp;
41 string s(n2, 'A');
42 bool flag = true;
43 while(flag){
44 flag = false;
45 int sumA = 0, sumB = 0, sumC = 0;
46 for(int i = 0; i < n2; ++i){
47 if(s[i] == 'A'){
48 sumA += a[n1 + i];
49 }
50 else if(s[i] == 'B'){
51 sumB += a[n1 + i];
52 }
53 else{
54 sumC += a[n1 + i];
55 }
56 }
57 if(sumA <= sum && sumB <= sum && sumC <= sum){
58 mp[{sumA, sumB, sumC}].push_back(s);
59 }
60 for(int i = n2 - 1; i >= 0; --i){
61 if(s[i] != 'C'){
62 s[i]++;
63 flag = true;
64 for(int j = i + 1; j < n2; ++j){
65 s[j] = 'A';
66 }
67 break;
68 }
69 }
70 }
71
72 string s1(n1, 'A');
73 flag = true;
74 while(flag){
75 flag = false;
76 int sumA = 0, sumB = 0, sumC = 0;
77 for(int i = 0; i < n1; ++i){
78 if(s1[i] == 'A'){
79 sumA += a[i];
80 }
81 else if(s1[i] == 'B'){
82 sumB += a[i];
83 }
84 else{
85 sumC += a[i];
86 }
87 }
88 if(sumA <= sum && sumB <= sum && sumC <= sum){
89 auto it = mp.find({sum - sumA, sum - sumB, sum - sumC});
90 if(it != mp.end()){
91 for(int i = 0; i < it->second.size(); ++i){
92 res.push_back(s1 + it->second[i]);
93 }
94 }
95 }
96 for(int i = n1 - 1; i >= 0; --i){
97 if(s1[i] != 'C'){
98 s1[i]++;
99 flag = true;
100 for(int j = i + 1; j < n1; ++j){
101 s1[j] = 'A';
102 }
103 break;
104 }
105 }
106 }
107 if(res.size() == 0){
108 cout << -1;
109 }
110 else{
111 cout << res.size();
112 for(int i = 0; i < res.size(); ++i){
113 cout << '\n' << res[i];
114 }
115 }
116 return 0;
117}

Giải thích một số chỗ trong code

  1. ios::sync_with_stdio(0); cin.tie(0); 2 dòng đầu tiên của hàm main sẽ giúp đẩy nhanh tốc độ nhập xuất dữ liệu nếu các bạn đang code theo ngôn ngữ C++ (để hiểu vì sao thì các bạn cứ gõ mấy từ này lên Google là sẽ ra rất nhiều bài viết giải thích nhé), bài này có thể phải in ra rất nhiều dòng đáp án nên ta hãy để 2 dòng này vào để chạy nhanh hơn. Tất nhiên nếu các bạn code theo cú pháp của C thì chẳng cần mấy dòng đó làm gì, tuy nhiên thường khi code các bài lập trình thi đấu thế này ta dùng C++ vì cú pháp ngắn gọn cũng như nhiều hỗ trợ của nó, vậy nên bạn nào code C++ thì nhớ thêm 2 dòng này vào đầu hàm main nhé.
  2. Sinh dãy tam phân kế tiếp: duyệt từ cuối chuỗi về đầu, gặp ký tự đầu tiên khác 'C' thì tăng nó lên 1 ('A' → 'B', 'B' → 'C'), các ký tự phía sau đặt lại hết thành 'A', giống hệt phép cộng 1 trong hệ cơ số 3. Nếu cả chuỗi toàn 'C' thì flag vẫn là false, vòng while kết thúc.
  3. mp[{sumA, sumB, sumC}] có value là vector<string> vì nhiều dãy nửa sau có thể cho ra cùng 1 bộ tổng. Chỉ lưu những dãy có cả 3 tổng đều $\le sum$, các dãy khác chắc chắn không ghép được.
  4. Khi $n = 1$ thì $n1 = 0$, chuỗi s1 rỗng vẫn được xét đúng 1 lần (vòng while chạy 1 lần với 3 tổng bằng 0) nên code vẫn đúng.
Không gian$O(3^{N/2})$
Thời gian$O(N \cdot 3^{N/2})$

Sinh $3^{n2}$ dãy, mỗi dãy tốn $O(n2)$ để tính tổng và $O(1)$ trung bình để thêm vào hash table; tương tự $3^{n1}$ dãy nửa đầu, mỗi dãy tra hash table $O(1)$. Tổng cộng cỡ $O(N \cdot 3^{N/2})$ cộng với số dòng đáp án phải in ra. Với $N = 20$ thì $20 \cdot 3^{10} \approx 1{,}2 \cdot 10^6$ phép tính, nhẹ nhàng.

Ghi chú kiểm tra của mình: code C++ và Python là của mình trong bài giải gốc (chỉ sửa định dạng). Cả 2 bản đã được kiểm tra với 300 bộ test ngẫu nhiên ($n \le 9$, diện tích là bội của 50 để có nhiều cách chia) so với chương trình vét cạn sinh đủ $3^n$ dãy: khớp 100% cả số cách lẫn thứ tự từ điển. Với $n = 20$ ngẫu nhiên, bản C++ chạy khoảng 0.04 giây, bản Python khoảng 0.4 giây. Riêng test cực đoan kiểu 18 thửa $200$ và 2 thửa $300$ có tới hơn 31 triệu cách chia, khi đó thời gian chủ yếu nằm ở việc in ra hơn 600 MB đáp án (C++ khoảng 4.5 giây).
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇

Xếp hậu (Tin học trẻ toàn quốc 2024 – Vòng khu vực – Bảng B)

Xếp hậu (Tin học trẻ toàn quốc 2024 – Vòng khu vực – Bảng B)
Nguồn: Tin học trẻ 2024 Độ khó: Trung bình Quay lui Vét cạn Nhánh cận

Đề bài

Nguồn đề: Hội thi Tin học trẻ toàn quốc 2024 – Vòng khu vực – Bảng B (bài Xếp hậu). Vòng khu vực dùng chung một đề cho cả ba miền Bắc, Trung, Nam. Đề bên dưới do mình viết lại bằng lời của mình cho dễ hiểu (giữ đúng nghĩa, đúng số liệu và ví dụ của đề gốc).

Có một bàn cờ $n \times n$, hàng đánh số $1..n$ từ trên xuống, cột đánh số $1..n$ từ trái sang. Ô ở hàng $i$, cột $j$ gọi là ô $(i, j)$ và có một trọng số $w_{ij}$ (số nguyên không âm).

Ta cần đặt đúng $k$ quân hậu lên bàn cờ sao cho không có hai quân nào chiếu nhau. Hai quân hậu chiếu nhau khi chúng cùng hàng, hoặc cùng cột, hoặc cùng một đường chéo (chéo xuôi hay chéo ngược đều tính).

Yêu cầu: gọi $s$ là tổng trọng số của các ô có đặt hậu. Hãy đặt sao cho $s$ lớn nhất và in ra giá trị đó.

Dữ liệu vào:

  • Dòng 1: hai số nguyên $n, k$ ($1 \le k \le n \le 8$).
  • $n$ dòng tiếp theo: dòng thứ $i$ gồm $n$ số nguyên không âm $w_{i1}, w_{i2}, \dots, w_{in}$ (mỗi số không quá $10^6$).

Dữ liệu đảm bảo luôn có cách đặt $k$ quân hậu không chiếu nhau.

Kết quả: một số nguyên là giá trị lớn nhất của $s$.

Subtask:

  • Subtask 1 (30%): $k = 1$;
  • Subtask 2 (30%): $k \le 3$;
  • Subtask 3 (40%): không có ràng buộc gì thêm.

Giới hạn thời gian: 1 giây. Dữ liệu nhập từ bàn phím, in ra màn hình.

Ví dụ – input
3 2
1 0 2
0 0 2
0 2 0
Ví dụ – output
4

Giải thích: đặt 2 quân hậu ở ô $(1, 3)$ và $(3, 2)$: hai ô này khác hàng, khác cột và không cùng đường chéo (hiệu hàng là 2, hiệu cột là 1). Tổng trọng số $2 + 2 = 4$. Không có cách nào được nhiều hơn: ô $(2, 3)$ cũng có trọng số 2 nhưng nó cùng cột với $(1, 3)$ và cùng đường chéo với $(3, 2)$.

Lưu ý: đề gốc chỉ ghi $1 \le k \le n \le 8$. Có hai trường hợp không thể đặt được: $n = k = 2$ và $n = k = 3$. Bộ test trên CLUE tránh hai trường hợp này (mình ghi rõ "dữ liệu đảm bảo luôn có cách đặt"); các lời giải bên dưới nếu gặp thì in $-1$.

Lời mở đầu

Bài toán 8 quân hậu là bài "nhập môn quay lui" kinh điển. Đề Tin học trẻ 2024 đổi đi một chút: không bắt đặt đủ $n$ quân mà chỉ đúng $k$ quân, và mỗi ô có trọng số, cần tổng lớn nhất. Vì $n \le 8$ nên bài này hoàn toàn là bài vét cạn có suy nghĩ: vét cạn "ngây thơ" thì chậm, còn vét cạn theo hàng có cắt nhánh thì chạy trong nháy mắt. Mình đi từ cách dễ nhất (chỉ ăn subtask 1) tới cách full điểm.

Dành cho học viên · Ưu tiênNộp bài trên hệ thống CLUE OJ

Link này chỉ học viên đã tham gia tổ chức mới vào được. Học viên nên ưu tiên nộp ở đây vì sẽ được chấm nhanh hơn.

Nộp bài trên CLUE OJ

Lộ trình lời giải, bấm vào từng phần để nhảy tới:

Bộ testBộ test có gì?

Bộ test trên CLUE do mình tự sinh theo đúng giới hạn và tỉ lệ điểm subtask của đề: 20 test (test 1 là ví dụ của đề, thuộc subtask 2 vì $k = 2$). Mỗi bảng có đủ kiểu: trọng số ngẫu nhiên, toàn số 0, toàn số $10^6$, bàn cờ có vài ô rất lớn nằm chiếu nhau để "bẫy" lời giải tham lam.

NhómTestCác giá trị $n$Các giá trị $k$Trọng số lớn nhấtBàn cờ mọi ô bằng nhauĐáp án lớn nhất
Subtask 12–81, 2, 5, 7, 811.000.000test 2, 4, 61.000.000
Subtask 21, 9–123, 6, 82, 31.000.000không có2.963.462
Subtask 313–204, 7, 84, 5, 6, 7, 81.000.000test 208.000.000
Đã nộp thử trên bộ chấm chính thức
Lời giải cuối cùng (cách 3) mình đã nộp lên hệ thống chấm chính thức của Hội thi (tinhoctre.vn) và được 100/100 điểm (AC).

Kết quả chạy thật các cách bên dưới theo từng subtask (ô xanh = đúng hết các test của subtask đó):

SubtaskĐiều kiệnTestCách 1
chỉ k = 1
Cách 2
vét cạn bộ ô
Cách 2
Python
Cách 3
quay lui
Cách 3
Python
Subtask 1 (30%)$k = 1$2–87/77/77/77/77/7
Subtask 2 (30%)$k \le 3$1, 9–120/55/55/55/55/5
Subtask 3 (40%)không ràng buộc thêm13–200/84/81/88/88/8

Ý tưởngMỗi hàng có nhiều nhất một quân hậu

Nhận xét quan trọng nhất: hai quân hậu cùng hàng thì chiếu nhau, nên mỗi hàng có tối đa 1 quân hậu. Vậy ta đi lần lượt từng hàng $1, 2, \dots, n$, với mỗi hàng chỉ có $n + 1$ lựa chọn:

  • bỏ trống hàng này, hoặc
  • đặt 1 quân hậu vào một cột $j$ chưa bị chiếu (cột $j$ chưa có hậu, hai đường chéo đi qua ô đó chưa có hậu).

Để kiểm tra "bị chiếu" trong $O(1)$, ta đánh dấu 3 mảng: cot[j], cheo1[i + j] (các ô trên cùng đường chéo ngược có cùng $i + j$) và cheo2[i - j + n - 1] (cùng đường chéo xuôi có cùng $i - j$; cộng $n - 1$ để chỉ số không âm).

Khi đã đặt đủ $k$ quân thì cập nhật đáp án. Thêm một phép cắt nhánh: nếu số hàng còn lại $n - i$ ít hơn số quân còn phải đặt $k - cnt$ thì đi tiếp cũng vô ích, quay lui luôn. Em nhập bàn cờ rồi bấm Tiếp để xem quá trình quay lui (ô đỏ nhạt = đang bị chiếu, hàng viền vàng = hàng đang xét):

Ví dụ nhanh:

Cách 1k = 1: chỉ cần ô lớn nhất (Subtask 1)

Khi $k = 1$ thì chỉ có một quân hậu, không có chuyện chiếu nhau. Đáp án chính là trọng số lớn nhất của bàn cờ. Đọc tới đâu lấy max tới đó:

C++▶ Chạy tay từng dòng codeCách 1: k = 1 – đọc bàn cờ, lấy trọng số lớn nhất.
Dữ liệu mẫu:

Đáp án không âm (trọng số không âm) nên khởi tạo best = 0 là được.

Không gian$O(1)$
Thời gian$O(n^2)$

Kết quả chạy thật: Bản C++ đúng 7/20 test, sai test 1, 9–20 (các test đúng chạy lâu nhất 0.003s) → 30 điểm (subtask 1). Các test có $k \ge 2$ đều sai vì chương trình vẫn chỉ in một ô.

Cách 2Vét cạn mọi bộ k ô (Subtask 1, 2)

Cách "thật thà" nhất: đánh số các ô từ $0$ tới $n^2 - 1$ (ô số $x$ nằm ở hàng $x / n$, cột $x \% n$), sinh mọi tổ hợp $k$ ô theo thứ tự tăng dần, khi đã chọn đủ $k$ ô mới kiểm tra từng cặp có chiếu nhau không. Hai ô $(r_1, c_1)$ và $(r_2, c_2)$ chiếu nhau khi cùng hàng, cùng cột, cùng $r - c$ (chéo xuôi) hoặc cùng $r + c$ (chéo ngược).

C++▶ Chạy tay từng dòng codeCách 2: sinh mọi bộ k ô theo thứ tự tăng dần, đủ k ô mới kiểm tra chiếu nhau.
Dữ liệu mẫu:
  • Hàm chon(id, cnt): đã chọn cnt ô, ô tiếp theo phải có số thứ tự từ id trở đi – nhờ vậy mỗi bộ ô chỉ được sinh đúng một lần.
  • Không cắt nhánh gì cả: kể cả khi 2 ô đầu đã chiếu nhau, chương trình vẫn sinh hết các ô còn lại rồi mới phát hiện.

Số bộ phải thử là $\binom{n^2}{k}$: với $n = 8$, $k = 3$ chỉ có $41\,664$ bộ, nhưng $k = 5$ đã là $7{,}6$ triệu, $k = 8$ là $4{,}4$ tỉ bộ.

Không gian$O(n^2)$
Thời gian$O\left(\binom{n^2}{k} \cdot k^2\right)$

Kết quả chạy thật: Bản C++ đúng 16/20 test, quá thời gian test 13–15, 20 (các test đúng chạy lâu nhất 0.571s). Ăn trọn subtask 1, 2; subtask 3 chỉ qua được các test có $k \le 6$ hoặc bảng nhỏ (test 13, 14, 20 có $n = k = 8$, test 15 có $n = 8, k = 7$ đều quá thời gian). Bản Python đúng 13/20 test, quá thời gian test 13–17, 19–20 (các test đúng chạy lâu nhất 0.050s) – Python chậm hơn nhiều nên rớt thêm vài test của subtask 3.

Cách 3Quay lui theo từng hàng + cắt nhánh (full)

Làm đúng như phần ý tưởng: hàm dat(i, cnt, s) xét hàng $i$ khi đã đặt cnt quân với tổng trọng số s.

C++▶ Chạy tay từng dòng codeCách 3: quay lui theo hàng – mỗi hàng đặt 1 hậu vào cột chưa bị chiếu hoặc bỏ trống, cắt nhánh khi không đủ hàng.
Dữ liệu mẫu:
  1. if(cnt == k): đủ $k$ quân thì cập nhật best và dừng nhánh này (không đặt thêm vì đề yêu cầu đúng $k$ quân).
  2. if(n - i < k - cnt) return; – cắt nhánh: còn $n - i$ hàng (hàng $i..n-1$, đếm từ 0) mà cần thêm $k - cnt$ quân thì không thể đủ. Dòng này cũng thay cho điều kiện dừng i == n.
  3. Đặt hậu: đánh dấu 3 mảng, gọi đệ quy, rồi gỡ đánh dấu (đây chính là bước "quay lui").
  4. Cuối cùng thử phương án bỏ trống hàng $i$.

Vì sao nhanh? Các nhánh bị chiếu bị loại ngay khi đặt, không đợi đủ $k$ quân. Mình đếm thử với $n = 8$: số lần gọi hàm dat lớn nhất là khoảng $97\,000$ (khi $k = 5$), còn $k = 8$ chỉ $4\,022$ lần – so với $4{,}4$ tỉ bộ của cách 2.

Không gian$O(n^2)$
Thời giantối đa $O((n+1)^n)$ trên lý thuyết, thực tế với $n = 8$ chưa tới $10^5$ lần gọi hàm

Kết quả chạy thật: Bản C++ đúng 20/20 test, chạy lâu nhất 0.004s. Full điểm. Bản Python đúng 20/20 test, chạy lâu nhất 0.080s – bài này Python cũng thoải mái.

Tổng kếtKết quả chạy thật trên bộ test

CáchNgôn ngữĐúngLâu nhất
(test đúng)
Điểm theo subtask
1. Chỉ lấy ô lớn nhất (k = 1)C++7/200.003s30%
Python7/200.018s30%
2. Vét cạn mọi bộ k ôC++16/200.571s60%
Python13/200.050s60%
3. Quay lui theo hàng + cắt nhánhC++20/200.004s100%
Python20/200.080s100%
Ghi chú của mình: thời gian là thời gian CPU đo trên máy mình (giới hạn 1 giây, các test quá giờ bị dừng ở khoảng 4 giây). Điều đáng nhớ ở bài này: cùng là "vét cạn" nhưng vét cạn theo cấu trúc bài toán (mỗi hàng một quân, loại ngay ô bị chiếu, cắt nhánh khi không đủ hàng) nhanh hơn vét cạn mù quáng hàng chục nghìn lần. Khi $n$ nhỏ (ở đây $n \le 8$), hãy nghĩ tới quay lui trước khi nghĩ tới công thức phức tạp.
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇