本文前面的部分讲的是博弈论简单理论与SG函数SG定理的一些理解,后面对小米OJ赛题做详细剖析,最后给出代码的详细解读。
目录
博弈论
在讲主要内容之前写说一下什么是博弈论,其实具体的定义在百度上都能找到,通俗的说就是选手在游戏中,通过变化自己的策略以达到取胜的目的,前提是处于平等对局中(一般都会给两个人智商很高不会失误的前提)。
博弈游戏一般是存在必胜或者必败局面的,而博弈者就要通过选择策略使自己处于必胜局面而对手处于必败局面。小米的这个题目本质上是判断某一给定局面是必胜还是必败局面。
为了能够公式化的获得必胜和必败局面,就需要SG函数与SG定理的帮助,下面笔者就SG函数与SG定理谈一谈个人理解,理解了SG函数与SG定理也就可以解决许多博弈问题了。
P-Position与N-Position
在理解SG函数与SG定理之前,首先定义P-Position与N-Position,P-Position是上一个做决策的人有必胜的可能,也就是先手必败的意思(下文简称P态),N-Position是当前做决策的人有必胜的可能,也就是先手必胜的意思(下文简称N态)。
怎么理解这个定义呢?如果第一个决策的人处于P状态,那么就是他此时此刻是没有必胜的可能的反之他上一个做出决策的人有必胜的可能(这里我们想象有上一个人,虽然此人先手),那么他是必败的;反之则必胜。画一张图理解一下。这张图A先手,如果走左边的决策,那么他是必胜的;反之如果走右边的决策那么他是必败的。
根据这个图我们作一个简单的推理就可以理解P/N的定义。对于终止状态(游戏结束)其状态为P,因为上一个人有必胜的可能,那么从终止态向上推理,P状态肯定是由N状态得来的,因为N状态是当前决策有必胜可能,所以终止态上面是N状态,同样的一步一步的推理,最终可以得到下面这张图A的初始决策状态,N态表示必胜,P态表示必败。
由此我们也可以得出P/N态的一些结论:1、任何无法移动的状态都是P状态(空集必是P状态);2、可以移动到P状态的是N状态(N状态的子集中存在P状态);3、任何选择都会导致N状态的是P状态(所有子集都是N状态的集合是P状态)。
为什么要定义这个呢,因为博弈论的本质就是找到自己和对手的N/P态,这样才可以决策以达到必胜的目的。
SG函数与SG定理
理解了N/P状态后,就要开始SG函数与SG定理的理解了。
SG函数
SG函数就是一种定义在有向无环图(就像上面的那个图)上的函数,通过计算SG函数值可以判断某一状态是必胜状态还是必败状态。
SG函数定义:首先定义mex运算,其表示的是找到一个不属于集合的最小非负整数,即mex{0,1,2,4}=3、mex{1,2,5}=0、mex{}=0;则SG函数:sg(x)=mex{sg(y) | y是x的后继}。画图理解一下。
由上图可知:
sg(0)=mex{}=0;
sg(1)=mex{sg(0)}=1;
sg(2)=mex{sg(1),sg(0)}=2;
sg(3)=mex{sg(2),sg(1),sg(0)}=3;
sg(4)=mex{sg(3),sg(2),sg(1)}=0;
sg(5)=mex{sg(4),sg(3),sg(2)}=1;
我们求这些sg值干嘛用,看下图:可以发现A的初态sg值是1不为0,而从决策图我们也可以看出这个游戏最终是A必胜的(A拿走一个石子后,B无论怎么拿A都会取得胜利),那么sg值是不是与游戏胜负状态有关?我们可以换一个A必败的例子:如果石子数为4颗 ,其他规则不变,那么A必败,计算一下sg值,发现A初态sg值为0,即sg值与游戏胜负状态是有关的!!
总结一下:初态sg值如果为0,则先手必败;反之,则先手必胜。
从上图我们也可以得到关于sg值的一些性质:1、终止态(不可以动局面)的sg值为0(因为其后继状态集合是空集);2、sg值为0的状态,其所有后继状态的sg值都不为0;3、sg值不为0的状态,其后继状态中肯定至少有一个状态的sg值为0。
再回忆一下P/N状态的性质,对比一下,是不是发现sg值性质与P/N状态性质完全对应!
所以我们可以利用sg值判断游戏的必胜与必败状态。
SG定理
单一的博弈游戏我们貌似可以解决了,只要求解sg值即可,那多个组合游戏呢?就要用到SG定理。
SG定理是指,对于组合游戏,其sg值等于所有子游戏sg值的异或,即SG=sg(子游戏1) ^ sg(子游戏2) ^ … ^ sg(子游戏n)。为什么总游戏的胜负态是子游戏胜负态的异或?为什么是异或?
严格的数学证明笔者数学功底不扎实,不会。。这里笔者给上自己的理解(这个理解其实不能完全解释,但是有助于读者理解)。两个子游戏sg(子游戏1)=0,sg(子游戏2)=0,都是先手必败的游戏,那么A先手,第一个子游戏败了,在第二个子游戏A就变成了后手,其必胜,所以对于整个游戏而言是先手必胜,0 ^ 0=1;再举个例子如果两个子游戏sg值分别为1,都是先手必胜游戏,那么A先手完成一个游戏的胜利的同时他也就变成了后手,最终的是必败的,也就是整个游戏是先手必败,1 ^ 1=0;对于两个sg值不相等但还不为0的子游戏呢?我们肯定能从一个游戏中找到故意让自己失败的情况,然后去获得另外一个游戏的胜利,这样仍然是先手必胜。举个例子有两堆石子个数分别为5,3,这两个子游戏都是先手必胜的,但A先手只要在3那堆石子中取两个,那么他就变成了必败局面,那么最终的游戏还会是A先手必胜。
SG函数值求取公式
理解了SG函数与SG定理之后,接下来要做的就是怎么求SG函数值,对于一个复杂游戏,我们不可能画出有向无环图来根据定义计算SG函数值,这里用到了数学归纳的方法,可以得到SG函数值的求取公式:
1、对于每次可以取任意数量的石子的情况(这里用石子举例子):sg(x)=x;
2、对于每次可以取1~m个石子的情况:sg(x)=x%(m+1);
3、对于每次可取的石子数不连续的(比如:可取1、3、5、7):通过编程打表的方式求取。模板程序会在最后给出,只需要程序的可以直接翻到最后
小米OJ12月常规赛赛题解析
题目内容
解析
这个题目完全可以看作是有两堆石子,A、B轮流按照规则取石子,谁最后没有石子可拿谁就输的博弈问题。
重新描述此题目:有两堆石子,分别有num1个和num2个,现A、B两人分别从两堆石子中取石子,每次可取的石子数量必须是数列(F(1)=1,F(2)=2,F(n)=F(n-1)+F(n-2))中的数字,现给出num1与num2,A先取,判断A必胜还是B必胜。
根据前面所说的内容,我们可以将整个游戏分为两个子游戏,分别计算sg(num1)与sg(num2)然后对两个sg值做异或运算,得到整个游戏的sg值,用该值判断胜负局面。
求解过程
根据上面的分析,整个题目的求解过程如下:
1、求取sg(num1)、sg(num2);
2、计算sg(sum)=sg(num1)^sg(num2);
3、根据sg(sum)判断胜负。
代码
#include <iostream>
#include <cstring>
using namespace std;
/*通过sg函数判断胜负*/
const int N = 1e4 + 10;
int f[N], sg[N], ahash[N];
//sg函数模板
void getsg(int n)
{
memset(sg,0,sizeof(0));
for (int i = 1; i <= n;i++)
{
memset(ahash,0,sizeof(ahash));
for (int j = 1; f[j] <= i;j++) //正因为此处才必须要计算f[20],否则此处循环不会停止,造成内存错误索引
{
ahash[sg[i-f[j]]]=1;
}
for (int j = 0; j <= n;j++)
{
if(ahash[j]==0)
{
sg[i]=j;
break;
}
}
}
}
int main()
{
f[0]=f[1]=1;
for (int i = 2; i <= 20;i++) //计算所有可取石子数
//实际上f[19]的数值是6565,f[20]是10000+,永远不会被取到的,之所以要把f数组的空间扩充至20是因为sg函数模板会索引到f[20]时会停止索引f[21](f[j]<=i),如果没有f[20],那么所有的f存储的数字都小于i,则循环不会停止会报错
{
f[i]=f[i-1]+f[i-2];
}
getsg(10000); //计算sg(1)~sg(10000)
int num1,num2;
cin >> num1 >> num2; //输入num1,num2,以空格分隔
if((sg[num1]^sg[num2])==0) //sg值为0,先手必败
cout << "Xiaobing Win" << endl;
else
cout << "Xiaoai Win" << endl;
return 0;
}
关于sg函数计算程序模板的说明见下面的内容,其他代码的说明已经写在注释中了。
SG函数程序模板即说明
此处的内容可以说是全文重点,记住了sg函数值计算模板,大多数博弈问题直接套用就可以了。sg函数计算有两种模板:1、打表;2、DFS。这两种模板适用于可取石子数不连续的情况。
附源代码链接
打表
代码
//f[]:可以取走的石子个数
//sg[]:0~n的SG函数值
//hash[]:mex{}
#include <cstring> //memset函数
int f[N],sg[N],hash[N];
void getSG(int n) //参数n是指求0~n的sg值
{
int i,j;
memset(sg,0,sizeof(sg));
for(i=1;i<=n;i++)
{
memset(hash,0,sizeof(hash));
for(j=1;f[j]<=i;j++)
hash[sg[i-f[j]]]=1;
for(j=0;j<=n;j++) //求mes{}中未出现的最小的非负整数
{
if(hash[j]==0)
{
sg[i]=j;
break;
}
}
}
}
此代码为其他大神的代码,是个模板代码,接下来笔者对该代码进行详细解读,帮助读者理解此代码。源代码链接
解析
此代码是模拟sg函数的定义。
i=1时,j只能等于1,hash(sg(1-f(1)))=hash(sg(0))=1,sg(1)=1;
i=2时,j=1时,hash(sg(2-f(1)))=hash(1)=1,j=2时,hash(sg(2-f(2)))=hash(0)=1,sg(2)=2;
…
i=n时,j=1时,hash(sg(n-f(1)))=hash(sg(n-1))=1,j=2时,hash(sg(n-f(2)))=hash(sg(n-2))=1,…
而sg(0)~sg(n-1)已经在之前的迭代过程中求得,所以sg(n)也就得到了。
i-f(j) 表示的是当前状态i的所有后继状态,这与定义相吻合;利用迭代从最开始的状态求出所有状态的sg值。
最后求mex中未出现的最小值,利用的是如果在j处没有存储大于0的数值,那么这个数字肯定没有出现在mex函数的集合中,那么其肯定是所求的最小值。
for(i=1;i<=n;i++) //i从1到n遍历,求sg[i]
{
memset(hash,0,sizeof(hash)); //每次求sg[i],都要把mex函数内的集合清空
for(j=1;f[j]<=i;j++)
hash[sg[i-f[j]]]=1; //计算i状态下,其所有的后继状态并存储在hash数组中,用于后面计算mex值
for(j=0;j<=n;j++) //求mex{}中未出现的最小的非负整数
{
if(hash[j]==0)
{
sg[i]=j;
break;
}
}
DFS
代码
//注意 S数组要按从小到大排序 SG函数要初始化为-1 对于每个集合只需初始化1遍
//n是集合s的大小 S[i]是定义的特殊取法规则的数组
int s[110],sg[10010],n;
int SG_dfs(int x)
{
int i;
if(sg[x]!=-1)
return sg[x];
bool vis[110];
memset(vis,0,sizeof(vis));
for(i=0;i<n;i++)
{
if(x>=s[i])
{
SG_dfs(x-s[i]);
vis[sg[x-s[i]]]=1;
}
}
int e;
for(i=0;;i++)
if(!vis[i])
{
e=i;
break;
}
return sg[x]=e;
}
解析
DFS方法与打表其实中心思想差不多,看懂了打表就会DFS方法。其与打表的区别是:打表是求出所有可能的sg值,比如题目中给定n<=10000,那么我们就可以用打表法暴力的列出所有的n的sg值;而DFS方法是求某一个点的sg值,比如题目并没有告诉n的取值范围,那么n有可能非常大,这时用暴力打表是不能解决此博弈问题的(可能会超时,超内存),此时就要用DFS法,可以精准的求取我们想要的点的sg值。
参考文献
https://blog.csdn.net/strangedbly/article/details/51137432
https://blog.csdn.net/lgz0921/article/details/85294164
https://blog.csdn.net/sleppypot/article/details/52331489
https://blog.csdn.net/myjs999/article/details/81316163
以上就是笔者对于SG函数SG定理,以及小米OJ数数字题目,打表法和DFS的一些个人理解,希望可以帮助到读者。