排列组合
1. 排列数
问题引入:有 个座位,坐 个同学,请问有多少种坐法?
对于第一个座位,可以从 个同学里面任选一个坐,所以共有 种选择。对于第二个座位,只能从剩下的 个同学中挑一个坐,故有 种选择。依此类推,最后一个座位,只剩最后一位同学,只有 种选择。根据乘法原理共有,
种坐法。不失一般性,我们把它记为 ,叫做排列数,其含义是从 个里面选 个做排列,共有多少种排列方法。如无特殊说明,默认 .
类似的,3个座位,5个同学,有多少种坐法?第一个座位可选5,第二个可选4,第三个可选3,共有 种坐法。再类似的,5个座位,3个同学,有多少种坐法?第一个同学任选5个座位,第二个任选余下的4个,第三个任选余下的3个,所以共有 种坐法。一般的,
特别的,称 为 的全排列。
2. 组合数
问题引入:有 个同学,挑 个去参加活动,请问有多少种挑法?
现在我们只是要挑选出来,不要求做排列。如果考虑挑出 个同学按顺序落座,那么答案显然就是 . 但我们其实不考虑顺序,想象一下,这件事我们其实分了两步,
- 挑选 个同学出来,
- 给 个同学落座(做排列)
其实我们想要的是第一步的答案,但我们额外做了 个同学的全排列。因此,要在结果种去掉这些重复的。所以从 选 ,不考虑顺序,共有,
种选法,我们把这个数叫做组合数。有些教材也用下面的记号表示组合数,
3. 组合数的计算
在C++中计算组合数时,由于阶乘增长很快,很容易发生整数溢出。从减少计算量的角度考量,组合数可以写成,
在迭代计算时,按照一定的计算顺序,还可以做到边乘边除,不用担心除不尽的问题。
long long comb(int n, int k) {
if (k < 0 || k > n) return 0;
if (k == 0 || k == n) return 1;
if (k > n - k) {
// 利用组合数的对称性 C(n, k) = C(n, n-k) 减少计算量
k = n - k;
}
long long res = 1;
for (int i = 1; i <= k; ++i) {
// 先乘后除,确保整除
res = res * (n - k + i) / i;
}
return res;
}如果我们查看每一步res的取值,
- 显然是整数
- 这是一个组合数,显然也是整数
- 也是整数
- 依此类推,一直是整数,没有除不尽的问题。
此外,中间计算结果最好用 long long 或者 unsigned long long 存储,防止整数相乘溢出。
3.1. 逆元计算
摘自ref1.
引理
如果 是一个质数, 是 的倍数且 和 互质( 不是 的倍数),那么有
组合数分母有阶乘,所以需要处理阶乘及其逆元,然后利用公式
计算。
对于阶乘 ,可以用 递推计算。对于阶乘的倒数 ,可以先计算 的逆元(其中 是 的最大值),然后用
倒着递推计算。
const int MOD = 1'000'000'007; // 质数
const int MX = 100'001; // 根据题目数据范围修改
long long F[MX]; // F[i] = i!
long long INV_F[MX]; // INV_F[i] = i!^-1 = qpow(i!, MOD-2)
long long qpow(long long x, int n) {
long long res = 1;
for (; n; n /= 2) {
if (n % 2) {
res = res * x % MOD;
}
x = x * x % MOD;
}
return res;
}
auto init = [] {
F[0] = 1;
for (int i = 1; i < MX; i++) {
F[i] = F[i - 1] * i % MOD;
}
INV_F[MX - 1] = qpow(F[MX - 1], MOD - 2);
for (int i = MX - 1; i; i--) {
INV_F[i - 1] = INV_F[i] * i % MOD;
}
return 0;
}();
// 从 n 个数中选 m 个数的方案数
long long comb(int n, int m) {
return m < 0 || m > n ? 0 : F[n] * INV_F[m] % MOD * INV_F[n - m] % MOD;
}
class Solution {
public:
int solve(vector<int>& nums) {
// 预处理的逻辑写在 class 外面,这样只会初始化一次
}
};