组合数学
组合数常见问题
插板法
\(n\) 个相同的球放进 \(m\) 个不同的盒子(盒子允许空):\(C_{n+m-1}^n\),将 \(n\) 个球与 \(m-1\) 个分隔板看成总共 \(n+m-1\) 个物品,其中 \(n\) 个是球。
\(n\) 个相同的球放进 \(m\) 个不同的盒子(盒子不能空):\(C_{n-1}^{m-1}\),在 \(n-1\) 个缝隙中选择 \(m-1\) 个放分隔板。
从 \(n\) 个不同元素中选 \(m\) 个互不相邻的元素:\(C_{n-m+1}^m\),在 \(n-m\) 个元素形成的 \(n-m+1\) 个缝隙(包括头尾)中插进去 \(m\) 个元素。
2025 CSP-S1 T1
有 \(5\) 个红色球和 \(5\) 个蓝色球,它们除了颜色之外完全相同。将这 \(10\) 个球排成一排,要求任意两个蓝色球都不能相邻,有多少种不同的排列方法?
答案
因为 \(5\) 个红色球除了颜色之外完全相同,将它们排成一排只有 \(1\) 种方法,可以将其示意为 红 红 红 红 红。
为了保证任意两个蓝色球都不相邻,蓝色球必须放在红色球之间的空隙,或者队伍的最两端。这 \(5\) 个红色球一共提供了 \(6\) 个可用的空位(下方用 _ 表示),即 _ 红 _ 红 _ 红 _ 红 _ 红 _。
现在有 \(5\) 个完全相同的蓝色球,需要放入这 \(6\) 个可用空位中,且每个空位最多只能放 \(1\) 个蓝色球。这在数学上相当于从 \(6\) 个位置中选出 \(5\) 个位置,计算其组合数 \(C_6^5 = 6\)。
因此,一共有 \(6\) 种不同的排列方法。
格线游走
从 \((0,0)\) 出发,每步往右或往上走一个单位,走到 \((n,m)\) 的路径数:\(C_{n+m}^n\),一共 \(n+m\) 步,其中 \(n\) 步往下。
习题:P14254 分割(divide)
解题思路
核心在于理解并简化等式 \(S_1 = \bigcap \limits_{i=2}^{k+1} S_i\)。
设 \(C = \{ b_1, \dots, b_k \}\) 为选中的节点集合。对于 \(i \in [1,k]\),子树 \(i\) 的深度集合为 \(S_i\) 是一个连续的整数区间,即 \(S_i = [d_{b_i}, h_{b_i}]\),其中 \(d_{b_i}\) 是节点 \(b_i\) 的深度,而 \(h_{b_i}\) 是 \(b_i\) 子树中节点的最大深度。题目要求 \(d_{b_1} \le d_{b_2} \le \cdots \le d_{b_k}\);\(S_1\) 是后面若干集合的交集意味着 \(S_1 \subseteq S_2\),说明 \([d_{b_1}, h_{b_1}] \le [d_{b_2}, h_{b_2}]\),这要求 \(d_{b_2} \le d_{b_1}\)。结合两者,可得 \(d_{b_1} = d_{b_2}\),依此类推,所有被选节点的深度必须相同,设这个共同深度为 \(D\)。
条件 \(S_1 \subseteq S_i\) 对于所有 \(i \in [2,k]\) 都成立,这意味着 \([d_{b_1}, h_{b_1}] \subseteq [d_{b_i}, h_{b_i}]\),因为深度都为 \(D\),这简化为 \(h_{b_1} \le h_{b_i}\)。这说明,\(b_1\) 必须是所选节点集合 \(C\) 中,\(h\) 值(子树最大深度)最小的节点之一。
\(S_{k+1}\) 是包含根节点 1 的剩余部分的深度集合。设 \(U_D\) 是深度为 \(D\) 的所有节点的集合,一个深度 \(d \gt D\) 要想出现在 \(S_{k+1}\) 中,当且仅当在 \(U_D\) 中存在一个未被选择的节点 \(u \notin C\),且 \(u\) 的子树中包含深度为 \(d\) 的节点(即 \(h_u \ge D\))。定义一个深度 \(d\) 被集合 \(C\) 覆盖,当且仅当所有未被选择的节点 \(u \in U_D \setminus C\) 的子树最大深度都小于 \(d\)(即 \(\forall u \in U_D \setminus C, h_u \lt d\)),反之,则称 \(d\) 未被覆盖。于是,条件 \(d \in S_{k+1}\) 等价于 \(d\) 未被覆盖。
令 \(h_{\min} = \min \limits_{c \in C} h_c\),而 \(C_{\min} = \{ c \in C \mid h_c = h_{\min} \}\)。
- 情况 A:\(|C_{\min}| \gt 1\)(被选节点中,拥有最小 \(h\) 值的节点不唯一),此时 \(b_1\) 可以是 \(C_{\min}\) 中任意一个,\(S_1 = [D, h_{\min}]\),要使等式成立,要求对于所有 \(d \in [D, h_{\min}]\),都有 \(d \in S_{k+1}\),即所有这些深度都未被覆盖。
- 情况 B:\(|C_{\min}| = 1\)(拥有最小 \(h\) 值的节点唯一),此时 \(b_1\) 必须是这个唯一的节点,设 \(h_2 = \min \limits_{c \in C \setminus C_{\min}} h_c\)(集合中第二小的 \(h\) 值)。等式要求成立:对所有 \(d \in [D, h_{\min}]\),深度 \(d\) 未被覆盖;且对所有 \(d \in (h_{\min}, h_2]\),深度 \(d\) 都被覆盖。
基于以上分析,可以设计一个组合计数的算法。
- 预处理:遍历整棵树,计算出每个节点的深度和子树最大深度,同时预处理计算排列组合需要的阶乘及其逆元。
- 按深度枚举:遍历所有可能的深度(从 2 到树的最大深度),对于每个深度 \(D\),将只在这一层的节点中选取 \(k\) 个。
- 分组与计数
- 对于固定的深度 \(D\),获取该层所有节点 \(U_D\),将这些节点按它们的 \(h\) 值进行分组。
- 为了方便计算,可以将这些组按 \(h\) 值排序,设共有 \(p\) 个组 \(G_1, \dots, G_p\),对应的 \(h\) 值为 \(H_1 \lt H_2 \cdots \lt H_p\),各组大小为 \(m_1, \dots, m_p\)。
- 遍历每个组 \(G_j\),假设选出的集合 \(C\) 的 \(h_{\min}\) 值就等于 \(H_j\)。
- 计算情况 A 的贡献
- 条件:\(h_{\min} = H_j\) 且 \(|C_{\min}| \ge 2\)。
- “未被覆盖”条件要求不能选走所有 \(h \gt H_j\) 的节点,设 \(h \gt H_j\) 的节点总数为 \(N_j\),则要求 \(N_j \gt k\)。
- 贡献的序列总数为 \(m_j \times (A_{N_j-1}^{k-1} - A_{M_{\gt j}}^{k-1})\),其中 \(M_{\gt j}\) 是 \(h \gt H_j\) 的节点总数,\(A\) 是排列数。
- 计算情况 B 的贡献
- 条件:\(h_{\min} = H_j\) 且 \(|C_{\min}| = 1\)。
- “被覆盖”条件要求必须选走所有 \(h \gt H_j\) 的节点,即 \(k-1 = M_{\gt j}\)。
- “未被覆盖”条件要求 \(m_j \gt 1\)。
- 若满足 \(k - 1 = M_{\gt j}\) 且 \(m_j \gt 1\),则贡献的序列总数为 \(m_j \times (k-1)!\)。
- 将所有深度、所有分组计算出的贡献累加,即为最终答案。
参考代码
#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;
// 常量定义
const int N = 1e6 + 5;
const int MOD = 998244353;
// 全局变量
int d[N], h[N], maxd, f[N], g[N]; // d:深度, h:子树最大深度, maxd:全局最大深度, f:阶乘, g:阶乘逆元
vector<int> tr[N], vts[N]; // tr:树的邻接表, vts:按深度存储节点
// DFS遍历,计算每个节点的深度d和子树最大深度h
void dfs(int u, int depth) {
d[u] = depth;
maxd = max(maxd, depth);
vts[depth].push_back(u);
h[u] = depth; // 初始化h值为自身深度
for (int v : tr[u]) {
dfs(v, depth + 1);
h[u] = max(h[u], h[v]); // 用子节点的h值更新父节点的h值
}
}
// 快速幂,用于计算模逆元
int qpow(int x, int y) {
int res = 1;
while (y > 0) {
if (y & 1) res = 1ll * res * x % MOD;
x = 1ll * x * x % MOD;
y >>= 1;
}
return res;
}
// 预处理阶乘和阶乘逆元
void init(int n) {
f[0] = g[0] = 1;
for (int i = 1; i <= n; i++) f[i] = 1ll * f[i - 1] * i % MOD;
g[n] = qpow(f[n], MOD - 2);
for (int i = n - 1; i >= 1; i--) g[i] = 1ll * g[i + 1] * (i + 1) % MOD;
}
// 计算排列数 A(n, m) = n! / (n-m)!
int A(int n, int m) {
if (n < 0 || m < 0 || n - m < 0) return 0;
return 1ll * f[n] * g[n - m] % MOD;
}
int main()
{
int n, k;
scanf("%d%d", &n, &k);
init(n);
d[1] = 1;
for (int i = 2; i <= n; i++) {
int p; scanf("%d", &p);
tr[p].push_back(i);
}
dfs(1, 1);
int ans = 0;
// 主循环:遍历所有可能的深度D
for (int i = 2; i <= maxd; i++) {
if (vts[i].size() < k) continue; // 如果该层节点数不足k,则跳过
// 收集当前深度所有节点的h值并排序,用于分组
vector<int> hi;
for (int vtx : vts[i]) {
hi.push_back(h[vtx]);
}
sort(hi.begin(), hi.end());
int s = hi.size();
int r = s - 1, l = s - 1;
// 从大到小遍历所有h值分组
while (r >= 0) {
while (l >= 0 && hi[l] == hi[r]) l--; // 找到当前h值分组的左边界
// cnt: 当前h值分组的节点数 (m_j)
int cnt = r - l;
// suf: h值大于等于当前h值的节点总数 (N_j)
int suf = s - 1 - l;
// s-1-r: h值严格大于当前h值的节点总数 (M_{>j})
int suf_greater = s - 1 - r;
// 情况A: C_min大小大于1。要求h_min=H_j, |C_min|>=2
// 条件: suf > k (确保未覆盖), cnt >= 2 (确保能选出至少2个)
if (cnt >= 2 && suf > k) {
// 计算 A(N_j-1, k-1) - A(M_{>j}, k-1)
int tmp = (A(suf - 1, k - 1) + MOD - A(suf_greater, k - 1)) % MOD;
// 乘以 m_j, 得到总贡献
ans = (ans + 1ll * cnt * tmp % MOD) % MOD;
}
// 情况B: C_min大小等于1。要求h_min=H_j, |C_min|=1
// 条件: M_{>j} == k-1 (确保覆盖), m_j > 1 (确保未覆盖)
if (suf_greater == k - 1 && cnt > 1) {
// 贡献为 m_j * (k-1)!
ans = (ans + 1ll * cnt * f[k - 1] % MOD) % MOD;
}
r = l; // 移动到下一个分组
}
}
printf("%d\n", ans);
return 0;
}
多重集排列
由 \(n_1\) 个 \(a_1\),\(n_2\) 个 \(a_2\),……,\(n_k\) 个 \(a_k\) 组成的多重集,全排列数量为:
- $ \dfrac{(n_1 + \cdots + n_k)!}{n_1! \times n_2! \times \cdots \times n_k!}$,任意排列,再把每种元素内部的顺序除掉
- \(C_{n_1+ \cdots + n_k}^{n_1} \times C_{n_2 + \cdots + n_k}^{n_2} \times \cdots \times C_{n_k}^{n_k}\),从所有位置中先选出 \(n_1\) 个位置放 \(a_1\),再在剩下的位置中选出 \(n_2\) 个位置放 \(a_2\),……
直接计算时一般使用第一种,两种方法都可以与 DP 思路结合。
习题:P7961 [NOIP2021] 数列
解题思路
测试点 \(1 \sim 4\)
直接枚举每个 \(a_i\) 的取值,时间复杂度 \(O(m^n)\)。
参考代码
#include <cstdio>
const int MOD = 998244353;
const int S = 1000005;
const int M = 105;
int n, m, k, ans;
int cnt[S], v[M];
void dfs(int u, int s, int val) {
if (u == n + 1) {
if (cnt[s] <= k) ans = (ans + val) % MOD;
return;
}
for (int i = 0; i <= m; i++) {
// a[u]填i
dfs(u + 1, s + (1 << i), 1ll * val * v[i] % MOD);
}
}
int lowbit(int x) {
return x & -x;
}
int main()
{
for (int i = 1; i < S; i++) cnt[i] = cnt[i - lowbit(i)] + 1;
scanf("%d%d%d", &n, &m, &k);
for (int i = 0; i <= m; i++) scanf("%d", &v[i]);
dfs(1, 0, 1);
printf("%d\n", ans);
return 0;
}
测试点 \(5 \sim 10\)
设 \(dp_{i,j}\) 表示填前 \(i\) 个数,得到的 \(S = j\) 时,所有方案的权值和。假设 \(a_i\) 要填的是 \(k\),则状态转移为 \(dp_{i,j} = \sum \limits_{k=0}^m dp_{i-1,j-2^k} \times v_k\)。时间复杂度为 \(O(n^2m2^m)\)。
参考代码
#include <cstdio>
const int MOD = 998244353;
const int S = 150005;
const int M = 105;
const int N = 35;
int cnt[S], v[M], dp[N][S];
int lowbit(int x) {
return x & -x;
}
int main()
{
for (int i = 1; i < S; i++) cnt[i] = cnt[i - lowbit(i)] + 1;
int n, m, k; scanf("%d%d%d", &n, &m, &k);
int maxs = (1 << m) * n;
for (int i = 0; i <= m; i++) scanf("%d", &v[i]);
dp[0][0] = 1;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= maxs; j++) {
for (int k = 0; k <= m; k++) {
if (j >= (1 << k)) {
dp[i][j] += 1ll * dp[i - 1][j - (1 << k)] * v[k] % MOD;
dp[i][j] %= MOD;
}
}
}
}
int ans = 0;
for (int j = n; j <= maxs; j++)
if (cnt[j] <= k) ans = (ans + dp[n][j]) % MOD;
printf("%d\n", ans);
return 0;
}
测试点 \(11 \sim 20\)
因为 \(S\) 的结果只和 \(a\) 中 \(0\) 到 \(m\) 的个数有关和具体顺序无关,而且如果按下标 \(1\) 到 \(n\) 的顺序来做的话需要记录 \(S\) 每一位有几个 \(1\),状态数太多,所以转而按值域 \(0\) 到 \(m\) 考虑,每次考虑一种数值填在了 \(a\) 数组的几个位置上。
设 \(dp_{i,j,p,q}\) 表示填 \(i\) 这种数(依次枚举 \(0\) 到 \(m\)),在 \(a\) 中已经有 \(j\) 个位置填了数,\(S\) 的二进制低位中已经有了 \(p\) 个 \(1\),\(S\) 的 \(2^i\) 这一位上已经从低位进位了 \(q\) 过来,这种状态下所有方案的权值之和。
如果考虑前边的状态转移过来(填表法)情况会有点多,从当前状态转出去(刷表法)会更好实现。
考虑填 \(x\) 个 \(i\),则 \((i,j,p,q)\) 这个状态会转移到 \((i+1,j+x,p+(q+x) \bmod 2, \left\lfloor \dfrac{q+x}{2} \right\rfloor)\),并且有 \(C_{n-j}^x\) 种填法,填这一次会让权值乘上 \(v_{i}^x\)。其中进位数 \(q\) 那一维不会超过 \(n/2\)(进位最多的情况是把 \(n\) 位都填成同一个数,往前进 \(n/2\) 位)。
最后看所有的 \(dp_{m+1,n,p,q}\),注意此时这 \(q\) 个进位可能会让 \(S\) 的二进制中 \(1\) 的数量不止 \(p\),\(S\) 的二进制中 \(1\) 的真正数量应该是 \(p+popcnt(q)\),在这个结果 \(\le k\) 的情况下才能把 \(dp_{m+1,n,p,q}\) 算进答案。
状态树为 \(m \times n \times k \times n\),转移的时间复杂度为 \(O(n)\),总时间复杂度为 \(O(mn^3k)\)。
参考代码
#include <cstdio>
const int MOD = 998244353;
const int N = 35;
const int M = 105;
int dp[M][N][N][N], v[M], c[N][N], power[M][N];
int lowbit(int x) {
return x & -x;
}
int popcnt(int x) {
int res = 0;
while (x > 0) {
x -= lowbit(x);
res++;
}
return res;
}
int main()
{
int n, m, k; scanf("%d%d%d", &n, &m, &k);
c[0][0] = 1;
for (int i = 1; i <= n; i++) {
c[i][0] = c[i][i] = 1;
for (int j = 1; j < i; j++) c[i][j] = (c[i - 1][j] + c[i - 1][j - 1]) % MOD;
}
for (int i = 0; i <= m; i++) {
scanf("%d", &v[i]);
power[i][0] = 1;
for (int j = 1; j <= n; j++) power[i][j] = 1ll * power[i][j - 1] * v[i] % MOD;
}
dp[0][0][0][0] = 1;
for (int i = 0; i <= m; i++)
for (int j = 0; j <= n; j++)
for (int p = 0; p <= k; p++)
for (int q = 0; q <= n / 2; q++)
for (int x = 0; x <= n - j; x++) {
int add = 1ll * dp[i][j][p][q] * c[n - j][x] % MOD * power[i][x] % MOD;
(dp[i + 1][j + x][p + (q + x) % 2][(q + x) / 2] += add) %= MOD;
}
int ans = 0;
for (int p = 0; p <= k; p++)
for (int q = 0; q <= n / 2; q++) {
int cnt = p + popcnt(q);
if (cnt <= k) {
ans += dp[m + 1][n][p][q];
ans %= MOD;
}
}
printf("%d\n", ans);
return 0;
}
卡特兰数
\(H_n = C_{2n}^n - C_{2n}^{n-1} = \dfrac{C_{2n}^n}{n+1} = H_{n-1} \times \dfrac{4n-2}{n+1}\)。
合法括号序列匹配问题,总数减去非法序列数,找到非法括号匹配序列中第一个右括号比左括号多一个的地方,将这个前缀取反(左括号变右括号,右括号变左括号),就会变成一个有 \(n+1\) 个左括号,\(n-1\) 个右括号的括号序列。
并且每一个有 \(n+1\) 个左括号,\(n-1\) 个右括号的括号序列都可以通过找到第一个左括号比右括号多一个的地方,把这个前缀取反,对应回原来的一个非法括号匹配序列,说明非法括号匹配序列和有 \(n+1\) 个左括号,\(n-1\) 个右括号的括号序列一一对应。
如果用格线游走解释的话,非法路径可以通过找到第一个突破 \(y=x\) 这条线的地方,一定是到某个 \((x,x+1)\),把前边这一段根据 \(y=x+1\) 这条线做对称变换,起点就会变成 \((x-(x+1),x+1-x)=(-1,1)\),终点还是 \((n,n)\),路径数就是 \(C_{2n}^{n-1}\)。
选择题:关于 Catalan 数 \(C_n = \dfrac{(2n)!}{(n+1)!n!}\),下列说法中错误的是?
- A. \(C_n\) 表示有 n+1 个节点的不同形态的二叉树的个数
- B. \(C_n\) 表示含 n 对括号的合法括号序列的个数
- C. \(C_n\) 表示长度为 n 的入栈序列对应的合法出栈序列个数
- D. \(C_n\) 表示通过连接顶点而将 n+2 边的凸多边形分成三角形的方法个数
答案
A。
应为 \(n\) 个节点的不同形态的二叉树个数。
习题:P1044 [NOIP 2003 普及组] 栈
解题思路
合法出栈序列数问题等价于卡特兰数。
参考代码
#include <cstdio>
int main()
{
int n; scanf("%d", &n);
int ans = 1;
for (int i = 1; i <= n; i++) ans = 1ll * ans * (4 * i - 2) / (i + 1);
printf("%d\n", ans);
return 0;
}
习题:P1754 球迷购票问题
解题思路
本质上是在任何位置前 \(50\) 元的人数不能少于 \(100\) 元的人数,卡特兰数问题。
参考代码
#include <cstdio>
using ll = long long;
int main()
{
int n; scanf("%d", &n);
ll ans = 1;
for (int i = 1; i <= n; i++) ans = ans * (4 * i - 2) / (i + 1);
printf("%lld\n", ans);
return 0;
}
习题:P1375 小猫
解题思路
枚举第一个点连第几个点,把问题拆成两部分,就可以得到 \(H_n = \sum \limits_{i=1}^n H_{i-1} \times H_{n-i}\) 这个递推式。
因此本题是卡特兰数问题,可以用卡特兰数的各种公式解决。
参考代码
#include <cstdio>
const int MOD = 1000000007;
const int N = 200005;
int f[N], g[N];
int qpow(int x, int y) {
int res = 1;
while (y > 0) {
if (y & 1) res = 1ll * res * x % MOD;
x = 1ll * x * x % MOD;
y >>= 1;
}
return res;
}
void init(int n) {
f[0] = 1; g[0] = 1;
for (int i = 1; i <= n; i++) f[i] = 1ll * f[i - 1] * i % MOD;
g[n] = qpow(f[n], MOD - 2);
for (int i = n - 1; i > 0; i--) g[i] = 1ll * g[i + 1] * (i + 1) % MOD;
}
int C(int n, int m) {
return 1ll * f[n] * g[m] % MOD * g[n - m] % MOD;
}
int main()
{
int n; scanf("%d", &n);
init(n * 2);
int ans = C(2 * n, n);
ans = (ans + MOD - C(2 * n, n - 1)) % MOD;
printf("%d\n", ans);
return 0;
}

浙公网安备 33010602011771号