容斥原理
容斥原理在组合数学中用来求 \(n\) 个有限集的并集的基数(又称势,即集合元素个数)。设 \(n\) 个有限集分别为 \(S_1, S_2, \dots, S_n\),那么它们的并集的基数可以表示为:\(|S_1 \cup \cdots \cup S_n| = \sum \limits_i |S_i| - \sum \limits_{i \lt j} |S_i \cap S_j| + \sum \limits_{i \lt j \lt k} |S_i \cap S_j \cap S_k| + \cdots + (-1)^{n-1} |S_1 \cap \cdots \cap S_n|\)。
要计算若干个集合并集的基数,可以先将单个集合的基数求和,然后减去任意两个集合交集的基数,再加上任意三个集合交集的基数,再减去任意四个集合交集的基数,依此类推。
选择题:一次期末考试,某班有 15 人数学得满分,有 12 人语文得满分,并且有 4 人语、数都是满分,那么这个班至少有一门得满分的同学有多少人?
- A. 23
- B. 21
- C. 20
- D. 22
答案
A。
设集合 \(M\) 为“数学得满分的学生”的集合,设集合 \(Y\) 为“语文得满分的学生”的集合。
根据题目描述,可以得到:
- 数学得满分的人数,即集合 \(M\) 的大小:\(|M| = 15\)
- 语文得满分的人数,即集合 \(Y\) 的大小:\(|Y| = 12\)
- 语、数都是满分的人数,即集合 \(M\) 和 \(Y\) 的交集的大小:\(|M \cap Y| = 4\)
问题是“至少有一门得满分的同学有多少人”,这等价于求解集合 \(M\) 和 \(Y\) 的并集的大小,即 \(|M \cup Y|\)。
对于两个集合,容斥原理的公式为:\(|M \cup Y| = |M| + |Y| - |M \cap Y|\)。相当于如果简单地将数学满分的人数和语文满分的人数相加,那么那些两门都得满分的同学就被计算了两次(一次在数学里,一次在语文里)。因此,必须减去这部分被重复计算的人数,才能得到至少一门满分的总人数。将已知数值代入公式可以得到结果 23。
例题:P1450 [HAOI2008] 硬币购物
初看题面,这是一个典型的多重背包问题。但是,对于每一组查询都重新跑一次多重背包会超时。
因此需要转换思路,问题的难点在于硬币数量的上限。如果去掉这个上限,问题就变成了完全背包问题:用 4 种面值的硬币(数量无限)凑成金额 \(s\) 有多少种方案?
这个无限制的问题可以很容易地用动态规划解决,而且只需要计算一次,就能得到凑成任意金额的方案数。
定义 \(dp_i\) 表示用 4 种硬币(数量无限)凑成金额 \(i\) 的方案数。跑完完全背包后,\(dp_s\) 就存储了用无限多的硬币凑成金额 \(s\) 的总方案数。
现在有了总方案数 \(dp_s\),但这里面包含了很多不合法的方案(即某些硬币用得超过了 \(d_i\) 的限制),需要从总方案数中减去这些不合法的方案。
直接减去不合法的方案很复杂,因为一个方案可能同时违反了多个限制(比如硬币 1 和硬币 2 都用超了)。这是,容斥原理就派上了用场。
设 \(S\) 为总方案集(无限制),\(A_i\) 为使用第 \(i\) 种硬币超过 \(d_i\) 枚的方案集。要求的就是:\(|S| - |A_1 \cup A_2 \cup A_3 \cup A_4|\)。根据容斥定理,这个式子展开之后就是总方案减去至少违反一个限制的,加上至少违反两个限制的,减去至少违反三个限制的,加上违反全部四个限制的。
那么如何计算 \(|A_i|, |A_1 \cap A_2|\) 诸如此类这样的集合的大小呢?
- 计算 \(|A_i|\):\(A_i\) 是指第 \(i\) 种硬币至少用了 \(d_i + 1\) 枚的方案集。可以想象,先强制性地拿出 \(d_i+1\) 枚第 \(i\) 种硬币,支付的金额为 \(c_i \cdot (d_i + 1)\)。剩下的金额 \(s - c_i \cdot (d_i + 1)\) 可以用任意无限多的硬币来凑。这个方案数正好就是预处理好的 \(dp_{s - c_i \cdot (d_i + 1)}\)。所以 \(|A_i| = dp_{s - c_i \cdot (d_i+1)}\)。
- 计算 \(|A_i \cap A_j|\):同理,这是指第 \(i\) 种硬币至少用 \(d_i + 1\) 枚,且第 \(j\) 种硬币至少用 \(d_j+1\) 枚。先强制拿出这些硬币,剩下的金额的凑法数量就是 \(dp_{s - c_i \cdot (d_i + 1) - c_j \cdot (d_j+1)}\)。
参考代码
#include <cstdio>
typedef long long LL;
const int S = 100005;
int c[5], d[5], s;
LL dp[S], tmp, ans;
LL calc(int i) {
return 1ll * (d[i] + 1) * c[i];
}
void update(int flag) {
if (tmp <= s) ans += dp[s - tmp] * flag;
}
int main()
{
int n;
for (int i = 1; i <= 4; i++) scanf("%d", &c[i]);
scanf("%d", &n);
dp[0] = 1;
for (int i = 1; i <= 4; i++) {
for (int j = c[i]; j < S; j++) {
dp[j] += dp[j - c[i]];
}
}
while (n--) {
for (int i = 1; i <= 4; i++) scanf("%d", &d[i]);
scanf("%d", &s); ans = dp[s];
tmp = calc(1); update(-1);
tmp = calc(2); update(-1);
tmp = calc(3); update(-1);
tmp = calc(4); update(-1);
tmp = calc(1) + calc(2); update(1);
tmp = calc(1) + calc(3); update(1);
tmp = calc(1) + calc(4); update(1);
tmp = calc(2) + calc(3); update(1);
tmp = calc(2) + calc(4); update(1);
tmp = calc(3) + calc(4); update(1);
tmp = calc(1) + calc(2) + calc(3); update(-1);
tmp = calc(1) + calc(2) + calc(4); update(-1);
tmp = calc(1) + calc(3) + calc(4); update(-1);
tmp = calc(2) + calc(3) + calc(4); update(-1);
tmp = calc(1) + calc(2) + calc(3) + calc(4); update(1);
printf("%lld\n", ans);
}
return 0;
}
习题:P13349 「ZYZ 2025」自然数序列
解题思路
题目的核心是求解一个带约束的计数问题。对于每次查询,要求解一个不定方程组的非负整数解的数量:
- \(a_1 b_1 + a_2 b_2 + \cdots + a_n b_n = S\),其中 \(S\) 在 \([l,r]\) 区间内。
- 有 \(k\) 个附加条件,形如 \(b_x = y\)。
注意到查询虽然多,但基础的“物品”序列即 \(a_i\) 是不变的,而附加限制的数量 \(k\) 非常小(\(k \le 8\)),这给出了一定的启示:先预处理,然后针对少量限制使用一些数学技巧求解。
第一步:预处理 —— 无限制的完全背包
首先,忽略所有的限制条件,只考虑问题:用 \(n\) 种物品,第 \(i\) 种物品的“体积”为 \(a_i\),每种物品可以无限次使用,凑成总体积恰好为 \(j\) 的方案数有多少?
这是一个经典的完全背包问题。先预处理出 \(dp_j\) 表示用全部 \(n\) 种物品凑成体积 \(j\) 的方案数。
第二步:预处理 —— 前缀和优化区间查询
题目要求总和在 \([l, r]\) 区间内,而不是一个固定的值。为了能快速查询一个区间的方案数,对 \(dp\) 数组再做一次预处理,计算出它的前缀和。
第三步:处理查询 —— 利用容斥原理
- 处理固定限制:对于每个限制 \(b_x = y\),这部分是确定的。它们贡献的总和是 \(\sum a_x y\)。将所有 \(k\) 个限制贡献的总和加起来,然后从目标区间 \([l, r]\) 中减掉,得到新的目标区间 \([l', r'] = [l - \sum a_x y, r - \sum a_x y]\)。
- 转化问题:现在问题变成了对于那 \(k\) 个被限制的物品,不能自由选择它们了(因为它们的数量已经被固定,贡献已经在目标区间中扣除)。需要用剩下 \(n-k\) 个无限制的物品,凑出在 \([l', r']\) 区间内的体积。但是,预处理的 \(dp\) 数组是基于全部 \(n\) 个物品的。如何从中得到只用 \(n-k\) 个物品的结果呢?
- 容斥原理:直接“撤销” \(k\) 个物品对 \(dp\) 数组的贡献很困难。但因为 \(k\) 很小,可以使用容斥原理。目标是计算只是用无限制物品的方案数,这等价于 \(使用全部物品的方案数 - 至少使用1个被限制物品的方案数 + 至少使用2个被限制物品的方案数 - \cdots\)。
- 总方案数:用全部 \(n\) 个物品凑成 \([l',r']\) 的方案数,相当于直接查询区间和。
- 减去一项:对于某个被限制的物品 \(x\),要减去“至少用了一次 \(a_x\)”的方案数。这可以通过先强制用一个 \(a_x\),然后用全部 \(n\) 个物品去凑剩下的体积 \([l' - a_x, r' - a_x]\) 来计算。
- 加上两项:对于被限制的物品 \(x_1\) 和 \(x_2\),减去了两次它们俩都被用上的情况,所以要加回来一次。相当于凑 \([l' - a_{x_1} - a_{x_2}, r' - a_{x_1} - a_{x_2}]\)。
- ……
- 这个过程可以通过遍历 \(k\) 个限制物品的所有 \(2^k\) 个子集来完成
参考代码
#include <cstdio>
#include <vector>
using namespace std;
using ll = long long;
const int MOD = 998244353;
int main()
{
int n, q; scanf("%d%d", &n, &q);
vector<int> a(n + 1), dp(5001);
dp[0] = 1;
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i]);
for (int j = a[i]; j <= 5000; j++) {
dp[j] = (dp[j] + dp[j - a[i]]) % MOD;
}
}
for (int i = 1; i <= 5000; i++) dp[i] = (dp[i] + dp[i - 1]) % MOD;
auto query = [&](int l, int r) {
int high = r >= 0 ? dp[r] : 0;
int low = l >= 1 ? dp[l - 1] : 0;
return (high + MOD - low) % MOD;
};
while (q--) {
int l, r, k; scanf("%d%d%d", &l, &r, &k);
vector<int> pos;
for (int i = 0; i < k; i++) {
int x, y; scanf("%d%d", &x, &y);
pos.push_back(x);
l -= a[x] * y; r -= a[x] * y;
}
int ans = 0;
for (int i = 0; i < (1 << k); i++) {
int sum = 0, add = 1;
for (int j = 0; j < k; j++) {
if ((i >> j) & 1) {
sum += a[pos[j]];
add ^= 1;
}
}
if (add) ans = (ans + query(l - sum, r - sum)) % MOD;
else ans = (ans + MOD - query(l - sum, r - sum)) % MOD;
}
printf("%d\n", ans);
}
return 0;
}
例题:CF451E Devu and Flowers
有 \(n\) 种颜色的花,第 \(i\) 种花有 \(f_i\) 朵。同颜色的花没有区别,不同颜色的花不同。现在要从这些花中恰好选出 \(s\) 朵,求方案数对 \(10^9+7\) 取模的结果。
数据范围:\(n \le 20, \ s \le 10^{14}, \ f_i \le 10^{12}\)
本题是一个典型的带限制的多重集组合问题。
由于 \(s\) 和 \(f_i\) 非常大,无法使用动态规划。但注意到 \(n\) 非常小,这提示可以使用容斥原理。
如果不考虑上界限制 \(x_i \le f_i\),从 \(n\) 种花中选出 \(s\) 朵的方案数为 \(C_{s+n-1}^{n-1}\)(插板法)。而考虑上界限制的方案数需要减去“至少有一个盒子 \(i\) 选了超过 \(f_i\) 朵花的方案”(即先强制选 \(f_i+1\) 朵,剩下的 \(s-(f_i+1)\) 朵任意选),加上“至少有两个盒子 \(i,j\) 违反限制的方案”,以此类推,这个过程可以使用二进制枚举来表示所有可能违反限制的情况。
由于 \(s\) 很大,不能预处理阶乘,但是 \(n\) 很小,因此计算组合数时可以利用公式 \(C_N^M = \dfrac{N(N-1)\cdots (N-M+1)}{M!}\)。
时间复杂度为 \(O(n \cdot 2^n)\)。
参考代码
#include <cstdio>
using ll = long long;
const int MOD = 1e9 + 7;
ll f[20];
int qpow(int a, int b) {
int res = 1;
while (b > 0) {
if (b & 1) res = 1ll * res * a % MOD;
a = 1ll * a * a % MOD;
b >>= 1;
}
return res;
}
// 计算组合数 C(n, m) % MOD,其中 n 很大,m 很小
int comb(ll n, int m) {
if (m > n) return 0;
int num = 1, den = 1;
for (int i = 0; i < m; i++) {
num = 1ll * num * ((n - i) % MOD) % MOD;
den = 1ll * den * (i + 1) % MOD;
}
return 1ll * num * qpow(den, MOD - 2) % MOD;
}
int main()
{
int n; ll s;
scanf("%d%lld", &n, &s);
for (int i = 0; i < n; i++) scanf("%lld", &f[i]);
int ans = 0;
// 二进制枚举容斥子集
for (int i = 0; i < (1 << n); i++) {
ll tmp = s;
int cnt = 0;
for (int j = 0; j < n; j++) {
if ((i >> j) & 1) {
tmp -= (f[j] + 1);
cnt++;
}
}
if (tmp < 0) continue;
int c = comb(tmp + n - 1, n - 1);
if (cnt & 1) {
ans = (ans - c + MOD) % MOD;
} else {
ans = (ans + c) % MOD;
}
}
printf("%d\n", ans);
return 0;
}

浙公网安备 33010602011771号