Chia 10 viên kẹo giống nhau cho 4 bạn, bạn nào cũng được nhận 0 hoặc nhiều hơn. Có bao nhiêu cách? Liệt kê thì mệt, nhưng đặt 3 vách ngăn vào giữa dãy kẹo là ra ngay .
Các bài đếm khó thường lắp từ vài viên gạch quen thuộc như vậy: tổ hợp theo mô-đun (giai thừa và nghịch đảo tính sẵn), bao hàm loại trừ (đếm cái dễ rồi trừ cái vi phạm), nguyên lý phản xạ cho đường đi không vượt một đường thẳng, đếm theo vi phạm đầu tiên cho lưới có ô cấm, và bổ đề Burnside khi hai cấu hình được coi là một nếu biến đổi được thành nhau. Việc chính là đưa đề về một trong các khuôn này.
Tổ hợp theo mô-đun
Theo mô-đun nguyên tố , tính sẵn fac[i] và ifac[i] : lấy ifac[n] bằng lũy thừa , rồi ifac[i-1] = ifac[i] * i. Khi đó trong .
Cách lùi dần này đáng dùng vì rẻ hơn hẳn việc lấy nghịch đảo từng số:
Một lũy thừa rồi lùi dần nhanh hơn lũy thừa từng số khoảng 8 lần. Quy tắc: chỉ lấy nghịch đảo bằng Fermat một lần, các số còn lại suy ra bằng phép nhân.
Hai công thức dùng nhiều nhất:
- Hoán vị đa tập: xâu dài có ký tự mỗi loại có cách sắp.
- Chia kẹo: chia vật giống nhau cho người (được nhận 0) có cách, bằng số cách đặt vách ngăn vào giữa vật.
Ví dụHoán vị của đa tập ký tự
Đề. Xâu chữ thường dài tới . Đếm số xâu khác nhau tạo được bằng cách sắp lại mọi ký tự, theo mô-đun .
Lời giải. Nếu mọi ký tự đều khác nhau thì có cách sắp. Chữ xuất hiện lần thì mỗi xâu bị đếm lặp lần do đổi chỗ các bản của với nhau. Vậy đáp án là ; phép chia theo mô-đun nguyên tố là nhân nghịch đảo.
Bước 1: bảng giai thừa và nghịch đảo. ifac[n] tính bằng Fermat, ; các ifac nhỏ hơn suy ra lùi bằng ifac[i - 1] = ifac[i] * i, vì . Cả bảng chỉ tốn một lần lũy thừa.
fac[0] = 1;
for (int i = 1; i <= n; i++) fac[i] = fac[i - 1] * i % MOD;
ifac[n] = power(fac[n], MOD - 2);
for (int i = n; i >= 1; i--) ifac[i - 1] = ifac[i] * i % MOD;
Bước 2: chia cho các . Chữ không xuất hiện có ifac[0] = 1, không làm đổi kết quả.
ll ans = fac[n];
for (int c = 0; c < 26; c++) ans = ans * ifac[cnt[c]] % MOD;
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll MOD = 1e9 + 7;
const int N = 1e6 + 5;
ll fac[N], ifac[N];
ll power(ll a, ll b) {
ll r = 1;
a %= MOD;
while (b) {
if (b & 1) r = r * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return r;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
string s;
cin >> s;
int n = s.size();
fac[0] = 1;
for (int i = 1; i <= n; i++) fac[i] = fac[i - 1] * i % MOD;
ifac[n] = power(fac[n], MOD - 2);
for (int i = n; i >= 1; i--) ifac[i - 1] = ifac[i] * i % MOD;
int cnt[26] = {};
for (char c : s) cnt[c - 'a']++;
ll ans = fac[n];
for (int c = 0; c < 26; c++) ans = ans * ifac[cnt[c]] % MOD;
cout << ans << '\n';
}
aab
3
Độ phức tạp: .
Không điểm cố định
Số hoán vị không có nào ở đúng vị trí : gọi là tập hoán vị giữ nguyên ; một giao tập có phần tử, nên
Cũng có truy hồi gọn: quà của người 1 tới người ( cách). Nếu đưa lại quà cho 1 thì còn cách cho những người khác. Nếu không thì coi “không nhận quà của 1” như “không nhận quà của mình” cho , còn cách. Vậy , , .
Hình dưới liệt kê mọi hoán vị không điểm cố định, chia theo việc người nhận quà của 1 có trả lại cho 1 hay không. Kéo để xem.
Hỏi nhanh
Có bao nhiêu hoán vị của không có điểm cố định?
, . Kiểm bằng bao hàm loại trừ: .
Ví dụHoán vị không điểm cố định
Đề. em, mỗi em một món quà. Đếm cách phát sao cho không em nào nhận lại quà của mình, theo mô-đun .
Lời giải. Truy hồi với , , đúng như lập luận ở trên. Chỉ cần hai giá trị trước, nên giữ hai biến thay cho cả mảng.
a, b lần lượt là , . Mỗi vòng tính rồi dời hai biến. tới nhân một số dưới vẫn vừa long long.
ll a = 1, b = 0;
for (ll i = 2; i <= n; i++) {
ll c = (i - 1) * ((a + b) % MOD) % MOD;
a = b, b = c;
}
Với vòng không chạy và in : một em thì không cách nào để em đó nhận quà người khác.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll MOD = 1e9 + 7;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
ll n;
cin >> n;
ll a = 1, b = 0;
for (ll i = 2; i <= n; i++) {
ll c = (i - 1) * ((a + b) % MOD) % MOD;
a = b, b = c;
}
cout << b << '\n';
}
1
0
Độ phức tạp: .
Nguyên lý phản xạ
Đường đi trên lưới gồm bước lên, bước xuống, bắt đầu ở độ cao . Đếm những đường không xuống dưới 0: lấy tổng trừ các đường chạm . Lật phần đường trước lần chạm đầu tiên qua đường cho một song ánh với các đường bất kỳ từ tới cùng điểm cuối; số đường đó là . Vậy
Với , , đây là số Catalan : số dãy ngoặc đúng dài .
Trong hình, đường xanh có chạm thì phần trước lần chạm đầu được lật (cam): mỗi đường xấu ứng đúng một đường xuất phát từ .
Ví dụHoàn thiện tiền tố ngoặc
Đề. Độ dài và một tiền tố ngoặc dài . Đếm số cách nối thêm để được dãy ngoặc đúng dài , theo mô-đun .
Lời giải. Coi ngoặc mở là bước lên, ngoặc đóng là bước xuống. Dãy ngoặc đúng là đường đi không xuống dưới 0 và kết thúc ở 0. Tiền tố cho trước đưa ta tới độ cao , đã dùng op ngoặc mở và cl ngoặc đóng. Phần nối thêm cần đúng bước lên và bước xuống, xuất phát từ độ cao và không xuống dưới 0. Công thức phản xạ cho .
Bước 1: đọc tiền tố. Theo dõi độ cao h; xuống dưới 0 ở bất kỳ lúc nào thì in 0 ngay.
for (char c : s) {
if (c == '(') h++, op++;
else h--;
if (h < 0) {
cout << 0 << '\n';
return 0;
}
}
Bước 2: các trường hợp vô nghiệm. lẻ, hay tiền tố dùng quá ngoặc một loại.
if (n % 2 || op > n / 2 || cl > n / 2) {
cout << 0 << '\n';
return 0;
}
Bước 3: công thức phản xạ. C(n, k) trả 0 khi ngoài , nên khi (không đường nào chạm được) số hạng thứ hai tự bằng 0. Phép trừ cộng thêm MOD.
ll a = n / 2 - op, b = n / 2 - cl;
cout << (C(a + b, a) - C(a + b, a + h + 1) + MOD) % MOD << '\n';
Kiểm với mẫu: , tiền tố (, có , , ; .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll MOD = 1e9 + 7;
const int N = 1e6 + 5;
ll fac[N], ifac[N];
ll power(ll a, ll b) {
ll r = 1;
a %= MOD;
while (b) {
if (b & 1) r = r * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return r;
}
ll C(ll n, ll k) {
if (k < 0 || k > n) return 0;
return fac[n] * ifac[k] % MOD * ifac[n - k] % MOD;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
ll n;
string s;
cin >> n >> s;
fac[0] = 1;
for (int i = 1; i <= n; i++) fac[i] = fac[i - 1] * i % MOD;
ifac[n] = power(fac[n], MOD - 2);
for (int i = n; i >= 1; i--) ifac[i - 1] = ifac[i] * i % MOD;
ll h = 0, op = 0;
for (char c : s) {
if (c == '(') h++, op++;
else h--;
if (h < 0) {
cout << 0 << '\n';
return 0;
}
}
ll cl = s.size() - op;
if (n % 2 || op > n / 2 || cl > n / 2) {
cout << 0 << '\n';
return 0;
}
ll a = n / 2 - op, b = n / 2 - cl;
cout << (C(a + b, a) - C(a + b, a + h + 1) + MOD) % MOD << '\n';
}
2
(
1
Độ phức tạp: .
Ô cấm đầu tiên
Đường đi đơn điệu (chỉ xuống, sang phải) từ tới có cách. Có ô cấm thì bao hàm loại trừ trên mọi tập ô là , nhưng chỉ cần phân loại theo ô cấm đầu tiên mà đường chạm: sắp các ô cấm theo , thêm đích làm ô cuối, gọi là số đường từ gốc tới ô không qua ô cấm nào trước đó:
Mỗi đường xấu tới có đúng một ô cấm đầu tiên , nên không bị trừ hai lần. Tổng .
Hỏi nhanh
Lưới , ô cấm . Có bao nhiêu đường từ tới ?
Tổng có đường; các đường qua : 1 đường tới nó nhân 1 đường đi tiếp. Còn đường, đi xuống trước.
Ví dụĐường đi tránh ô cấm
Đề. Lưới (), ô đen. Đếm đường đi xuống, sang phải từ tới không qua ô đen, theo mô-đun .
Lời giải. Sắp các ô đen theo và thêm đích làm ô cuối. là số đường từ tới ô không đi qua ô đen nào trước khi tới . Lấy mọi đường tới rồi trừ các đường xấu, phân loại theo ô đen đầu tiên mà đường chạm: có cách tới sạch, rồi cách đi tiếp tới . Mỗi đường xấu có đúng một ô đen đầu tiên nên bị trừ đúng một lần.
Bước 1: sắp. Ô có thể đi trước ô (cả hai tọa độ không lớn hơn) luôn đứng trước , nên khi tính dp[i] mọi dp[j] cần dùng đã xong. Bảng giai thừa dựng tới , đủ cho .
sort(p, p + n);
p[n] = {h, w};
Bước 2: trừ đường xấu. Chỉ trừ những ô nằm ở góc trên trái của ; ô khác không thể nằm trên đường đơn điệu tới .
dp[i] = C(r + c - 2, r - 1);
for (int j = 0; j < i; j++)
if (p[j].first <= r && p[j].second <= c) {
int dr = r - p[j].first, dc = c - p[j].second;
dp[i] = (dp[i] - dp[j] * C(dr + dc, dr) % MOD + MOD) % MOD;
}
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll MOD = 1e9 + 7;
const int N = 2e5 + 5;
ll fac[N], ifac[N], dp[2005];
pair<int, int> p[2005];
ll power(ll a, ll b) {
ll r = 1;
a %= MOD;
while (b) {
if (b & 1) r = r * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return r;
}
ll C(int n, int k) {
if (k < 0 || k > n) return 0;
return fac[n] * ifac[k] % MOD * ifac[n - k] % MOD;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
int h, w, n;
cin >> h >> w >> n;
fac[0] = 1;
for (int i = 1; i < N; i++) fac[i] = fac[i - 1] * i % MOD;
ifac[N - 1] = power(fac[N - 1], MOD - 2);
for (int i = N - 1; i >= 1; i--) ifac[i - 1] = ifac[i] * i % MOD;
for (int i = 0; i < n; i++) cin >> p[i].first >> p[i].second;
sort(p, p + n);
p[n] = {h, w};
for (int i = 0; i <= n; i++) {
int r = p[i].first, c = p[i].second;
dp[i] = C(r + c - 2, r - 1);
for (int j = 0; j < i; j++)
if (p[j].first <= r && p[j].second <= c) {
int dr = r - p[j].first, dc = c - p[j].second;
dp[i] = (dp[i] - dp[j] * C(dr + dc, dr) % MOD + MOD) % MOD;
}
}
cout << dp[n] << '\n';
}
2 2 1
1 2
1
Độ phức tạp: phép.
Bổ đề Burnside
Khi hai cấu hình được coi là một nếu một phép biến đổi trong nhóm (như các phép quay) đưa cái này thành cái kia, số lớp tương đương là trung bình số cấu hình bất động qua các phép biến đổi:
Vòng cổ hạt, màu, nhóm gồm phép quay: phép quay bước chia các hạt thành chu trình, mỗi chu trình phải cùng màu, nên cố định cách tô. Đáp án ; gom theo ước của được .
Trong hình, các hạt cùng màu là cùng chu trình của phép quay bước; bên phải là số cách tô bất động cho từng .
Ví dụVòng màu dưới phép quay
Đề. Vòng cổ hạt, màu; hai vòng như nhau nếu quay được thành nhau. Đếm số vòng theo mô-đun .
Lời giải. Bổ đề Burnside với nhóm phép quay: phép quay bước có chu trình hạt, mỗi chu trình một màu, nên giữ nguyên cách tô. Số vòng khác nhau là trung bình các số đó, . Chia cho là nhân nghịch đảo của (theo Fermat, nên có nghịch đảo).
pw[i] tính sẵn tới . là phép quay không làm gì, __gcd(0, n) = n, giữ nguyên cả cách tô.
for (ll k = 0; k < n; k++) sum = (sum + pw[__gcd(k, n)]) % MOD;
cout << sum * power(n, MOD - 2) % MOD << '\n';
Gom theo ước như công thức với ở trên thì nhanh hơn, nhưng ở đây chưa cần.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll MOD = 1e9 + 7;
ll power(ll a, ll b) {
ll r = 1;
a %= MOD;
while (b) {
if (b & 1) r = r * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return r;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
ll n, m;
cin >> n >> m;
vector<ll> pw(n + 1);
pw[0] = 1;
for (int i = 1; i <= n; i++) pw[i] = pw[i - 1] * m % MOD;
ll sum = 0;
for (ll k = 0; k < n; k++) sum = (sum + pw[__gcd(k, n)]) % MOD;
cout << sum * power(n, MOD - 2) % MOD << '\n';
}
1 1
1
Độ phức tạp: .
Khi đi thi
- Tính sẵn giai thừa và nghịch đảo giai thừa tới cận trên của mọi tham số trong ; hàm
Ctrả 0 khi hay . - “Không có cái nào…”: bao hàm loại trừ. Nhiều ô cấm có thứ tự: đếm theo vi phạm đầu tiên. Đường đi không vượt đường thẳng: phản xạ.
- “Coi là một nếu quay, lật được”: Burnside, đếm số chu trình của từng phép biến đổi.
Tóm tắt
- trong nhờ
fac,ifac; hoán vị đa tập ; chia kẹo . - Hoán vị không điểm cố định .
- Không xuống dưới 0 từ độ cao : .
- Burnside: số lớp bằng trung bình số cấu hình bất động; vòng cổ .
Bài tập
Đọc thêm
- Binomial Coefficientscp-algorithms
- The Inclusion-Exclusion Principlecp-algorithms
- Burnside's lemma / Pólya enumeration theoremcp-algorithms