博弈论

博弈论

相关概念

公平组合游戏

公平组合游戏(ICG 游戏)定义为:

  • 双人(游戏有两个人参与),回合制(二者轮流做出决策),信息完全公开(双方均知道游戏的完整信息)。
  • 任意一个游戏者在某一确定状态可以作出的决策集合只与当前的状态有关,而与游戏者无关。
  • 游戏中的同一个状态不可能多次抵达,游戏以玩家无法行动为结束,且游戏一定会在有限步后以非平局结束。
    • 一般来说,无法行动的玩家为败者。

因此 ICG 游戏可以转化成 DAG,一个状态是一个点,一个决策是一条边,而终止状态指的就是出度为 \(0\) 的点,称其为博弈状态图。

这样,对于组合游戏中的每一对弈,我们都可以将其抽象成图中的一条从某一顶点到出度为 \(0\) 的点的路径。

必胜态与必败态

在公平组合游戏中,定义:

  • (先手)必胜状态:从当前状态开始操作的玩家有必胜策略。
  • (先手)必败状态:无论从当前状态开始操作的玩家如何操作,另外一位玩家在之后都有相对应必胜策略。

通过推理可得下面两条定理:

  • 一个状态是必胜状态,当且仅当它存在至少一个必败的后继状态。
  • 一个状态是必败状态,当且仅当它的所有后继状态均为必胜状态。

如果博弈图是一个 DAG ,则通过这两个定理,我们可以在绘出博弈图的情况下用 \(O(n + m)\) 的时间复杂度 DP 出每个态是必胜状态还是必败状态。

有向图游戏

有向图游戏:在一个有向无环图中,只有一个起点,上面有一个棋子,两个玩家轮流沿着有向边推动棋子,不能走的玩家判负。

有向图游戏的和:一个游戏可能是由多个子游戏组成的(将其记作 \(G_{1 \sim n}\) ),如果整个游戏进行时,玩家可以任意选取一个子游戏进行上面的合法操作,所有子游戏均无法进行合法操作时判负。整个游戏称为子游戏的游戏和,记作 \(G = G_1 + G_2 + \cdots + G_n\)

Nim 博弈及变种

Nim 游戏

P2197 【模板】Nim 游戏

\(n\) 堆石子,第 \(i\) 堆有 \(a_i\) 个石子。

游戏双方轮流取石子,每次只能从其中一堆中取出任意数目的石子,不能不取。

取完者获胜,求先手是否有必胜策略。

\(n, a_i \le 10^4\)

Bouton 定理:定义 Nim 和为 \(a_1 \oplus a_2 \oplus \cdots \oplus a_n\) ,当且仅当 Nim 和为 \(0\) 时先手必败。

证明:只需要证明:

  • 没有后继状态的状态是必败状态。
    • 没有后继状态的状态只有一个,即全 \(0\) 局面,此时 Nim 和为 \(0\)
  • 对于 Nim 和不为 \(0\) 的局面,一定存在某种移动使得 Nim 和为 \(0\)
    • 不妨假设 Nim 和为 \(k\) ,若要将 \(a_i\) 改为 \(a_i'\) ,则 \(a_i' = a_i \oplus k\) 。设 \(k\) 的二进制最高位 \(1\)\(d\) ,根据异或定义,一定有奇数个 \(a_i\) 的二进制第 \(d\) 位为 \(1\) 。满足这个条件的 \(a_i\) 一定也满足 \(a_i > a_i \oplus k\) ,因而这是合法的移动。
  • 定理三:对于 Nim 和为 \(0\) 的局面,一定不存在某种移动使得 Nim 和为 \(0\)
    • 如果我们要将 \(a_i\) 改为 \(a_i'\) ,则根据异或运算律可以得出 \(a_i = a_i'\) ,这不是合法的移动。
#include <bits/stdc++.h>
using namespace std;
signed main() {
    int T;
    scanf("%d", &T);

    while (T--) {
        int n, ans = 0;
        scanf("%d", &n);

        for (int i = 1; i <= n; ++i) {
            int x;
            scanf("%d", &x);
            ans ^= x;
        }

        puts(ans ? "Yes" : "No");
    }

    return 0;
}

HDU1730 Northcott Game

有一个 \(n\)\(m\) 列的棋盘,每行有一个黑棋(先手)和一个白棋(后手)。每次可以把某一行自己的棋子移动到同一行任意一格,但不能越过对方的棋子。不能移动的人输,求先手是否必胜。

\(n \le 1000\)\(m \le 100\)

考虑黑棋在左边时黑棋的移动情况,其余情况是对称的。若黑棋往左移动,则白棋可以模仿其进行同样的移动。

那么一行就可以抽象为有 \(|a - b| - 1\) 个石子( \(a, b\) 为横坐标),于是可以转化为 Nim 游戏。

#include <bits/stdc++.h>
using namespace std;

int n, m;

signed main() {
    while (~scanf("%d%d", &n, &m)) {
        int ans = 0;

        for (int i = 1; i <= n; ++i) {
            int a, b;
            scanf("%d%d", &a, &b);
            ans ^= abs(a - b) - 1;
        }

        puts(ans ? "I WIN!" : "BAD LUCK!");
    }
    
    return 0;
}

Anti-Nim

P4279 [SHOI2008] 小约翰的游戏

\(n\) 堆石子,第 \(i\) 堆有 \(a_i\) 个石子。

游戏双方轮流取石子,每次只能从其中一堆中取出任意数目的石子,不能不取。

取完者失败,求先手是否有必胜策略。

这与 Nim 游戏唯一不同的地方就是胜负判定与其相反。

\(n \le 50\)

结论:

  • 全部 \(a_i = 1\) 时,当且仅当石子堆数为奇数时先手必败。
  • 至少一个 \(a_i > 1\) 时,当且仅当 Nim 和为 \(0\) 时先手必败。

证明:第一种情况显然,考虑证明第二种情况。

  • Nim 和非 \(0\)
    • 若还有至少两堆石子数量 \(> 1\) ,直接将 Nim 和变为 \(0\) 即可。
    • 否则直接考虑取 \(> 1\) 的那一堆,并且可以控制剩下 \(1\) 的堆数,因此此时先手必胜。
  • Nim 和为 \(0\)
    • 若还有至少两堆石子数量 \(> 1\) ,决策完后 Nim 和一定非 \(0\)
    • 否则显然先手必败。

注:Anti-Nim 的结论不能直接搬到一般的 ICG 游戏的 SG 函数上。因为之前的推导中运用到了必败态为 \(0\) 的条件,但这在 Anti-Nim 中不成立。

#include <bits/stdc++.h>
using namespace std;
const int N = 5e1 + 7;

int a[N];

int n;

signed main() {
    int T;
    scanf("%d", &T);

    while (T--) {
        scanf("%d", &n);
        int all = 0;

        for (int i = 1; i <= n; ++i)
            scanf("%d", a + i), all ^= a[i];

        puts((count(a + 1, a + n + 1, 1) == n ? ~n & 1 : all) ? "John" : "Brother");
    }

    return 0;
}

Staircase-Nim

给定 \(n\) 个阶梯,每一个阶梯有若干个石子。

游戏双方轮流任意选取一个有石子的阶梯,将任意数量(至少为 \(1\) )石子移动到下一个阶梯上。

具体地,从编号为 \(i\) 的阶梯拿到 \(i - 1\) 阶梯上。若 \(i = 1\) ,则直接拿走石子。

取完者获胜,求先手是否有必胜策略。

结论:Staircase-Nim 游戏的性质与在奇数编号阶梯上做 Nim 游戏的性质相同。

证明:若在奇数阶梯上 Nim 和非 \(0\) ,那么可以采取以下策略:

  • 按照 Nim 游戏的必胜策略将某一奇数阶梯上的一些石子移动到其下的偶数阶梯上(相当于丢弃)。
  • 若对手也移动奇数阶梯上的石子,相当于配合你做 Nim 游戏,否则只要将这些石子再移动到再其下的偶数堆即可。

不难发现这样操作始终能保证自己必胜,必败情况的等价性质类似证明即可。

P8382 [POI 2004] Gra

有一排 \(m\) 个格子,有 \(n\) 个棋放在上面,每个棋子所放格子不同,保证最后一格没有棋子。

先后手轮流行动,每次可以选择任意一个棋子,将其移动到后面的第一个空位上,先放到 \(m\) 处的人胜利。

求先手必胜的第一步操作方案数。

\(n \le 10^6\)\(n + 1 \le m \le 10^9\)

注意到空格子数量是不变的,考虑设 \(f_i\) 表示右起第 \(i\) 个空格左边连续的棋子数量,那么一次操作等价于选择一个 \(i > 0\) 满足 \(f_i > 0\) ,并选择一个 \(p \in (0, f_i)\) ,令 \(f_i \gets f_i - p, f_{i - 1} \gets f_{i - 1} + p\) ,第一个使得 \(f_0 > 0\) 的人胜利。

发现转换后的游戏类似于 Staircase-Nim,但是胜负判定有些差异。观察到若 \(f_0 > 0\) 时胜利,则 \(f_1 > 0\) 时失败,则 \(f_2 = n\) 时胜利。特判掉 \(f_1 > 0\) 的情况即可转化为 Staircase-Nim 博弈。

设所有奇数位上 \(f\) 的异或和为 \(c\) ,按第一步操作的奇偶性分类讨论:

  • 第一步移动奇数位:若 \(f_i \oplus c < f_i\) 则可以移动。
  • 第一步移动偶数位:若 (\(f_{i - 1} \oplus c) - f_{i - 1} \le f_i\)\(f_{i - 1} \oplus c > f_{i - 1}\) 则可以移动。

注意到很多 \(f\) 都是 \(0\) ,有用的 \(f\) 只有 \(O(n)\) 个,因此不难做到空间 \(O(n)\)

注意只有间隔为一的两点才能当成相邻的两点,而其他的应视为间隔为二的两个位置。

时空复杂度 \(O(n)\)

#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 7;

int a[N], f[N * 3];

int n, m;

signed main() {
    scanf("%d%d", &m, &n);

    for (int i = 1; i <= n; ++i)
        scanf("%d", a + i);

    if (a[n] == m - 1) {
        int ans = 1;

        while (ans <= n && a[n - ans] == m - ans - 1)
            ++ans;

        printf("%d", ans);
        return 0;
    }

    a[n + 1] = m - 1;
    int tot = 0;

    for (int i = n; i; --i)
        if (a[i] == a[i + 1] - 1)
            ++f[tot];
        else if (a[i] == a[i + 1] - 2)
            f[++tot] = 1;
        else if ((a[i + 1] - a[i] - 1) & 1)
            f[tot += 3] = 1;
        else
            f[tot += 2] = 1;

    int c = 0;

    for (int i = 1; i <= tot; i += 2)
        c ^= f[i];

    int ans = 0;

    for (int i = 1; i <= tot; ++i) {
        if (i & 1)
            ans += ((f[i] ^ c) < f[i]);
        else
            ans += ((f[i - 1] ^ c) - f[i - 1] <= f[i] &&(f[i - 1] ^ c) > f[i - 1]);
    }

    printf("%d", ans);
    return 0;
}

Nim-K 游戏

\(n\) 堆石子,每堆有 \(a_i\) 个。

游戏双方轮流取石子,每次可以选堆数 \(\in [1, m]\) 的若干堆,并从这几堆中各取若干石子。

取完者获胜,求先手是否有必胜策略。

结论:当前状态必败,当且仅当对于每一个二进制位,这一位 \(1\) 的数量 \(\bmod (m + 1)\)\(0\)

证明:类似 Nim 博弈,只需证明:

  • 结论一:全 \(0\) 的局面一定是必败态。
    • 显然。
  • 结论二:任何一个必败态,经过一次操作后必然会回到必胜态。
    • 一次移动中至少有一个二进制位被改变。由于最多只能操作 \(m\) 堆石子,所以对于任意位,\(1\) 的个数至多改变 \(m\) 。而由于原先每一位 \(1\) 的数量均为 \(m+1\) 的整数倍,所以操作后必然存在一位 \(1\) 的数量不是 \(m+1\) 的整数倍。
  • 结论三:任何一个必胜态,总有一种操作后会回到必败态。
    • 考虑归纳法,假设用某种方法改变了 \(k\) 堆,使比第 \(i\) 位高的所有位的 \(1\) 的个数都变成 \(m+1\) 的整数倍。现在要证明总有一种方法让第 \(i\) 位也变成 \(m+1\) 的整数倍。显然,对于已经改变的那 \(k\) 堆,当前位可以自由选择 \(1\)\(0\) 。设除去已经更改的 \(k\) 堆,剩下的堆第 \(i\) 位上 \(1\) 的总和 \(\bmod m + 1 = S\) 。若 \(S \le m - k\) ,此时可以将这些堆上的 \(1\) 全部拿掉,然后让那 \(k\) 堆的第 \(i\) 位全部置为 \(0\) ;否则此时在之前改变的 \(k\) 堆中选择 \(m + 1 - S\) 堆,将它们的第 \(i\) 位置为 \(1\) 且剩下位置置为 \(0\)

SG 定理

对于状态 \(x\) 和它的所有 \(k\) 个后继状态 \(y_{1 \sim k}\) ,定义 SG 函数:

\[\mathrm{SG}(x) = \mathrm{mex} \{ \mathrm{SG}(y_1), \mathrm{SG}(y_2), \cdots, \mathrm{SG}(y_k) \} \]

而对于由 \(n\) 个有向图游戏组成的组合游戏,设它们的起点分别为 \(s_{1 \sim n}\)

SG 定理:当且仅当

\[\mathrm{SG}(s_1) \oplus \mathrm{SG}(s_2) \oplus \cdots \oplus \mathrm{SG}(s_n) \not = 0 \]

时,这个游戏是先手必胜的,同时这是这一个组合游戏的游戏状态 \(x\) 的 SG 值。

证明:考虑数学归纳法,假设对于游戏状态 \(x'\) ,其当前节点 \(s_{1 \sim n}'\)\(\forall i, s_i' < s_i\) )符合 SG 定理,SG 定理便成立。

事实上这一个状态可以看作一个 Nim 游戏,对于某个节点 \(s_i\) ,它可以移动到任意一个 SG 值比它小或比它大的节点。

在有向图游戏中,当一方将某一节点 \(s_i\) 移动到 SG 值比它大的节点时,另一方可以移动回和 SG 值和 \(s_i\) 一样的节点,所以向 SG 值较大节点移动是无效操作。

否则考虑移动到 SG 值较小的点,不难发现由于取 \(\mathrm{mex}\) 操作的限制,因此可以移动到任意 SG 值小于当前 SG 值的点,即 Nim 游戏。

因此状态 \(x\) 符合 SG 定理。

SG 定理适用于任何公平的两人游戏,它常被用于决定游戏的输赢结果。

计算给定状态的 SG 值的步骤一般包括:

  • 获取从此状态所有可能的转换。
  • 每个转换都可以导致一系列独立的博弈(退化情况下只有一个)。计算每个独立博弈的 SG 值并对它们进行异或求和。
  • 在为每个转换计算了 SG 值之后,状态的值是这些数字的 \(\operatorname{mex}\)
  • 如果该值为零,则当前状态为输,否则为赢。

事实上还有很多非 ICG 游戏,不能够直接用 SG 函数解决,例如:

  • 非回合制游戏:可能可以通过收益矩阵等方法解决。
  • 不平等回合制博弈:可能可以通过对抗搜索、超现实数等方法解决。
  • 博弈图带环的回合制博弈:可能会出现无法终止的情况。

SG 游戏的变种

定义 SG 游戏:其等价于 ICG 游戏。

SG 游戏的胜负状态可以用 SG 定理求解。

Anti-SG 游戏

规则与 SG 游戏基本相同,但是胜负判定条件相反,即无法操作者胜。

SJ 定理:对于任意一个 Anti-SG 游戏,若规定当前局面中所有子游戏的 SG 值均为 \(0\) 时游戏结束,则先手必胜当且仅当:

  • 总游戏的 SG 值非 \(0\) 且某个子游戏的 SG 值 \(>1\)
  • 或总游戏的 SG 值为 \(0\) 且不存在某个子游戏的 SG 值 \(>1\)

证明类似 Anti-Nim。

Multi-SG 游戏

规则与 SG 游戏基本相同,在符合拓扑原则的前提下,一个单一子游戏的后继可以为多个单一子游戏。

不难发现此时仍然可以用 SG 游戏定义局面。

Every-SG 游戏

规则与 SG 游戏基本相同,但是每次要操作所有可以操作的单一子游戏,无法操作者败。

由于每个 DAG 游戏是否先手必胜的状态是确定的,因此对结果有影响的只有游戏时间。

显然对于先手必败的游戏,先手希望其进行时间尽量短;对于先手必胜的游戏,先手希望其进行时间尽量长。

因此只要算出先手必胜的最长时间和先手必败的最短时间,比较二者即可。

一个简单的实现是求出每个状态的可能结束时间取 \(\max\) 时候判定奇偶性,当其为奇数时先手必胜。

HDU3595 GG and MM

一个游戏由 \(n\) 个子游戏组成,每次操作者必须操作所有可以操作的游戏,无法操作者输。

每个子游戏由两堆石子 \(x, y\) 组成,每次可以从数量较多堆中取走数量较小堆的数量的倍数个石子。

求先手是否存在必胜策略。

\(n, x, y \le 1000\)

显然是 Every-SG 模型,考虑对每个 \((x, y)\) 预处理胜负状态与进行时间,最后将每个游戏的时间比较即可。

对于一个游戏 \((a, b)\) ,钦定 \(a \le b\)

  • \(\lfloor \frac{b}{a} \rfloor = 1\) ,则操作者只能令 \(b\) 减去 \(a\) ,因此胜负状态为 \((b - a, a)\) 的胜负状态取反,进行时间为 \((b - a, a)\) 的进行时间 \(+1\)
  • 否则考虑 \((b \bmod a, a)\) 的胜负状态:
    • 若其为先手必胜态,则可以选择剩下一次操作给后手,即令 \(b \to b - (\lfloor \frac{b}{a} \rfloor - 1) \times a\) ,此时先手必胜,进行时间为 \((b \bmod a, a)\) 的进行时间 \(+2\)
    • 否则只要一次取完即可,此时先手必胜,进行时间为 \((b \bmod a, a)\) 的进行时间 \(+1\)

时间复杂度 \(O(V^2)\)

#include <bits/stdc++.h>
using namespace std;
const int N = 1e3 + 7;

int sg[N][N], tim[N][N];

int n;

void dfs(int a, int b) {
    if (~sg[a][b])
        return;

    if (!a) {
        sg[a][b] = 0;
        return;
    }

    dfs(b % a, a);

    if (b / a == 1)
        sg[a][b] = sg[b % a][a] ^ 1, tim[a][b] = tim[b % a][a] + 1;
    else
        sg[a][b] = 1, tim[a][b] = sg[b % a][a] + 1 + tim[b % a][a];
}

signed main() {
    memset(sg, -1, sizeof(sg));

    while (~scanf("%d", &n)) {
        int mxt = 0;

        while (n--) {
            int a, b;
            scanf("%d%d", &a, &b);

            if (a > b)
                swap(a, b);

            dfs(a, b), mxt = max(mxt, tim[a][b]);
        }

        puts(mxt & 1 ? "MM" : "GG");
    }

    return 0;
}

经典模型

巴什博弈

\(n\) 个石子,两个人轮流取,每次可以取 \(1 \sim m\) 个。

取完者获胜,求先手是否有必胜策略。

结论:当且仅当 \(n \bmod (m + 1) = 0\) 时先手必败。

威佐夫博弈

P2252 [SHOI2002] 取石子游戏|【模板】威佐夫博弈

有两堆各 \(n, m\) 个物品,两个人轮流操作,操作有两种:

  • 从任意一堆中取出至少一个。
  • 同时从两堆中取出同样多的物品,规定每次至少取一个。

取完者获胜,求先手是否有必胜策略。

\(n, m \le 10^9\)

结论:记两堆石子为 \(n, m\) ,其中 \(n \le m\) ,若 \(n = \lfloor \frac{\sqrt{5} + 1}{2} \times (m - n) \rfloor\) 则先手必败。

注意稍微化一下式子,避免浮点数运算,不然会被卡精度。

#include <bits/stdc++.h>
using namespace std;
signed main() {
    int n, m;
    scanf("%d%d", &n, &m);

    if (n > m)
        swap(n, m);

    m -= n;
    printf("%d", !(1ll * (n * 2 - m) * (n * 2 - m) <= 5ll * m * m && 
            5ll * m * m < 1ll * (n * 2 - m + 2) * (n * 2 - m + 2)));
    return 0;
}

斐波那契博弈

有一堆个数为 \(n\) 的石子,双方轮流取石子。要求:

  • 先手不能一次取完所有石子。
  • 之后每次可以取的石子数介于 \(1\) 到对手上一次取的石子数两倍之间。

取完者获胜,求先手是否有必胜策略。

结论:先手必败当且仅当 \(n\) 是斐波那契数。

证明:先引入齐肯多夫定理:任何整数可以分解成若干个不连续的斐波那契数之和。

\(n = f_i + f_{i - 1} + \cdots f_{i - k}\) ,那么先手先取 \(f_{i - k}\) ,由于后手不能取大于等于 \(2 \times f_{i - k}\) 的项,则 \(n\) 中剩下的斐波那契项,先手都可以取到最后一颗。

翻硬币博弈

\(n\) 枚硬币,已知其初始状态。

游戏者根据某些约束翻硬币(如:每次只能翻一或两枚,或者每次只能翻连续的几枚),但所翻动的硬币中,最右边的必须是从正面翻到反面。

不能翻转者输,求先手是否有必胜策略。

结论:局面的 SG 值为局面中每个正面朝上的棋子单一存在时的 SG 值的异或和。

P4077 [SDOI2016] 硬币游戏

\(n\) 枚硬币,已知其初始状态。每次可以选择一个反面向上的硬币 \(x = c 2^a 3^b\) ,其中 \(2 \not \mid c\)\(3 \not \mid c\) ,并选择执行一种操作:

  • 选择 \(p, q\) 满足 \(pq \le a\)\(p \ge 1\)\(1 \le q \le MAXQ\) ,然后同时翻转所有编号为 \(c 2^{a - pj} 3^b\) 的硬币,其中 \(j = 0, 1, 2, \cdots, q\)
  • 选择 \(p, q\) 满足 \(pq \le b\)\(p \ge 1\)\(1 \le q \le MAXQ\) ,然后同时翻转所有编号为 \(c 2^a 3^{b - pj}\) 的硬币,其中 \(j = 0, 1, 2, \cdots, q\)

无法操作者输,多组数据询问是否先手必胜。

\(n \le 3 \times 10^4\)

首先可以发现不同的 \(c\) 之间互相独立,最后用 SG 定理合并即可。

现在仅需考虑独立的 \(2^a 3^b\) ,可以发现 \(2^a\)\(3^b\) 局面的并就是 \(2^a 3^b\) 的局面,因此仅需考虑 \(2^a\) 的情况。

将指数拿下类,那么一次操作相当于翻转 \(a, a - p, a - 2p, \cdots, a - qp\) 的硬币。

根据经典结论,\(SG(a)\) 的状态来源有 \(SG(a - p), SG(a - p) \oplus SG(a - 2p), \cdots\) ,因此:

\[SG(a, b) = \operatorname{mex} \{ SG(a - p, b), SG(a - p, b) \oplus SG(a - 2p, b), \cdots, SG(a, b - p), SG(a, b - p) \oplus SG(a, b - 2p) \} \]

#include <bits/stdc++.h>
using namespace std;
const int N = 3e4 + 7, Q = 21, M = 15;

int sg[Q][M][M], lg2[N], lg3[N];

inline void prework() {
    for (int i = 1; i < N; ++i) {
        if (~i & 1)
            lg2[i] = lg2[i >> 1] + 1;

        if (!(i % 3))
            lg3[i] = lg3[i / 3] + 1;
    }

    for (int mxq = 1; mxq < Q; ++mxq)
        for (int a = 1, j = 0; a < N; a <<= 1, ++j)
            for (int b = 1, k = 0; a * b < N; b *= 3, ++k) {
                int s = 0;

                for (int p = 1; p <= j; ++p) {
                    int t = 0;

                    for (int q = 1; p * q <= j && q <= mxq; ++q)
                        s |= 1 << (t ^= sg[mxq][j - p * q][k]);
                }

                for (int p = 1; p <= k; ++p) {
                    int t = 0;

                    for (int q = 1; p * q <= k && q <= mxq; ++q)
                        s |= 1 << (t ^= sg[mxq][j][k - p * q]);
                }

                sg[mxq][j][k] = __builtin_ctz(~s);
            }
}

signed main() {
    prework();
    int T;
    scanf("%d", &T);

    while (T--) {
        int n, mxq, ans = 0;
        scanf("%d%d", &n, &mxq);

        for (int i = 1; i <= n; ++i) {
            int x;
            scanf("%d", &x);

            if (!x)
                ans ^= sg[mxq][lg2[i]][lg3[i]];
        }

        puts(ans ? "win" : "lose");
    }

    return 0;
}

P2594 [ZJOI2009] 染色游戏

\(n \times m\) 的网格上,每个有一个硬币,状态为正或反。

先后手轮流操作,每次可以选择一个连通块,并翻转内部所有硬币,该联通块需要满足:内部存在一个反面朝上的硬币,使得其横纵坐标均为连通块横纵坐标的最大值。

无法操作者败,求先手是否有必胜策略。

\(n, m \le 100\)

一维的结论搬到二维同样适用,因此只要考虑每个硬币单独反面朝上时的 SG 值即可。

\(SG(i, j)\) 表示 \((i, j)\) 单独反面朝上的 SG 值,打表发现规律:

\[SG(i, j) = \begin{cases} \mathrm{lowbit}(j) & i = 1 \\ \mathrm{lowbit}(i) & j = 1 \\ 2^{i + j - 2} & \text{otherwise} \end{cases} \]

bitset 存 SG 值即可。

#include <bits/stdc++.h>
using namespace std;
const int N = 2e2 + 7;

char str[N];

int n, m;

inline bitset<N> SG(int x, int y) {
    bitset<N> sg;

    if (y == 1)
        sg.set(__lg(x & -x));
    else if (x == 1)
        sg.set(__lg(y & -y));
    else
        sg.set(x + y - 2);

    return sg;
}

signed main() {
    int T;
    scanf("%d", &T);

    while (T--) {
        scanf("%d%d", &n, &m);
        bitset<N> all;

        for (int i = 1; i <= n; ++i) {
            scanf("%s", str + 1);

            for (int j = 1; j <= m; ++j)
                if (str[j] == 'T')
                    all ^= SG(i, j);
        }

        puts(all.any() ? "-_-" : "=_=");
    }

    return 0;
}

二分图博弈

特点:有向图游戏,满足有向图为二分图。

将每个状态看做一个点,那么这些点和合法转移会构成一个二分图。

\(S\) 集合视作轮到先手决策的点,\(T\) 集合则代表轮到后手决策的点。

先跑一遍二分图匹配,对于任意一点 \(x\) ,有两种情况:

  • 不属于最大匹配(非匹配点):走一步后必然会走到一个匹配点(否则相当于找到了一条增广路)。而走到一个匹配点后,对方可以不断沿着匹配边走,最后必然会停留在 \(S\) 集合,也就是先手必败(如果停留在 \(T\) 集合,相当于找到了一条增广路)。
  • 最大匹配的非必须点:不管它怎么走,走到的目标节点一定会在某种情况下属于最大匹配,因此先手必败。
  • 最大匹配的必须点:这个点先手必胜。因为总能走向另一个匹配点。

P4055 [JSOI2009] 游戏

给定一个 \(n \times m\) 的网格图,有若干格子有障碍。A 先选择一个起点放棋子,然后从 B 开始,两人轮流移动棋子,不能移动到走过的位置,无法移动者输。求 A 是否有必胜策略,若有还需求出所有有必胜策略的起点。

\(n, m \le 100\)

将相邻两个非障碍点连边,可以得到一张二分图,找最大匹配的非必须点即可。

#include <bits/stdc++.h>
using namespace std;
const int dx[] = {0, 0, 1, -1};
const int dy[] = {1, -1, 0, 0};
const int N = 1e2 + 7;

struct Graph {
    vector<int> e[N * N];
    
    inline void insert(int u, int v) {
        e[u].emplace_back(v);
    }
} G;

int vis[N * N], obj[N * N];
char a[N][N];
bool tag[N * N];

int n, m;

inline int getid(int x, int y) {
    return (x - 1) * m + y;
}

bool Hungary(int u, const int tag) {
    for (int v : G.e[u]) {
        if (vis[v] == tag)
            continue;

        vis[v] = tag;
        
        if (!obj[v] || Hungary(obj[v], tag))
            return obj[v] = u, obj[u] = v, true;
    }
    
    return false;
}

void dfs(int u) {
    tag[u] = true;
    
    for (int v : G.e[u])
        if (obj[v] != u && !tag[obj[v]])
            dfs(obj[v]);
}

signed main() {
    scanf("%d%d", &n, &m);
    
    for (int i = 1; i <= n; ++i)
        scanf("%s", a[i] + 1);
    
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            if (a[i][j] != '#')
                for (int k = 0; k < 4; ++k) {
                    int x = i + dx[k], y = j + dy[k];
                    
                    if (1 <= x && x <= n && 1 <= y && y <= m && a[x][y] != '#')
                        G.insert(getid(i, j), getid(x, y));
                }

    int Tag = 0;
    
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            if (a[i][j] != '#' && ((i + j) & 1))
                Hungary(getid(i, j), ++Tag);
    
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            if (a[i][j] != '#' && !obj[getid(i, j)])
                dfs(getid(i, j));
    
    bool flag = false;
    
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            if (a[i][j] != '#' && tag[getid(i, j)]) {
                flag = true;
                break;
            }
    
    if (!flag)
        return puts("LOSE"), 0;
    
    puts("WIN");
    
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            if (a[i][j] != '#' && tag[getid(i, j)])
                printf("%d %d\n", i, j);
    
    return 0;
}

删边博弈

树上删边博弈

[AGC017D] Game on Tree

给出一棵树,先后手轮流操作,每次可以断开一条边,并删去不含根的连通块。

无法操作者输,求先手是否有必胜策略。

\(n \le 10^5\)

结论:叶子的 SG 值为 \(0\) ,其余点的 SG 值为其儿子的 SG 值 \(+1\) 后的异或和。

证明:设 \(u\) 的 SG 值为 \(f_u\)\(u\) 子树对应的 DAG 为 \(G_u\)

对于 \(v \in son(u)\)\(v\) 子树都可以视为一个子 DAG。同时由于随时可以割掉 \((u, v)\) 边,因此将 \(G_v\) 中的每个状态都连向 \(0\) 得到 \(G_v'\) ,此时 \(G_v'\) 中每个点的 SG 值均为 \(G_v\) 中对应点的 SG 值 \(+1\)

由于每个 \(v\) 子树都是独立的,因此 \(f_u = \oplus_{v \in son(u)} (f_v + 1)\)

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 7;

struct Graph {
    vector<int> e[N];
    
    inline void insert(int u, int v) {
        e[u].emplace_back(v);
    }
} G;

int f[N];

int n;

void dfs(int u, int fa) {
    for (int v : G.e[u])
        if (v != fa)
            dfs(v, u), f[u] ^= f[v] + 1;
}

signed main() {
    scanf("%d", &n);

    for (int i = 1; i < n; ++i) {
        int u, v;
        scanf("%d%d", &u, &v);
        G.insert(u, v), G.insert(v, u);
    }

    dfs(1, 0);
    puts(f[1] ? "Alice" : "Bob");
    return 0;
}
CF1610I Mashtali vs AtCoder

给出一棵树,规定若干点为关键点。先后手轮流操作,每次可以删去一条边,并删去不含关键点的连通块。

对于每个 \(k \in [1, n]\) ,求:若 \(1 \sim k\) 为关键点,先手是否有必胜策略。

\(n \le 3 \times 10^5\)

只有 \(1\) 为关键点时,游戏即为树上删边游戏。

对于 \(k > 1\) 的情况,有结论:将 \(1 \sim k\) 的虚树缩成一个点,得到根的 SG 值异或上虚树边数的奇偶性即为整个游戏的 SG 值。

\(1\) 为根建树,添加一个点就把到根路径上的所有边加入即可,时间复杂度线性。

#include <bits/stdc++.h>
using namespace std;
const int N = 3e5 + 7;

struct Graph {
    vector<int> e[N];
    
    inline void insert(int u, int v) {
        e[u].emplace_back(v);
    }
} G;

int fa[N], sg[N];
bool vis[N];

int n;

void dfs(int u, int f) {
    fa[u] = f;

    for (int v : G.e[u])
        if (v != f)
            dfs(v, u), sg[u] ^= sg[v] + 1;
}

signed main() {
    scanf("%d", &n);

    for (int i = 1; i < n; ++i) {
        int u, v;
        scanf("%d%d", &u, &v);
        G.insert(u, v), G.insert(v, u);
    }

    dfs(1, 0);
    int res = sg[1];
    putchar(res ? '1' : '2');

    for (int i = 2; i <= n; ++i) {
        vector<int> vec;

        for (int x = i; fa[x] && !vis[x]; x = fa[x])
            vis[x] = true, vec.emplace_back(x), res ^= 1;

        for (int it : vec)
            res ^= sg[it] ^ (sg[it] + 1);

        putchar(res ? '1' : '2');
    }

    return 0;
}

仙人掌删边博弈

给出一个仙人掌图,确定一个根。

先后手轮流操作,每次可以断开一条边,并删去不含根的连通块。

无法操作者输,求先手是否有必胜策略。

结论:长度为奇数的环 SG 值为 \(1\) ,长度为偶数的环 SG 值为 \(0\)

证明:

  • 对于长度为奇数的环,删边之后剩下两条链的奇偶性相同,因此其异或和不能为奇数,因此 SG 值为 \(1\)
  • 对于长度为偶数的环,删边之后剩下两条链的奇偶性不同,因此其异或和不能为偶数,因此 SG 值为 \(0\)

无向图删边博弈

给出一张无向图,确定一个根。

先后手轮流操作,每次可以断开一条边,并删去不含根的连通块。

无法操作者输,求先手是否有必胜策略。

结论(Fusion Principle):将偶环替换为一个新点,奇环替换为一个新点联储一条边,原图中连向环的边全部连向新点,不改变图的 SG 值,且图变为一棵树。

推广:对于一个边双,其 SG 值仅与其边数的奇偶性有关,偶数则为 \(0\) ,奇数则为 \(1\)

动态减法游戏

形式一

给出一个整数 \(n\) ,先后手轮流操作,每次可以令 \(n\) 减去一个正整数。

规定第一次不能减完,且之后每一次减的数不能超过上一次。

先减到 \(0\) 者胜,求先手是否有必胜策略。

结论:后手必胜当且仅当 \(n = 2^k\)

证明:先手每次减去 \(lowbit(n)\) 即可。

形式二

给出一个整数 \(n\) ,先后手轮流操作,每次可以令 \(n\) 减去一个正整数。

规定第一次不能减完,且之后每一次减的数不能超过上一次的两倍。

先减到 \(0\) 者胜,求先手是否有必胜策略

结论:后手必胜当且仅当 \(n\) 为斐波那契数。

应用

操作步数的奇偶性

UOJ51. 【UR #4】元旦三侠的游戏

初始时有 \(a, b\) ,双方轮流操作,每次可以令 \(a\)\(b\) 增加 \(1\) 并满足 \(a^b \le n\)

无法操作者失败,给定 \(n\)\(m\) 次询问 \((a, b)\) 是否先手必胜。

\(n \le 10^9\)\(m \le 10^5\)\(a \ge 2\)\(b \ge 1\)

对于一个 \(b\)\(a \le \sqrt[b]{n}\) ,于是状态只有 \(\sum_{b = 1}^{\log n} \sqrt[b]{n}\) 种。

发现当 \(a > \sqrt[b + 1]{n}\) 时只能增加 \(a\) ,因此有用的状态只有 \(\sum_{b = 2}^{\log n} \sqrt[b]{n} < \sqrt{n} \log n\) 个,剩下的只要求出 \(a\) 与其的差值的奇偶性即可。

时间复杂度 \(O(\sqrt{n} \log n)\)

#include <bits/stdc++.h>
typedef long long ll;
using namespace std;

map<pair<int, int>, bool> mp;

int n, m;

bool check(int a, int b) {
    if (mp.find(make_pair(a, b)) != mp.end())
        return mp[make_pair(a, b)];

    ll pw = 1;

    for (int i = 1; i <= b; ++i)
        pw *= a;

    if (pw > n)
        return true;

    if (b == 1 && 1ll * a * a > n)
        return (n - a) & 1;
    else
        return mp[make_pair(a, b)] = !check(a + 1, b) || !check(a, b + 1);
}

signed main() {
    scanf("%d%d", &n, &m);

    while (m--) {
        int a, b;
        scanf("%d%d", &a, &b);
        puts(check(a, b) ? "Yes" : "No");
    }

    return 0;
}

P4101 [HEOI2014] 人人尽说江南好

\(n\) 堆石子,每堆石子数量只有一个。先后手轮流操作,每次可以将数量和不超过 \(m\) 的两堆石子合并,无法操作者败,求先手是否有必胜策略。

\(n, m \le 10^9\)

首先,若最后剩下 \(k\) 堆,则说明合并了 \(n - k\) 次。若合并次数为奇数则先手必胜,否则后手必胜。

考虑加强限制,每次合并的两堆石子必须有一对是 \(1\) ,则合并次数为 \(k = n - \lceil \frac{n}{m} \rceil\) ,最终局面为若干堆 \(m\) 和一堆 \(n \bmod m\) (若 \(b \bmod m \ne 0\) )。

考虑原问题:

  • \(2 \nmid k\) ,则先手总能控制决策使得最终合并次数为 \(k\)
    • 考虑后手的操作。若后手操作的两堆均为 \(1\) ,则可以尝试将其与最大的合并,若无法合并则将其与 \(1\) 合并;否则这与加强限制版本的决策没有差别。不难发现如此总会保持场上为若干 \(m\) 、一个 \(\in [2, m - 1]\) 的堆、至多一个 \(2\) 、若干 \(1\)
  • \(2 \mid k\) ,则后手总能控制决策使得最终合并次数为 \(k\)
    • 分析与上一步基本没有差别。
#include <bits/stdc++.h>
using namespace std;
signed main() {
    int T;
    scanf("%d", &T);

    while (T--) {
        int n, m;
        scanf("%d%d", &n, &m);
        puts(((n - (n + m - 1) / m) & 1) ? "0" : "1");
    }

    return 0;
}

[ABC398G] Not Only Tree Game

给出一张无向简单二分图,先后手轮流操作,每次可以向途中加入一条不存在的无向边,满足加入边后图不出现奇环。

无法操作者败,求先手是否有必胜策略。

\(n, m \le 2 \times 10^5\)

先考虑只有一个连通块的情况,记左右部数量为 \(a, b\) ,则还能加 \(a \times b - m\) 条边,若其为奇数则先手必胜。

在考虑有多个连通块的情况,如果确定了连通块的连接方式,则总边数就可以确定。不难发现连通块的连接方式只和左右部的奇偶性有关,因此考虑分类讨论。

\(s\) 表示所有连通块内部可以加的总边数,接下来考虑将连通块按左右部奇偶性分类为 \(cnt_{0 \sim 3}\) ,其中:

  • \(cnt_0\) :左右部均为偶数。
  • \(cnt_1\) :左右部一侧为奇数一侧为偶数。
  • \(cnt_2\) :左右部均为奇数。
  • \(cnt_3\) :孤立点。

首先不难发现 \(cnt_0\)\(cnt_2\) 对结果没有影响,因为不会改变左右部奇偶性,从而不改变总边数奇偶性。其次 \(s\) 的奇偶性等价于 \(m + cnt_2\) 的奇偶性,因此无需记录 \(s\)

\(n\) 为奇数时,此时无论哪种合并方式,合并成一个连通块后左右部必然一奇一偶,因此总边数必然为偶数,则先手必胜当且仅当 \(m\) 为奇数。

\(n\) 为偶数时,考虑继续分类讨论:

  • \(cnt_1 = 0\) :此时 \(cnt_3\) 必然为偶数,考虑讨论连边的情况。

    • 若不操作孤立点,则不会改变连通块内部边数的奇偶性,而消耗一条边,即改变了 \(s\) 的奇偶性。
    • 若连接两个孤立点,则会改变 \(\frac{cnt_3}{2}\) 的奇偶性。
    • 若连接一个孤立点和另一个连通块,此时 \(cnt_1\) 会增加 \(1\) ,根据下面的讨论该情况显然不会出现,因为 \(cnt_1 = 1\) 时先手必胜。

    因此此时当 \(\frac{cnt_3}{2} + s\) 为奇数时先手必胜。

  • \(cnt_1 = 1\) :此时 \(cnt_3\) 必然为奇数,考虑先手先将 \(cnt_1\) 的点与一个孤立点合并,若将其放在偶数部,则会转化为 \(cnt_2\) ,否则会转化为 \(cnt_0\) 。不难发现这可以转化为 \(cnt_1 = 0\) 的情况,并且先手可以控制判定式的奇偶性,因此先手必胜。

  • \(cnt_1 = 2\) :和 \(cnt_1 = 1\) 类似,控制两个 \(cnt_1\) 的连接方式即可控制判定式的奇偶性,因此先手必胜。

  • \(cnt_1 \ge 3\) :此时 \(cnt_1 + cnt_3\) 必然为偶数,不难发现这可以归约到 \(cnt_1 + cnt_3 = 4\) 的情况,此时只能在内部连边(否则会归约到上文先手必胜的局面),由于每个连通块内部能连的边的数量均为偶数,因此先手必胜当且仅当 \(m\) 为奇数。

#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 7;

struct Graph {
    vector<int> e[N];
    
    inline void insert(int u, int v) {
        e[u].emplace_back(v);
    }
} G;

int col[N];

int n, m;

pair<int, int> dfs(int u) {
    pair<int, int> sum = make_pair(1, 0);

    for (int v : G.e[u]) {
        if (~col[v])
            continue;

        col[v] = col[u] ^ 1;
        auto res = dfs(v);
        sum = make_pair(sum.first + res.second, sum.second + res.first);
    }

    return sum;
}

signed main() {
    scanf("%d%d", &n, &m);

    if (n & 1)
        return puts(m & 1 ? "Aoki" : "Takahashi"), 0;

    for (int i = 1; i <= m; ++i) {
        int u, v;
        scanf("%d%d", &u, &v);
        G.insert(u, v), G.insert(v, u);
    }

    memset(col + 1, -1, sizeof(int) * n);
    vector<int> cnt(4);

    for (int i = 1; i <= n; ++i)
        if (col[i] == -1) {
            col[i] = 0;
            pair<int, int> now = dfs(i);

            if (!now.second)
                ++cnt[3];
            else
                ++cnt[(now.first & 1) + (now.second & 1)];
        }

    if (!cnt[1])
        puts((cnt[3] / 2 + m + cnt[2]) & 1 ? "Aoki" : "Takahashi");
    else if (cnt[1] <= 2)
        puts("Aoki");
    else
        puts(m & 1 ? "Aoki" : "Takahashi");

    return 0;
}

探索必胜条件

[AGC010F] Tree Game

有一棵树,每个点上有一些石头,两个人轮流扔掉所在点的一个石头并将棋子放到一个相邻的点,无法操作者输。求哪些初始棋子位置先手必胜。

\(n \le 3000\)

首先可以发现,走到权值不小于当前点的点是不优的,因为对手可以不断走回来。

考虑枚举每个点判断,将当前枚举的点设为根,则每次做树形 DP 即可。

#include <bits/stdc++.h>
using namespace std;
const int N = 3e3 + 7;

struct Graph {
    vector<int> e[N];
    
    inline void insert(int u, int v) {
        e[u].emplace_back(v);
    }
} G;

int a[N];
bool f[N];

int n;

bool dfs(int u, int fa) {
    f[u] = false;

    for (int v : G.e[u])
        if (v != fa)
            f[u] |= (a[u] > a[v] && !dfs(v, u));

    return f[u];
}

signed main() {
    scanf("%d", &n);

    for (int i = 1; i <= n; ++i)
        scanf("%d", a + i);

    for (int i = 1; i < n; ++i) {
        int u, v;
        scanf("%d%d", &u, &v);
        G.insert(u, v), G.insert(v, u);
    }

    for (int i = 1; i <= n; ++i)
        if (dfs(i, 0))
            printf("%d ", i);

    return 0;
}

[AGC014D] Black and White Tree

有一棵树,每个点初始都是灰色。两个人轮流选择一个灰色的点,先手染白、后手染黑。求后手能否让所有白点的邻域内都存在黑点。

\(n \le 10^5\)

若一个点底下挂了至少两个儿子,则先手选该点和其中一个儿子,后手就无法满足条件。否则可以选一个叶子的父亲,然后后手只能选这个叶子,因此可以把这两个点删掉。

因此 \(2 \nmid n\) 时一定先手必胜,\(2 \mid n\) 时问题转化为判断该树是否存在完美匹配,树形 DP 即可。

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 7;

struct Graph {
    vector<int> e[N];
    
    inline void insert(int u, int v) {
        e[u].emplace_back(v);
    }
} G;

int n;

int dfs(int u, int f) {
    int cnt = 0;

    for (int v : G.e[u])
        if (v != f) {
            int now = dfs(v, u);

            if (now == -1)
                return -1;
            else
                cnt += !now;
        }

    return cnt > 1 ? -1 : cnt;
}

signed main() {
    scanf("%d", &n);

    if (n & 1)
        return puts("First"), 0;

    for (int i = 1; i < n; ++i) {
        int u, v;
        scanf("%d%d", &u, &v);
        G.insert(u, v), G.insert(v, u);
    }

    puts(dfs(1, 0) == -1 ? "First" : "Second");
    return 0;
}

CF2070E Game with Binary String

考虑如下游戏:对于一个二进制字符串,玩家轮流行动。每次需要删除相邻两个数(首尾也算相邻),更进一步的限制为:

  • 先手只能删掉 \(00\)
  • 后手不能删掉 \(00\)

无法进行有效删除者败。

给出一个二进制字符串,求有多少子串先手必胜。

\(n \le 3 \times 10^5\)

首先可以发现后手一定不会选 \(11\) ,因此二者操作后 \(0\) 的数量会减少 \(3\)\(1\) 的数量会减少 \(1\)

先不考虑先手取得必须是相邻的 \(00\) 的限制,将其放宽为任意取两个 \(0\) 。定义一轮为先后手各操作一次,记 \(0\) 的数量为 \(x\)\(1\) 的数量为 \(y\) 。考虑先手必胜的条件:

  • 若干轮后没有 \(1\) ,且剩下至少两个 \(0\) ,即 \(x - 3y \ge 2\)
  • 若干轮后剩下两个 \(0\) 和一个 \(1\) ,即 \(x + 2 = 3y + 1\) ,即 \(x - 3y = -1\)

考虑原限制,发现唯一不同的地方就是可能会出现 \(01\) 交替的情况,此时先手必败。但是注意到该情况一定不会被归约到上面两种情况,因此正确性得证。

问题转化为前缀和序列上的一段值域查询,不难用树状数组做到 \(O(n \log n)\)

#include <bits/stdc++.h>
typedef long long ll;
using namespace std;
const int N = 1.2e6 + 7;

char str[N];

int n;

namespace BIT {
int c[N];

inline void insert(int x) {
    for (; x <= n * 4 + 1; x += x & -x)
        ++c[x];
}

inline int ask(int x) {
    int res = 0;

    for (; x; x -= x & -x)
        res += c[x];

    return res;
}

inline int query(int l, int r) {
    return ask(r) - ask(l - 1);
}
} // namespace BIT

signed main() {
    scanf("%d%s", &n, str + 1);
    BIT::insert(n * 3 + 1);
    ll ans = 0;

    for (int i = 1, s = n * 3 + 1; i <= n; ++i) {
        s += (str[i] & 1 ? -3 : 1);
        ans += BIT::query(s + 1, s + 1) + BIT::ask(max(s - 2, 1));
        BIT::insert(s);
    }

    printf("%lld", ans);
    return 0;
}

CF2062E2 The Game (Hard Version)

给出一棵树,点有点权。

双方轮流操作,每次可以选择点权严格大于上一次点权的点,并删掉其整个子树(包括这个点)。

第一次可以任意选取,无法操作者获胜。

找出所有先手必胜的操作点,或报告无解。

CF2062E1 The Game (Easy Version) :找到一个先手必胜的操作点,或报告无解。

\(n \le 4 \times 10^5\)

先考虑 E1,答案即为点权最大的点 \(u\) ,满足子树外存在一点 \(v\) 满足 \(val_v > val_u\) 。这样对手只能选 \(v\) ,于是先手就无法操作。

接下来考虑 E2,一个暴力是枚举所有操作点,然后把删去点权不超过它的点和它的子树,再用 E1 的方法判定。

记先手选的点为 \(u\) ,则必胜条件为对于所有 \(w_v > w_u\) 均满足两个条件之一:

  • \(v\) 位于 \(u\) 的子树内。
  • 权值大于 \(v\) 的点 \(w\)\(u\)\(v\) 的子树内。

那么降序扫到 \(v\) 时,记所有不在 \(v\) 子树内的 \(w\) 的 LCA 为 \(W\) ,那么判定条件为 \(u\)\(v\)\(W\) 的祖先。

考虑按点权倒序枚举点,记点权大于该点的 LCA 为 \(W\) ,则 \(1 \to W\) 的链上的点的交就是 \(u\) 的可行点集,树上差分判断即可,注意判定第一次选完点之后不能让对手无法操作。

接下来考虑求 \(W\) ,只要用一个 set 维护权值大于 \(u\) 的节点的 dfn,根据经典结论每次找到不在 \(u\) 子树内 dfn 最大最小点的 LCA 即可。

时间复杂度 \(O(n \log n)\)

#include <bits/stdc++.h>
using namespace std;
const int N = 4e5 + 7, LOGN = 21;

struct Graph {
    vector<int> e[N];

    inline void clear(int n) {
        for (int i = 1; i <= n; ++i)
            e[i].clear();
    }
    
    inline void insert(int u, int v) {
        e[u].emplace_back(v);
    }
} G;

struct BIT {
    int c[N];

    int n;

    inline void prework(int _n) {
        memset(c + 1, 0, sizeof(int) * (n = _n));
    }

    inline void update(int x, int k) {
        for (; x <= n; x += x & -x)
            c[x] += k;
    }

    inline int ask(int x) {
        int res = 0;

        for (; x; x -= x & -x)
            res += c[x];

        return res;
    }

    inline int query(int l, int r) {
        return ask(r) - ask(l - 1);
    }
} bit1, bit2;

vector<int> vec[N];

int fa[N][LOGN], dep[N], in[N], out[N], id[N];

int n, dfstime;

template <class T = int>
inline T read() {
    char c = getchar();
    bool sign = (c == '-');
    
    while (c < '0' || c > '9')
        c = getchar(), sign |= (c == '-');
    
    T x = 0;
    
    while ('0' <= c && c <= '9')
        x = (x << 1) + (x << 3) + (c & 15), c = getchar();
    
    return sign ? (~x + 1) : x;
}

void dfs(int u, int f) {
    fa[u][0] = f, dep[u] = dep[f] + 1, id[in[u] = ++dfstime] = u;

    for (int i = 1; i < LOGN; ++i)
        fa[u][i] = fa[fa[u][i - 1]][i - 1];

    for (int v : G.e[u])
        if (v != f)
            dfs(v, u);

    out[u] = dfstime;
}

inline int LCA(int x, int y) {
    if (!x || !y)
        return x | y;

    if (dep[x] < dep[y])
        swap(x, y);

    for (int i = 0, h = dep[x] - dep[y]; h; ++i, h >>= 1)
        if (h & 1)
            x = fa[x][i];

    if (x == y)
        return x;

    for (int i = LOGN - 1; ~i; --i)
        if (fa[x][i] != fa[y][i])
            x = fa[x][i], y = fa[y][i];

    return fa[x][0];
}

signed main() {
    int T = read();

    while (T--) {
        n = read();

        for (int i = 1; i <= n; ++i)
            vec[i].clear();

        for (int i = 1; i <= n; ++i)
            vec[read()].emplace_back(i);

        G.clear(n);

        for (int i = 1; i < n; ++i) {
            int u = read(), v = read();
            G.insert(u, v), G.insert(v, u);
        }

        dfstime = 0, dfs(1, 0);
        bit1.prework(n), bit2.prework(n);
        vector<int> ans;
        set<int> st;

        for (int i = n, cnt1 = 0, cnt2 = 0; i; --i) {
            for (int it : vec[i])
                if (bit1.query(in[it], out[it]) < cnt1 && bit2.query(in[it], out[it]) == cnt2)
                    ans.emplace_back(it);

            for (int it : vec[i]) {
                if (bit1.query(in[it], out[it]) == cnt1)
                    continue;

                int w = 0;

                if (*st.begin() < in[it])
                    w = LCA(w, id[*st.begin()]);
                else if (st.upper_bound(out[it]) != st.end())
                    w = LCA(w, id[*st.upper_bound(out[it])]);

                if (*st.rbegin() > out[it])
                    w = LCA(w, id[*st.rbegin()]);
                else if (st.lower_bound(in[it]) != st.begin())
                    w = LCA(w, id[*prev(st.lower_bound(in[it]))]);

                bit2.update(in[w], 1), bit2.update(in[it], 1), bit2.update(in[LCA(w, it)], -1), ++cnt2;
            }

            for (int it : vec[i])
                st.emplace(in[it]), bit1.update(in[it], 1), ++cnt1;
        }

        sort(ans.begin(), ans.end());
        printf("%d ", (int)ans.size());

        for (int it : ans)
            printf("%d ", it);

        puts("");
    }
    return 0;
}

DAG 上 DP

P9169 [省选联考 2023] 过河卒

有一个 \(n \times m\) 的棋盘,棋盘上有一些障碍,还有一个黑棋和两个红棋。

红方先走,黑方后走,双方轮流走棋。红方每次可以选择一个红棋 \((i, j)\) ,走到 \((i-1,j),(i+1,j),(i,j-1),(i,j+1)\) 中的一个,只要这个目的地在棋盘内且没有障碍且没有红方的另一个棋子。

黑方每次可以将自己的棋子 \((i, j)\) 走到 \((i-1,j),(i,j-1),(i,j+1)\) 这三个格子中的一个,只要这个目的地在棋盘内且没有障碍。

在一方行动之前,如果发生以下情况之一,则立即结束游戏,按照如下的规则判断胜负(列在前面的优先):

  • 黑棋位于第一行:黑方胜。
  • 黑棋和其中一个红棋在同一位置:上一步移动者胜。
  • 当前玩家无法操作:对方胜。

假设双方采用最优策略:

  • 若存在必胜策略,则选择所需步数最大值最少的操作。
  • 若不存在必胜策略,但存在平局策略,则选择任意一种平局策略。
  • 若不存在不败策略,则选择对方获胜所需步数最小值最大的操作。

判断游戏是否会平局,若不会需要求出结束时双方一共移动了多少步。

\(n, m \le 10\)

设状态 \((i, j, a, b, x, y, 0/1)\) 表示黑棋在 \((i, j)\) 、红棋在 \((a, b), (x, y)\) 、红/黑先手。先建出有向图,然后考虑移动步数,显然这是一个 DAG 上的最短路问题,直接拓扑排序即可。

时间复杂度 \(O((nm)^3)\)

51. 【UR #4】元旦三侠的游戏#include <bits/stdc++.h>
using namespace std;
const int dx[] = {-1, 0, 0, 1};
const int dy[] = {0, -1, 1, 0};
const int N = 11, M = 2e6 + 7;

struct Graph {
    struct Edge {
        int nxt, v;
    } e[M << 3];
    
    int head[M], indeg[M];
    
    int tot;
    
    inline void clear(int n) {
        memset(head, 0, sizeof(int) * n);
        memset(indeg, 0, sizeof(int) * n);
        tot = 0;
    }
    
    inline void insert(int u, int v) {
        e[++tot] = (Edge) {head[u], v}, head[u] = tot, ++indeg[v];
    }
} G;

int id[N][N][N][N][N][N][2], dis[M];
bool win[M], vis[M];
char str[N][N];

int n, m, tot;

inline bool check(const int &i, const int &j, const int &a, const int &b, const int &x, const int &y) {
    return  1 <= i && i <= n && 1 <= j && j <= m && 
        1 <= a && a <= n && 1 <= b && b <= m && 
        1 <= x && x <= n && 1 <= y && y <= m &&
        str[i][j] != '#' && str[a][b] != '#' && str[x][y] != '#' &&  (a != x || b != y);
}

inline void AllocateIndex() {
    tot = 0;
    
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            for (int a = 1; a <= n; ++a)
                for (int b = 1; b <= m; ++b)
                    for (int x = 1; x <= n; ++x)
                        for (int y = 1; y <= m; ++y)
                            if (check(i, j, a, b, x, y))
                                id[i][j][a][b][x][y][0] = tot++, id[i][j][a][b][x][y][1] = tot++;
}

inline void BuildEdge() {
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            for (int a = 1; a <= n; ++a)
                for (int b = 1; b <= m; ++b)
                    for (int x = 1; x <= n; ++x)
                        for (int y = 1; y <= m; ++y)
                            if (check(i, j, a, b, x, y)) {
                                int cur = id[i][j][a][b][x][y][0];
                                
                                for (int k = 0, nx, ny; k < 3; ++k)
                                    if (check(nx = i + dx[k], ny = j + dy[k], a, b, x, y))
                                        G.insert(id[nx][ny][a][b][x][y][0], cur ^ 1);
                                
                                for (int k = 0, nx, ny; k < 4; ++k)
                                    if (check(i, j, nx = a + dx[k], ny = b + dy[k], x, y))
                                        G.insert(id[i][j][nx][ny][x][y][1], cur);
                                
                                for (int k = 0, nx, ny; k < 4; ++k)
                                    if (check(i, j, a, b, nx = x + dx[k], ny = y + dy[k]))
                                        G.insert(id[i][j][a][b][nx][ny][1], cur);
                                
                                if (i == 1) {
                                    vis[cur] = vis[cur ^ 1] = true;
                                    win[cur] = false, win[cur ^ 1] = true;
                                    dis[cur] = dis[cur ^ 1] = 0;
                                } else if ((i == a && j == b) || (i == x && j == y)) {
                                    vis[cur] = vis[cur ^ 1] = true;
                                    win[cur] = win[cur ^ 1] = false;
                                    dis[cur] = dis[cur ^ 1] = 0;
                                } else {
                                    if (!G.indeg[cur])
                                        vis[cur] = true, win[cur] = false, dis[cur] = 0;
                                        
                                    if (!G.indeg[cur ^ 1])
                                        vis[cur ^ 1] = true, win[cur ^ 1] = false, dis[cur ^ 1] = 0;
                                }
                            }
}

inline void TopoSort(int goal) {
    queue<int> q;
    
    for (int i = 0; i < tot; ++i)
        if (vis[i])
            q.emplace(i);
    
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        
        if (u == goal)
            break;
        
        for (int i = G.head[u]; i; i = G.e[i].nxt) {
            int v = G.e[i].v;
            
            if (vis[v])
                continue;
            
            --G.indeg[v];
            
            if (!win[u] || !G.indeg[v])
                win[v] = win[u] ^ 1, dis[v] = dis[u] + 1, vis[v] = true, q.emplace(v);
        }
    }
}

signed main() {
    int testid, T;
    scanf("%d%d", &testid, &T);
    
    while (T--) {
        scanf("%d%d", &n, &m);
        pair<int, int> black, red1 = make_pair(0, 0), red2;
        
        for (int i = 1; i <= n; ++i) {
            scanf("%s", str[i] + 1);
            
            for (int j = 1; j <= m; ++j) {
                if (str[i][j] == 'X')
                    black = make_pair(i, j);
                else if (str[i][j] == 'O') {
                    if (red1 == make_pair(0, 0))
                        red1 = make_pair(i, j);
                    else
                        red2 = make_pair(i, j);
                }
            }
        }
        
        AllocateIndex(), G.clear(tot);
        memset(dis, 0, sizeof(int) * tot);
        memset(win, 0, sizeof(bool) * tot);
        memset(vis, 0, sizeof(bool) * tot);
        BuildEdge();
        int goal = id[black.first][black.second][red1.first][red1.second][red2.first][red2.second][0];
        TopoSort(goal);
        51. 【UR #4】元旦三侠的游戏
        if (vis[goal])
            printf("%s %d\n", win[goal] ? "Red" : "Black", dis[goal]);
        else
            puts("Tie");
    }
    
    return 0;
}

SG 定理的应用

P3235 [HNOI2014] 江南乐

\(n\) 堆石子,第 \(i\) 堆有 \(a_i\) 个。

一次操作可以将一堆数量为 \(i (i \ge F)\) 的石子分为:\(j - i \bmod j\) 堆数量为 \(\lfloor \frac{i}{j} \rfloor\) 的石子、\(i \bmod j\) 堆数量为 \(\lceil \frac{i}{j} \rceil\) 的石子。其中 \(j\) 可以自己选定,需要满足 \(2 \le j \le i\)

无法操作者输,求是否存在先手必胜策略。

多组数据,但 \(F\) 不改变。

\(T, n \le 100\)\(F, a_i \le 10^5\)

考虑求出每个数的 SG 值,这样直接判断异或和是否非 \(0\) 即可。

首先,对于 \(i < F\) ,显然先手必败,故 \(SG(i) = 0\) 。否则枚举每个 \(j\) ,计算各个子游戏的 SG 函数的异或和的 mex 即可,直接做是 \(O(V^2 + Tn)\) 的。

考虑整除分块,对于一段 \([l, r]\) ,发现 SG 函数相同的点交替出现,自然想到异或和奇偶性的关系,分类讨论:

  • \(2 \not \mid \lfloor \frac{i}{j} \rfloor\) :此时 \(j - i \bmod j = j(1 + \lfloor \frac{i}{j} \rfloor) - i\) 奇偶性不变,因此该部分贡献不变。
  • \(2 \mid \lfloor \frac{i}{j} \rfloor\) :此时 \(i \bmod j = i - j \lfloor \frac{i}{j} \rfloor\) 奇偶性不变,因此该部分贡献不变。

因此两个部分中,总有一个部分贡献不变,于是只要枚举两个 \(j\) 即可。

时间复杂度 \(O(V \sqrt{V} + Tn)\)

#include <bits/stdc++.h>
using namespace std;
const int N = 1e3 + 7, V = 1e5 + 7;

int sg[V];

int F;

int SG(int n) {
    if (n < F)
        return 0;

    if (~sg[n])
        return sg[n];

    bitset<N> vis;

    for (int l = 2, r; l <= n; l = r + 1) {
        r = n / (n / l);
        int res = 0;

        if ((n % l) & 1)
            res ^= SG(n / l + 1);

        if ((l - n % l) & 1)
            res ^= SG(n / l);

        vis.set(res);

        if (l < r) {
            res = 0;

            if ((n % (l + 1)) & 1)
                res ^= SG(n / (l + 1) + 1);

            if (((l + 1) - n % (l + 1)) & 1)
                res ^= SG(n / (l + 1));

            vis.set(res);
        }
    }

    return sg[n] = (~vis)._Find_first();
}

signed main() {
    memset(sg, -1, sizeof(sg));
    int T;
    scanf("%d%d", &T, &F);

    while (T--) {
        int n, ans = 0;
        scanf("%d", &n);

        for (int i = 1; i <= n; ++i) {
            int x;
            scanf("%d", &x);
            ans ^= SG(x);
        }

        printf("%d ", ans ? 1 : 0);
    }

    return 0;
}

P3185 [HNOI2007] 分裂游戏

\(n\) 堆石子,第 \(i\) 堆有 \(a_i\) 个。每次操作可以选择三个位置 \(i < j \le k\) ,令 \(a_i\) 减一,并令 \(a_j, a_k\) 均加一,其中 \(j, k\) 可以相等。

双方轮流操作,求是否存在先后必胜策略,若存在需要求出先手必胜时可能的第一次操作种数以及字典序最小的操作。

\(n \le 21\)

先考虑结束状态,显然前 \(n - 1\) 堆都没有石子时结束。

考虑将每个石子视作一个子游戏,一次操作相当于拿走第 \(i\) 堆的一个石子到 \(n\) ,然后在第 \(j, k\) 堆产生一个新的子游戏,那么有 \(SG(i) = \operatorname{mex}_{i < j \le k} \{ SG(j) \oplus SG(k) \}\)

由 SG 定理,一个位置的石子只要保留 \(\bmod 2\) 即可(异或抵消)。

对于方案求解,可以直接枚举第一次操作算 SG 值。

时间复杂度 \(O(n^3)\)

#include <bits/stdc++.h>
using namespace std;
const int N = 21;

int a[N], sg[N];

int n;

signed main() {
    int T;
    scanf("%d", &T);

    while (T--) {
        scanf("%d", &n);

        for (int i = 0; i < n; ++i)
            scanf("%d", a + i);

        for (int i = n - 1; ~i; --i) {
            bitset<N * N> vis;

            for (int j = i + 1; j < n; ++j)
                for (int k = j; k < n; ++k)
                    vis.set(sg[j] ^ sg[k]);

            sg[i] = (~vis)._Find_first();
        }

        int ans = 0;

        for (int i = 0; i < n; ++i)
            if (a[i]) {
                for (int j = i + 1; j < n; ++j)
                    for (int k = j; k < n; ++k) {
                        --a[i], ++a[j], ++a[k];
                        int res = 0;

                        for (int d = 0; d < n; ++d)
                            if (a[d] & 1)
                                res ^= sg[d];

                        if (!res) {
                            ++ans;

                            if (ans == 1)
                                printf("%d %d %d\n", i, j, k);
                        }

                        ++a[i], --a[j], --a[k];
                    }
            }

        if (!ans)
            puts("-1 -1 -1");
        
        printf("%d\n", ans);
    }

    return 0;
}

P6665 [清华集训 2016] Alice 和 Bob 又在玩游戏

给出一个森林,每个树的根为编号最小的点。

先后手轮流操作,每次可以选择一个点 \(x\) ,将 \(x\) 及其祖先全部删除。

无法操作者败,求先手是否有必胜策略。

\(n \le 10^5\)

整体的 SG 值即为每个树的 SG 值的异或和,考虑求解每个树的 SG 值。

考虑已知所有 \(v \in son(u)\) 子树的 SG 值后求解 \(u\) 子树的 SG 值,记 \(v\) 子树所有决策集合的 SG 值为 \(A_v\) ,则接上父亲 \(u\) 之后每一中决策都会多出 \(son(u) \setminus \{ v \}\) 的子树,因此需要将 \(A_v\) 整体异或上其他子树的 SG 值。

\(u\) 子树的 SG 值即为所有决策的 SG 值的 \(\mathrm{mex}\)

考虑用 01-Trie 维护 SG 值,需要支持 01-Trie 合并、全局异或 \(k\) 查询 \(\mathrm{mex}\)

不难做到 \(O(n \log n)\)

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 7, B = 17;

struct Graph {
    vector<int> e[N];

    inline void clear(int n) {
        for (int i = 1; i <= n; ++i)
            e[i].clear();
    }
    
    inline void insert(int u, int v) {
        e[u].emplace_back(v);
    }
} G;

int sg[N];
bool vis[N];

int n, m;

namespace Trie {
const int S = 2e6 + 7;

int ch[S][2], tag[S], siz[S], rt[N];

int tot;

inline int newnode() {
    ++tot, ch[tot][0] = ch[tot][1] = tag[tot] = siz[tot] = 0;
    return tot;
}

inline void clear(int n) {
    tot = 0;

    for (int i = 1; i <= n; ++i)
        rt[i] = newnode();
}

inline void spread(int x, int d, int k) {
    if (~d && (k >> d & 1))
        swap(ch[x][0], ch[x][1]);

    tag[x] ^= k;
}

inline void pushdown(int x, int d) {
    if (tag[x]) {
        if (ch[x][0])
            spread(ch[x][0], d - 1, tag[x]);

        if (ch[x][1])
            spread(ch[x][1], d - 1, tag[x]);

        tag[x] = 0;
    }
}

inline void insert(int u, int k) {
    ++siz[u];

    for (int i = B - 1; ~i; --i) {
        pushdown(u, i);
        int c = k >> i & 1;

        if (!ch[u][c])
            ch[u][c] = newnode();

        ++siz[u = ch[u][c]];
    }
}

int merge(int a, int b, int d) {
    if (!a || !b)
        return a | b;
    else if (d == -1)
        return a;

    pushdown(a, d), pushdown(b, d);
    ch[a][0] = merge(ch[a][0], ch[b][0], d - 1);
    ch[a][1] = merge(ch[a][1], ch[b][1], d - 1);
    return siz[a] = siz[ch[a][0]] + siz[ch[a][1]], a;
}

inline int querymex(int u) {
    int res = 0;

    for (int i = B - 1; ~i && u; --i) {
        pushdown(u, i);

        if (!ch[u][0] || siz[ch[u][0]] != (1 << i))
            u = ch[u][0];
        else
            res |= 1 << i, u = ch[u][1];
    }

    return res;
}
} // namespace Trie

int dfs(int u, int f) {
    vis[u] = true;
    int sum = 0;

    for (int v : G.e[u])
        if (v != f)
            sum ^= dfs(v, u);

    Trie::insert(Trie::rt[u], sum);

    for (int v : G.e[u])
        if (v != f)
            Trie::spread(Trie::rt[v], B - 1, sum ^ sg[v]), Trie::rt[u] = Trie::merge(Trie::rt[u], Trie::rt[v], B - 1);

    return sg[u] = Trie::querymex(Trie::rt[u]);
}

signed main() {
    int T;
    scanf("%d", &T);

    while (T--) {
        scanf("%d%d", &n, &m);
        G.clear(n);

        for (int i = 1; i <= m; ++i) {
            int u, v;
            scanf("%d%d", &u, &v);
            G.insert(u, v), G.insert(v, u);
        }

        memset(vis + 1, false, sizeof(bool) * n);
        Trie::clear(n);
        int ans = 0;

        for (int i = 1; i <= n; ++i)
            if (!vis[i])
                ans ^= dfs(i, 0);

        puts(ans ? "Alice" : "Bob");
    }

    return 0;
}

P3179 [HAOI2015] 数组游戏

有一排 \(n\) 个硬币,编号 \(1 \sim n\) 。其中一些硬币是正面朝上,剩下全为反面朝上。

先后手轮流操作,每次可以选择一个正面朝上的硬币 \(x\) 和正整数 \(k \le \frac{n}{x}\) ,并翻转 \(x, 2x, 3x, \cdots, kx\) 的硬币,无法操作者败。

\(k\) 次询问,每次给出 \(w_i\) 个正面朝上的硬币编号,每次求解先手是否有必胜策略。

\(n \le 10^9\)\(k, w \le 100\)

考虑 SG 定理,则需要求和每个正面朝上的硬币单独存在的 SG 值异或和。

先考虑暴力,对于一个 \(x\) ,枚举所有的 \(k\) ,此时新状态的 SG 值即为 \(\oplus_{i = 2}^k SG(ix)\) ,对这些 SG 值取 \(\mathrm{mex}\) 即可,时间复杂度 \(O(n \ln n)\) ,无法通过。

观察 SG 值,可以发现 \(\lfloor \frac{n}{x} \rfloor\) 相同的 \(x\) 的 SG 值相同,这是因为它们转移与后续状态本质相同。

考虑只维护 \(O(\sqrt{n})\)\(\lfloor \frac{n}{x} \rfloor\) 不同的 \(x\) 的 SG 值,整除分块套整除分块求解 SG 值,时间复杂度 \(O(n^{\frac{3}{4}})\)

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 7;

int sg[N][2], pos[N], vis[N];

int n, q, tot;

inline int &SG(int x) {
    return 1ll * x * x <= n ? sg[x][0] : sg[n / x][1];
}

signed main() {
    scanf("%d%d", &n, &q);

    for (int i = 1; i <= n; i = n / (n / i) + 1)
        pos[++tot] = i;

    for (int i = tot; i; --i) {
        vis[0] = i;
        int t = 0, res = 0;

        for (int l = pos[i] * 2, r; l <= n; l = r + pos[i]) {
            r = n / (n / l) / pos[i] * pos[i];
            vis[res ^ SG(l)] = i;

            if (((r - l) / pos[i] + 1) & 1)
                res ^= SG(l);
        }

        res = 0;

        while (vis[res] == i)
            ++res;

        SG(pos[i]) = res;
    }

    while (q--) {
        int k, ans = 0;
        scanf("%d", &k);

        while (k--) {
            int x;
            scanf("%d", &x);
            ans ^= SG(x);
        }

        puts(ans ? "Yes" : "No");
    }

    return 0;
}

先后手对权值目标不同

CF794E Choosing Carrot

给出序列 \(a_{1 \sim n}\) ,先后手轮流操作,每次可以从开头或末尾删去一个数。先手希望最后剩下的数尽量大,后手希望最后剩下的数尽量小。

对于 \(k \in [0, n - 1]\) ,求先手在游戏前执行 \(k\) 次操作后(仍然是先手操作)游戏最后剩下的数。

\(n \le 3 \times 10^5\)

此类问题一般套路是:若先手能保证答案不小于某个值,后手能保证答案不大于某个值,那么答案就是该值。

先考虑 \(k = 0\) 的情况,有结论:当 \(n > 1\) 时,记 \(mid = \lfloor \frac{n}{2} \rfloor\) ,答案即为

\[\begin{cases} \max(a_{mid}, a_{mid + 1}) & 2 \mid n \\ \max(\min(a_{mid}, a_{mid - 1}), \min(a_{mid}, a_{mid + 1})) & 2 \nmid n \end{cases} \]

证明就有提到的套路即可。

接下来考虑 \(k > 0\) 的情况,不难发现先手能控制得就是 \(mid\) 的位置,因此答案不难得出:

\[ans_k = \begin{cases} \max_{i = \frac{n - k}{2}}^{\frac{n + k}{2} + 1} a_i & 2 \mid (n - k) \\ \max_{i = \frac{n - k - 1}{2}}^{\frac{n + k + 1}{2}} ( \min(a_i, a_{i + 1}) ) & 2 \nmid (n - k) \\ \end{cases} \]

需要特判 \(k = n - 1\) 的情况,时间复杂度线性。

#include <bits/stdc++.h>
using namespace std;
const int N = 3e5 + 7, LOGN = 19;

int a[N], b[N], ans[2][N];

int n;

signed main() {
    scanf("%d", &n);

    for (int i = 1; i <= n; ++i)
        scanf("%d", a + i);

    for (int i = 1; i < n; ++i)
        b[i] = min(a[i], a[i + 1]);

    for (int i = n & 1; i < n - 1; i += 2) // ans = max a[(n - i) / 2 ~ (n + i) / 2 + 1]
        ans[0][i] = max(i >= 2 ? ans[0][i - 2] : (n & 1 ? a[n / 2 + 1] : 0), max(a[(n - i) / 2], a[(n + i) / 2 + 1]));

    for (int i = ~n & 1; i < n - 1; i += 2) // ans = max b[(n - i) / 2 ~ (n + i) / 2 + 1]
        ans[1][i] = max(i >= 2 ? ans[1][i - 2] : (~n & 1 ? b[n / 2] : 0), max(b[(n - i - 1) / 2], b[(n + i + 1) / 2]));

    for (int i = 0; i < n - 1; ++i)
        printf("%d ", ans[(n - i) & 1][i]);

    printf("%d", *max_element(a + 1, a + n + 1));
    return 0;
}

P10200 [省选联考 2024] 迷宫守卫

给一棵 \(n+1\) 层的完全二叉树,结点从上到下、从左到右编号为 \(1\sim 2^{n+1}-1\)

每个非叶点有一个代价 \(w_i\) ,而 \(2^n\) 个叶子的权值构成 \(1\sim 2^n\) 的排列。

Alice 可以用不超过 \(k\) 的代价选一些非叶点,随后 Bob 对这棵树 dfs :

  • 对于 Alice 选的结点, Bob 必须先走左儿子再走右儿子。
  • 对于其余点,Bob 可以任意决定 dfs 顺序。

按照 Bob 访问叶子的顺序将所有 \(q_i\) 排成序列 \(Q\) 。Alice 希望 \(Q\) 的字典序最大,Bob 希望 \(Q\) 的字典序最小。求最终的 \(Q\)

\(T \le 100\)\(n \le 16\)\(\sum 2^n \le 10^5\)\(w_i, k \le 10^{12}\)

考虑先求第一个点的点权,可以二分。设 \(f_x\) 表示走到节点 \(x\) 后,使得第一个叶子点权 \(\ge mid\) 的最少要花多少代价,则:

\[f_x = \begin{cases} [q_x < mid] \times \infty & \text{x is a leaf} \\ f_{ls(x)} + \min(w_x, f_{rs(x)}) & \text{otherwise} \end{cases} \]

然后递归求解,遍历到每个点时都递归整个子树做一遍该决策,即可求出前去的子树。

由于是完全二叉树,时间复杂度 \(O(n^2 2^n)\)

#include <bits/stdc++.h>
typedef long long ll;
using namespace std;
const int N = 1 << 17 | 1;

vector<int> h[N];

ll a[N], f[N];

ll m;
int n;

template <class T = int>
inline T read() {
    char c = getchar();
    bool sign = (c == '-');
    
    while (c < '0' || c > '9')
        c = getchar(), sign |= (c == '-');
    
    T x = 0;
    
    while ('0' <= c && c <= '9')
        x = (x << 1) + (x << 3) + (c & 15), c = getchar();
    
    return sign ? (~x + 1) : x;
}

inline int ls(int x) {
    return x << 1;
}

inline int rs(int x) {
    return x << 1 | 1;
}

void dfs1(int u) {
    if (u >= 1 << n) {
        h[u] = {a[u]};
        return;
    }

    dfs1(ls(u)), dfs1(rs(u));
    h[u].clear(), h[u].resize(h[ls(u)].size() + h[rs(u)].size());
    merge(h[ls(u)].begin(), h[ls(u)].end(), h[rs(u)].begin(), h[rs(u)].end(), h[u].begin());
}

ll calc(int u, ll lim) {
    if (u >= (1 << n))
        return f[u] = (a[u] < lim ? m + 1 : 0);
    else
        return f[u] = calc(ls(u), lim) + min(calc(rs(u), lim), a[u]);
}

pair<ll, vector<int> > dfs2(int u, ll m) {
    if (u >= (1 << n))
        return make_pair(0, vector<int>{a[u]});

    int l = 0, r = h[u].size() - 1, res = h[u][0];

    while (l <= r) {
        int mid = (l + r) >> 1;

        if (calc(u, h[u][mid]) <= m)
            res = h[u][mid], l = mid + 1;
        else
            r = mid - 1;
    }

    calc(u, res);

    auto merge = [](vector<int> a, vector<int> b) {
        for (int it : b)
            a.emplace_back(it);

        return a;
    };

    if (find(h[ls(u)].begin(), h[ls(u)].end(), res) != h[ls(u)].end()) {
        auto lans = dfs2(ls(u), m - min(a[u], f[rs(u)]));
        m -= lans.first;

        if (m >= f[rs(u)]) {
            auto rans = dfs2(rs(u), m);
            return make_pair(lans.first + rans.first, merge(lans.second, rans.second));
        } else {
            auto rans = dfs2(rs(u), m - a[u]);
            return make_pair(lans.first + rans.first + a[u], merge(lans.second, rans.second));
        }
    } else {
        auto rans = dfs2(rs(u), m - f[ls(u)]), lans = dfs2(ls(u), m - rans.first);
        return make_pair(rans.first + lans.first, merge(rans.second, lans.second));
    }
}

signed main() {
    int T = read();

    while (T--) {
        n = read(), m = read<ll>();

        for (int i = 1; i < (1 << n); ++i)
            a[i] = read<ll>();

        for (int i = 1 << n; i < (1 << (n + 1)); ++i)
            a[i] = read();

        dfs1(1);
        auto ans = dfs2(1, m).second;

        for (int it : ans)
            printf("%d ", it);

        puts("");
    }

    return 0;
}

P7137 [THUPC 2021 初赛] 切切糕

\(n\) 个切糕,每次 A 选择一块未切的切糕,将其任意切成两块,其中每块切糕的大小可以是任意非负实数。切完后二人选择切糕拿走,B 有 \(m\) 次优先选择的机会。

二人均需最大化自己的切糕大小,求二人均采用最优策略下 A 获得的切糕大小。

\(n, m \le 2500\)

先考虑 A 切糕的顺序,不难发现最优决策一定是从小到大切,因为后面的切糕会限制 B 的决策。

\(f_{i, j}\) 表示 A 考虑 \([i, n]\) 的决策, B 剩下 \(j\) 次选择权时 A 获得的切糕大小,设第 \(i\) 块 A 切出的更大块大小为 \(x\) ,则:

\[f_{i, j} = \min(f_{i + 1, j} + x, f_{i + 1, j - 1} + a_i - x) \]

由于 A 要最大化 \(f_{i, j}\) ,因此二者取等时最优,此时 \(x = \frac{1}{2}(f_{i + 1, j - 1} + a_i - f_{i + 1, j})\) 。此时 \(x\) 可能不在 \([\frac{1}{2} a_i, a_i]\) 内,取一个相对优的值即可。

注意特殊处理一下 \(j = 0\) 的情况,此时 \(f_{i, 0} = f_{i + 1, 0} + a_i\)

时间复杂度 \(O(nm)\)

#include <bits/stdc++.h>
using namespace std;
const int N = 2.5e3 + 7;

double a[N], f[N][N];

int n, m;

signed main() {
    scanf("%d%d", &n, &m);

    for (int i = 1; i <= n; ++i)
        scanf("%lf", a + i);

    sort(a + 1, a + n + 1);

    for (int i = n; i; --i) {
        f[i][0] = f[i + 1][0] + a[i];

        for (int j = 1; j <= m; ++j) {
            double x = (f[i + 1][j - 1] + a[i] - f[i + 1][j]) / 2;

            if (x < a[i] / 2)
                x = a[i] / 2;
            else if (x > a[i])
                x = a[i];

            f[i][j] = min(f[i + 1][j] + x, f[i + 1][j - 1] + a[i] - x);
        }
    }

    printf("%.6lf", f[1][m]);
    return 0;
}

博弈论相关计数

七管荧光灯

一个七管荧光灯如图所示:

对于一次游戏,七条边上分别有一些石子(可能没有)。先后手轮流取石子,每一次可以选择某些连通的边(但是选择的边不 得成环),并从其中的每条边上各选择若干个石子移走(可以为 \(0\) ,但是一次操作至少要取走一个石子)。无法操作者输。

给出七条边石子数量的上下界 \(l_{1 \sim 7}, r_{1 \sim 7}\) ,求有多少种先手必胜的方案。

\(l_{1 \sim 7}, r_{1 \sim 7} \le 10^{18}\)

结论:先手必败当且仅当 \(1, 2, 3\) 条边的石子相等,\(5, 6, 7\) 条边的石子相等,且 \(1,4, 7\) 条边的石子异或和为 \(0\)

证明类似 Nim 游戏:

  • \(\{1, 2, 3 \}, \{ 4 \}, \{ 5, 6, 7 \}\) 三个集合内部石子不相等,则先手可以将它们调整至相等,同时可以使得 \(1,4, 7\) 条边的石子异或和为 \(0\)
  • 对于剩下的操作,显然可以钦定每次集合内选取状态相同(否则后手仍可以在操作的同时调整至相同),又因为每次只能选一个集合,于是转化为 Nim 游戏。

然后直接容斥配合数位 DP 统计必败态即可。

#include <bits/stdc++.h>
typedef long long ll;
using namespace std;
const ll inf = 0x3f3f3f3f3f3f3f3f;
const int Mod = 998244353;
const int B = 63;

ll L[3], R[3];
int f[B][2][2][2];

ll n[3];

inline int add(int x, int y) {
    x += y;
    
    if (x >= Mod)
        x -= Mod;
    
    return x;
}

inline int dec(int x, int y) {
    x -= y;
    
    if (x < 0)
        x += Mod;
    
    return x;
}

inline int sgn(int n) {
    return n & 1 ? Mod - 1 : 1;
}

int dfs(int x, bool limit0, bool limit1, bool limit2) {
    if (x == -1)
        return 1;
    else if (~f[x][limit0][limit1][limit2])
        return f[x][limit0][limit1][limit2];

    int high0 = (limit0 ? n[0] >> x & 1 : 1), high1 = (limit1 ? n[1] >> x & 1 : 1), 
        high2 = (limit2 ? n[2] >> x & 1 : 1), res = 0;

    for (int i = 0; i <= high0; ++i)
        for (int j = 0; j <= high1; ++j)
            for (int k = 0; k <= high2; ++k)
                if (!(i ^ j ^ k))
                    res = add(res, dfs(x - 1, limit0 && i == high0, limit1 && j == high1, limit2 && k == high2));

    return f[x][limit0][limit1][limit2] = res;
}

signed main() {
    R[0] = R[1] = R[2] = inf;
    int ans = 1;

    for (int i = 1; i <= 7; ++i) {
        ll l, r;
        scanf("%lld%lld", &l, &r);
        ans = 1ll * ans * ((r - l + 1) % Mod) % Mod;
        int x = (i <= 3 ? 0 : (i == 4 ? 1 : 2));
        L[x] = max(L[x], l), R[x] = min(R[x], r);
    }
    
    if (L[0] > R[0] || L[1] > R[1] || L[2] > R[2])
        return printf("%d", ans), 0;
    
    for (int s = 0; s < (1 << 3); ++s) {
        bool flag = true;
        
        for (int i = 0; i < 3; ++i)
            flag &= ((s >> i & 1) || L[i]);
        
        if (!flag)
            continue;
        
        for (int i = 0; i < 3; ++i)
            n[i] = (s >> i & 1 ? R[i] : L[i] - 1);

        memset(f, -1, sizeof(f));
        ans = add(ans, 1ll * sgn(__builtin_popcount(s)) * dfs(B - 1, true, true, true) % Mod);
    }

    printf("%d", ans);
    return 0;
}

取石子游戏

有一个游戏:

\(n\) 堆石子,第 \(i\) 堆有 \(a_i\) 个颜色为 \(c_i\) 的石子。给定常数 \(m\) ,先后手轮流行动,每次可以选择两种操作之一:

  • 选择一堆石子以及正整数 \(x\)\(x\) 不超过这堆石子目前的数量),从这堆石子中取出 \(x\) 个石子。
  • 选择一种颜色 \(c\) ,从一堆或多堆颜色为 \(c\) 的石子中总共取出至少一个、至多 \(m\) 个石子。

无法操作者败。

给定 \(n, m\)\(n\) 个集合 \(A_{1 \sim n}\) ,求每堆石子在集合内随机选取时,上述游戏先手获胜的概率乘上 \(\prod_{i = 1}^n |A_i|\)

\(n, m \le 20\)\(|A_i|, a_i \le 10^5\)

首先可以想到每种颜色相互独立,总的 SG 值为所有颜色的 SG 值的异或。

考虑两个观察:

  • 只有一堆石子时,发现操作一本质是没用的,类似巴什博弈,其 SG 值即为 \(a_i \bmod (m + 1)\)
  • 对所有位置每 \(m + 1\) 个石子分为一个块,那么能一次消除超过一个整块的操作一定是操作一,也就是只会操作一堆石子。

\(x\) 表示总和 \(\bmod (m + 1)\) 的值,\(y\) 表示所有堆除以 \(m + 1\) 后的 Nim 游戏值(异或和),不难发现 SG 值仅与 \(x, y\) 有关,考虑两个操作的影响:

  • 操作一:会让 \(y\) 减少至少 \(1\) ,可以任意改变 \(x\)
  • 操作二:可以任意改变 \(x\)

因此一个状态 \((x, y)\) 能到达 \(y\) 更小或 \(x\) 不同的状态,设计 SG 函数为 \(y \times (m + 1) + x\) ,容易证明这是正确的。

剩下的 DP 过程不难用 FWT 优化做到 \(O(n V \log \frac{V}{m} + nmV)\)

#include <bits/stdc++.h>
using namespace std;
const int Mod = 998244353, inv2 = (Mod + 1) / 2;
const int N = 21, V = 1e5, M = 1 << 18;

int f[N][N][M], g[N][M], h[N][M], F[M], G[M];
int siz[N], col[N];

int n, m;

inline int add(int x, int y) {
    x += y;
    
    if (x >= Mod)
        x -= Mod;
    
    return x;
}

inline int dec(int x, int y) {
    x -= y;
    
    if (x < 0)
        x += Mod;
    
    return x;
}

inline void FWT(int *f, int n, int op) {
    for (int k = 1; k < n; k <<= 1)
        for (int i = 0; i < n; i += k << 1)
            for (int j = 0; j < k; ++j) {
                int fl = f[i + j], fr = f[i + j + k];
                f[i + j] = 1ll * (op == 1 ? 1 : inv2) * add(fl, fr) % Mod;
                f[i + j + k] = 1ll * (op == 1 ? 1 : inv2) * dec(fl, fr) % Mod;
            }
}

signed main() {
    scanf("%d%d", &n, &m);
    int mm = 1 << (__lg(V / (m + 1)) + 1);

    for (int i = 1; i <= n; ++i) {
        int k;
        scanf("%d%d", &k, col + i);

        while (k--) {
            int x;
            scanf("%d", &x);
            ++f[i][x % (m + 1)][x / (m + 1)];
        }

        for (int j = 0; j <= m; ++j)
            FWT(f[i][j], mm, 1);
    }

    F[0] = 1, FWT(F, M, 1);

    for (int i = 1; i <= n; ++i) {
        memset(g, 0, sizeof(g));
        g[0][0] = 1, FWT(g[0], mm, 1);

        for (int j = 1; j <= n; ++j) {
            if (col[j] != i)
                continue;

            memset(h, 0, sizeof(h));

            for (int x = 0; x <= m; ++x)
                for (int y = 0; y <= m; ++y)
                    for (int k = 0; k < mm; ++k)
                        h[(x + y) % (m + 1)][k] = add(h[(x + y) % (m + 1)][k], 1ll * g[x][k] * f[j][y][k] % Mod);

            memcpy(g, h, sizeof(g));
        }

        for (int j = 0; j <= m; ++j)
            FWT(g[j], mm, -1);

        memset(G, 0, sizeof(G));

        for (int x = 0; x <= m; ++x)
            for (int y = 0; y < mm; ++y)
                G[y * (m + 1) + x] = add(G[y * (m + 1) + x], g[x][y]);

        FWT(G, M, 1);

        for (int j = 0; j < M; ++j)
            F[j] = 1ll * F[j] * G[j] % Mod;
    }

    FWT(F, M, -1);
    int ans = 0;

    for (int i = 1; i < M; ++i)
        ans = add(ans, F[i]);

    printf("%d", ans);
    return 0;
}

AT_arc134_e [ARC134E] Modulo Nim

对于序列 \(x_{1 \sim n}\) ,双方轮流操作,每次可以选择一个 \(m \le \max x\) ,然后将所有数模去 \(m\)\(\max x = 0\) 时失败。

给定 \(a_{1 \sim n}\) ,求所有 \(x_i \in [1, a_i]\) 的序列中先手必胜的序列数量。

\(n, a_i \le 200\)

考虑如何快速判断一个局面是否为先手必胜,将数字去重后分类讨论一些 corner case:

  • \(\emptyset\) 必胜。
  • \(\{ 1 \}\)\(\{ 2 \}\) 必败;\(\{ x \} (x > 2)\) 必胜。
  • \(\exist x \in S, 2 \nmid x\) ,则 \(S\) 必胜(整体模 \(2\) 即可)。
  • 否则若 \(\exist x \in S, x \bmod 4 = 2\) ,则 \(S\) 必胜(整体模 \(4\) 即可)。
  • 否则若 \(\exist x \in S, x \bmod 12 \in \{ 4, 8 \}\) ,则 \(S\) 必胜(整体模 \(12\) 得到的状态暴搜发现必败)。

最后剩下 \(\forall x \in S, 12 \mid x\) 的情况,注意到此时 \(|S| \le \lfloor \frac{\max a}{12} \rfloor = 16\) ,因此直接状压即可 \(O(A \times 2^{16} \times 16)\) 预处理每个状态的情况。

接下来考虑计数:

  • 对于讨论的 corner case,不难发现必败态很少,直接用总方案数减去必败态即可。
  • 对于 \(\forall x \in S, 12 \mid x\) 的情况,枚举每一位做 DP 即可。

时间复杂度 \(O((n + A) \times 2^{16} \times 16)\)

#include <bits/stdc++.h>
using namespace std;
const int Mod = 998244353;
const int N = 2e2 + 7, M = 1 << 16;

int a[N], f[N][M];
bool legal[M];

int n;

inline int add(int x, int y) {
    x += y;
    
    if (x >= Mod)
        x -= Mod;
    
    return x;
}

inline int dec(int x, int y) {
    x -= y;
    
    if (x < 0)
        x += Mod;
    
    return x;
}

signed main() {
    scanf("%d", &n);
    int all1 = 1, all2 = 1;

    for (int i = 1; i <= n; ++i)
        scanf("%d", a + i), all1 = 1ll * all1 * a[i] % Mod, all2 = 1ll * all2 * (a[i] / 12) % Mod;

    int ans = dec(all1, all2);
    ans = dec(ans, 1); // {1}

    if (*min_element(a + 1, a + n + 1) >= 2) {
        ans = dec(ans, 1); // {2}

        if (*min_element(a + 1, a + n + 1) >= 4) {
            int pw = 1;
            bool flag = true;

            for (int i = 1; i <= n; ++i) {
                if (a[i] < 8)
                    flag = false;
                else
                    pw = 2ll * pw % Mod;
            }

            ans = dec(ans, dec(pw, flag + 1));
        }
    }

    if (*min_element(a + 1, a + n + 1) < 12)
        return printf("%d", ans), 0;

    f[0][0] = 1;

    for (int i = 1; i <= n; ++i)
        for (int s = 0; s < M; ++s)
            for (int j = 0; j + 1 <= a[i] / 12; ++j)
                f[i][s | (1 << j)] = add(f[i][s | (1 << j)], f[i - 1][s]);

    legal[0] = true;

    for (int s = 1; s < M; ++s) {
        int mx = (__lg(s) + 1) * 12;

        for (int i = 1; i <= mx; ++i) {
            set<int> st;

            for (int t = s; t; t &= t - 1)
                st.emplace((__builtin_ctz(t) + 1) * 12 % i);

            st.erase(0);

            if ((st.size() == 1 && *st.begin() <= 2) || 
                (st.size() == 2 && *st.begin() == 4 && *st.rbegin() == 8)) {
                legal[s] = true;
                break;
            }

            int t = 0;
            bool flag = true;

            for (int it : st) {
                if (it % 12) {
                    flag = false;
                    break;
                }

                t |= (1 << (it / 12 - 1));
            }

            if (flag && !legal[t]) {
                legal[s] = true;
                break;
            }
        }
    }

    for (int s = 0; s < M; ++s)
        if (legal[s])
            ans = add(ans, f[n][s]);

    printf("%d", ans);
    return 0;
}
posted @ 2025-01-12 14:57  wshcl  阅读(137)  评论(0)    收藏  举报