1. 组合计数实战宝典:7道经典例题深度解析
作为一名算法竞赛老手,我深知组合计数是许多选手的痛点。今天我就用7道洛谷经典题目,带大家彻底吃透排列组合的核心玩法。这些题目覆盖了从入门到高阶的所有关键技巧,每道题我都会详细拆解解题思路,并提供可直接提交的C++代码。
2. 组合计数基础回顾
在开始实战前,我们先快速过一遍必备的核心知识点。这些就像工具箱里的万能钥匙,遇到任何组合计数问题都能派上用场。
2.1 加法原理与乘法原理
加法原理用于分类计数:如果完成一件事有n类不同的方法,每类方法有a_i种方式,且这些方法互不干扰,那么总方法数就是各类方法数之和。
乘法原理用于分步计数:如果完成一件事需要n个步骤,每个步骤有a_i种选择,且各步骤缺一不可,那么总方法数就是各步骤方法数的乘积。
举个生活例子:从北京到上海,可以坐飞机(3个航班)或高铁(5个车次),这是加法原理(3+5=8种选择)。如果要从北京经武汉再到上海,北京到武汉有4种方式,武汉到上海有6种方式,就是乘法原理(4×6=24种选择)。
2.2 组合数与排列数
组合数C(n,m)表示从n个不同元素中选出m个元素的组合数,不考虑顺序。计算公式为:
C(n,m) = n! / (m! × (n-m)!)
排列数A(n,m)则表示考虑顺序的排列数:
A(n,m) = n! / (n-m)!
组合数有几个重要性质:
- C(n,m) = C(n,n-m)
- C(n,0) + C(n,1) + ... + C(n,n) = 2^n
- 递推关系:C(n,m) = C(n-1,m) + C(n-1,m-1)
2.3 正难则反思想
当直接计算符合条件的方案数比较困难时,可以先计算总方案数,再减去不符合条件的方案数。这个思想在"越狱"和"数三角形"问题中会大显身手。
2.4 乘法逆元
在模运算中,我们需要用乘法逆元来处理除法。当p是质数时,a的逆元就是a^(p-2) mod p(根据费马小定理)。这在组合数取模的计算中至关重要。
3. 经典题目实战解析
3.1 题目1:编号问题(洛谷P1866)
问题描述
有N只兔子,每只兔子i想要一个1到M_i之间的整数编号,且所有编号必须不同。求合法编号方案数,对10^9+7取模。
解题思路
- 首先将M_i从小到大排序。这一步很关键,它让后续的计数变得有序。
- 对于第i只兔子(排序后),前面已经选了i-1个不同的编号,所以它可选的范围是M_i - (i-1)。
- 如果某只兔子的可选数≤0,直接返回0。
- 总方案数就是各兔子可选数的乘积。
关键点
- 排序确保计数有序性
- 乘法原理分步计算
- 及时取模防止溢出
C++代码实现
cpp复制#include <iostream>
#include <algorithm>
using namespace std;
typedef long long LL;
const int N = 55;
const int MOD = 1e9 + 7;
int main() {
int n;
cin >> n;
int m[N];
for (int i = 0; i < n; ++i) cin >> m[i];
sort(m, m + n);
LL res = 1;
for (int i = 0; i < n; ++i) {
int choices = m[i] - i;
if (choices <= 0) {
cout << 0 << endl;
return 0;
}
res = res * choices % MOD;
}
cout << res << endl;
return 0;
}
复杂度分析
- 时间复杂度:O(nlogn)(排序占主导)
- 空间复杂度:O(n)
3.2 题目2:文的数学游戏(洛谷P8469)
问题描述
给定长度为n的整数序列a,构造序列b满足1≤b_i≤a_i,且gcd(b_1,...,b_n)最大。求这个最大gcd和对应的方案数。
解题思路
- 最大gcd只能是a_i中的最小值min_a(设为d)
- 对于每个a_i,b_i必须是d的倍数且≤a_i,所以有⌊a_i/d⌋种选择
- 总方案数就是这些选择的乘积
关键点
- 最大gcd的确定
- 方案数的乘法原理计算
- 边界情况处理(如d=1)
C++代码实现
cpp复制#include <iostream>
#include <climits>
using namespace std;
typedef long long LL;
const int MOD = 1e9 + 7;
int main() {
int n;
cin >> n;
int min_a = INT_MAX;
int a[n];
for (int i = 0; i < n; ++i) {
cin >> a[i];
if (a[i] < min_a) min_a = a[i];
}
LL cnt = 1;
for (int i = 0; i < n; ++i) {
cnt = cnt * (a[i] / min_a) % MOD;
}
cout << min_a << " " << cnt << endl;
return 0;
}
复杂度分析
- 时间复杂度:O(n)
- 空间复杂度:O(n)
3.3 题目3:Bulls And Cows(洛谷P6191)
问题描述
在N个位置上安排公牛和奶牛,要求任意两只公牛之间至少有K只奶牛。求方案数模5000011。
解题思路
- 枚举公牛数量i
- 对于i只公牛,需要预留(i-1)*K只奶牛
- 剩余可用位置为N - (i-1)*K
- 在这些位置中选择i个放公牛
- 对所有合法的i的方案数求和
关键点
- 预留位置的思想
- 组合数的计算
- 枚举终止条件
C++代码实现
cpp复制#include <iostream>
using namespace std;
typedef long long LL;
const int MOD = 5000011;
const int N = 1e5 + 10;
LL fact[N], inv[N];
LL qpow(LL a, LL b) {
LL res = 1;
while (b) {
if (b & 1) res = res * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return res;
}
void init() {
fact[0] = 1;
for (int i = 1; i < N; ++i)
fact[i] = fact[i-1] * i % MOD;
inv[N-1] = qpow(fact[N-1], MOD-2);
for (int i = N-2; i >= 0; --i)
inv[i] = inv[i+1] * (i+1) % MOD;
}
LL C(int n, int m) {
if (n < m) return 0;
return fact[n] * inv[m] % MOD * inv[n-m] % MOD;
}
int main() {
init();
int n, k;
cin >> n >> k;
LL res = 0;
for (int i = 0; ; ++i) {
int avail = n - (i-1)*k;
if (avail < i) break;
res = (res + C(avail, i)) % MOD;
}
cout << res << endl;
return 0;
}
复杂度分析
- 时间复杂度:O(n)(预处理阶乘和逆元)
- 空间复杂度:O(n)
3.4 题目4:炼金术(洛谷P8557)
问题描述
有n种金属和k个熔炉,每个熔炉随机产生若干种金属。求所有熔炉产生的金属包含全部n种的方案数模998244353。
解题思路
- 对每种金属,至少被一个熔炉选中
- 每个熔炉对每种金属有选或不选两种可能
- 所以每种金属的有效方案数是2^k - 1
- 总方案数是(2^k - 1)^n
关键点
- 独立事件的分步计数
- 幂运算的快速计算
- 模运算处理
C++代码实现
cpp复制#include <iostream>
using namespace std;
typedef long long LL;
const int MOD = 998244353;
LL qpow(LL a, LL b) {
LL res = 1;
while (b) {
if (b & 1) res = res * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return res;
}
int main() {
LL n, k;
cin >> n >> k;
LL pow2k = qpow(2, k);
LL per_metal = (pow2k - 1 + MOD) % MOD;
LL res = qpow(per_metal, n);
cout << res << endl;
return 0;
}
复杂度分析
- 时间复杂度:O(logk + logn)
- 空间复杂度:O(1)
3.5 题目5:越狱(洛谷P3197)
问题描述
n个房间排成一排,每个房间可以选m种宗教。求至少有一对相邻房间宗教相同的方案数模100003。
解题思路
- 总方案数:m^n
- 不越狱的方案数:m×(m-1)^(n-1)
- 越狱方案数=总方案数-不越狱方案数
关键点
- 正难则反思想
- 快速幂计算
- 负数取模处理
C++代码实现
cpp复制#include <iostream>
using namespace std;
typedef long long LL;
const int MOD = 100003;
LL qpow(LL a, LL b) {
LL res = 1;
a %= MOD;
while (b) {
if (b & 1) res = res * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return res;
}
int main() {
LL m, n;
cin >> m >> n;
LL total = qpow(m, n);
LL no_escape = m % MOD * qpow(m-1, n-1) % MOD;
LL escape = (total - no_escape + MOD) % MOD;
cout << escape << endl;
return 0;
}
复杂度分析
- 时间复杂度:O(logn)
- 空间复杂度:O(1)
3.6 题目6:车的放置(洛谷P1350)
问题描述
在特殊形状的棋盘上放置k个互不攻击的车,求方案数模1e5+3。
解题思路
- 将棋盘拆分为上下两部分
- 枚举上部分放x个车,下部分放k-x个车
- 分别计算两部分的方案数
- 对所有x的方案数求和
关键点
- 棋盘拆分技巧
- 组合数与排列数的结合
- 复杂情况的分类处理
C++代码实现
cpp复制#include <iostream>
using namespace std;
typedef long long LL;
const int N = 2005;
const int MOD = 1e5 + 3;
LL fact[N], inv[N];
LL qpow(LL a, LL b) {
LL res = 1;
while (b) {
if (b & 1) res = res * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return res;
}
void init() {
fact[0] = 1;
for (int i = 1; i < N; ++i)
fact[i] = fact[i-1] * i % MOD;
inv[N-1] = qpow(fact[N-1], MOD-2);
for (int i = N-2; i >= 0; --i)
inv[i] = inv[i+1] * (i+1) % MOD;
}
LL C(int n, int m) {
if (n < m) return 0;
return fact[n] * inv[m] % MOD * inv[n-m] % MOD;
}
int main() {
init();
LL a, b, c, d, k;
cin >> a >> b >> c >> d >> k;
LL res = 0;
for (int x = 0; x <= k; ++x) {
LL part1 = C(d, x) * C(c, x) % MOD * fact[x] % MOD;
LL part2 = C(b + d - x, k - x) * C(a, k - x) % MOD * fact[k - x] % MOD;
res = (res + part1 * part2) % MOD;
}
cout << res << endl;
return 0;
}
复杂度分析
- 时间复杂度:O(n^2)(预处理阶乘)
- 空间复杂度:O(n)
3.7 题目7:数三角形(洛谷P3166)
问题描述
在N×M的网格中,求三个格点组成的非退化三角形个数。
解题思路
- 总三点数:C((N+1)(M+1), 3)
- 共线三点数:
- 水平线:(N+1)×C(M+1,3)
- 垂直线:(M+1)×C(N+1,3)
- 斜线:2×ΣΣ (gcd(i,j)-1)×(N-i+1)(M-j+1)
- 答案=总三点数-共线三点数
关键点
- 正难则反思想
- gcd计算斜线上的点数
- 斜线的对称性处理
C++代码实现
cpp复制#include <iostream>
using namespace std;
typedef long long LL;
LL gcd(LL a, LL b) {
return b == 0 ? a : gcd(b, a % b);
}
LL C3(LL n) {
return n * (n - 1) * (n - 2) / 6;
}
int main() {
LL n, m;
cin >> n >> m;
LL total = C3((n+1)*(m+1));
LL horizontal = (n + 1) * C3(m + 1);
LL vertical = (m + 1) * C3(n + 1);
LL diagonal = 0;
for (LL i = 1; i <= n; ++i) {
for (LL j = 1; j <= m; ++j) {
diagonal += (gcd(i, j) - 1) * (n - i + 1) * (m - j + 1);
}
}
diagonal *= 2;
LL ans = total - horizontal - vertical - diagonal;
cout << ans << endl;
return 0;
}
复杂度分析
- 时间复杂度:O(nm)
- 空间复杂度:O(1)
4. 组合计数解题思维总结
通过这7道题目,我们可以总结出组合计数的核心解题方法:
- 问题转化:将实际问题抽象为组合模型(排列、组合、分步等)
- 正难则反:当直接计算困难时,考虑用总数减去不满足条件的数
- 分类讨论:将复杂问题拆分为多个简单子问题
- 数学工具:熟练运用gcd、快速幂、逆元等数学工具
- 边界处理:特别注意边界条件和特殊情况
在实际解题时,建议按照以下步骤思考:
- 明确计数对象是什么
- 确定是否有顺序要求
- 判断是否可以分步或分类处理
- 考虑是否需要使用正难则反
- 检查是否需要特殊数学工具
5. 实战建议
- 多练习基础题目:熟练掌握基本排列组合问题的解法
- 总结常见模型:如隔板法、容斥原理、卡特兰数等
- 注意模运算:竞赛题通常要求取模,要处理好负数和除法
- 优化计算:预处理阶乘、逆元等,避免重复计算
- 调试技巧:用小数据验证,检查边界情况
组合计数能力的提升需要大量练习和总结。建议读者在理解这7道题的基础上,再去尝试洛谷上的其他组合计数题目,逐步提高解题能力。