容斥原理

容斥原理在组合数学中用来求 \(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」自然数序列

解题思路

题目的核心是求解一个带约束的计数问题。对于每次查询,要求解一个不定方程组的非负整数解的数量:

  1. \(a_1 b_1 + a_2 b_2 + \cdots + a_n b_n = S\),其中 \(S\)\([l,r]\) 区间内。
  2. \(k\) 个附加条件,形如 \(b_x = y\)

注意到查询虽然多,但基础的“物品”序列即 \(a_i\) 是不变的,而附加限制的数量 \(k\) 非常小(\(k \le 8\)),这给出了一定的启示:先预处理,然后针对少量限制使用一些数学技巧求解。


第一步:预处理 —— 无限制的完全背包

首先,忽略所有的限制条件,只考虑问题:用 \(n\) 种物品,第 \(i\) 种物品的“体积”为 \(a_i\),每种物品可以无限次使用,凑成总体积恰好为 \(j\) 的方案数有多少?

这是一个经典的完全背包问题。先预处理出 \(dp_j\) 表示用全部 \(n\) 种物品凑成体积 \(j\) 的方案数。


第二步:预处理 —— 前缀和优化区间查询

题目要求总和在 \([l, r]\) 区间内,而不是一个固定的值。为了能快速查询一个区间的方案数,对 \(dp\) 数组再做一次预处理,计算出它的前缀和


第三步:处理查询 —— 利用容斥原理

  1. 处理固定限制:对于每个限制 \(b_x = y\),这部分是确定的。它们贡献的总和是 \(\sum a_x y\)。将所有 \(k\) 个限制贡献的总和加起来,然后从目标区间 \([l, r]\) 中减掉,得到新的目标区间 \([l', r'] = [l - \sum a_x y, r - \sum a_x y]\)
  2. 转化问题:现在问题变成了对于那 \(k\) 个被限制的物品,不能自由选择它们了(因为它们的数量已经被固定,贡献已经在目标区间中扣除)。需要用剩下 \(n-k\) 个无限制的物品,凑出在 \([l', r']\) 区间内的体积。但是,预处理的 \(dp\) 数组是基于全部 \(n\) 个物品的。如何从中得到只用 \(n-k\) 个物品的结果呢?
  3. 容斥原理:直接“撤销” \(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;
}
posted @ 2026-03-28 09:00  RonChen  阅读(19)  评论(0)    收藏  举报