学习了将近一个星期的组合游戏之后,笔者对于组合游戏的解决方法和思考方向有了自己的理解和体会,如果你也恰巧在学习组合游戏,不妨与笔者一起来探讨博弈游戏的奥秘。
本篇文章结合了以下几篇国家集训队论文:
(1). 《从感性到理性,透析一类搏弈游戏的解答过程》--张一飞
(2). 《解析一类组合游戏》--王晓珂
(3). 《组合游戏略述——浅谈SG游戏的若干拓展及变形》--贾志豪
也结合了队友唐俊@TDD_Master的博客,https://blog.csdn.net/TDD_Master/article/details/84572957
为了让大家更好的理解文章的思想,笔者将所有的例子转化成比较"裸"的模型,方便大家更好的了解组合游戏的本质。
首先,我们平常所说的博弈游戏,在各种集训队论文中叫做组合游戏,下面笔者所说的组合游戏其实就是大家平常所见到的博弈游戏。
下面我用几个简单的例子向大家展示一下组合游戏:
游戏 (A):A和B两人面对一堆共n个石子,两个人轮流从石子堆中拿出至少一个,至多两个石子,无法取走石子的人为负,A先手,请问,谁有必胜策略?
游戏 (B): A和B两人面对n堆石子,每堆有ai个,两个人轮流从石子堆中选择一堆,拿走其中的石子,一次至少拿一个,至多将这堆石子全部取走,无法取走石子的人为负,A先手,请问,谁有必胜策略?
游戏(C): A和B两人面对n堆石子,每堆有ai个,两个人轮流从石子堆中选择一堆,拿走其中的石子,一次至少拿一个,至多拿两个,无法取走石子的人为负,A先手,请问,谁有必胜策略?
游戏(D): A和B两人面对n堆石子,每堆有ai个,两个人轮流从石子堆中选择一堆,拿走其中的石子,每次拿走的石子数必须是给定集合S中的数目,无法取走石子的人为负,A先手,请问,谁有必胜策略?
游戏(E): A和B两人面对两堆各若干个石子,两个人轮流从任意一堆中取出至少一个或者同时从两堆中取出同样多的石子,规定每次至少取一个,至多不限,无法取走石子的人为负,A先手,请问,谁有必胜策略?
大家应该发现上面4个游戏中的共同点了吧。
没错!上面4个游戏确实有很多相同之处,他们的游戏规则其实都大同小异,下面笔者直接引用(《解析一类组合游戏》--王晓珂)中的组合游戏的定义:
- 游戏有2名参与者。
- 游戏过程中任意时刻有确定的状态。
- 参与者操作时可以的操作时将游戏从当前状态改变为另一状态,规则规定了在任意一状态时,可以到达的状态集合。
- 参与者轮流进行操作。
- 在游戏出于某状态,当前参与者不能进行操作时,游戏结束。此时参照规则决定胜负。
- 无论参与者做出怎样的操作游戏在有限部数之内结束(没有平局)。
- 参与者拥有游戏本身,和游戏过程的所有信息,比如规则、以前自己和对手的操作。
有了以上的对于组合游戏的例子和定义,大家应该对组合游戏有了一定的了解。下面我从就从最简单的游戏A入手,一起来探讨组合游戏的奥秘吧!
游戏(A)分析:对于游戏A来说,我们每次最多取两个石子,假设n为3,我们面对3个石子,那么先手无论如何都无法将石子取完,后手一定是胜利的。我们将n表示为更加一般的状态,其实,如果n%3==0,先手一定是必败的,因为先手无论怎么取石子,他的后继局面一定是可以将石子数变成3的倍数,那么最后先手面对的局面一定是3,我们上面已经分析出来,n为3的必败点,所以先手是必败的。 如果n%3!=0,先手一定是必胜的,因为先手总可以将3的倍数留给对手,那么对手无论怎么取,先手一定可以将石子数变成3的倍数留给对手,所以最后对手一定面对的是3,那么先手一定胜利。
通过上面的分析,我们可以发现,n%3==0先手必输,n%3!=0先手必胜。但是我们每次只能取最多两个石子,我们可以将游戏转化成更加一般的情形吗? 答案是肯定的! 下面我们来定义一下这类博弈游戏的游戏规则。
游戏规则:只有一堆n个物品,两个人轮流从这堆物品中取物,规定每次至少取一个,最多取m个.最后取光者得胜.
这种组合游戏叫做巴什博弈,游戏中先手有必胜策略当且仅当 n%(m+1)!=0。反之如果n%(m+1)==0,先手必败。
下面我们来证明一下定理的正确性:
如果n=(m+1)r+s,(r为任意自然数,s≤m),那么先取者要拿走s个物品,如果后取者拿走k(≤m)个,那么先取者再拿走m+1-k个,结果剩下(m+1)(r-1)个,以后保持这样的取法,那么先取者肯定获胜.总之,要保持给对手留下(m+1)的倍数,就能最后获胜.
游戏(B)分析: 下文引自(《从感性到理性,透析一类搏弈游戏的解答过程》--张一飞)
我们先假设一共有3堆石子,其中分别有(3,3,1)个石子。如下图:
用一个 n 元组(a1,a2,…,an),来描述游戏过程中的一个局面。
可以用 3 元组(3,3,1)来描述图 1 所示的局面。
改变这个 n 元组中数的顺序,仍然代表同一个局面。
(3,3,1)和(1,3,3),可以看作是同一个局面。
如果初始局面只有一堆石子,则甲有必胜策略。
A:甲可以一次把这一堆石子全部取完,这样乙就无石子可取了。
如果初始局面有两堆石子,而且这两堆石子的数目相等,则乙有必胜策略。
A:有两堆石子,所以甲无法一次取完;
A:如果甲在一堆中取若干石子,乙便在另一堆中取同样数目的石子;
A:根据对称性,在甲取了石子之后,乙总有石子可取;
A:石子总数一直在减少,最后必定是甲无石子可取。
对于初始局面(3,3,1),甲有必胜策略,而初始局面(3,3),乙有必胜策略。
下面我们来定义局面的加法和局面分解:
局面的加法:(a1,a2,…,an)+(b1,b2,…,bm)=(a1,a2,…,an,b1,b2,…,bm)。
例子: (3)+(3)+(1)=(3,3)+(1)=(3,3,1)。
对于局面 A,B,S,若 S=A+B,则称局面 S 可以分解为“子局面”A 和 B。
例子:局面(3,3,1)可以分解为(3,3)和(1)。
如果初始局面可以分成两个相同的“子局面”,则乙有必胜策略。
- 设初始局面 S=A+A,想象有两个桌子,每个桌子上放一个 A 局面;
- 若甲在一个桌子中取石子,则乙在另一个桌子中对称的取石子;
- 根据对称性,在甲取了石子之后,乙总有石子可取;
- 石子总数一直在减少,最后必定是甲无石子可取。
初始局面(2,2,5,5,5,5,7,7),可以分成两个(2,5,5,7),故乙有必胜策略。
对于局面S,若先手有必胜策略,则称S胜,若后手有必胜策略,则称S负。
- 若 A=(1),B=(3,3),C=(2,2,5,5,5,5,7,7),则 A 胜,B 负,C 负。
下面记:
- 如果局面 S 胜,则必存在取子的方法 S→T,且 T 负。
- 如果局面 S 负,则对于任意取子方法 S→T,有 T 胜。
局面分解理论:
-
若A和B一胜一负,则S胜。
不妨设 A 胜 B 负;
- 想象有两个桌子 A 和 B,桌子上分别放着 A 局面和 B 局面;
- 因为 A 胜,所以甲可以保证取桌子 A 上的最后一个石子;
- 与此同时,甲还可以保证在桌子 B 中走第一步的是乙;
- 因为 B 负,所以甲还可以保证取桌子 B 中的最后一个石子;
- 综上所述,甲可以保证两个桌子上的最后一个石子都由自己取得。
-
若A负B负,则S负。
- 无论甲先从 A 中取,还是先从 B 中取,都会变成一胜一负的局面;
- 因此,乙面临的局面总是“胜”局面,故甲面临的 S 是“负”局面。
若 B 负,则 S 的胜负情况与 A 的胜负情况相同。
-
若A胜B胜,则有时S胜,有时S负。
经过上述了解,我们可以大概的感觉到,局面的胜负加法和二进制加法有极大的相似性(不进位二进制)。
那么我们是否可以用一个二进制数来表示一个局面呢?
答案是肯定的!
下面我们引入SG函数:
SG函数是对游戏中状态的评估函数,其定义如下:
SG(x) = mex{SG(x1),x1为x的后继状态},
其中mex{A} = 不属于集合A的最小非负整数。
例如:mex(2)=0,mex(0)=1,mex(1,2,3)=0,mex(0,1,2,3)=4。
由SG函数的定义可知,如果SG(x)=0,则后继局面的SG值一定不为0,其意义为,必败态的后继状态一定为必胜态。
若SG(x)!=0,则后继局面的SG值为0,其意义为,必胜态可以转化为必败态。
有了对于SG函数的了解,我们就要思考如何将SG函数运用到组合游戏中。
在上文中,我们已经将游戏局面和二进制不进位加法做了类比,我们可以发现,其实我们是将局面进行异或运算。
那么,我们用SG函数表示一个局面的状态,局面的状态符合异或运算的性质,所以SG函数符合异或运算的性质。
SG函数适用于局面可以分解的状态,每一个小局面合起来就是一个游戏。那么每一个小局面都用SG函数表示,我们就可以知道游戏的胜败了。
游戏(B)是一类nim游戏,我们将游戏X分解为局面(x1,x2,x3,x4,...,xn),先手胜当且仅当SG(x1)^SG(x2)^SG(x3)^...^SG(xn)!=0.
反之SG(x1)^SG(x2)^SG(x3)^...^SG(xn)=0,则先手必败。
那么游戏的SG值怎么求呢,聪明的读者应该已经发现了,SG函数的定义是mex{SG(x1),x1为x的后继状态},那么我们就可以逆推出每个局面的SG函数。
对于游戏(B),我们每次可以从某一堆中至少一个,假设某一堆中有5个石子,其后继状态为(0,1,2,3,4),那么SG(5)=5。所以对于游戏(B),我们就直接将每一堆石子的个数异或起来,其结果为0,则先手败,不为0,则先手胜。
游戏(C)分析: 我们可以发现,游戏C其实和游戏A和游戏B有着很密切的联系。
由游戏A和游戏B的做法可知。
将每一堆石子当作游戏A,将所有石子当作游戏B,那么其解决方案就是当且仅当SG(x1)^SG(x2)^SG(x3)^...^SG(xn)!=0先手胜,其中SG(xi)=ai%3。
我们将游戏更加一般化,对于巴什博弈和nim博弈的结合问题,其结果为当且仅当SG(x1)^SG(x2)^SG(x3)^...^SG(xn)!=0先手胜,其中SG(xi)=ai%(m+1)。
游戏(D)分析: 因为我们必须从操作集合中选择石子个数,那么SG函数显然就要经过求解了,操作集合给定,我们显然可以通过操作集合得出后继状态,从而算出SG函数。
对于操作集合f[maxn],我们可以通过如下操作求出SG函数:其中s[maxn]表示mex函数操作。
void getSG(int n)
{
memset(SG,0,sizeof(SG));
for(int i=1;i<=n;i++)
{
memset(s,0,sizeof(s));
for(int j=0;f[j]<=i && j<3;j++)
{
s[SG[i-f[j]]] = 1;
}
for(int j=0;j<maxn;j++)
{
if(!s[j])
{
SG[i] = j;
break;
}
}
}
}
但是我们发现这样求SG函数常数比较大,因为每一次都要memset数组,我们可以采用如下方式求解SG函数:
void getSg(int n) {
SG[0] = 0;//主要是让终止状态的sg为0
memset(s, -1, sizeof(s));
for(int i = 1; i <=n ; i++) {
for(int j = 1; j <=k && f[j] <= i; j++) {
s[SG[i-f[j]]]=i;//将所有后继的sg标记为i,然后找到后继的sg没有出现过的最小正整数
//优化:注意这儿是标记成了i,刚开始标记成了1,这样每次需初始化mk,而标记成i就不需要了
}
int j = 0;
while(f[j] == i) j++;
SG[i] = j;
}
}
游戏(D)分析: 该游戏为威佐夫博弈,其结论如下:两堆物品a,b , c=floor((b-a)*((sqrt(5.0)+1)/2)); 若a==c则后手赢,反之先手赢
其中a为min(a,b)。
其他
除了上文中提到的博弈问题,还有很多种不同的,状态比较复杂的,或者SG函数比较难求的游戏,我们可以通过打表的方法观察出游戏获胜的规律。这里就不赘述了。
下面是练习题,有兴趣的同学可以试着做一下。
POJ2311
#include <iostream>
#include <cstring>
#include <set>
#include <cstdio>
using namespace std;
const int maxn = 1010;
int mex[maxn][maxn];
int SG(int w,int h)
{
if(mex[w][h]!=-1) return mex[w][h];
set<int> s;
for(int i=2;w-i>=2;i++) s.insert(SG(i,h)^SG(w-i,h));
for(int i=2;h-i>=2;i++) s.insert(SG(w,i)^SG(w,h-i));
int res = 0;
while(s.count(res)) res++;
return mex[w][h] = res;
}
int main()
{
int w,h;
memset(mex,-1,sizeof(mex));
while(~scanf("%d%d",&w,&h))
{
if(SG(w,h)!=0)
{
puts("WIN");
}
else
{
puts("LOSE");
}
}
return 0;
}
洛谷P2252
#include <bits/stdc++.h>
using namespace std;
int main()
{
int n,m;
cin>>n>>m;
if(n>m) swap(n,m);
int tmp = m-n;
int ans = tmp*((sqrt(5.0)+1.0)/2.0);
if(ans==n) cout<<"0"<<endl;
else cout<<"1"<<endl;
return 0;
}
HDU2516
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn = 1010;
ll f[maxn];
map<ll,int> vis;
void init()
{
f[0] = f[1] = 1;
for(int i=2;i<90;i++)
{
f[i] = f[i-1]+f[i-2];
}
for(int i=0;i<90;i++)
{
vis[f[i]] = 1;
}
}
int main()
{
init();
int n;
while(cin>>n)
{
if(n==0) break;
if(!vis[n]) cout<<"First win"<<endl;
else cout<<"Second win"<<endl;
}
return 0;
}