算法中的数学知识(三)—容斥原理与简单博弈论(SG定理)

- 本人的LeetCode账号:魔术师的徒弟,欢迎关注获取每日一题题解,快来一起刷题呀~

一、容斥原理

1 容斥原理介绍

  高中中,我们学过一个经典的容斥原理的图:

  这便是最简单的容斥原理。

  这个公式可以推广到一般的n个圆的交集。
S = S ( 1 ) + S ( 2 ) + . . . + S ( n ) − ∑ i , j , i ! = j s ( i ) s ( j ) + ∑ i , j , k s ( i ) s ( j ) s ( k ) . . . + ( − 1 ) n − 1 s ( 1 ) s ( 2 ) ∗ . . . ∗ s ( n ) S = S(1)+S(2) +...+S(n)\\ -\sum_{i,j, i!=j}s(i)s(j)\\ +\sum_{i,j,k}s(i)s(j)s(k)\\ ... +(-1)^{n - 1}s(1)s(2)*...*s(n) S=S(1)+S(2)+...+S(n)i,j,i!=js(i)s(j)+i,j,ks(i)s(j)s(k)...+(1)n1s(1)s(2)...s(n)
  它总共有:
C n 1 + C n 2 + . . . + C n n + C n 0 − C n 0 = 2 n − 1 等 价 于 从 n 个 数 中 选 任 意 多 个 数 的 组 合 个 数 每 个 数 选 与 不 选 C_n^1+C_n^2+...+C_n^n+C_n^0-C_n^0 = 2^n - 1\\ 等价于从n个数中选任意多个数的组合个数 每个数选与不选 Cn1+Cn2+...+Cnn+Cn0Cn0=2n1n
  所以它的时间复杂度是O(2^n)级别。

2 例题—能被整除的数

  如果对每个数都把所有质数能否被其整除来判断一次,时间复杂度是O(NM),绝对会超时。

  利用容器原理:以测试用例为例:

  容斥原理的时间复杂度是2^m2^16,完全ok。
S p 怎 么 求 呢 ? n / p 下 取 整 即 可 。 S i ∩ S j 怎 么 求 呢 ? 因 为 i 和 j 都 是 质 数 , 所 以 就 是 能 被 i ∗ j 整 除 的 数 的 个 数 n / ( i ∗ j ) S_p怎么求呢?n/p下取整即可。\\ S_i∩S_j怎么求呢?因为i和j都是质数,所以就是能被i * j整除的数的个数\\ n / (i * j) Spn/pSiSjijijn/(ij)

  所以算每个集合的大小的时间复杂度是O(k)的(k是素数个数)。

  所以总时间复杂度是O(2^16 * 2^4) = o(2^20) = 1000000

  那么我们如何把容斥原理实现出来呢?一方面,当然我们可以用DFS来完成,但是对于这种枚举所有集合的问题,我们常常用位运算来实现。

  所以用16个二进制位就可以了,所以我们只要从1枚举到2^m就行了。

#include <iostream>
#include <vector>
using namespace std;
typedef long long LL;

int n, m;

int main()
{
    cin >> n >> m;
    vector<int> p(m);
    for (int i = 0; i < m; ++i) cin >> p[i];
    int res = 0;
    // 枚举所有的集合 通过16位表示状态
    for (int i = 1; i < (1 << m); ++i)
    {
        int cnt = 0, t = 1;
        for (int j = 0; j < m; ++j)
        {
            if (i >> j & 1) 
            {
                ++cnt;
                // 如果t * p[j]大于n了,1~n中不可能有整除t的数 break掉就行
                if ((LL)t * p[j] > n)
                {
                    t = -1;
                    break;
                }
                // 把这个质数乘上
                t *= p[j];
            }
        }
        if (t != -1)
        {
            // 1~n中被t整除的数的个数 n / t
            if (cnt % 2) res += n / t;
            else res -= n / t;
        }
    }
    cout << res << endl;
    return 0;
}

二、简单博弈论

1 常见概念

  公平组合游戏ICG

  若一个游戏满足:

  • 由两名玩家交替行动
  • 在游戏进程的任意时刻,可以执行的合法行动与轮到哪名玩家无关
  • 不能行动的玩家判负

  则称该游戏为一个公平组合游戏,Nim博弈属于公平组合游戏,但城建类的棋盘类游戏,比如围棋,就是不公平组合游戏。因为围棋交战双方只能落黑子或白子,胜负判定也比较复杂。

  有向图游戏

  给定一个有向无环图,图中有一个唯一的起点,在起点放一枚棋子,两名玩家交替的把这枚棋子沿着有向边进行移动,每次可以移动一步,无法移动者判负,这类游戏被称为有向图游戏。

  可以证明,任何一个公平组合游戏都可以转化为有向图游戏。具体方法是:把每个局面看成图中的一个结点,并且从一个局面经过一个合法的操作到达下一个局面,则这两个局面之间就有一个有向边。

2 Nim游戏

  以2 3为例,先手可以先从3中拿一个石子,然后构成2 2这种石子个数相等的情况,然后不管它怎么拿,我镜像的从另一个堆里头拿,就可以实时维护两堆的数量相等,这样一定是后手放先一道石头被拿完的情况。

  必胜态(先手):先手拿完之后可以让剩下的状态变成一个必败状态,则先手就是必胜状态。

  必败态(先手):不管怎么操作,最终到达的所有状态都是必败的,就是必败态。

  定理:设n堆石子的个数是a1,a2,...,an,若a1 ^ a2 ^...^an = 0,则先手必败,否则先手必胜。

事实1.当走到终点时,异或值是0;

定理1.a_1 ^ a_2 ^ ... ^a_n = x != 0时,我们一定可以从某一堆中拿出若干石子,让剩下的值异或起来变成0。

定理1Proof:

  设在x的二进制表示中,最高的一位1在第k位,根据异或的性质,这说明a_1 - a_n中必然有一个数,其第k为为1(否则第k位必然为0);

  不妨设a_i的第k为是1;

  那么有a_i ^ x < a_i(a_i前面不变,第k位变0了,所以是严格小);

  所以我们可以从a_i中拿走a_i - (a_i ^ x)个石子(这个数一定大于0);

  它就剩了a_i ^ x个石子,这时剩下的所有数异或起来就是0,定理1就得证了.

定理2.a_1 ^ a_2 ^ ... ^a_n = x == 0时,不管接下来怎么去拿,剩下所有值异或起来一定不是0。

定理2Proof:

  利用反证法:假设拿了以后,剩下的所有数的异或值是0,则有a_1 ^ a_2 ^ ... ^ a_{i'} ^ ... ^ a_n = x ==0;

  将它与题目中所给的条件式子异或一下,有:a_i ^ a_{i'} == 0,这说明a_i = a_{i'},但我们不可能拿0个石子,矛盾了。

  所以如果a1^a2^...^an != 0,根据定理1,则先手一定可以转移到状态a1 ^ a2 ^ ... ^ an == 0,根据定理2,后手的操作必然会把总异或值弄的不是0,然后先手又一定可以把异或值再变成0,根据事实1,必败态是0 ^ 0 ^ 0 ^...^ 0 == 0,所以最终一定是后手者面对这个必败态,所以此时先手必胜。

  显然反之,后手必胜。

#include <iostream>
using namespace std;

const int N = 1e5 + 10;
int a[N];
int n;

int main()
{
    cin >> n;
    int x = 0;
    for (int i = 0; i < n; ++i)
    {
        scanf("%d", &a[i]);
        x ^= a[i];
    }
    if (x) puts("Yes");
    else puts("No");
}

3 有向无环图游戏的判断方法—SG函数

  假定自然数组成的集合A的mex运算是,找到一个自然数x,它是不在集合A中最小的所有自然数中最小的一个数。

  在一个有向无环图游戏中,定义终点状态的SG函数值是0,即SG(终点) = 0

  那么对任意一个状态x,其SG(x)怎么求呢?

  假设x可以转移到k个状态:y1、y2、y3、y4...yk,那么定义
S G ( x ) = m e x ( S G ( y 1 ) , S G ( y 2 ) , . . . , S G ( y k ) ) SG(x)=mex(SG(y_1), SG(y_2), ..., SG(y_k)) SG(x)=mex(SG(y1),SG(y2),...,SG(yk))
  一个求SG函数的值的测试:

  如何通过SG(x)来判断其是否为必胜态或必败态?

  显然有:任何一种SG(x) != 0的状态,它一定可以通过某条边到SG(yi) = 0的状态;任何一种SG(x) == 0的状态,它一定不可以转移到SG(yi) == 0的状态。

  所以如果我们先手是SG(x) != 0的状态,我们一定可以通过一个方法让下一个状态面对SG(i) = 0,然后后手通过任何操作,下一状态都一定是SG(x) != 0,而必败态是SG(i) = 0的终点,所以此时先手必胜,反之先手必败。

  一般比较复杂的题目中,玩家会同时面对多个有向无环图游戏,每次可以选择任意一个图去走一步,此时怎么办呢?

  假设我们有n个图,每个图起点的SG函数值为:SG(xi),若SG(x1) ^ SG(x2) ^ ... ^ SG(xn) == 0,则先手必败,否则先手必胜,它的证明和Nim游戏是一样的。

Proof:

定理1:必败态对应SG(x1) ^ SG(x2) ^ ... ^ SG(xn) == 0

  必败态对应每个有向无环图游戏都必败,即SG(Xi) == 0,i = 1, 2, 3, ..., n,显然此时SG(x1) ^ SG(x2) ^ ... ^ SG(xn) == 0

定理2:若SG(x1) ^ SG(x2) ^ ... ^ SG(xn) != 0,则可以一定可以通过在某个图上转移一步,使其转移到SG(x1) ^ SG(x2) ^ ... ^ SG(xn) == 0

  设x = SG(x1) ^ SG(x2) ^ ... ^ SG(xn) ,那么因为x不为0,假设其二进制位表示中最高的一位1的位数是k,因为SG(x1) ^ SG(x2) ^ ... ^ SG(xn) != 0,所以必然有一个SG(xi),其第k位不为0。

  那么必然有SG(xi) ^ x < SG(xi).

  根据SG函数的定义,所以SG(xi)一定可以通过一步操作转移到SG(xi) ^ x,此时全体异或值就等于x ^ x = 0,这样就完成了转移。

定理3:若SG(x1) ^ SG(x2) ^ ... ^ SG(xn) == 0,则任何操作都会转移到一个SG(x1) ^ SG(x2) ^ ... ^ SG(xn) != 0的状态。

  反证法:假设我们修改了SG(xi)使其变为SG(xi'),并且满足SG(x1) ^ SG(x2) ^ ...^ SG(xi') ^ ... ^ SG(xn) == 0,那么将它与条件式相异或,有:SG(xi) ^ SG(xi') == 0,因此SG(xi) == SG(xi'),而根据SG函数的定义,每次转移我们不可以转移到和自己SG函数值相等的状态,不然自己这个状态的SG值一定不等于当前值,所以矛盾,所以原命题得证。

  因此如果先手时,若SG(x1) ^ SG(x2) ^ ... ^ SG(xn) != 0,那么先手一定可以通过一种方法转移到SG(x1) ^ SG(x2) ^ ... ^ SG(xn) == 0的状态,而后手不论是什么操作,它都将转移到SG(x1) ^ SG(x2) ^ ... ^ SG(xn) != 0的状态,所以我们可以一直控制先手的SG(x1) ^ SG(x2) ^ ... ^ SG(xn) != 0,后手的SG(x1) ^ SG(x2) ^ ... ^ SG(xn) == 0,而必败态对应SG(x1) ^ SG(x2) ^ ... ^ SG(xn) == 0,所以后手一定会失败,先手必胜。

  所以我们定义N个有向无环图游戏组成的游戏的SG值为:SG(x1, x2, ...,xn) = SG(x1) ^ SG(x2) ^ ... ^ SG(xn)

4 SG函数例题—集合Nim函数

  仅考虑一堆石头,有:

  N堆石子我们可以看成有N个有向无环图,我们只要求出他们的SG值异或起来,根据SG定理判断一下是不是0就行了。

#include <iostream>
#include <unordered_set>
#include <cstring>
using namespace std;

const int N = 110;
int s[N];// 集合中的个数s 
const int M = 1e4 + 10;
int f[M];// 记忆化搜索求SG(x)时的状态
int k, n;

int SG(int x)
{
    if (f[x] != -1) return f[x];
    unordered_set<int> Y;// 可以转移到的所有状态
    // 枚举集合s 来转移
    for (int i = 0; i < k; ++i)
    {
        int sum = s[i];
        // 如果x > sum 说明它可以拿走num个转移到x - num状态
        if (x >= sum) Y.insert(SG(x - sum));
    }
    // 求SG值 即第一个不在Y中的自然数
    for (int i = 0; ; i++)
        if (Y.count(i) == 0) return f[x] = i;
}

int main()
{
    cin >> k;
    for (int i = 0; i < k; ++i) cin >> s[i];
    cin >> n;
    // 初始化都为-1 表示每个状态都未计算过
    memset(f, -1, sizeof(f));
    int res = 0;
    while (n--)
    {
        int x;
        cin >> x;
        res ^= SG(x);// 求每一堆对应的有向无环图游戏的起点的SG值
    }
    if (res) puts("Yes");
    else puts("No");
    return 0;
}

5 台阶Nim游戏

  对于n个台阶的思考结论是:

x1 ^ x3 ^ x5 ^ .... = x != 0,则先手必胜,否则先手必败。

证明:

  假设x != 0,根据经典Nim游戏的结论,那么我们一定可以把一个奇数位置的石头拿一定数量起来,移动到偶数位,使得x1 ^ x3 ^ x5 ^ .... = x == 0;

  下一轮操作,如果对手是从偶数台阶拿的,那我我们就把它拿的数量再放到下一级偶数台阶,使得抛给对手的盘是一个x1 ^ x3 ^ x5 ^ .... = x == 0

  如果对手是从奇数级台阶拿的,那么根据经典Nim游戏的结论,此时他抛给我们的局面一定是x1 ^ x3 ^ x5 ^ .... = x != 0,然后根据经典Nim游戏的结论,我们有一定可以抛给对面x1 ^ x3 ^ x5 ^ .... = x == 0.

  也就是说,不管怎么样,我们可以严格控制对手面对的是x1 ^ x3 ^ x5 ^ .... = x == 0,并且我们永远是有石子可以拿的,而必败态是x1 ^ x3 ^ x5 ^ .... = x == 0,所以终止局面一定会被对手遇到。

  反之,先手必败。

#include <iostream>
using namespace std;

int main()
{
    int res = 0;
    int n, x;
    cin >> n;
    int cnt = 1;
    while (n--)
    {
        cin >> x;
        if (cnt & 1) res ^= x;
        ++cnt;
    }
    if (res) puts("Yes");
    else puts("No");
    return 0;
}

6 拆分Nim游戏

  首先,这个游戏一定可以结束,因为我们每次操作后,最大值一定会不断减小,虽然堆的总数可能会变多,但是拿走后放回来的两个对的最大值数量会减少。

  我们把开始的每堆石子看做一个独立的局面,求得每个局面的SG值即可。

  本题比较特别,一次操作会使局面由a->(b1, b2),根据SG函数的性质,局面SG(b1, b2) = SG(b1) ^ SG(b2),所以这样就可以求出a状态转移到的每个SG值,然后异或起来就行了,所以我们可以用记忆化搜索来做这个题。

#include <iostream>
#include <cstring>
#include <unordered_set>
using namespace std;

const int N = 110;
int f[N];
int n;

int sg(int x)
{
    if (f[x] != -1) return f[x];
    unordered_set<int> S;
    for (int i = 0; i < x; ++i)
        for (int j = 0; j <= i; ++j)
            S.insert(sg(i) ^ sg(j));
    for (int i = 0; ; ++i)
        if (S.count(i) == 0) return f[x] = i;
}

int main()
{
    cin >> n;
    int res = 0;
    int x;
    memset(f, -1, sizeof(f));
    while (n--)
    {
        cin >> x;
        res ^= sg(x);
    }
    if (res) puts("Yes");
    else puts("No");
    return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值