NeFut Logo NeFut
EN 管理员登录

深入理解容斥原理及其在算法中的应用

发布于:2026-05-27 09:17 最后更新:2026-06-10 09:20
#algorithm #Math #Combinatorics

核心逻辑与数学原理

容斥原理(Inclusion-Exclusion Principle)是组合数学中处理非互斥集合并集大小的核心工具。其核心思想在于:在计算多个重叠集合的并集大小时,先将所有集合的大小相加,随后通过交替加减重叠部分的交集大小,来精确修正被重复计算的元素贡献。

数学公式推导

设 $A_1, A_2, \dots, A_n$ 为有限集合,它们的并集大小公式为:

$$\left| \bigcup_{i=1}^n A_i \right| = \sum_{i=1}^n |A_i| - \sum_{1 \le i < j \le n} |A_i \cap A_j| + \sum_{1 \le i < j < k \le n} |A_i \cap A_j \cap A_k| - \dots + (-1)^{n-1} |A_1 \cap A_2 \cap \dots \cap A_n|$$

用符号表达可紧凑表述为:

$$\left| \bigcup_{i=1}^n A_i \right| = \sum_{S \subseteq \{1, 2, \dots, n\}, S \neq \emptyset} (-1)^{|S|-1} \left| \bigcap_{i \in S} A_i \right|$$

证明逻辑(贡献度分析):假设某个元素 $x$ 恰好属于 $k$ 个集合($1 \le k \le n$),该元素在左侧并集中的真实贡献度计数应当精确为 $1$。检查右侧公式对该元素的计算次数:

右侧对 $x$ 的总计贡献次数为:

$$\sum_{m=1}^k (-1)^{m-1} C_k^m$$

根据二项式定理,我们知道:

$$(1 - 1)^k = \sum_{m=0}^k (-1)^m C_k^m = C_k^0 + \sum_{m=1}^k (-1)^m C_k^m = 1 - \sum_{m=1}^k (-1)^{m-1} C_k^m$$

由于 $(1 - 1)^k = 0$(当 $k \ge 1$),移项变形得:

$$\sum_{m=1}^k (-1)^{m-1} C_k^m = 1$$

任何被包含在至少一个集合中的元素,其在右侧代数和中的净贡献权值恒为 $1$。数理逻辑严丝合缝。


状态设计与算法推导

在集合数量 $n$ 较小(通常 $n \le 20$)的情况下,我们可以利用状态压缩(State Compression)思想,将集合的选取状态压缩进一个二进制整数 mask 中。

二进制状态设计

总并集大小即为所有 mask 状态贡献的代数和。


C++ 标准源码

#include <iostream>
#include <vector>

// 计算最大公约数,为求最小公倍数服务
long long gcd(long long a, long long b) {
    return b == 0 ? a : gcd(b, a % b);
}

// 计算最小公倍数,严防中间乘法爆 long long
long long lcm(long long a, long long b) {
    if (a == 0 || b == 0) return 0;
    long long g = gcd(a, b);
    // 先除后乘,最大化利用 long long 的空间承载力
    return (a / g) * b; 
}

// 容斥原理计数:求 1~N 中能被 primes 数组中至少一个数整除的个数
long long count_multiples(long long N, const std::vector<long long> &primes) {
    int n = primes.size();
    long long total_union = 0;
    int max_states = 1 << n; // 2^n 个可能的状态组合

    // 从 1 开始循环,直接跳过 0000... 的空集状态
    for (int mask = 1; mask < max_states; ++mask) {
        int bits_count = 0;
        long long current_lcm = 1;
        bool overflow = false;

        for (int i = 0; i < n; ++i) {
            if ((mask >> i) & 1) { // 捕捉状态位
                bits_count++;
                current_lcm = lcm(current_lcm, primes[i]);
                // 防御性拦截:如果当前最小公倍数已经大于 N,则 1~N 范围内其倍数必然为 0
                if (current_lcm > N || current_lcm <= 0) { 
                    overflow = true;
                    break;
                }
            }
        }

        if (overflow) continue; // 超出上限的交集大小直接归零,不予统计

        long long count = N / current_lcm; // 计算当前交集集合的大小

        // 奇加偶减内核逻辑
        if (bits_count & 1) {
            total_union += count;
        } else {
            total_union -= count;
        }
    }

    return total_union;
}

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    long long N;
    int n;
    if (std::cin >> N >> n) {
        std::vector<long long> primes(n);
        for (int i = 0; i < n; ++i) {
            std::cin >> primes[i];
        }
        std::cout << count_multiples(N, primes) << "\n";
    }
    return 0;
}

NOIP 实战避坑指南

  1. 求交集时乘法爆发引发死循环或负数截断 在求多个数的最小公倍数时(如上述源码中的 lcm 累乘),多个数的乘积会以几何级数爆炸。即使使用了 long long,也极易在四五个非质数累乘后爆出符号位变成负数。如果不做 current_lcm > N 的防御拦截,N / current_lcm 会算出一个错误的巨大数据,彻底摧毁容斥的加减平衡。
  2. 位运算优先级低级失误 在判断二进制位时,很多选手会顺手写成 if (mask >> i & 1 == 1)。注意:C++中位移运算符 >> 和按位与 & 的优先级低于相等运算符 ==。这段代码在编译器的视角里等价于 mask >> (i & (1 == 1)),逻辑当场畸变。必须严格加括号:if ((mask >> i) & 1)

经典 NOIP/洛谷 真题

1. 洛谷 P1450 [HAOI2008] 硬币购物

2. 洛谷 P2567 [SCOI2010] 幸运数字

原文链接: local://20.1

[h] 返回首页