前言:从“选人”说起
“从 10 个人里选 3 个组成小组,有多少种选法?” ——你可能学过这是 。但在实际题目中,问题往往没有这么直接:
- “把 10 块相同的糖分给 3 个小朋友,每人至少 1 块,有多少种分法?“(隔板法)
- “100 个人参加考试,至少有一个人满分的可能情况有多少种?“(容斥原理)
- ” 对括号有多少种合法的嵌套方式?“(Catalan 数)
这些问题看似各不相同,但有一个共同的核心:计数——数出满足条件的方案有多少种。
这篇笔记的目标,是帮你建立起一套完整的计数工具箱。我们从最基本的排列组合出发,解释公式背后的直觉(而不只是死记公式),然后逐步引入更强大的工具:
- 排列与组合 —— 基本计数原理,以及几个常用的恒等式
- 隔板法 —— 解决”把相同的东西分到若干组”这类问题
- 容斥原理 —— 解决”至少满足一个条件”的计数问题,核心思路是”加了又减、减了又加”
- Catalan 数 —— 一类非常经典的计数问题,括号匹配、二叉树形态等都是它
每个工具都会讲清楚”什么时候用”和”为什么这样算是对的”。最后用一道综合题,把隔板法和容斥原理配合使用。
一、排列与组合的基本概念
1.1 排列
从 个不同元素中选 个排成一列:
直觉:第一个位置有 种选法,第二个 种……第 个 种,连乘。
1.2 组合
从 个不同元素中选 个(不关心顺序):
排列除以 消除顺序—— 个元素的排列有 种,但组合只算一种。
1.3 基本恒等式
| 恒等式 | 直觉 |
|---|---|
| 选 个留下 = 选 个去掉 | |
| 杨辉三角递推:元素 要么不选,要么选 | |
| 每个元素选或不选,共 个子集 | |
| 用于递推计算,避免大数阶乘 |
二、多重集的排列与组合
2.1 多重集排列
个物品中有 个 A、 个 B、……、 个 K(),排列数为:
直觉:先当所有物品不同(),然后同类物品互换不产生新排列,除以各自的阶乘。
2.2 多重集组合
从 种物品中选 个(每种无限供应):
这就是隔板法的直接结果——下一节展开。
三、隔板法(Stars and Bars)
3.1 问题
将 个不可区分的物品放入 个可区分的组,每组至少 1 个,有多少种分法?
3.2 方法
把 个物品排成一排,中间有 个间隙。插入 个隔板,把物品分成 组:
从 个间隙中选 个放隔板:
3.3 允许空组的变体
很多时候,“每组至少 1 个”这个约束太强了。比如”把 个苹果分给 个小朋友,有人可以不分”就属于允许空组的情况。
如果组可以为空,先给每组预放 1 个物品(共 个),剩余 个物品分到 组、每组至少 1 个:
这就是多重集组合的公式来源。
什么时候用隔板法? 当问题是”把若干相同的东西分到若干组”时。关键在于”相同”和”分组”——如果物品不同,就是子集/排列问题;如果分组有额外约束,可能需要容斥。
四、容斥原理
4.1 直觉
要计数”满足至少一个条件的对象”——直接加会多算(满足多个条件的被重复计数),减回来,加回来……交替进行。
两个集合:
三个集合:
一般形式:
4.2 补集形式(竞赛更常用)
上面的形式是”至少满足一个”。但很多题目问的是”全都不满足”——比如”每个人都不去自己原来的位置”(错排问题)。用全集减去并集:
这里 时贡献 (全集大小)。
4.3 二进制枚举子集
理论公式写出来了,但怎么编程实现?如果有 个条件,就要枚举 个子集——每个子集可以用一个 位二进制数表示,第 位是 1 就代表”第 个条件成立”。
long long inclusion_exclusion(int n) {
long long ans = 0;
for (int mask = 0; mask < (1 << n); mask++) {
long long cnt = count_intersection(mask); // 满足 mask 中所有条件的对象数
if (__builtin_popcount(mask) % 2 == 0)
ans += cnt; // 偶数个条件:加
else
ans -= cnt; // 奇数个条件:减
}
return ans;
}
4.4 一个经典例子:错排
个元素的排列,每个元素都不在原位的排列数 。
用容斥:全集 , = “第 个元素在原位”。
是任意固定 个位置在原位的排列数。
五、Catalan 数
5.1 定义
前几项:
5.2 等价问题
同时是以下问题的答案:
| 问题 | 描述 |
|---|---|
| 括号序列 | 对括号的合法匹配方式数 |
| Dyck 路径 | 从 到 ,每步 或 ,始终 的路径数 |
| 二叉树 | 个节点的不同形态二叉树数 |
| 出栈序列 | 依次入栈,合法出栈序列数 |
| 多边形三角剖分 | 凸 边形的不同三角剖分数 |
5.3 推导(折线反射法)
这个公式怎么来的?以括号问题为例: 个 ( 和 个 ) 排成一列,合法条件是任意前缀中 ( 不少于 )。
先不管合法性,总共有 种排列。其中不合法的排列,某个位置 ) 第一次比 ( 多。关键技巧是反射:从这个位置开始,把所有括号取反(( ↔ ))。这会把 个 ( 和 个 ) 变成 个 ( 和 个 )。而且这个变换是双射(一一对应),所以:
不合法数 = 个 ( 和 个 ) 的排列数 = 。
5.4 递推关系
直觉:以二叉树为例,根的左子树 个节点、右子树 个节点,遍历所有 求和。
long long catalan[100];
catalan[0] = 1;
for (int n = 0; n < 99; n++)
for (int i = 0; i <= n; i++)
catalan[n + 1] += catalan[i] * catalan[n - i];
或直接用组合数公式:。
六、实战:方程的非负整数解
求方程 的非负整数解的个数,满足 ()。
不加约束的解数是 (隔板法,允许空组)。加上上界约束 后,用容斥:
令 = ""(即 ),求的是”不满足任何 “的对象数。
对于子集 ,令 (对 ),转化为无约束方程,剩余总量 ,解数 (当剩余量非负时,否则为 0)。
#include <cstdio>
#include <algorithm>
using namespace std;
const int MOD = 1e9 + 7;
const int MAXN = 800006;
long long fact[MAXN], inv_fact[MAXN];
long long qpow(long long a, long long b, long long mod) {
long long res = 1; a %= mod;
while (b) { if (b & 1) res = res * a % mod; a = a * a % mod; b >>= 1; }
return res;
}
void init(int n) {
fact[0] = 1;
for (int i = 1; i <= n; i++) fact[i] = fact[i - 1] * i % MOD;
inv_fact[n] = qpow(fact[n], MOD - 2, MOD);
for (int i = n - 1; i >= 0; i--) inv_fact[i] = inv_fact[i + 1] * (i + 1) % MOD;
}
long long C(int n, int k) {
if (k < 0 || k > n || n < 0) return 0;
return fact[n] * inv_fact[k] % MOD * inv_fact[n - k] % MOD;
}
int main() {
int n, k;
scanf("%d%d", &n, &k);
vector<int> a(k);
for (int i = 0; i < k; i++) scanf("%d", &a[i]);
init(n + k);
long long ans = 0;
for (int mask = 0; mask < (1 << k); mask++) {
long long rem = n;
int bits = 0;
for (int i = 0; i < k; i++)
if (mask >> i & 1) { rem -= a[i] + 1; bits++; }
long long cnt = C(rem + k - 1, k - 1);
if (bits % 2 == 0) ans = (ans + cnt) % MOD;
else ans = (ans - cnt + MOD) % MOD;
}
printf("%lld\n", ans);
return 0;
}
解析:隔板法给出无约束解数,容斥处理上界约束。每个子集 对应”强制这些变量超过上界”的情况,减去(或加回)。当 较小()时 枚举完全可行; 较大时需要更精细的方法(如生成函数)。