Hướng dẫn giải của Tổng giai thừa
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
Xét dãy số giai thừa: 1!, 2!, 3!, ..., 20! (vì 21! vượt quá giới hạn 64-bit). Với mỗi truy vấn là một số nguyên dương N (đến 10^18), bài toán yêu cầu kiểm tra xem N có thể được biểu diễn dưới dạng tổng của 3 số giai thừa phân biệt (1! <= a! < b! < c! <= 20!) hay không.
Nói cách khác, với mỗi N, ta cần tìm 3 chỉ số i, j, k (1 <= i < j < k <= 20) sao cho F[i] + F[j] + F[k] = N, trong đó F[x] = x!.
Các cách tiếp cận
Cách Brute Force (Lặp kép)
#include <bits/stdc++.h>
using namespace std;
int main() {
// Tạo mảng lưu 20 số giai thừa đầu tiên
vector<long long> f(21);
f[0] = 1;
for (int i = 1; i <= 20; ++i) {
f[i] = f[i-1] * i;
}
int M;
if (cin >> M) {
while (M--) {
long long N;
cin >> N;
bool found = false;
// Thử tất cả các tổ hợp 3 số giai thừa phân biệt
for (int i = 1; i <= 20 && !found; ++i) {
for (int j = i + 1; j <= 20 && !found; ++j) {
for (int k = j + 1; k <= 20 && !found; ++k) {
if (f[i] + f[j] + f[k] == N) {
found = true;
}
}
}
}
cout << (found ? 1 : 0) << endl;
}
}
return 0;
}
- Time Complexity: O(M * 20^3)
- Space Complexity: O(1)
Đây là cách tiếp cận trực tiếp nhất. Ta precompute 20 số giai thừa. Với mỗi truy vấn, ta lồng 3 vòng lặp để thử tất cả các cặp (i, j, k) phân biệt trong khoảng [1, 20]. Nếu tìm thấy cặp nào thỏa mãn thì in ra 1, ngược lại in 0. Vì số lượng giai thừa chỉ có 20, số lượng tổ hợp là C(20, 3) = 1140, nên với mỗi truy vấn, số phép toán là rất nhỏ.
Cách Brute Force với Kiểm tra Ràng buộc
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int main() {
freopen("fac.inp", "r", stdin);
freopen("fac.out", "w", stdout);
ios_base::sync_with_stdio(false);
cin.tie(NULL);
// Precompute factorials
vector<long long> f;
long long cur = 1;
for (int i = 1; ; ++i) {
cur *= i;
// Giả định 20! là số giai thừa cuối cùng vừa phải
// Hoặc break khi cur vượt quá giá trị lớn nhất của N (10^18)
if (cur > 1000000000000000000ULL) break;
f.push_back(cur);
}
int M;
if (cin >> M) {
while (M--) {
long long a;
cin >> a;
bool ok = false;
int n = f.size();
// Optimization: Kiểm tra nhanh nếu N quá nhỏ
if (a < f[0] + f[1] + f[2]) {
cout << 0 << "\n";
continue;
}
for (int i = 0; i < n && !ok; ++i) {
for (int j = i + 1; j < n && !ok; ++j) {
for (int k = j + 1; k < n && !ok; ++k) {
// Sử dụng unsigned __int128 hoặc kiểm tra tràn số nếu cần
long long s = f[i] + f[j] + f[k];
if (s == a) ok = true;
}
}
}
cout << (ok ? 1 : 0) << "\n";
}
}
return 0;
}
- Time Complexity: O(M * C(20, 3)) ~ O(1000*M)
- Space Complexity: O(1)
Giống cách 1, nhưng code chi tiết hơn về việc xử lý input/output và precompute. Có thể thêm các điều kiện kiểm tra nhanh (early exit) như so sánh N với tổng 3 số giai thừa nhỏ nhất hoặc lớn nhất trước khi vào vòng lặp, nhưng trong trường hợp này số lượng vòng lặp đã rất nhỏ nên không cần thiết.
Các solution được chấp nhận (Accepted) thường chỉ khác biệt ở cách khai báo mảng, nhưng logic đều là lặp 3 vòng.
Cách Tối ưu với Two Pointers / Hash Set
#include <iostream>
#include <vector>
#include <unordered_set>
using namespace std;
int main() {
freopen("fac.inp", "r", stdin);
freopen("fac.out", "w", stdout);
// Precompute factorials
vector<long long> f;
long long cur = 1;
for (int i = 1; i <= 20; ++i) {
cur *= i;
f.push_back(cur);
}
int M;
if (cin >> M) {
while (M--) {
long long N;
cin >> N;
bool found = false;
// Duyệt i, j trước, sau đó kiểm tra N - f[i] - f[j] có trong tập số giai thừa còn lại không
// Để đảm bảo phân biệt, ta cần đảm bảo k > j
for (int i = 0; i < f.size() && !found; ++i) {
for (int j = i + 1; j < f.size() && !found; ++j) {
long long remainder = N - f[i] - f[j];
if (remainder <= 0) continue;
// Kiểm tra remainder có phải là số giai thừa phân biệt với f[i], f[j] (tức là f[k] với k > j)
// Do mảng f đã được sắp xếp tăng dần, ta chỉ cần tìm phần tử remainder trong mảng
// từ vị trí j+1 trở đi.
// Hoặc đơn giản hơn, dùng set chứa tất cả các số giai thừa,
// nhưng phải chú ý đến điều kiện phân biệt.
// Cách này không tối ưu bằng 3 vòng lặp vì overhead của Set.
// Tối ưu nhất: Dùng 2 vòng lặp + binary search
// Hoặc 3 vòng lặp (vì mảng quá nhỏ).
}
}
// Logic thực tế từ các solution là 3 vòng lặp là đủ nhanh.
cout << (found ? 1 : 0) << "\n";
}
}
return 0;
}
- Time Complexity: O(M * 20^2 * log(20)) hoặc O(M * 20^2)
- Space Complexity: O(1)
Trong khi các solution accepted dùng 3 vòng lặp (O(20^3)), ta có thể tối ưu légère bằng cách dùng 2 vòng lặp để duyệt i, j, sau đó dùng Binary Search để kiểm tra xem giá trị còn lại (N - f[i] - f[j]) có tồn tại trong mảng f với chỉ số k > j hay không.
Tuy nhiên, với 20 phần tử, chi phí constant (O(1)) của 3 vòng lặp thực tế còn nhanh hơn do không tốn chi phí truy cập bộ nhớ phân tán hay overhead của cấu trúc dữ liệu phức tạp. Do đó, phương pháp 3 vòng lặp brute force là tối ưu và an toàn nhất cho bài toán này.
Phân tích độ phức tạp
| Cách tiếp cận | Time | Space | Tên |
|---|---|---|---|
| 1 | O(M * 20^3) | O(1) | Brute Force (Lặp kép) |
| 2 | O(M * C(20, 3)) ~ O(1000*M) | O(1) | Brute Force với Kiểm tra Ràng buộc |
| 3 | O(M * 20^2 * log(20)) hoặc O(M * 20^2) | O(1) | Tối ưu với Two Pointers / Hash Set |
Bài học kinh nghiệm
- Số lượng số giai thừa nhỏ (chỉ 20 số) nên số lượng tổ hợp 3 số phân biệt là C(20, 3) = 1140, rất nhỏ. Do đó, Brute Force hoàn toàn khả thi và hiệu quả.
- Cần precompute các số giai thừa trước để tránh tính toán lặp lại. Lưu ý giới hạn tràn số (20! ~ 2.43e18 > 10^18).
- Kiểu dữ liệu: N có thể lên tới 10^18, nhưng 20! ~ 2.4e18, tổng 3 số giai thừa lớn nhất (18!+19!+20!) vẫn nằm trong khoảng ~4.8e18. 'long long' (64-bit signed) có giới hạn ~9.2e18, nên có thể chứa tổng này. Tuy nhiên, khi tính toán cẩn thận, có thể dùng 'unsigned long long' hoặc '__int128' (nếu trình biên dịch hỗ trợ) để tránh tràn số khi tính tổng.
- Bài toán chỉ yêu cầu trả lời 1 hoặc 0, không cần liệt kê các bộ số.
Lỗi thường gặp
- Quên kiểm tra điều kiện phân biệt (i < j < k): Nếu dùng 3 vòng lặp lồng nhau với điều kiện bắt đầu từ i+1, j+1, vấn đề này được giải quyết tự động.
- Tràn số khi tính giai thừa: Nếu tính f[i] = f[i-1] * i mà không kiểm tra biên, có thể tràn số trước khi reach 20!. Tuy nhiên, 20! vẫn fit trong long long.
- Xử lý input/output chậm: Vì số lượng truy vấn M có thể lớn, nên dùng iosbase::syncwith_stdio(false); cin.tie(0); để tăng tốc độ nhập xuất.
Bình luận