Cách tính số tổ hợp
Người viết: - Nguyễn Minh Hiển - Trường Đại học Công nghệ, ĐHQGHN
Reviewer: - Phạm Công Minh - Trường THPT chuyên Khoa học Tự nhiên, ĐHQGHN - Nguyễn Đức Kiên - Trường Đại học Công nghệ, ĐHQGHN - Nguyễn Minh Nhật - Trường THPT chuyên Khoa học Tự nhiên, ĐHQGHN
Tổ hợp (Combination)¶
Giới thiệu về tổ hợp¶
Trong toán học, tổ hợp là cách chọn các phần tử từ một nhóm mà không phân biệt thứ tự chọn. Mỗi tập con gồm \(k\) phần tử khác nhau (không phân biệt thứ tự) của tập hợp \(n\) phần tử đã cho (\(0 ≤ k ≤ n\)) được gọi là một tổ hợp chập \(k\) của \(n\) phần tử. Số các tổ hợp chập \(k\) của \(n\) phần tử khác nhau được kí hiệu là \(C_n^k\) hoặc \(\dbinom{n}{k}\):
Để bạn đọc tiện theo dõi, trong bài viết này, chúng ta thống nhất sử dụng ký hiệu \(C_n^k\). Ta quy ước: - \(C_n^0 = 1\) với mọi \(n \ge 0\) - Nếu \(k > n\) thì \(C_n^k = 0\)
Một số tính chất của tổ hợp¶
-
Với mọi \(n ≥ 1\) và \(0 ≤ k ≤ n\), ta có:
- \(C_n^k = C_n^{n - k}\)
- \(C_n^k = C_{n - 1}^{k - 1} + C_{n - 1}^{k}\)
-
\(C_n^k\) còn được gọi là hệ số nhị thức (binomial coefficients) do \(C_n^k\) là hệ số trong khai triển: $\((x + y)^n= \sum\limits_{k = 0}^{n} C_n^k \cdot x^k \cdot y ^ {n - k}\)$
Tính số tổ hợp¶
Sử dụng định nghĩa¶
$\(C_n^k = \dfrac{n!}{k! (n - k)!}\)$ - Với công thức này, ta nghĩ ngay đến một thuật toán "ngây thơ": Tính \(n!\), \(k!\) và \((n - k)!\). Từ đó tính được \(C_n^k\).
- Mở rộng hơn, ta có thể biến đổi một chút như sau:
Vì \(C_n^k\) là số nguyên, nên bạn yên tâm rằng \(C_{n-1}^{k-1} \cdot (n - k + 1)\) luôn chia hết cho \(k\).
- Hai cách tiếp cận trên rất tự nhiên, dễ nghĩ, dễ thực hiện nhưng lại có một trở ngại: giá trị của \(n!\) có thể rất lớn (khi \(n = 20\) thì \(n! \approx 2.42\times10^{18}\))
Sử dụng công thức truy hồi¶
Với công thức truy hồi này, ta sẽ sử dụng một mảng hai chiều C[n][k] để tính \(C_n^k\)
Code C++ minh họa
Độ phức tạp không gian: \(O(n^2)\) Độ phức tạp thời gian: - Tiền xử lý: \(O(n^2)\) - Truy vấn: \(O(1)\)
Tính số tổ hợp theo modulo M¶
Vì kết quả của \(C_n^k\) có thể rất lớn nên trong lập trình thi đấu, thí sinh thường được yêu cầu tính \(C_n^k\) theo một modulo \(M\) nào đó (phần dư của phép chia \(C_n^k\) cho \(M\)). Dưới đây là một số cách sử dụng để tính \(C_n^k\) theo modulo \(M\). Chú ý rằng độ phức tạp dưới đây được tính toán khi sử dụng một modulo \(M\) cho toàn bài!
| Cách | Tiền xử lý | Truy vấn | Bộ nhớ | Độ khó | Giới hạn |
|---|---|---|---|---|---|
| Sử dụng công thức truy hồi | \(O(n^2)\) | \(O(1)\) | \(O(n^2)\) | Cơ bản | \(M\) bất kỳ, \(n \sim 5000\) |
| Sử dụng định nghĩa | \(O(n + \log M)\) | \(O(1)\) | \(O(n)\) | Cơ bản | \(M\) nguyên tố, \(n < M, n \sim 10^6\) |
| Sử dụng định lý Lucas | \(O(M)\) | \(O\big(\log_M(n)\big)\) | \(O(M)\) | Trung bình | \(M\) nguyên tố, \(M \sim 10^6\) |
| Sử dụng định lý Lucas mở rộng | \(O(M)\) | \(O\big(\log_p(n)\big)\) | \(O(M)\) | Trung bình | \(M = p^q\) với \(p\) nguyên tố, \(M \sim 10^6\) |
| Sử dụng định lý thặng dư Trung Hoa | Trung bình | \(M\) bất kỳ |
Ngoài ra còn có hai cách tính dựa trên cách tính giai thừa modulo \(M\) khá hiệu quả. Tham khảo thêm tại đây. Dưới đây là đánh giá về hai cách đó:
| Cách | Tiền xử lý | Truy vấn | Bộ nhớ | Độ khó | Giới hạn |
|---|---|---|---|---|---|
| Chia căn | \(O\left(\frac{M}{S}\right)\) | \(O\left(S+\frac{M}{S}\right)\) | \(O\left(\frac{M}{S}\right)\) | Cơ bản | \(n < M \le 2 \cdot 10^9\) |
| Biến đổi FFT | không | \(O\left(\sqrt M\log M\right)\) | \(O\left(\sqrt M\right)\) | Khó | \(M\) nguyên tố, \(n < M \le 10^{12}\) |
Sử dụng công thức truy hồi¶
Ở đây, ta sẽ sử dụng công thức truy hồi ở trên và thay đổi một chút: $\(C_n^k = (C_{n - 1}^{k - 1} + C_{n - 1}^{k}) \mod M\)$
Code C++ minh họa
Độ phức tạp không gian: \(O(n^2)\) Độ phức tạp thời gian: - Tiền xử lý: \(O(n^2)\) - Truy vấn: \(O(1)\)
Nhận xét: Đây là cách đơn giản, dễ nghĩ, dễ code đúng, nên sử dụng trong trường hợp \(n\) nhỏ để tiết kiệm thời gian.
Sử dụng định nghĩa¶
Rào cản lớn nhất cho việc sử dụng định nghĩa \(C_n^k = \dfrac{n!}{k! (n - k)!}\) là \(n!\) quá lớn. Tuy nhiên khi ta cần lấy kết quả theo modulo \(M\), đó lại là vấn đề khác.
- Điều kiện sử dụng: \(M\) nguyên tố và \(n < M\)
- Kiến thức sử dụng:
- Nghịch đảo modulo (Modular Inverse): Tham khảo ở bài viết Số học 4.5 - Nghịch đảo modulo của VNOI.
- Định lý Fermat nhỏ :::spoiler Cho \(p\) là một số nguyên tố và số nguyên \(a\) không chia hết cho \(p\). Khi đó, ta có: $\(a^{p - 1} \equiv 1 \pmod p\)$ Từ đó, ta rút ra: $\(a^{-1} \equiv a^{p-2} \pmod p\)$ :::
- Lũy thừa nhanh
-
Ý tưởng:
- Ta viết lại: $\(C_n^k = n! \times \left( k! \right)^{-1} \times \left( (n - k)! \right)^{-1} \mod M\)$
-
Ta sử dụng hai mảng: mảng \(\text{fact}[i]\) để lưu \(i! \bmod M\) và mảng \(\text{ifact}[i]\) để lưu \((i!)^{-1} \bmod M\). Sau đó dùng công thức (sử dụng định lý Fermat nhỏ):
\[\text{ifact}[i] = (\text{fact}[i]) ^ {-1} \bmod M = (\text{fact}[i])^{M-2} \bmod M\]Chú ý rằng \(\text{fact}[i] \equiv 0 \pmod M \;\;\forall i \ge M\) nên ta chỉ tính \(\text{fact}[i]\) và \(\text{ifact}[i]\) với \(0 \le i \le M - 1\).
-
Ta sẽ tính mảng \(\text{fact}[i]\) như sau:
\[\begin{align} \begin{cases} \text{fact}[0] &= 1\\ \text{fact}[i] &= (\text{fact}[i - 1] \times i ) \bmod M &\text{ nếu } 1 \le i \le n \end{cases} \end{align}\]- Tiếp theo ta sử dụng thuật toán lũy thừa nhanh để tính \(\text{ifact}[n] = \left( \text{fact}[n] \right)^{M-2} \mod M\) với độ phức tạp \(O(\log M)\).
- Còn mảng \(\text{ifact}[i]\) thì tính như sau:
\[\begin{align} \begin{cases} \text{ifact}[n] &= \left( \text{fact}[n] \right)^{M-2} &\mod M\\ \text{ifact}[i - 1] &= \text{ifact}[i] \times i &\mod M &\text{nếu } 1 \le i \le n \end{cases} \end{align}\]- Cuối cùng, \(C_n^k = \text{fact}[n] \times \text{ifact}[k] \times \text{ifact}[n - k] \mod M\).
Code C++ minh họa
Độ phức tạp không gian: \(O(n)\) Độ phức tạp thời gian: - Tiền xử lý: \(O(n + \log M)\) - Truy vấn: \(O(1)\)
Sử dụng định lý Lucas¶
Đây là cải tiến của cách sử dụng định nghĩa để có thể sử dụng cho \(n > M\) - Điều kiện sử dụng: \(M\) nguyên tố - Ý tưởng: Các bạn có thể tham khảo tại bài viết Định lý Lucas của VNOI
Code C++ minh họa
Bạn đọc tham khảo thêm code đầy đủ ở đây
=== "C++"Độ phức tạp không gian: \(O(M)\) Độ phức tạp thời gian: - Tiền xử lý: \(O(M)\) - Truy vấn: \(O\big(\log_M(n)\big)\)
Sử dụng định lý Lucas mở rộng¶
- Điều kiện sử dụng: \(M\) lũy thừa của số nguyên tố
- Kiến thức sử dụng:
- Nghịch đảo modulo, lũy thừa nhanh
-
Định lý Euler (mở rộng của định lý Fermat nhỏ) Cho \(2\) số nguyên \(a, m\) nguyên tố cùng nhau. Khi đó, ta có: \(a^{\varphi(m)} \equiv 1 \pmod m\).
Trong đó, \(\varphi(m)\) là hàm phi Euler: \(\varphi(m) = m \cdot \prod\limits_{p \text{ nguyên tố}, p \mid m} \dfrac{p - 1}{p}\). Từ đó, ta rút ra: $\(a^{-1} \equiv a^{\varphi(m)-1} \pmod p\)$ - Mở rộng định lý Lucas cho modulo là lũy thừa số nguyên tố: Andrew Granville đã chứng minh được công thức sau: (Xem bài báo tại đây hoặc tại đây)
\[\dfrac{t^{e_q}}{p^{e_1}} C_n^k \equiv \dfrac{(n_0!)_p}{(k_0!)_p(r_0!)_p} \cdot \dfrac{(n_1!)_p}{(k_1!)_p(r_1!)_p} \ldots \dfrac{(n_d!)_p}{(k_d!)_p(r_d!)_p} \,(\bmod p^q)\]Trong đó: - \(t = \begin{cases} 1 &\text{ nếu } p = 2 \text{ và } q \ge 3\\ -1&\text{ còn lại} \end{cases}\) - \(e_j = \sum\limits_{i \ge j} \left( \left\lfloor \dfrac{n}{p^i} \right\rfloor - \left\lfloor \dfrac{k}{p^i} \right\rfloor - \left\lfloor \dfrac{r}{p^i} \right\rfloor \right)\) Bạn đọc có thể thấy, \(e_1\) là số mũ của \(p\) khi phân tích \(C_n^k\) ra thừa số nguyên tố. - \(\left( n! \right)_p\) là tích tất cả các số từ \(1\) đến \(n\) và không bao gồm các số chia hết cho \(p\) (với \(p\) là số nguyên tố). - \(r = n-k\) - \(n_i = \left\lfloor \dfrac{n}{p^i} \right\rfloor \bmod p ^ q\) - \(k_i, r_i\) định nghĩa tương tự \(n_i\) - \(d\) là vị trí cuối cùng mà \(n_i \neq 0\). Nghĩa là ta chỉ cần chạy cho đến khi \(n_i = 0\).
Chi tiết các bước:
- Chú ý rằng ở bước chuẩn bị, \(\text{fact}[i]\) sử dụng để lưu \(\left( i! \right)_p\) (Xem phần mô tả mở rộng định lý Lucas) thay vì \(i!\) và ta cần sử dụng định lý Euler thay cho định lý Fermat nhỏ.
- Tiếp theo ta sử dụng công thức bên trên
Độ phức tạp không gian: \(O(M)\) Độ phức tạp thời gian: - Tiền xử lý: \(O(M)\) - Truy vấn: \(O\big(\log_p(n)\big)\)
Sử dụng định lý thặng dư Trung Hoa¶
Định lý thặng dư Trung Hoa là cầu nối giúp ta tính toán được khi \(M\) không phải là số nguyên tố. - Kiến thức sử dụng: - Định lý Thặng dư trung hoa - Chinese remainder theorem (CRT)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | |
Để minh họa rõ hơn này, ta sẽ giải quyết bài nCr trên Hackerrank
Tóm tắt: Tính \(C_n^k \bmod 142857\) với \(0 \le k \le n \le 10^{9}\)
Lời giải: - Giả sử bằng những cách trên, bạn đã tính được \(C_n^k\) modulo là số nguyên tố (ĐL Lucas) hoặc lũy thừa của chúng (ĐL Lucas mở rộng). Tiếp theo ta sẽ sử dụng CRT xử lý các phần còn lại. - Đầu tiên, ta sẽ phân tích modulo \(142857 = 3^3 \cdot 11 \cdot 13 \cdot 17\)
- Ta chuẩn bị sẵn một mảng tính \(M_i N_i\) trong công thức \(a = \sum a_i M_i N_i\) để tiện cho việc truy vấn.
- Cuối cùng tính ra kết quả cuối cùng sử dụng CRT
Bạn đọc tham khảo thêm code nộp AC bài nCr ở đây
=== "C++"1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 | |
Bài tập luyện tập¶
- SPOJ - Marbles
- Codeforces 181C Div.2
- Codechef - Long Sandwich
- Hackerearth - Binomial Coefficient
- SPOJ - ADATEAMS
- SPOJ - UCV2013E
- Codeforces 239C Div. 1
- VNOJ - Xông nhà ngày Tết
- LQDOJ - Tổ hợp nCk
- Hackerrank - nCr
- Hackerearth - Army parade
- Library Checker - Binomial Coefficient
- Libary Checker - Binomial Coefficient (Prime Mod)
Tài liệu tham khảo¶
- Codeforces - Binomial Coefficients with Large Mod
- Codeforces - nCk for small k
- Mở rộng định lý Lucas của Andrew Granville
- CP-Algorithms - Binomial Coefficients