Bộ Vũ Khí Tốt (Codeforces 145C
Đề bài
Hải là một chiến binh của một bộ lạc thời tiền sử. Nhiệm vụ của anh ta là đột nhập vào lãnh thổ của các bộ lạc khác và triệt hạ đi các thủ lĩnh bộ lạc này. Trong một lần hành động thành công, Hải đã cướp được $N$ vũ khí. Theo thông tin của nhà tiên tri cùng bộ lạc, mỗi vũ khí có đánh một con số, vũ khí thứ $i$ sẽ mang số $a_i$. Được biết các vũ khí có mang số được tạo bởi từ các chữ số $4$, $7$ (ví dụ: $4, 7, 44, 47, 74, 77, 444, 447, 474, 477, \ldots$) là các vũ khí đã bị nguyền rủa. Nhà tiên tri đã cho Hải biết rằng, một bộ vũ khí tốt là bộ vũ khí không chứa hai hay nhiều vũ khí bị nguyền rủa có cùng giá trị.
Trong số các vũ khí mà Hải mang về được, Hải không biết được rằng có bao nhiêu bộ vũ khí tốt có đúng $K$ vũ khí.
Bạn được cho một dãy số $a$ chứa các số tương ứng với các số trong bộ vũ khí của Hải lấy được. Nhiệm vụ của bạn là tính xem có bao nhiêu bộ vũ khí tốt có $K$ vũ khí mà Hải lấy được theo lời khuyên của nhà tiên tri. Hai bộ trang bị khác nhau nếu có ít nhất 1 vũ khí mà bộ còn lại không có. Vì số bộ vũ khí có thể rất lớn nên hãy đưa ra kết quả với phép chia lấy dư cho $1000000007$ $(10^9 + 7)$.
Dữ liệu vào:
- Dòng đầu tiên chứa hai số nguyên $N, K$ $(1 \le K \le N \le 10^5)$.
- Dòng thứ hai chứa $N$ số nguyên $a_i$ $(1 \le a_i \le 10^9)$, là dãy các số $a$.
Dữ liệu ra:
- Số bộ vũ khí tốt có $K$ vũ khí.
3 2
10 10 1034 2
4 4 7 74Giải thích: Trường hợp đầu tiên: do không có vũ khí nào bị nguyền rủa nên ta có thể chọn bất cứ vũ khí nào vào bộ trang bị.
Lời mở đầu
Hôm qua có một bạn trong nhóm comment nhờ mình hỗ trợ bài tập này, bạn ấy hỏi: "Thưa anh có cách nào giải bài toán tìm tổ hợp một cách nhanh nhất không ạ :((( Em gần xong bài này nhưng đoạn tìm tổ hợp của em chưa tối ưu :((". Mình thấy bài này cũng hay nên chia sẻ lên để anh em cùng xem và thử sức, rồi viết luôn bài giải chi tiết theo hướng mình đã trả lời bạn ấy. Hi vọng mang đến giá trị cho anh em 😇.
Bài này chính là bài Lucky Subsequence (Codeforces 145C) được "Việt hoá" lại: số bị nguyền rủa chính là lucky number, bộ vũ khí là dãy con (chọn theo chỉ số). 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 dưới đây nha:
Bấm vào nút bên dưới để nộp bài và được hệ thống CLUE OJ chấm tự động.
Nộp bài trên CLUE OJMình giải thích ví dụ mẫu để bạn nào chưa hiểu đề sẽ hiểu rõ:
- Ví dụ 1: ba vũ khí đều mang số $10$ (có chữ số $1$, $0$ nên không bị nguyền rủa). Số không bị nguyền rủa thì trùng nhau thoải mái, nên chọn 2 trong 3 vũ khí kiểu gì cũng được: $C_3^2 = 3$ bộ: $\{1, 2\}, \{1, 3\}, \{2, 3\}$ (đánh số theo vị trí).
- Ví dụ 2: dãy $4, 4, 7, 7$ toàn số bị nguyền rủa. Bộ 2 vũ khí không được có hai số $4$ hay hai số $7$, nên bộ phải gồm đúng một số $4$ và một số $7$: có $2$ cách chọn số $4$ (vị trí 1 hoặc 2) và $2$ cách chọn số $7$ (vị trí 3 hoặc 4) ⇒ $2 \times 2 = 4$ bộ: $\{1, 3\}, \{1, 4\}, \{2, 3\}, \{2, 4\}$.
Lộ trình lời giải, bấm vào từng phần để nhảy tới:
Cách 1Vét cạn mọi bộ vũ khí – O(2^N · N)
Cách "làm theo đề" đơn giản nhất: duyệt mọi tập con (bitmask) của $N$ vũ khí, chỉ giữ những tập có đúng $K$ phần tử, rồi kiểm tra trong tập đó các số bị nguyền rủa có bị trùng không (dùng set). Tập nào không trùng thì đếm.
Cách này chỉ chạy được với $N$ khoảng $20$ đổ lại, mình đưa vào để các bạn hiểu đề và dùng làm code đối chiếu (mình đã dùng chính code này để kiểm tra cách 2).
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1e9 + 7;
bool isLucky(long long x){
while(x > 0){
int d = x % 10;
if(d != 4 && d != 7) return false;
x /= 10;
}
return true;
}
int main(){
int n, k;
cin >> n >> k;
vector<long long> a(n);
for(int i = 0; i < n; i++) cin >> a[i];
long long ans = 0;
for(int mask = 0; mask < (1 << n); mask++){
if(__builtin_popcount(mask) != k) continue;
set<long long> used;
bool ok = true;
for(int i = 0; i < n; i++){
if(!(mask >> i & 1) || !isLucky(a[i])) continue;
if(used.count(a[i])) { ok = false; break; }
used.insert(a[i]);
}
if(ok) ans++;
}
cout << ans % MOD;
return 0;
}
MOD = 10**9 + 7
def is_lucky(x):
while x > 0:
if x % 10 not in (4, 7):
return False
x //= 10
return True
n, k = map(int, input().split())
a = list(map(int, input().split()))
ans = 0
for mask in range(1 << n):
if bin(mask).count('1') != k:
continue
used = set()
ok = True
for i in range(n):
if not (mask >> i & 1) or not is_lucky(a[i]):
continue
if a[i] in used:
ok = False
break
used.add(a[i])
if ok:
ans += 1
print(ans % MOD)
Cách 2Quy hoạch động trên các số nguyền rủa + tổ hợp – O(sz(b)² + N)
Nhận xét quan trọng: số nguyền rủa khác nhau rất ít
Để ý như sau: giá trị số nguyền rủa khác nhau không có nhiều. Do $a_i \le 10^9$ nên $a_i$ có tối đa 9 chữ số, thì số lượng số bị nguyền rủa khác nhau có 1 chữ số là $2$ ($4, 7$), có 2 chữ số là $4$ số, có 3 chữ số là $8$ số, ..., có 9 chữ số là $2^9 = 512$ số. Như vậy số lượng số bị nguyền rủa khác nhau là:
Và bởi vì ta không được phép chọn 2 số nguyền rủa giống nhau, nên mỗi giá trị nguyền rủa chỉ được chọn tối đa 1 lần. Ví dụ giả sử trong dãy có 10 số $4$, 20 số $44$, thì có 10 cách chọn một số $4$, 20 cách chọn một số $44$.
Tách dãy a thành 2 loại
Ta hãy tách riêng dãy $a$ ra gồm 2 loại: dãy gồm các số nguyền rủa và dãy gồm các số không bị nguyền rủa.
- Với dãy không bị nguyền rủa, thực tế ta không cần biết dãy số đó gồm những số nào (trùng thoải mái mà). Ta chỉ cần quan tâm dãy số đó có bao nhiêu số thôi. Gọi số lượng số không bị nguyền rủa là
count. - Với dãy bị nguyền rủa, ta đếm số lượng của từng giá trị: với mỗi số bị nguyền rủa $x$, gọi
cnt[x]là số lượng số $x$ có trong dãy. Ví dụ dãy $a = [4, 4, 2, 5, 4, 77]$ thìcnt[4] = 3,cnt[77] = 1(vàcount = 2ứng với hai số $2$, $5$).
Ta lưu các cặp <số bị nguyền rủa, số lượng> vào một dãy $b$. Như ví dụ trên thì $b = [\langle 4, 3\rangle, \langle 77, 1\rangle]$. Kích thước $sz(b) \le 1022$.
Quy hoạch động chọn số nguyền rủa
Vì mỗi số bị nguyền rủa chỉ được phép chọn 1 lần, ta gọi $f[i][j]$ là số cách chọn ra $j$ số bị nguyền rủa khi ta xét đến phần tử thứ $i$ của dãy $b$.
- Bài toán cơ sở: $f[0][0] = 1$ (chưa xét gì, chọn 0 số: 1 cách).
- Quy hoạch động: với $f[i][j]$ hiện tại $\gt 0$, ta xét số bị nguyền rủa thứ $i + 1$:
- nếu không chọn số nguyền rủa thứ $i + 1$ thì $f[i + 1][j] \mathrel{+}= f[i][j]$;
- nếu chọn số nguyền rủa thứ $i + 1$ thì có
b[i + 1].secondcách chọn ra 1 vũ khí mang số đó: $f[i + 1][j + 1] \mathrel{+}= f[i][j] \cdot b[i + 1].second$.
Ghép với số không bị nguyền rủa
Sau khi quy hoạch động xong, ta có bảng $f$. Bây giờ, nếu ta chọn $j$ số bị nguyền rủa thì đồng nghĩa với việc $k - j$ số còn lại sẽ là số không bị nguyền rủa. Số cách chọn $k - j$ số không bị nguyền rủa trong count số là $C(k - j, count)$ (chọn $k - j$ phần tử từ count phần tử). Còn số cách chọn $j$ số bị nguyền rủa là $f[sz(b)][j]$.
Thử với $a = [4, 4, 2, 5, 4, 77]$, $k = 3$: $b = [\langle 4,3\rangle, \langle 77,1\rangle]$, $count = 2$. Bảng QHĐ: $f[1][0] = 1,\ f[1][1] = 3$; rồi $f[2][0] = 1,\ f[2][1] = 3 + 1 = 4,\ f[2][2] = 3$. Đáp án $= 1 \cdot C(3, 2) + 4 \cdot C(2, 2) + 3 \cdot C(1, 2) = 0 + 4 + 6 = 10$.
Các bạn nhập dãy rồi bấm Tiếp để xem từng bước tách dãy, điền bảng $f$ và cộng đáp án:
Tính tổ hợp C nhanh
Đây chính là chỗ bạn học viên hỏi "tìm tổ hợp nhanh nhất". Vì $count \le N \le 10^5$, ta tính trước giai thừa fact[i] = i! mod (10^9 + 7) và nghịch đảo giai thừa inv[i]. Do $10^9 + 7$ là số nguyên tố, theo định lý Fermat nhỏ: $inv[n] = (n!)^{10^9 + 5} \bmod (10^9 + 7)$ (tính bằng lũy thừa nhanh), rồi lùi dần $inv[i - 1] = inv[i] \cdot i$. Khi đó mỗi lần tính:
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1e9 + 7;
const int MAXN = 100005;
long long fact[MAXN], inv[MAXN];
long long f[1100][1100];
bool isLucky(long long x){
while(x > 0){
int d = x % 10;
if(d != 4 && d != 7) return false;
x /= 10;
}
return true;
}
long long power(long long a, long long b){
long long res = 1;
while(b > 0){
if(b & 1) res = res * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return res;
}
long long C(int k, int n){ // số cách chọn k phần tử từ n phần tử
if(k < 0 || k > n) return 0;
return fact[n] * inv[k] % MOD * inv[n - k] % MOD;
}
int main(){
int n, k;
cin >> n >> k;
map<long long, int> cnt;
int count = 0;
for(int i = 0; i < n; i++){
long long x;
cin >> x;
if(isLucky(x)) cnt[x]++;
else count++;
}
vector<pair<long long, int>> b(1); // b[0] bỏ trống, b đánh số từ 1
for(auto p : cnt) b.push_back(p);
int m = b.size() - 1;
fact[0] = 1;
for(int i = 1; i <= n; i++) fact[i] = fact[i - 1] * i % MOD;
inv[n] = power(fact[n], MOD - 2);
for(int i = n; i >= 1; i--) inv[i - 1] = inv[i] * i % MOD;
f[0][0] = 1;
for(int i = 0; i < m; i++){
for(int j = 0; j <= i; j++){
if(f[i][j] == 0) continue;
f[i + 1][j] = (f[i + 1][j] + f[i][j]) % MOD;
f[i + 1][j + 1] = (f[i + 1][j + 1] + f[i][j] * b[i + 1].second) % MOD;
}
}
long long ans = 0;
for(int j = 0; j <= min(k, m); j++){
ans = (ans + f[m][j] * C(k - j, count)) % MOD;
}
cout << ans;
return 0;
}
import sys
MOD = 10**9 + 7
def is_lucky(x):
while x > 0:
if x % 10 not in (4, 7):
return False
x //= 10
return True
data = sys.stdin.buffer.read().split()
n, k = int(data[0]), int(data[1])
cnt = {}
count = 0
for i in range(n):
x = int(data[2 + i])
if is_lucky(x):
cnt[x] = cnt.get(x, 0) + 1
else:
count += 1
b = [(0, 0)] + sorted(cnt.items()) # b[0] bỏ trống, b đánh số từ 1
m = len(b) - 1
fact = [1] * (n + 1)
for i in range(1, n + 1):
fact[i] = fact[i - 1] * i % MOD
inv = [1] * (n + 1)
inv[n] = pow(fact[n], MOD - 2, MOD)
for i in range(n, 0, -1):
inv[i - 1] = inv[i] * i % MOD
def C(k, n): # số cách chọn k phần tử từ n phần tử
if k < 0 or k > n:
return 0
return fact[n] * inv[k] % MOD * inv[n - k] % MOD
f = [[0] * (m + 2) for _ in range(m + 1)]
f[0][0] = 1
for i in range(m):
cur, nxt, c = f[i], f[i + 1], b[i + 1][1]
for j in range(i + 1):
if cur[j] == 0:
continue
nxt[j] = (nxt[j] + cur[j]) % MOD
nxt[j + 1] = (nxt[j + 1] + cur[j] * c) % MOD
ans = 0
for j in range(min(k, m) + 1):
ans = (ans + f[m][j] * C(k - j, count)) % MOD
print(ans)
Đánh giá độ phức tạp của cách làm này:
- Không gian: mảng
fact,invcỡ $N$ và bảng $f$ cỡ $sz(b)^2 \approx 1022^2$: $O(sz(b)^2 + N)$. - Thời gian: đọc và phân loại $N$ số (mỗi số kiểm tra tối đa 10 chữ số, đưa vào map), tính giai thừa $O(N)$, QHĐ $O(sz(b)^2)$, cộng đáp án $O(sz(b))$. Tổng cộng $O(sz(b)^2 + N)$. Độ phức tạp này đẹp vì $sz(b)$ là số lượng số bị nguyền rủa khác nhau và $sz(b) \le 1022$.
f[i][j] * b[i + 1].second có thể tới cỡ $10^9 \cdot 10^5$, nên phải dùng long long và lấy dư ngay sau mỗi phép nhân. Tương tự với fact[n] * inv[k] % MOD * inv[n - k] % MOD: phải % MOD sau mỗi lần nhân, nhân liền 3 số là tràn.C phải trả về $0$ chứ không được truy cập inv với chỉ số âm.- Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
- Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
- Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
- Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
- Dễ nản giữa chừng khi không có ai theo sát.
- Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
- Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
- Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
- Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.
Nhận xét
Đăng nhận xét