【1 月小记】Part 6: 组合数学

组合数学

update 2026.1.17: 更改了部分题目顺序,优化了 \(\LaTeX\)

计数原理与排列组合

P3197 [HNOI2008] 越狱

可以算出总的情况,然后减去信仰宗教不同的情况。

总的情况就是每一个人可能信仰 \(m\) 种宗教,显然总的情况数就是 \(n^m\)

至于不同的情况,我们可以这么考虑:如果第一个人有 \(m\) 种选择方式,第二个人为了不跟他重复,只有 \(m-1\) 种选择方式,第三个人为了不跟第二个人重复,也只有 \(m-1\) 种选择方式……总之,如果所有相邻的人信仰的宗教都不相同的情况有 \(m(m-1)^{n-1}\) 种。所以,答案就是

\[m^n-m(m-1)^{n-1} \]

P5520 [yLOI2019] 青原樱

组合数问题,需要使用多种技巧求解。

因为上一步的树苗们已经占用了 \(m\) 个空位,所以现在还剩下 \(n-m\) 个空位。

因为任意两个树苗之间必须有空位,所以我们可以把剩下的 \(n-m\) 个空位看作物品,把树苗看作隔板,进行隔板法计算。

这样做的原因是,因为多个隔板不能放在同一个位置,所以将树苗看作隔板这一操作,确保了相邻两树苗之间必定至少有一个空位。

于是 \(n-m\) 个空位这些物品中,供隔板放置的空位就有 \(n-m+1\) 个,要把上面的 \(m\) 个树苗放在这些空位里。

因为树苗是有编号的,所以这里使用排列数计算。答案为

\[\dbinom{m}{n-m+1}m! \]

P1595 信封问题

设在 \(n\) 个数构成的排列 \(\{a_i\}_{i = 1}^n\) 中,使得 \(\forall i, a_i \ne i\) 恒成立的排列方案数为 \(D_n\)(这种排列称作错排)。

下面考虑 \(D_n\) 的递推式。

举例:假设 \(n=4\)。首先考虑 \(i=1\) 的情况,此时它不能且仅不能被放到 \(1\) 号位置,有 \((n - 1)\) 种选择。

对于 \(i=2\),以下分两种情况讨论:

  • 当它没被放到 \(1\) 号位置时,\(2\) 号不能被放到 \(1\) 号位置,那么就可以把现在的 \(1\) 号位置看作一个 \(2\) 号位置使得 \(2\) 号物品不能被放进去,也就是 \(2\) ~ \(4\) 号物品做错排,方案数为 \(D_{n - 1}\)
  • 当它被放到了 \(1\) 号位置时,相当于 \(3\)\(4\) 号做错排,方案数为 \(D_{n - 2}\)

根据计数原理,有

\[D_n = \begin{cases} 0, & n = 1 \\ 1, & n = 2 \\ (n - 1) (D_{n - 1} + D_{n - 2}), & \text{otherwise} \end{cases} \]

P4071 [SDOI2016] 排列计数

考虑从 \(n\) 个位置中挑选 \(m\) 个位置使得它们固定,不遵循错排规则;其余位置遵循错排规则。所以答案为

\[\dbinom nm D_{n-m} \]

注意:1. 逆元;2. 特判 \(m=0\) 的情况,不然你会获得 0 pts!

P1287 盒子与球

如果忽略没有空的条件,答案显然是 \(r^n\)。但如果我们考虑上这个条件呢?

我们要钦定恰好有 \(k\) 个盒子为空,求方案数。钦定 \(k\) 很难做,但我们不妨钦定转至少。

那么现在考虑一种放法,使得该方法内,至少\(k\) 个空盒子。

\(r\) 个盒子内,选出 \(k\) 个盒子,使它们一定为空,显然有 \(\dbinom{r}{k}\) 种选法。所以,现在还剩下 \(r-k\)可不空也可空,即忽略限制的盒子。

对于那 \(n\) 个球,放入 \(r-k\) 个盒子,每个球都有 \(r-k\) 种放法,那么这 \(n\) 个球放入这 \(r-k\) 个盒子中的总方案就是 \((r-k)^n\)

刚才叙述的两个方面是解决同一问题的两个步骤,故使用乘法,因此得到:某种放法内,至少有 \(k\) 个空盒子的总方案数为 \(\dbinom{r}{k}(r-k)^n\)

因为这里考虑的是至少,所以至少转钦定,要用容斥原理容斥一下。

P2638 安全系统

分别考虑 \(0\)\(1\) 的放置方案,然后相乘即可。

考虑隔板法,这里允许袋子为空,所以添加 \(n\) 个虚拟物品,然后跑隔板法,相当于在 \(n+a+1\) 个物品中,即 \(n+a\) 个间隔中,插入 \(n\) 个隔板,即 \(\dbinom{a+n}{n}\)

Lucas 定理

P3807 【模板】卢卡斯定理 / Lucas 定理

Lucas 定理表明:对于素数 \(p\),有

\[\dbinom nk \equiv \dbinom {\lfloor \frac np \rfloor}{\lfloor \frac kp \rfloor} \dbinom {n \mod p} {k \mod p} \pmod p \]

证明非常复杂,此处略。

卡特兰数

P1044 [NOIP 2003 普及组] 栈

考虑一种递推写法。设 \(f_{i,j}\) 表示目前有 \(i\) 个元素未入栈、\(j\) 个元素已入栈的出栈方案数。

如果目前栈外存在元素(即 \(j>0\)),则可以让这个元素从输入序列挪入栈中,决策来源于 \(f_{i-1,j+1}\)

如果目前栈内存在元素(即 \(i>0\)),则可以让这个元素从栈中移出到输出序列中,不会改变未入栈的数字个数,决策来源于 \(f_{i,j-1}\)

将上两式求和,可得

\[f_{i,j}=f_{i-1,j+1}+f_{i,j-1} \]

答案为 \(f_{n,0}\)


让我们再把这道题抽象成更抽象的数学模型。

我们知道,对于一个合法的栈,任意时刻,入栈的次数不得少于出栈的次数。

定义 \(C_n\) 表示序列长度为 \(n\) 的输出序列方案数。

  • 不妨记第 \(1\) 个入栈的元素是第 \(k\) 个出栈的元素,则前面的元素 \([1,k-1]\) 必须在 \(k\) 之前完成入栈并出栈。因此,方案数依赖于 \(C_{k-1}\)

  • \(k\) 后面的数则有 \(C_{n-k}\) 种方案。

\(\forall k \in [1,n]\)\(C_n\) 的转移都从上面两种情况而来。因此,\(C_n\) 的递推式应为

\[C_n = \begin{cases} 1, & n=0 \\ \sum \limits _{k=1}^n (C_{k-1}C_{n-k}), & \text{otherwise} \end{cases} \]

事实上,比利时数学家 Eugène Charles Catalan 在 1958 年研究括号序列计数问题时发现了这一数列,它也因此得名 Catalan 数(卡特兰数)。

事实上,卡特兰数还有如下的计算方式

\[\begin{aligned} C_n &= \frac1{n+1}\dbinom{2n}n \\ &= \frac{(2n)!}{n!(n+1)!} \\ &= \dbinom{2n}n-\dbinom{2n}{n+1} \end{aligned} \]

以及其顺次递推式

\[C_n = \begin{cases} 1, & n=0 \\ \dfrac{4n-2}{n+1}C_{n-1}, & \text{otherwise} \end{cases} \]

它能解决什么问题呢?

  1. 路径计数问题

    有一个大小为 \(n\times n\) 的方格图,左下角为 \((0,0)\),右上角为 \((n,n)\)。从左下角开始,每次都只能向右或者向上走一单位,不能走到对角线 \(y=x\) 的上方(但可以触碰),则到达右上角的路径总条数为 \(C_n\)

    证明1 存在一个性质:一条合法路径迟早会碰到直线 \(y=x\),且碰到至少一次。

    设方案数为 \(C_n\),设路径第一次碰到 \(y=x\) 的点为 \((k,k)\)

    称一条路径是好的,当且仅当它从 \((0,0)\)\((k,k)\) 除起点和终点外,中间的点从未经过或触碰 \(y=x\)

    可以发现,对于所有好的路径,它的第一步一定向右,最后一步一定向上;即这些好的路径就是从 \((1,0)\)\((k,k-1)\) 的不越过直线 \(y=x-1\) 的路径,方案数为 \(C_{k-1}\)。同理,对于 \((k,k)\)\((n,n)\) 的方案数就是 \(C_{n-k}\)

    枚举所有的 \(k\),可得 \(C_n = \sum \limits _{k=1} ^n (C_{k-1} C_{n-k})\),恰好满足卡特兰数的递推式。

    证明2 直接求合法的路径数目不太容易,考虑正难则反,用总路径数目减去不合法的路径数目求解。

    • 无视限制的总路径数目

      一共要走 \(2n\) 步,其中 \(n\) 步是向右走的,所以方案数为 \(\dbinom{2n}n\)

    • 不合法的路径数目

      注意到一条路径不合法,当且仅当它越过了直线 \(y=x\),即碰到了直线 \(y=x+1\)。对于任意一条不合法的路径,将其第一次碰到直线 \(y=x+1\) 的位置沿着这条直线对称,会发现这条路径的终点变为了 \((n-1,n+1)\)

      因此,任意一条从 \((0,0)\)\((n-1,n+1)\) 的路径都要穿过直线 \(y=x+1\),进而每条这样的路径都映射着一条从 \((0,0)\)\((n,n)\) 的非法路径。

      这样的路径方案数为 \(\dbinom{2n}{n+1}\)

    综上,用总路径数目减去不合法的路径数目,我们成功证明了 \(C_n = \dbinom{2n}n - \dbinom{2n}{n+1}\)

  2. 括号序列计数问题

    \(n\) 对括号构成的合法括号序列数为 \(C_n\)

    一个括号序列是合法括号序列,当且仅当在任意位置,左括号的数量不少于右括号的数量。

    我们发现了形如”在任意位置,向右走的步数不少于向上走的步数“或者”在任意位置,左括号的数量不少于右括号的数量“这种限制,就说明这个问题应当使用卡特兰数求解。

  3. 二叉树计数问题

    含有 \(n\) 个节点的不同的二叉树有 \(C_n\) 种。

    请读者自行证明。

  4. \(\cdots \cdots\)


为什么最开始的递推式算出来的就是卡特兰数呢?涉及到生成函数的知识,这里不过多展开(其实是因为我不会

容斥原理

P2398 GCD SUM

考虑把贡献挂到二元组上。

定义 \(f_k\) 表示满足 \(\gcd(i,j)=k\) 的二元组 \((i,j)\) 的个数。

\(F\) 表示满足 \(k|\gcd(i,j)\) 的二元组个数。可以看到,这个条件明显比上一个条件更弱,这是因为我们钦定转至少了。对于含有 \(n\) 个元素的序列,满足 \(k|a_i\)\(a_i\) 一共有 \(\Big \lfloor \dfrac nk \Big \rfloor\) 个;满足这个条件的二元组就有 \(F = \Big \lfloor \dfrac nk \Big \rfloor ^2\) 对。

根据容斥原理,有

\[f_k = \Big \lfloor \dfrac nk \Big \rfloor ^2 - \sum_{j = 2}^{jk\leq n}f_{jk} \]

每一组 \(\gcd(i,j)=k\) 的二元组 \((i,j)\) 都会产生 \(k\) 的贡献,因此答案为

\[\sum_{k=1}^n kf_k \]

P1450 [HAOI2008] 硬币购物

数据范围太大,跑多重背包必死。于是考虑可以先跑出来一个完全背包,计算不加硬币数量限制时的方案数。

定义 \(f_j\) 表示要买的物品价格为 \(j\) 时的付款方案总数。对于每一种 \(c_i\),有

\[f_j = f_j + f_{j - c_i} \]

但是这里我们有硬币数量的限制。我们知道,当第 \(i\) 种硬币用数超限时,此时的硬币用数 \(>d_i\),即 \(\geq d_i + 1\)。因此,状态一定从 \(s-c_i(d_i+1)\) 及更大的用数转移而来。

由此可得,至少存在一种硬币超限的状态都从 \(f_{s-c_i(d_i+1)}\) 转移而来,所以这些东西我们要从答案 \(f_s\) 中减去;然后发现至少存在两种硬币超限的状态(即形如 \(f_{s-(c_1(d_1+1)+c_2(d_2+1))}\) 的东西)算重了,把它们加回来………这不就是容斥原理么!

为什么这里会用到容斥原理呢?因为我们算的是至少,一个”至少“的状态里包含很多内容,从至少转到钦定就需要用容斥原理把正确答案容斥出来。

想法有了,代码怎么写呢?因为我们只有四种硬币,考虑枚举四位二进制数,通过二进制数的 \(0/1\) 状态代表子集情况,根据二进制数的 \(1\) 的个数判断这一项是应该被加还是减到贡献里。

#include <bits/stdc++.h>
#define int long long
using namespace std;
constexpr int N = 1e5;
int c[5], d[5], f[N + 5], n, s;
int sign(int x) {
	int res = 0;
	while (x) {
		res++;
		x -= x & -x;
	}
	return ((res & 1) ? -1 : 1);
}
signed main() {
	cin.tie(0) -> sync_with_stdio(0);
	for (int i = 1; i <= 4; i++) {
		cin >> c[i];
	}
	cin >> n;
	f[0] = 1;
	for (int i = 1; i <= 4; i++) {
		for (int j = c[i]; j <= N; j++) {
			f[j] += f[j - c[i]];
		}
	}
	while (n--) {
		int ans = 0;
		for (int i = 1; i <= 4; i++) {
			cin >> d[i];
		}
		cin >> s;
		for (int i = 0; i < 16; i++) {
			int sum = 0;
			for (int j = 1; j <= 4; j++) {
				if (i & (1 << (j - 1))) {
					sum += c[j] * (d[j] + 1);
				}
			}
			if (s - sum >= 0) {
				ans += sign(i) * f[s - sum];
			}
		}
		cout << ans << '\n';
	}
	return 0;
}
posted @ 2026-01-17 16:43  L-Coding  阅读(5)  评论(0)    收藏  举报