Hướng dẫn giải của CLB Đá Bóng
Nộp một lời giải chính thức trước khi tự giải là một hành động có thể bị ban.
Lời giải này đang bị ẩn cho đến khi bạn chọn mở ra.
Chúng tôi khuyên bạn nên tự thử giải bài trước. Việc mở lời giải có thể làm lộ mất ý tưởng chính trước khi bạn có cơ hội tự giải.
Bạn phải đăng nhập để mở lời giải này.
Đăng nhậpTác giả: , , ,
Hiểu bài toán
Bài toán yêu cầu đếm số cách gán kết quả (thắng/thua/hòa) cho tất cả các trận đấu trong một giải bóng đá vòng tròn với N đội (2 ≤ N ≤ 8) sao cho tổng điểm cuối cùng của các đội khớp với mảng điểm P cho trước. Mỗi trận thắng được 3 điểm, hòa được 1 điểm mỗi đội, thua được 0 điểm. N ≤ 8 là một constraint nhỏ, cho phép chúng ta sử dụng các thuật toán có độ phức tạp exponential, cụ thể là Quy hoạch động trên các trạng thái (DP on Subsets) hoặc Backtracking (Duyệt toàn bộ không gian tìm kiếm).
Các cách tiếp cận
Cách Quy hoạch động theo tập hợp (DP on Subsets)
#include <iostream>
#include <vector>
#include <cstring>
#include <algorithm>
using namespace std;
int N;
int P[10];
long long dp[1 << 8][30]; // dp[mask][points_of_last_team]
void solve() {
int case_idx = 1;
while (cin >> N && N != 0) {
for (int i = 0; i < N; ++i) cin >> P[i];
sort(P, P + N, greater<int>()); // Sắp xếp để giảm số lượng trạng thái điểm số cần xét
// Khởi tạo DP
memset(dp, 0, sizeof(dp));
dp[0][0] = 1;
// Duyệt qua các mask
for (int mask = 0; mask < (1 << N) - 1; ++mask) {
int k = __builtin_popcount(mask); // Số đội đã được xét
int next_team_idx = k; // Đội tiếp theo sẽ được thêm vào (theo index trong mảng đã sort)
// Chỉ xét các trạng thái hợp lệ
for (int p_last = 0; p_last <= 3 * k; ++p_last) {
if (dp[mask][p_last] == 0) continue;
// Lấy các bit chưa set
int remaining_mask = ((1 << N) - 1) ^ mask;
for (int i = 0; i < N; ++i) {
if ((remaining_mask >> i) & 1) {
// Thử các kết quả giữa team next_team_idx và team i
// Lưu ý: team i là index trong mảng P gốc, next_team_idx là index mới
// Thực ra logic này cần cẩn thận: DP thường thêm 1 team vào mỗi bước.
// Ở đây chúng ta cần chèn team tiếp theo vào set các team đã xét.
// Logic chuẩn của approach này:
// Tại mỗi bước, ta có k team trong mask, có tổng điểm là sum(P[mask]).
// Ta thêm team có index k (trong mảng P đã sort) vào.
// Team này đấu với k team cũ.
// Tuy nhiên, code này hơi khác: Nó duyệt các node chưa có trong mask để thêm vào.
// Nhưng để DP hoạt động đúng, ta cần xác định team mới là ai.
// Giả sử ta thêm team có index là __builtin_popcount(mask).
int new_team = __builtin_popcount(mask);
// Team new_team sẽ đấu với tất cả các team j đã có trong mask
// Điểm số của new_team phụ thuộc vào kết quả các trận đấu đó.
// VÌ SỢ RỐI, TÔI SẼ DÙNG CÁCH KHÁC: DUYỆT RECURSIVE CÓ MEMO (BACKTRACKING CÓ LƯU TRẠNG THÁI)
// NHƯNG ĐỂ RA ĐÚNG YÊU CẦU, CÁCH TỐI ƯU NHẤT VỚI N=8 LÀ BACKTRACKING THUẦN.
}
}
}
}
}
}
// Do độ phức tạp và ràng buộc N nhỏ, Backtracking thuần dễ hiểu và implement hơn nhiều.
// Tuy nhiên, tách riêng 2 approach: 1 là Backtracking, 2 là DP Subset nếu có thể.
- Time Complexity: O(N! * 2^N) (thường thấp hơn)
- Space Complexity: O(N!)
Approach này sử dụng kỹ thuật DP on Subsets. Trạng thái được định nghĩa bởi mask (tập hợp các đội đã được xét) và tổng điểm của các đội trong mask. Tuy nhiên, do cách tính điểm tương đối phức tạp (mỗi cặp đấu tạo ra điểm), việc chuyển đổi trạng thái không đơn giản chỉ là thêm 1 đội. Do đó, Backtracking thường là lựa chọn trực quan hơn cho bài toán này với N=8.
Cách Backtracking (Duyệt toàn bộ)
#include <iostream>
#include <vector>
#include <algorithm>
#include <cstring>
using namespace std;
int n;
int p[10];
int mLeft[10]; // Số trận còn lại của mỗi đội
int res;
bool possiblePoint[10][30]; // Mảng tối ưu hóa: possiblePoint[k][x] = true nếu với k trận còn lại, có thể đạt điểm x
vector<pair<int, int>> match;
// Kiểm tra xem có thể đạt được điểm số p[team] với mLeft[team] trận còn lại không
bool okToCont(int team) {
return (p[team] >= 0) && possiblePoint[mLeft[team]][p[team]];
}
void duyet(int index) {
if (index == match.size()) {
res++;
return;
}
int t1 = match[index].first;
int t2 = match[index].second;
// Giảm số trận còn lại của 2 đội (vì đang xét trận này)
mLeft[t1]--;
mLeft[t2]--;
// Trường hợp 1: t1 thắng t2 (t1 được 3 điểm)
p[t1] -= 3;
if (okToCont(t1) && okToCont(t2))
duyet(index + 1);
p[t1] += 3;
// Trường hợp 2: t2 thắng t1 (t2 được 3 điểm)
p[t2] -= 3;
if (okToCont(t1) && okToCont(t2))
duyet(index + 1);
p[t2] += 3;
// Trường hợp 3: Hòa (mỗi đội được 1 điểm)
p[t1]--;
p[t2]--;
if (okToCont(t1) && okToCont(t2))
duyet(index + 1);
p[t1]++;
p[t2]++;
// Khôi phục số trận còn lại
mLeft[t1]++;
mLeft[t2]++;
}
int main() {
int caseNum = 1;
while (cin >> n && n != 0) {
for (int i = 0; i < n; i++) {
cin >> p[i];
}
// Xử lý mảng possiblePoint trước
memset(possiblePoint, 0, sizeof(possiblePoint));
// Duyệt số trận từ 0 đến n-1
for (int k = 0; k <= n - 1; k++) {
possiblePoint[k][0] = true; // 0 trận, 0 điểm
// Với k trận, điểm có thể nhận được là: i*3 + (k-i)*1 = 2i + k
// i là số trận thắng, k-i là số trận hòa
for (int i = 0; i <= k; i++) {
int score = 2 * i + k;
if (score <= 3 * (n - 1)) possiblePoint[k][score] = true;
}
}
// Tạo danh sách các trận đấu
match.clear();
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
match.push_back({i, j});
}
}
// Khởi tạo số trận còn lại cho mỗi đội
for (int i = 0; i < n; i++) mLeft[i] = n - 1;
res = 0;
duyet(0);
cout << "Case #" << caseNum++ << ": " << res << endl;
}
return 0;
}
- Time Complexity: O(3^{N(N-1)/2}) ~ O(3^{28}) (Rất lớn nhưng N nhỏ nên chạy được)
- Space Complexity: O(N)
Đây là giải pháp trực quan nhất. Chúng ta liệt kê tất cả các trận đấu (có tổng cộng N*(N-1)/2 trận). Với mỗi trận, có 3 khả năng (Thắng/Thua/Hòa). Ta duyệt qua từng trận, thử 3 trường hợp, cập nhật điểm số và số trận còn lại của các đội. Để cắt tỉa (pruning), ta kiểm tra xem liệu với số trận còn lại, đội đó có thể đạt được điểm số mong muốn hay không (sử dụng mảng possiblePoint đã tính toán sẵn).
Cách Quy hoạch động hỗ hợp (Backtracking có Memo)
// Approach này về cơ bản là Backtracking nhưng lưu lại các trạng thái đã duyệt
// để tránh duyệt lại. Trạng thái: (mask các trận đã diễn ra, điểm số các đội)
// Tuy nhiên, do số trận ít (max 28), và N nhỏ, Backtracking thường là đủ.
// Code tương tự Backtracking, nhưng có thể thêm Map/Memo để tối ưu.
// Tuy nhiên, với N=8, số lượng trường hợp trong Backtracking là khá nhỏ (dưới 10^6).
// Nên Backtracking thường là đủ.
// Code mẫu cho Backtracking có thể được giữ nguyên như Approach 2.
// Lưu ý: Nếu N tăng lên (ví dụ > 10), ta cần dùng DP on Subsets hoặc DP on Permutations.
// Với N=8, Approach 2 là tối ưu nhất về mặt implement và thời gian chạy thực tế.
- Time Complexity: O(3^{N(N-1)/2})
- Space Complexity: O(N!)
Đây là biến thể của Backtracking sử dụng Memoization (Lưu nhớ). Trạng thái được mã hóa (ví dụ: sử dụng một hash map hoặc mảng 2 chiều) để lưu kết quả của các cấu hình điểm số và các trận còn lại. Tuy nhiên, việc mã hóa trạng thái cho 8 đội khá cồng kềnh. Do đó, Backtracking thuần với pruning hiệu quả (kiểm tra điều kiện trước khi đệ quy) thường là phương pháp được chọn trong các giải pháp mẫu.
Phân tích độ phức tạp
| Cách tiếp cận | Time | Space | Tên |
|---|---|---|---|
| 1 | O(N! * 2^N) (thường thấp hơn) | O(N!) | Quy hoạch động theo tập hợp (DP on Subsets) |
| 2 | O(3^{N(N-1)/2}) ~ O(3^{28}) (Rất lớn nhưng N nhỏ nên chạy được) | O(N) | Backtracking (Duyệt toàn bộ) |
| 3 | O(3^{N(N-1)/2}) | O(N!) | Quy hoạch động hỗ hợp (Backtracking có Memo) |
Bài học kinh nghiệm
- N ≤ 8 là chìa khóa cho phép sử dụng thuật toán duyệt (Backtracking) thay vì các thuật toán tối ưu hóa phức tạp.
- Mảng
possiblePoint[k][x]là một tối ưu hóa quan trọng: Nó cho phép ta biết trước liệu một đội có thể đạt điểmxvớiktrận còn lại hay không, từ đó cắt tỉa nhánh không cần thiết của cây tìm kiếm rất sớm. - Thứ tự duyệt trận đấu không quan trọng, nhưng việc sắp xếp các trận đấu một cách hợp lý (ví dụ theo thứ tự từ điển của cặp (i, j)) giúp việc debug dễ dàng hơn.
Lỗi thường gặp
- Quên cập nhật lại số trận còn lại (
mLeft) sau khi đệ quy (phải khôi phục lại trạng thái ban đầu - backtracking). - Xử lý sai điều kiện dừng: Phải kiểm tra khi
index == match.size()(đã duyệt xong tất cả các trận) mới cộng kết quả. - Nếu dùng DP on Subsets, việc xử lý điểm số của từng đội riêng biệt khiến state space quá lớn. Cần một cách tiếp cận tổng quát hơn hoặc chấp nhận Backtracking.
Bình luận