浅谈公平组合游戏
本文同步发表在博客园。
简单来说就是 Nim 游戏和 SG 函数,但是浅谈,只有一些比较基础的知识喔 qwq。可以用作入门,但并不建议使用本文进行完整学习。
贴一个并非上集,以及一个整体难度更高的优质专栏。大家都可以去看一下,尤其后者,额感觉前者写的太烂了。
upd on 2026/6/2:
- 例题部分增加了评论区中 LawrenceQwQ 提到的 P6791。
- 例题部分增加了评论区中 xiaozhengguoaaa 提到的 AT_arc219_d,并同时写了一篇题解。
同样的,也欢迎各位在评论区捉虫、提建议或推荐例题,我会视情况采纳的!
公平组合游戏的定义
公平组合游戏,又称公平组合博弈,是一种满足如下条件的博弈游戏:
- 不论当前游戏处于什么状态,所有玩家能做的行动完全一致,仅取决于状态情况,与身份无关。
- 博弈游戏中,同一个状态不可能多次到达,博弈以玩家不能移动为结束,且游戏一定会在有限步后以非平局状态结束。
Nim 游戏
Nim 游戏的规则很简单,如下:
共有
显然,Nim 游戏是一个常规的公平组合游戏。
博弈图的表示和状态的定义
在 Nim 游戏中,局面的变化情况可以用博弈图来描述。
将每个可能的状态看作图中一个节点,如果从某个状态
事实上,对于任意公平组合游戏,都可以建出一张有向无环图作为博弈图。
在公平组合游戏中的博弈图里,可以对每个状态标记为必胜状态或必败状态。具体的,可以按照以下引理:
- 无法移动,即在博弈图中没有出边的状态是必败状态。
- 对于一个点,若其是必胜状态,当且仅当其后继状态(博弈图中出边)中存在至少一个必败状态。
- 对于一个点,若其是必败状态,当且仅当其后继状态(博弈图中出边)全部都是必胜状态。
证明是显然的。
这样就能在
Nim 和
还是继续考虑 Nim 游戏。上一部分简要介绍了「博弈图」这个东西,虽然其已经可以在
有一个结论,就是说,一个 Nim 游戏的状态是否为先手必胜状态,只与当前局面的石子数目的「Nim 和」有关。
对,这里引入了一个新词:Nim 和。对于一个有
知道 Nim 和这个概念以后,Nim 游戏的必胜必败情况也就能很快的算出来了,因为有一个特别厉害的定理:
Nim 游戏中,对于一个有
这样就可以在
考虑对所有可能出现的状态用归纳法。具体的:
-
对于状态
a_i = 0 (1 \le i \le n) ,其没有后继状态,是必败状态且 Nim 和为0 ,命题成立。 -
如果
k = a_1 \oplus a_2 \oplus \dots \oplus a_n \not= 0 ,需证明该状态为必胜状态,等价于需要构造一个合法的移动方案使得后继状态为必败状态;由归纳假设,只需证明后继状态满足a^{\prime}_1 \oplus a^{\prime}_2 \oplus \dots \oplus a^{\prime}_n = 0 。利用异或的性质,这等价于说,需要存在一堆石子a_i 使a_i > a_i \oplus k 。设k 的二进制表示中最高位的1 是第pos 位,那么由异或的性质,一定存在一个a_i 使其第pos 为也为1 ,那么就一定有a_i > a_i \oplus k ,因为a_i \oplus k 的第pos 位为0 ,更高位与a_i 相同。 -
如果
a_1 \oplus a_2 \oplus \dots \oplus a_n = 0 ,需证明该状态为必败状态,由归纳假设,只需证明后继状态满足a^{\prime}_1 \oplus a^{\prime}_2 \oplus \dots \oplus a^{\prime}_n \not= 0 。这是必然的,因为任何移动都会使恰好一个a^{\prime}_i \not= a_i ,就会使 Nim 和变为a^{\prime}_i \oplus a_i \not= 0 。
综上,得证。
SG 理论
SG 理论指出,任何一个公平组合游戏都等价于一个单堆 Nim 游戏。
游戏的记法
前面提到过,任意一个公平组合游戏都可以绘制博弈图。由于博弈图中,每个状态的性质只由它的后继状态决定,所以,可以将博弈图中的状态用它的后继状态的集合来表示。
一个游戏则可以用它的初始状态表示。
公平游戏的表示通常相当复杂,但单堆 Nim 游戏的表示相对来说简单得多。当石子数为
其中
之后会用到记号
游戏的和与等价关系
游戏的等价关系依赖于游戏的和的定义,所以我们先看游戏的和的定义:
游戏
游戏的和,可以理解为由两个同时进行且互不干扰的子游戏组成的游戏,玩家在每一步只能选择恰好一个子游戏移动一步,且游戏在两个子游戏都无法移动时结束。游戏的和的概念可以推广到任意多个游戏的情形,且也满足结合律和交换律。
不难发现,对于一个单堆 Nim 游戏,除了没有石子的情况,都是先手必胜状态,但是这些不同的单堆 Nim 游戏在和其他的单堆 Nim 游戏组合起来时,得到的游戏并不相同。比如
这可以带来一个启示,启发我们通过考察与其他游戏的和来研究某个游戏的性质。于是就引出了游戏的等价这个概念:
如果对于所有游戏
当然,这一切都只是在为下一部分的内容做铺垫。
SG 函数
前面对 Nim 游戏的分析说明不同的单堆 Nim 游戏互不等价,但是所有公平组合游戏都等价于某个单堆 Nim 游戏。因此,可以为每个公平组合游戏分配一个整数,这个就叫做 SG 函数。
为了证明这些结论,首先需要用到关于游戏等价的两个引理。
-
第一条引理:对于游戏
A 和任意必败游戏P ,都有A \approx A+P 。- 证明:
按照定义,只需证明对于任意游戏G 都有A + G \approx A+P+G 成立。
如果游戏A+G 有必胜策略,那么游戏A+P+G 也一定有必胜策略。因为,如果对手在游戏P 中进行了移动,就也进行移动将其恢复至必败状态;否则按照游戏A+G 中的必胜策略移动。这样一定能取得最后的胜利。
如果游戏A+G 是必败的,那么游戏A+P+G 也同样必败。因为无论这一回合先手在子游戏A+G 还是A+P+G 中进行了移动,后手都可以在同一子游戏中移动将其恢复回必败状态,最终先手一定无法获胜。
- 证明:
-
第二条引理:游戏
A 和游戏A^{\prime} 等价,当且仅当A+A^{\prime} 是必败游戏。反过来也成立。- 证明:
如果游戏A 和A^{\prime} 等价,那么A + A^{\prime} 和A+A 同时必败或必胜。而游戏A+A 是必败游戏,这是因为对于先手的任何操作,后手都可以在另一个子游戏中采取相同的行动,最后一定是先手不能移动、后手获胜。
反过来,如果A + A^{\prime} 是必败游戏,那么由第一条引理可知A \approx A + (A + A^{\prime}) \approx (A+A) + A^{\prime} \approx A^{\prime} 。
- 证明:
利用这些引理,最终能得到以下定理(即 SG 定理):
对于任何(有限的)公平游戏
知道定理是没用的,我们需要证明它。
可以用数学归纳法来证明。设游戏
首先要说明
接着还需说明
由等价关系的传递性,
这就说明了,可以为每一个公平游戏
一个公平游戏的对应的 Nim 数就是使
这个将公平游戏映射到 Nim 数的函数被称为 Sprague–Grundy 函数,简称为 SG 函数,记作
根据定理的证明过程,可以得到 SG 函数的递归计算方式
利用 SG 函数值(即 Nim 数)可判断一个状态是否为先手必胜:公平游戏
最后还有一个定理,是用于计算游戏的和的 SG 函数值的:
对于公平游戏
来证明一下。
因为
利用这个定理,在计算游戏的和的 SG 函数值的时候,能极大程度上减少计算量、降低思维难度。
部分常见公平游戏
Bachet 游戏
与单堆 Nim 游戏类似,但 Bachet 游戏限制了每次取的石子的个数。
Bachet 游戏规则:有一堆共
对此,有结论:先手必败当且仅当
对于这个结论,有两种不同的证明,第一种证明是从构造方案角度入手,第二种证明是从 SG 函数值入手。你可以选择自己喜欢的证明方式以学习、运用。
-
「构造方案」式证明:
- 当
n \equiv 0 \pmod {k+1} 时,每一轮中若先手取1 \le x \le k 枚石子,则后手取k+1-x 枚(可以证明在1 \sim k 范围内),最终一定是先手无法移动,后手获胜。 - 反之,若
n \not\equiv 0 \pmod {k+1} ,那么先手可以先取走n \bmod (k+1) 枚石子,剩下的部分则满足n^{\prime} \equiv 0 \pmod {k+1} ,可以理解为先手变为了后手,后手成了先手,由于前面所述的后手必胜,因此事实上先手必胜。
- 当
-
「SG 函数值」式证明:
- 计算
f(n) 表示剩下n 枚棋子时对应局面的 SG 函数值。 - 对于
n \le k ,可以归纳证明f(n) = n ,这与单堆 Nim 游戏完全相同,因为取走石子数量的限制没有发挥作用。对于n > k ,可以证明f(n) = n \bmod (k+1) ,所以有f(x) = \text{mex}\{ f(n-k),f(n-k+1),\dots,f(n-1) \} ,这遍历了模k+1 的所有余数0 \sim k 除了n \bmod (k+1) ,因此就有f(n) = n \bmod (k+1) 。
- 计算
Moore's Nim-k 游戏
相较于 Nim 游戏,Moore's Nim-
Moore's Nim-
对此,有结论:将每堆石子的个数
接下来考虑证明。
我们仿照 Nim 游戏的证明方式。设
阶梯 Nim 游戏
阶梯 Nim 游戏相较于普通 Nim 游戏更为复杂,它允许将石子在相邻堆之间移动。
阶梯 Nim 游戏规则:共
对此,有结论:先手必败当且仅当
怎么证明?当任意一名玩家将偶数位置堆中石子移动至奇数位置堆时,另一名玩家都可以将这些石子移到下一个偶数位置堆或直接取走,因此这种移动操作不会影响奇数位置堆的情况,那么它们就是互相独立的普通 Nim 游戏,根据 Nim 游戏的结论,当且仅当
Fibonacci Nim 游戏
Fibonacci Nim 游戏与 Bachet 游戏十分类似,都只有一堆石子且限制了每次取走的数量,唯一不同的是 Fibonacci Nim 游戏中每次限制的数量是动态变化的。
Fibonacci Nim 游戏规则:有一堆共
对此,有结论:先手必败当且仅当最初的石子个数
然后我们需要证明这个结论。
设
必胜策略是:如果可以,移走所有剩余石子;否则,移走分解中最小的 Fibonacci 数。由于分解中,次小的 Fibonacci 数一定大于最小的 Fibonacci 数的两倍,所以只要处于必胜状态的当前回合无法取完所有石子,对手在下一回合也取不走次小的 Fibonacci 数(即下一回合最小的 Fibonacci 数),对手一定处于必败状态。
反过来,如果当前处于必败状态,那么设当前取走的个数为
例题选讲
SG 函数、Nim 游戏这部分还是有很多经典例题的。
以下例题不保证按真实难度排序(并且博弈论这方面的内容,对难度的评价也有很多主观因素),顺序仅供参考,如果你要做里面的题的话不一定非得按照讲解顺序来做喔 qwq。
题外话:如果你把这里提到的例题全做了的话你会收获一个绿三个蓝两个紫三个黑,以及 RMJ 的一绿一蓝。
P7589 黑白棋
首先可以发现,两列棋子之间的距离是完全没用的东西,因为在移动的时候并不会出现这一列的棋子移到另一列上去,所以题目读入给的所谓
任意两个不同的列中的棋子的移动是互不干扰的,也就是说是
对于一个单独的游戏,如果只有前进操作,那么其实一个子游戏就是一个有
嗯,但是现在出现了后退操作,怎么办呢?其实本质没变——如果对手执行后退操作,那么另一方前进同样步数,石子的数量还是没有变,也不影响游戏的局面。后退操作的数量是有限的,所以本质还是一个单堆 Nim 游戏。
两个人都足够聪明。假设参与游戏的是 A 和 B 两个人,它们都已经提前知道了在看作 Nim 游戏时谁会赢,假设此时 A 会赢。那么按照最优策略,A 全程都不会也不需要执行后退操作,因为后退操作做的事情是增多石子数量,此时要是数字选择不当,B 可能会借机赢得游戏;但 B 可能会抱着侥幸心理执行后退操作,而 A 为了保住自己的胜利机会也会选择前进同样步数保证局面不变。所以到了最后,就是一个普通的 Nim 游戏罢了,题目给定的什么
::::success[sample code]
提交记录在这里。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
int T,n,k,d,sum;
int read(){
int su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
int main(){
T=read();
while(T--){
n=read(),k=read(),d=read();
sum=0;
for(int i=1;i<=n;i++){
int o=read(),x=read(),y=read();
sum^=abs(x-y)-1;
}
if(sum)cout<<"Yes\n";
else cout<<"No\n";
}
return 0;
}
::::
AT_arc168_b Arbitrary Nim
和 Nim 游戏的规则类似,但是存在每次取石子的个数限制,那这不就是前面提到过的「Bachet 游戏」嘛!好像不太一样,这里有
我们先考虑
那就只剩异或和为
那么把这些二元组一个个消掉后,会剩下一个数值互不相同的数组。注意!如果这个数组已经空了,那么这种情况下无论你模多少都没有胜利的希望了,此时不存在让先手获胜的
最后一种情况,最大的
这一定是能取到的最大
::::success[sample code]
提交记录在这里。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 3e5+5;
int n,a[N],sum;
set<int> st;
int read(){
int su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
int main(){
n=read();
for(int i=1;i<=n;i++){
a[i]=read(),sum^=a[i];
if(st.count(a[i]))st.erase(a[i]);
else st.insert(a[i]);
}
if(sum)cout<<"-1\n";
else if(st.empty())cout<<"0\n";
else cout<<*st.rbegin()-1<<"\n";
return 0;
}
::::
P4279 小约翰的游戏
我们可以发现,这个游戏和 Nim 游戏极其相似,但是在仔细阅读后,会发现,正常 Nim 游戏的规则是「取到最后一枚石子的玩家胜利」,而在这道题中是「取到最后一枚石子的玩家失败」。唔,那这就不能直接用正常 Nim 游戏的结论做了,因为这是反常 Nim 游戏。
既然正常 Nim 游戏有一个万用的结论,反常 Nim 游戏也一定有,对吧?嗯,直觉挺准。不过反常 Nim 的游戏稍微比正常 Nim 游戏的结论要复杂一些。
当局面为
- 存在至少一个
a_i > 1 且a_1 \oplus a_2 \oplus \dots \oplus a_n = 0 ,或 - 对于所有
i 都有a_i \le 0 且a_i = 1 的i 有奇数个。
如何证明?
先考虑后者情况,显然不论轮到先手还是后手都只有一个操作方案,就是将一堆(只有
接着考虑前者情况,这需要分类讨论:
- 只存在恰好一个
i 使a_i > 1 其他j \not= i 都是a_j \le 1 ,那么此时先手可以在第一部操作使a_i \le 1 且可以控制局面非空石子堆的奇偶性(选择将a_i 变为0 还是1 ),此时先手必胜; - 存在至少两个
i 使a_i > 1 ,那么无论当前轮如何操作,下一轮都一定也存在j 满足a_j > 1 ,这就可以用数学归纳法了。类似 Nim 游戏的证明方式即可证明。(这里真的不想再写一遍了呜,只要你认真看了 Nim 函数部分的证明,这部分是很好理解的。)
于是就可以证明反常 Nim 游戏的结论了!再直接利用这个结论做本题即可。
::::success[sample code]
提交记录在这里。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
int T,n,cnt,sum;
int read(){
int su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
int main(){
T=read();
while(T--){
n=read();
cnt=0,sum=0;
for(int i=1;i<=n;i++){
int x=read();
cnt+=(x<=1),sum^=x;
}
if(cnt==n)cout<<(sum?"Brother\n":"John\n");
else cout<<(sum?"John\n":"Brother\n");
}
return 0;
}
::::
P2148 E&D
备注:为了不影响阅读体验感,将本题讲解中打的所有表都放进了折叠框里,要看的时候自己点开。
显然可以发现的是,不同组的石子的游戏过程是互不影响的,所以整个游戏其实是
关键在于这个 SG 函数值怎么算呐,这并没有什么能快速得到的结论,于是我们可以先考虑开始颓废摆烂不做题了打个表看看有没有规律。设
::::info[戳这里看表]
0 1 0 2 0 1 0 3 0 1 0 2 0 1 0 4
1 1 2 2 1 1 3 3 1 1 2 2 1 1 4 4
0 2 0 2 0 3 0 3 0 2 0 2 0 4 0 4
2 2 2 2 3 3 3 3 2 2 2 2 4 4 4 4
0 1 0 3 0 1 0 3 0 1 0 4 0 1 0 4
1 1 3 3 1 1 3 3 1 1 4 4 1 1 4 4
0 3 0 3 0 3 0 3 0 4 0 4 0 4 0 4
3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4
0 1 0 2 0 1 0 4 0 1 0 2 0 1 0 4
1 1 2 2 1 1 4 4 1 1 2 2 1 1 4 4
0 2 0 2 0 4 0 4 0 2 0 2 0 4 0 4
2 2 2 2 4 4 4 4 2 2 2 2 4 4 4 4
0 1 0 4 0 1 0 4 0 1 0 4 0 1 0 4
1 1 4 4 1 1 4 4 1 1 4 4 1 1 4 4
0 4 0 4 0 4 0 4 0 4 0 4 0 4 0 4
4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
::::
感觉这个表一看就很有规律,首先可以发现,它由若干个
::::info[戳这里看表]
1 2 1 3 1 2 1 4
2 2 3 3 2 2 4 4
1 3 1 3 1 4 1 4
3 3 3 3 4 4 4 4
1 2 1 4 1 2 1 4
2 2 4 4 2 2 4 4
1 4 1 4 1 4 1 4
4 4 4 4 4 4 4 4
::::
这个表……怎么感觉那么熟悉呢?左上角是
此时到了这里就已经得出结论了,如果你还不太信的话,还可以再次压缩一下新表验证思想。
这就是我们最后得到的结论了,此时利用这个结论就可以做这道题了。
是不是感觉有点不安……嗯哼,因为我们这个所谓结论是打表得出的,谁给你的勇气说它是结论啊,所以我们还是要证明一下!(如果你是打表、猜结论选手,可以跳过这一部分证明。)
我们设
那么此时需要证明的就是,
利用数学归纳法,
这样也就完成了证明。
::::success[sample code]
提交记录在这里。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
int T,n,res;
int read(){
int su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
int f(int x,int y){return ((x&1||y&1)?f(x/2,y/2)+1:0);}
int sg(int x,int y){return f(x-1,y-1);}
int main(){
T=read();
while(T--){
n=read();res=0;
for(int i=1;i<=n/2;i++){
int x=read(),y=read();
res^=sg(x,y);
}if(res)cout<<"YES\n";else cout<<"NO\n";
}
return 0;
}
::::
P5675 取石子游戏
我们发现这个游戏的规则和 Nim 游戏的规则是一样的(换句话说,这个游戏就是 Nim 游戏),但是这次,先手第一轮操作具体在哪堆石子里选是可以被指定的。
那么先手必败有且仅有两种情况:
- 全局石子数的异或和为
0 ,此时无论第一轮操作在哪取都不可能赢。 - 全局石子数的异或和不为
0 ,但第一轮指定石子堆无论怎么取也不能让异或和为0 ,此时到了后手那里全局异或和还是不为0 ,于是后手站在先手的角度就是必胜的,等价于先手必败。
第一种情况是好处理的,重点在于第二种情况。有一个通过并不非常惊人的注意力得出的结论:只有当第一轮指定的石子数严格小于其他石子数的异或和时才能满足第二种情况的条件。
数据范围很小,可以暴力一点,枚举第一轮指定先手取石子堆
于是就做完了,DP 是简单的,真不会可以看下代码。
::::success[sample code]
提交记录在这里。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 305;
const int MOD = 1e9+7;
int n,a[N],dp[N][N],Ans;
int read(){
int su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
int main(){
n=read();
for(int i=1;i<=n;i++)a[i]=read();
for(int k=1;k<=n;k++){
dp[0][0]=1;
for(int i=1;i<=n;i++)
for(int x=0;x<256;x++)
if(i==k)dp[i][x]=dp[i-1][x];
else dp[i][x]=(dp[i-1][x]+dp[i-1][x^a[i]])%MOD;
for(int x=a[k];x<256;x++)
Ans=(Ans+dp[n][x])%MOD;
}cout<<Ans<<"\n";
return 0;
}
::::
AT_arc219_d Grid Game
没绷住之 Bachet 套上阶梯 Nim 再塞进一个网格图里,什么神秘大嵌套啊。
先不考虑网格图,如果是 Bachet 套上阶梯 Nim 该怎么做呢?很简单的,我们把 Bachet 里的
那么,现在有了一个网格图,怎么办呢?也是简单的,想到黑白染色,就能发现每步操作都是在两个不同色格子之间互相移动,最后移动到
::::success[sample code]
提交记录在这里。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
int T,n,k,sum;
int read(){
int su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
int main(){
T=read();
while(T--){
n=read(),k=read(),sum=0;
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++){
int x=read()%(k+1);
if((i+j)%2==1)sum^=x;
}
if(sum)cout<<"Alice\n";
else cout<<"Bob\n";
}
return 0;
}
::::
P3480 KAM-Pebbles
取石子的数量限制取决于相邻两堆石子的石子数之差,于是可以考虑从差的方面入手,令
肯定需要知道操作转化到
是不是很熟悉?对啊,这不就是之前讲过的阶梯 Nim 游戏吗?嗯,只不过这里唯一的一个差别是,前面讲过的阶梯 Nim 游戏是由
::::success[sample code]
提交记录在这里。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 1005;
int T,n,a[N],d[N],sum;
int read(){
int su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
int main(){
T=read();
while(T--){
n=read();sum=0;
for(int i=1;i<=n;i++)a[i]=read();
for(int i=1;i<=n;i++)d[i]=a[i]-a[i-1];
for(int i=n;i>=1;i-=2)sum^=d[i];
if(sum)cout<<"TAK\n";
else cout<<"NIE\n";
}
return 0;
}
::::
P3185 分裂游戏
首先有一个,嗯,技巧,就是说在这种规模比较大的游戏中,直接去考虑是不好做的,我们可以讲其拆成若干个子游戏分别去考虑。但在这题中,可以发现以瓶子为单位拆子游戏是不好做的,可以考虑以巧克力豆为单位拆子游戏。
终止状态是什么?游戏会结束,当且仅当所有巧克力豆都在最后一个瓶子里,此时不可能再有其他任何移动方案,相反,只要不全在最后一个瓶子里,总能选到合适的
可以发现,每次在
预处理结束,接下来考虑具体求解,令
知道了胜负态还不够,题目还要求我们算出第一步的方案个数和字典序最小的方案,于是暴力枚举
::::success[sample code]
提交记录在这里。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 1e4+5;
int T,n,sg[N],sum,cnt,a,b,c;
bool vis[N];
int read(){
int su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
void init_SG(){
sg[0]=0;
for(int i=1;i<=100;i++){
memset(vis,0,sizeof(vis));
for(int j=0;j<i;j++)
for(int k=0;k<=j;k++)vis[sg[j]^sg[k]]=1;
for(int j=0;j<=500;j++)
if(!vis[j]){sg[i]=j;break;}
}
}
int main(){
init_SG();
T=read();
while(T--){
n=read();sum=0;
for(int i=n-1;i>=0;i--){
int x=read();
if(x&1)sum^=sg[i];
}
if(!sum){
cout<<"-1 -1 -1\n0\n";
continue;
}
cnt=0;
for(int i=n-1;i>=0;i--)
for(int j=i-1;j>=0;j--)
for(int k=j;k>=0;k--){
int now=sum^sg[i]^sg[j]^sg[k];
if(now)continue;
if(!cnt)a=n-1-i,b=n-1-j,c=n-1-k;
++cnt;
}
cout<<a<<" "<<b<<" "<<c<<"\n"<<cnt<<"\n";
}
return 0;
}
::::
P6487 HRPA
为了方便描述,以下描述中提到的
看到这个题目是不是感觉挺熟悉的?对啊,这不就是前面提到过的 Fibonacci Nim 游戏吗?嗯,确实,基本上是一样的,但是这题有个不同点,就是它没有限制第一步不能把所有石子取完,所以先手一定是必胜的(第一轮就全取完不就结束了嘛)。呵呵,题目可不会这么简单,它要求你算的是先手第一步最小取多少能必胜。
根据前面 Fibonacci Nim 游戏的结论,当且仅当
剩下的就是第一次不必全部取完的情况了。回忆之前 Fibonacci Nim 游戏结论的证明,我们用到了一个知识,就是 Fibonacci 拆分,即将
那么,设
最后代码就只需要算出这个拆分即可,时间复杂度
::::success[sample code]
提交记录在这里。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
LL n;
LL read(){
LL su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
int main(){
n=read();
while(n>0){
if(n<=2){cout<<n<<"\n";break;}
LL x=1,y=2,z=3;
while(z<n)x=y,y=z,z=x+y;
if(z==n){cout<<n<<"\n";break;}
n-=y;
}
return 0;
}
::::
P6791 取石子
上一题的数位 DP 版本。
先不考虑第一次取的个数的限制,那么这就是一个反常 Fibonacci Nim 游戏,因为其规则是取到最后一枚石子的玩家输。这非常该死啊,导致我们用不了结论了,但是由于这个游戏的性质,可以让
由 Fibonacci Nim 游戏的结论,即先手必败当且仅当
那么这个问题就转化成:
即可,最后容斥变成
发现这个分解就和二进制很像啊,考虑数位 DP 去做,感觉在这里记搜比递推好写啊,主要记录一个上界标记(当前是否顶格)以及上一位是否为
::::success[sample code]
提交记录在这里。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
LL T,n,k,f[105],dp[105];
bool vis[105];
LL read(){
LL su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
LL DFS_dp(int u,bool up,bool lst){
if(u<k)return 1;
if(!lst&&!up&&dp[u]!=-1)return dp[u];
LL res=DFS_dp(u-1,up&(!vis[u]),0);
if(!lst&&(!up||vis[u]))
res+=DFS_dp(u-1,up,1);
if(!lst&&!up)dp[u]=res;
return res;
}
int main(){
T=read();
f[1]=1,f[2]=1;
for(int i=3;i<=90;i++)
f[i]=f[i-1]+f[i-2];
while(T--){
k=read(),n=read()-1;
LL tmp=n,pp=k;
memset(vis,0,sizeof(vis));
memset(dp,-1,sizeof(dp));
for(int i=90;i>=1;i--)if(f[i]>pp)k=i;
for(int i=90;i>=1;i--)
if(tmp>=f[i])tmp-=f[i],vis[i]=1;
cout<<n-DFS_dp(90,1,0)+1<<"\n";
}
return 0;
}
::::
P2599 取石子游戏
神仙题啊。会讲的比较详细,这部分讲解内容也会比较长,希望你能耐心看完哦。(如果你并不想了解其本质,只是想水黑的话,可以选择直接跳过推导部分看最后的转移结论和代码,虽然我并不建议这么做。)
首先有一个很厉害的状态定义,令
由于
对于
-
- 用反证法。 假设不存在满足定义的 $\text{L}(i,j)$,则对于任意非负整数 $x$,$(x,a_i,a_{i+1},\dots,a_j)$ 都是必胜局面。 由于该局面为必胜局面,则一定能**一步**转移到某个**必败局面**。 若拿的是左边一堆,则不可能变为必败局面,因为其局面状态与当前局面是同一类,根据假设应均为必胜状态。 于是只能拿右边一堆,设当前局面能一步到达的某个必败局面为 $(x,a_i,a_{i+1},\dots,a_{j-1},y)$,要求 $0 \le y < a_j$。 由于 $x$ 有**无限个**,而 $y$ 只有 $a_j$ 个,由抽屉原理,一定存在 $x_1 > x_2$ 满足 $(x_1,a_i,a_{i+1},\dots,a_{j-1},y)$ 和 $(x_2,a_i,a_{i+1},\dots,a_{j-1},y)$ **都是必败状态**,而这两个状态之间实际上**一步可达**,矛盾!故原命题成立。 -
- 还是用反证法。 假设 $\text{L}(i,j)$ 不唯一,则必存在 $x_1 > x_2$ 使 $(x_1,a_i,a_{i+1},\dots,a_j)$ 和 $(x_2,a_i,a_{i+1},\dots,a_j)$ **都是必败状态**,而这两个状态之间实际上**一步可达**,矛盾!故原命题成立。
这样,我们就证明了
嗯,说了这么多,终于要开始状态转移了!
DP 一定要有边界情况,这里的边界情况就是
然后就是具体的转移了。
为了方便,我们令
转移时需要分类讨论:
-
- 这个是最简单的一种情况——由 $\text{R}(i,j-1)$ 的定义,$(a_i,a_{i+1},\dots,a_j)$ 自身就是必败状态,因此右侧不需要添加任何石子堆。 -
- 要做的就是证明 $(x,a_i,a_{i+1},\dots,a_{j-1},x)$ 为必败局面。 由于最左边一堆和最右边一堆的石子数相同,后手可先与先手在相对堆上做相同操作,直到某个时刻后手得到形如 $(y,a_i,a_{i+1},\dots,a_{j-1})$ 或 $(a_i,a_{i+1},\dots,a_{j-1},y)$ 的局面,此时因 $0 < y \le x < \min(L,R)$,结合 $\text{L}(i,j-1)$ 及 $\text{R}(i,j-1)$ 的定义知此时是必胜局面,即先手必败,得证。 -
- 如果先手先拿左边一堆,设拿了以后还剩 $z$ 枚石子: 若 $z > L$,则后手可以将最右边拿成 $z-1$ 枚石子,就能回到第三种情况本身,递归证明即可。 若 $z = L$,则后手将最右边拿完,根据 $\text{L}(i,j-1)$ 的定义知此局面先手必败。 若 $0 < z < L$,则后手将最右边拿成 $z$ 枚石子,由第二种情况知此时先手必败。 若 $z=0$,此时最右边石子数 $k$ 满足 $L \le k < R$,结合 $\text{R}(i,j-1)$ 知此局面必胜,即对于先手来说必败。 如果先手先拿右边一堆,设拿了以后还剩 $z$ 枚石子: 若 $z \ge L$,则后手可以将最左边拿成 $z+1$ 枚石子,回到其本身,递归证明。 若 $0 < z < L$,则后手将最左边拿成 $z$ 枚石子,由第二种情况知此时先手必败。 若 $z = 0$,则后手将最左边拿成 $L$ 枚石子,由 $\text{L}(i,j-1)$ 的定义知此局面先手必败。 -
- 证明与上一种(第三种情况)是类似的,可以先尝试自己证一下。 如果先手先拿左边一堆,设拿了以后还剩 $z$ 枚石子: 若 $z \ge R$,则后手可以将最右边拿成 $z+1$ 枚石子,就能回到第四种情况本身,递归证明即可。 若 $0 < z < R$,则后手将最右边拿成 $z$ 枚石子,由第二种情况知此时先手必败。 若 $z=0$,则后手将最右边拿成 $R$ 枚石子,由 $\text{R}(i,j-1)$ 的定义知先手必败。 如果先手先拿右边一堆,设拿了以后还剩 $z$ 枚石子: 若 $z > R$,则后手可以将最左边拿成 $z-1$ 枚石子,回到其本身,递归证明。 若 $z = R$,则后手把最左边拿完,根据 $\text{R}(i,j-1)$ 的定义知此局面先手必败。 若 $0 < z < R$,则后手将最左边拿成 $z$ 枚石子,由第二种情况知此时先手必败。 若 $z = 0$,此时最左边石子数 $k$ 满足 $0 < k < L$,结合 $\text{L}(i,j-1)$ 的定义知此局面必胜,即对于先手来说必败。 -
- 设先手将左右两堆中其中一堆拿成了 $z$ 枚石子。 若 $z > \max(L,R)$,回到第五种情况本身,递归证明。 若 $0 < z < \min(L,R)$,后手把另一堆也拿成 $z$ 回到第二种情况,先手必败。 若 $z=0$ 将另一堆拿成 $L$ 或 $R$ 个即可。 剩下的就是 $L \le z \le R$ 或 $R \le z \le L$,第三种情况可以解决最左边 $L < z \le R$ 及最右边 $L \le z < R$ 的状态,第四种情况可以解决最左边 $R \le z < L$ 及最右边 $R < z \le L$ 的状态,因此只剩最左边 $z = L$ 和最右边 $z = R$,而这两种情况下只需把另一堆拿完即可由 $\text{L}(i,j-1)$ 或 $\text{R}(i,j-1)$ 的定义证明。
综上,我们得到了整个
同理能求
回到本题最后要求输出的内容,只有当
总时间复杂度是
::::success[sample code]
提交记录在这里。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 1005;
int T,n,a[N],L[N][N],R[N][N];
int read(){
int su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
int main(){
T=read();
while(T--){
n=read();
for(int i=1;i<=n;i++)
a[i]=read(),L[i][i]=a[i],R[i][i]=a[i];
for(int len=2;len<=n;len++)
for(int i=1;i+len-1<=n;i++){
int j=i+len-1,x=a[j];
int l=L[i][j-1],r=R[i][j-1];
if(x==r)L[i][j]=0;
else if(l<=x&&x<r)L[i][j]=x+1;
else if(r<x&&x<=l)L[i][j]=x-1;
else L[i][j]=x;
l=L[i+1][j],r=R[i+1][j],x=a[i];
if(x==l)R[i][j]=0;
else if(r<=x&&x<l)R[i][j]=x+1;
else if(l<x&&x<=r)R[i][j]=x-1;
else R[i][j]=x;
}
cout<<(L[2][n]!=a[1])<<"\n";
}
return 0;
}
::::
简单总结
-
明确子游戏拆分:很多题目直接给出的博弈游戏是十分复杂、混乱的,要找到合适的单位拆分成若干个形式类似且互相独立的子游戏,最后求它们的和即可得到最终答案。
-
寻找规律:通过状态分析或打表找到 SG 函数固定的递推式以通过预处理或其他方式能快速求得每种状态下的 SG 函数值,或经过一些变形后转变为常见公平组合游戏(如 Bachet 游戏、阶梯 Nim 游戏等)以套用常规公式求解。
-
进行证明:很多情况下,SG 函数的公式或结论是由打表后的合理猜测或感性理解得到,此时在时间充足的情况下,最好还是要尽力给出尽可能理性的证明,防止被假解蒙蔽双眼。
-
确保边界:不要漏掉
0,1,\inf 这类容易被忽略的边界情况,分类讨论的话需要考虑完全所有情况,如果漏了或重了很可能导致整个过程逻辑崩塌,进而影响整体思路。
嗯,最后感谢你能看到这里!码字也不容易,整篇文章有超过三万个字符(虽然可能有一些滥竽充数的代码喵 qwq),还希望你能留个赞支持一下,真是太谢谢你啦!>^<