学习笔记——博弈论与 SG 函数
前言
在 CodeTon R2 比赛中被 F 题锤爆了。
公平博弈
这篇博客主要探讨公平博弈。
公平博弈的一个局面我们称之为状态,记作 \(G\)。显然一个博弈有意义,需要满足对于任意的 \(G\to G'\) 的转移不成环,否则可以永远进行下去。并且这些状态中,有一些是没有后继状态的,我们称之为终止态,一般定义终止态为胜态。
公平博弈的特征:
- 两个玩家对于一个相同的状态 \(G\),拥有相同的决策集合;
- 两个玩家知道关于博弈的所有信息;
- 不存在随机的部分;
- 只存在输赢态,没有平局。
基于此,我们可以知道:剪刀石头布游戏不是公平博弈,因为存在平局;两人猜一个随机数比谁先中也不是,因为存在随机的部分。
对于一个状态 \(G\),如果是先手必胜的,我们称之为必胜态,记作 \(N\) 态,反之记作 \(P\) 态。显然,如果一个状态 \(G\) 所能到达的状态全都是 \(N\) 态,则 \(G\) 是 \(P\) 态,反之 \(G\) 是 \(N\) 态。
习题
一堆若干个石子,每次取质数个,问先手是否必胜,如果必胜,在后手尽量拖延时间的情况下,先手最少要几步才能赢。
直接筛质数,然后对于每个状态,记录一个 \(f_i\) 表示目前是否为必胜态,和一个 \(g_i\) 表示如果当前是必胜态所需的最小步数,以及如果当前是必败态所可以的最大步数。
My Code
using namespace std;
const int MAXN=2e4+10;
int f[MAXN],g[MAXN];//1->N 0->P
int p[MAXN],pr[MAXN],tot;
void init(){
rep(i,2,MAXN-10){
if(!p[i]) pr[++tot]=i;
for(int j=1;j<=tot&&i*pr[j]<=MAXN-10;j++){
p[i*pr[j]]=1;if(i%pr[j]==0) break;
}
}rep(i,2,MAXN-10){
vector<int> nxt;
for(int j=1;j<=tot&&pr[j]<=i;j++)
nxt.pb(i-pr[j]);
for(int x:nxt) f[i]|=(!f[x]);
if(f[i]){
g[i]=inf;
for(int x:nxt)
if(!f[x])
g[i]=min(g[i],g[x]);
}else{
for(int x:nxt)
if(f[x])
g[i]=max(g[i],g[x]);
}
g[i]++;
}
}
void solve(){
int n;cin>>n;
if(f[n]) cout<<g[n]<<'\n';
else cout<<-1<<'\n';
}
signed main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
init();
int T;for(cin>>T;T--;)solve();
return 0;
}
非常有智慧。考虑到只能在一个左上都有棋子的格子放棋子,那也就是相当于向内凹陷的一个角。我们考虑怎么方便地记录当前的状态。可以发现,有放棋子和没放棋子的分界线其实是是从左下到右上的一条线。这条线我们如果把向右一格记作 0
,向上一格记作 1
,那么整个棋盘的状态可以记作一个长度为 \(n+m\) 的 01
串。而我们每次操作可以看成是交换任意两个相邻的 10
。
然后发现数据范围出奇的小,可以直接爆搜+记忆化。
My Code
using namespace std;
const int MAXN=15;
int S,T,n,m;
int a[MAXN][MAXN],b[MAXN][MAXN],dp[1048600][2];
int dfs(int sta,bool isf){
if(~dp[sta][isf]) return dp[sta][isf];
if(sta==T) return 0;
int cnt0=0,cnt1=0,ret=(isf?-inf:inf);
rep(i,0,n+m-2){
cnt0+=((sta>>i)&1)==0;
cnt1+=((sta>>i)&1)==1;
if(((sta>>i)&1)!=1||((sta>>(i+1))&1)!=0) continue;
int nxt=sta^(1<<i|1<<(i+1));
int x=n-cnt1+1,y=cnt0+1;
if(isf) ret=max(ret,dfs(nxt,isf^1)+a[x][y]);
else ret=min(ret,dfs(nxt,isf^1)-b[x][y]);
}return dp[sta][isf]=ret;
}
signed main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n>>m;
rep(i,1,n) rep(j,1,m) cin>>a[i][j];
rep(i,1,n) rep(j,1,m) cin>>b[i][j];
memset(dp,-1,sizeof(dp));
S=0;rep(i,1,n) S=S<<1|1;
T=0;rep(i,1,n) T=T<<1|1;rep(j,1,m) T<<=1;
cout<<dfs(S,1);
return 0;
}
SG 函数
可以把 SG 函数理解成一个对状态记录的构造方式使得其满足一些优美的性质来简化计算。
所以就不讲具体含义了。
定义
对于一个公平博弈,我们定义其 SG 函数为:
- 终止态的 SG 函数是 \(0\);
- 对于非终止态 \(S\),我们设它能够转移到状态集合为 \(T\),则有:
也就是说,\(SG\) 转移的时候是所有后继状态中第一个没有出现过的数。
并且其用于判定的方式是如果是 \(0\),则是必败态,否则是必胜态。这很好理解,如果 \(T\) 中全部都是非 \(0\) 的,那意味着后继的状态全部都是必胜的,所以当前是必败态;反之,如果存在一个后继的 \(0\),则存在一个后继必败,那么当前就是非 \(0\) 的必胜态了。
SG 和
其实 SG 函数的定义在极其简单的情况下,是比直接推导略显得麻烦。但是由于它满足一些优美的性质使得在某些情况下,可以很方便地得出一些信息。
我们先来看看大家都会的 Bash 博弈怎么用 SG 来推导:
有一堆石子,A 和 B 每次轮流取 \([1,k]\) 个石子,不能取者败。
- 首先,考虑终态,应该是 \(SG(0)=0\);
- 然后,对于 \(SG(1\sim k)\),都可以通过取石子直接到必败态,所以都是必胜的,更具体地,如果算出 SG,则有:\(SG(i)=i,i\in[1,k]\);
- 当 \(SG(k+1)\) 时,发现后继状态中都是必胜态,则其为必败态。可以算出 \(SG(k+1)=0\);
- 以此类推,可以得到 \(SG(i)=0\) 当且仅当 \((k+1)|i\)。
很简单对吧?接下来考虑一个 Nim 博弈,即:
有 \(n\) 堆石子,每次可以取走一堆中任意多个,不能取者败。
可以发现,这个游戏可能并不能很好地用一个 SG 就记录其全部的状态。所以我们需要 SG 和来帮助我们解决这个问题。
SG 和,就是如果两个人同时对立地进行多个公平博弈。如果现在有游戏一的一个状态 \(A\) 和游戏二的一个状态 \(B\)。我们将这样得到的并列游戏状态记作 \(A+B\)。那我们记 \(SG(A+B)\) 的一个后继的 SG 值为 \(SG(A'+B)\) 或者 \(SG(A+B')\),其中 \(A',B'\) 都是 \(A,B\) 的后继。
此时我们有:
Proof
记 \(y=SG(A)\operatorname{xor}SG(B)\),\(G=(A,B)\)。
此时我们记 \(G\) 的任意一个后继状态为 \(G'\),则相当于时在说:
\[\begin{cases} \forall x<y,\exist G',SG(G')=x\\ \forall G',SG(G')\not ={y} \end{cases} \]接下来我们利用归纳法,依次来证明上面这两条。首先,可以得到边界,就是由于所有的终态都是 \(0\),也就是必败态,则异或起来之后必然还是 \(0\),仍然是必败态,成立。现在我们假设对于 \(G\) 的任意后继 \(G'\),已经有上式成立,此时希望证明 \(G\) 是满足上式的。
- \(\forall x<y,\exist G',SG(G')=x\)。
记 \(d=x\operatorname{xor}y\),令其最高位为 \(k\),则 \(x,y\) 中有且仅有一个二进制第 \(k\) 位是 \(1\)。此时,如果 \(x\) 的第 \(k\) 位是 \(1\),则从高到低第一位与 \(y\) 不同的位就是第 \(k\) 位,而这一位上 \(x>y\),与假设 \(x<y\) 矛盾,故有 \(y\) 的第 \(k\) 位是 \(1\)。
那么也就是说,\(SG(A)\) 和 \(SG(B)\) 中有且仅有一个第 \(k\) 位是 \(1\)。由于 \(SG(A)\) 和 \(SG(B)\) 是地位相等的,我们可以假设 \(SG(A)\) 的第 \(k\) 位是 \(1\)。此时构造 \(SG(A)\operatorname{xor}d\),这东西与 \(SG(A)\) 相比,由于 \(k\) 是 \(d\) 的最高位,所以 \(SG(A)\) 比 \(k\) 更高的位一定不变。而把第 \(k\) 位变成了 \(0\),所以最终是变小了,即:\(SG(A)\operatorname{xor}d<SG(A)\)。
既然比 \(SG(A)\) 小,则 \(SG(A)\operatorname{xor}d\) 必然是 \(A\) 的一个后继状态 \(A'\) 的 SG 值。即:\(\exist A',SG(A')=SG(A)\operatorname{xor}d\)。这样我们把 \(d\) 带入得到:\(SG(A')=x\operatorname{xor}SG(B)\)。移项得到:\(SG(A')\operatorname{xor}SG(B)=SG(A'+B)=SG(G')=x\)。- \(\forall G',SG(G')\not ={y}\)。
直接反证,若存在 \(SG(G')=y=SG(A)\operatorname{xor}SG(B)\),而又有 \(SG(G')=SG(A')\operatorname{xor}SG(B)\)。所以得到 \(SG(A)=SG(A')\),矛盾。故原命题成立。\(\square\)
有了 SG 和,就可以很好地解决 Nim 博弈了。我们把每一堆都看成是一个单独的博弈。可以算出其 SG:\(SG(i)=i\)。那么每一堆的 SG 和就是每一堆大小的异或和。
至于策略的构造……还是按照套路,每次把必败态扔给对方。如果只有必胜态就认输。
习题
你可以用 SG 函数乱杀这些题。
给定若干堆石子,每次操作可以选择三个数 \(i<j\le k\),将 \(i\) 中的一颗石子取走,并在 \(j,k\) 中各放一颗。不能操作者输。
看到若干堆,可能会想到对每堆先做 SG 然后合并。但是这里每一堆之间并不是独立的,所以不能用 SG 合并。但是可以换一个角度,就是每一个石子之间好像确实是独立的。所以容易想到对于每一颗石子都算出 SG 后用 SG 和合并。
那我们用 \(SG(i)\) 表示距离 \(m\) 为 \(i\) 的位置的一颗石子的 SG 值。那我们每一次操作相当于把一个 \(i\) 变成 \(j,k\) 满足:\(j,k<i\)。也就是说被分成两个子博弈,把这两个子博弈用 SG 和合并后就可以正常求 SG 了。
至于求第一步的所有方案,可以直接暴力枚举,只要后继状态的 SG 和为 \(0\) 的就行了。
My Code
using namespace std;
int sg[25],ha[25];
void init(){
rep(i,1,21){
memset(ha,0,sizeof(ha));
rep(j,0,i-1)
rep(k,0,i-1)
ha[sg[j]^sg[k]]=1;
while(ha[sg[i]]) sg[i]++;
}
}
int a[25];
void solve(){
int n;cin>>n;
rep(i,1,n) cin>>a[i];
int ans=0;
rep(i,1,n) if(a[i]&1) ans^=sg[n-i];
if(!ans){
cout<<"-1 -1 -1\n0\n";
return;
}int cnt=0;
rep(i,1,n) rep(j,i+1,n) rep(k,j,n)
if((ans^sg[n-i]^sg[n-j]^sg[n-k])==0){
if(!cnt) cout<<i-1<<' '<<j-1<<' '<<k-1<<'\n';
cnt++;
}
cout<<cnt<<'\n';
}
signed main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
init();
int T;for(cin>>T;T--;)solve();
return 0;
}
给定一个集合的大小和值域,但是不知道集合内具体的元素。两人可以轮流选取集合内的数 \(x\),并任选 \(y\in[1,x]\),且 \(x\operatorname{xor}y\in[0,x)\),然后把 \(x\) 变成 \(x\operatorname{xor}y\)。不能操作者败。求先手有多少种必胜的局面。
首先考虑 SG 来求出所有必胜的局面。和上面那题类似,可以求出每个值的 SG。这个 SG 的转移就是直接枚举 \(y\) 然后暴力算后继即可。接下来的问题就变成,有多少种组合,使得值域为 \([1,m]\) 的 \(|V|\) 个数,它们的 SG 函数异或和为 \(0\)。
然后发现 SG 暴力求是 \(O(m^2)\) 的,寄寄了。于是我们考虑这肯定有规律。通过打表发现,SG 函数其实是:\(2^0\sim 2^0,2^0\sim 2^1,2^0\sim 2^2,\cdots\) 这样一直拼接下去的。所以就有:\(SG(2^k+d)=d+1\),其中 \(k\) 取到极大。那直接开 \(\log\) 再左移就行了。
接下来 就是 FWT 优化一下就行了。
My Code
#define int long long
using namespace std;
const int MAXN=2e6+10;
const int MOD=998244353;
int ksm(int a,int p){
int ret=1;while(p){
if(p&1) ret=ret*a%MOD;
a=a*a%MOD; p>>=1;
}return ret;
}
int sg[MAXN],a[MAXN];
void init(){rep(i,1,MAXN-10) sg[i]=(i-(1<<(__lg(i))))+1;}
int len=1<<20;
void FWT(int A[],int opt){
for(int p=2;p<=len;p<<=1) for(int i=0;i<len;i+=p)
for(int j=0;j<(p>>1);j++){
int x=A[i+j],y=A[i+j+(p>>1)];
A[i+j]=(x+y)*opt%MOD;
A[i+j+(p>>1)]=(x-y+MOD)%MOD*opt%MOD;
}
}
int ans[MAXN];
signed main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
init();
int V,m;cin>>V>>m;
rep(i,1,m) a[sg[i]]++;
ans[0]=1;FWT(a,1);FWT(ans,1);
while(V){
if(V&1){
rep(i,0,len-1) (ans[i]*=a[i])%=MOD;
}rep(i,0,len-1) (a[i]*=a[i])%=MOD;
V>>=1;
}FWT(ans,ksm(2,MOD-2));
int tot=0;
rep(i,1,m) tot=(tot+ans[i])%MOD;
cout<<tot<<'\n';
return 0;
}
这题直接按题意求 SG 就行了。其实和 Nim 差不多,可以把后继状态看成是若干个之和。
只要注意一下用前缀和优化一下 SG 的转移即可。
My Code
using namespace std;
const int MAXN=3e4+10;
int n,MAXQ,sg[MAXN],ha[303];
void init(){
rep(i,1,n){
int c=i,a=0,b=0;
while(c%2==0) a++,c/=2;
while(c%3==0) b++,c/=3;
memset(ha,0,sizeof(ha));
rep(p,1,a){
int sum=0;
rep(q,1,MAXQ){
if(p*q>a) break;
sum^=sg[i/(int)pow(2,p*q)];
if(sum<303) ha[sum]=1;
}
}rep(p,1,b){
int sum=0;
rep(q,1,MAXQ){
if(p*q>b) break;
sum^=sg[i/(int)pow(3,p*q)];
if(sum<303) ha[sum]=1;
}
}while(ha[sg[i]]) sg[i]++;
}
}
void solve(){
cin>>n>>MAXQ;
init();
int sta,ans=0;
rep(i,1,n){
cin>>sta;
if(!sta) ans^=sg[i];
}if(!ans) cout<<"lose\n";
else cout<<"win\n";
memset(sg,0,sizeof(sg));
}
signed main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
int T;for(cin>>T;T--;)solve();
return 0;
}
SG 重要模型
感觉最常用的就是 Nim。然后就是阶梯 Nim 的转化,只要能想到偶数级阶梯是垃圾桶就可以了。
anti-SG
接下来主要介绍一种可能比较困难的模型,叫做反 SG(anti-SG)。
先来看这样一道题:
Nim 博弈,但是不能取石子的人算赢,也就是取走最后一个石子的人输。
同样可以对每一堆石子都算出其 SG 函数。但是问题是对于 anti-SG 问题而言,SG 没有上述这种良好的合并的性质。因为这个规则已经被完全改变了。
为了解决这类问题,我们提出了另一种较为繁琐但是仍然可以合并两个 SG 的引理:
-
SJ 引理,令 \(G_{1\sim n}\) 为 \(n\) 个游戏的状态。则先手必胜的充要条件是:
- \(\operatorname{xor}SG(G_i)\not ={0} \,\&\max SG(G_i)>1\)
- \(\operatorname{xor}SG(G_i) ={0} \,\&\max SG(G_i)\le1\)
两者中有一个成立。
Proof
略(反正会忘的而且没啥用)
有了这个引理,上面那题就可以很轻松地解决了。
My Code
using namespace std;
const int MAXN=5010;
void solve(){
int n;cin>>n;
int sum=0,mx=0,v;
rep(i,1,n){
cin>>v;
sum^=v;mx=max(v,mx);
}
if((sum&&mx>1)||(!sum&&mx<=1)) cout<<"John\n";
else cout<<"Brother\n";
}
signed main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
int T;for(cin>>T;T--;)solve();
return 0;
}
Nim-k
考虑一个 Nim 博弈的时候,一次可以同时操作 \([1,k]\) 堆石子,于是原来异或在这种条件下显然是不成立的。
但是可以稍微扩展一下,就是异或可以看成是二进制下每一位模 \(2\) 的加法。原因是两堆一样的石子可以通过模仿对方来消耗掉。
考虑可以同时取 \(k\) 堆的情况。实际上可以看成相同的 \(k+1\) 堆的情况,可以通过在剩下没有操作的堆中模仿,来消耗这 \(k+1\) 堆。
所以直接在二进制下做模 \(k+1\) 的加法就可以了。
板子题,要求方案数直接组合数算一下就行了。
My Code
//远古代码
using namespace std;
const ll MOD=1e9+7;
const int MAXN=1e4+10;
ll ksm(ll a,ll p){
ll ret=1;while(p){
if(p&1) ret=ret*a%MOD;
a=a*a%MOD; p>>=1;
}return ret;
}ll inv(ll x){return ksm(x,MOD-2);}
ll fac[MAXN],ifac[MAXN];
void init(){
fac[0]=ifac[0]=1;
for(int i=1;i<=MAXN-10;i++) fac[i]=fac[i-1]*i%MOD;
ifac[MAXN-10]=inv(fac[MAXN-10]);
for(int i=MAXN-11;i>=1;i--) ifac[i]=ifac[i+1]*(i+1)%MOD;
}ll C(int n,int m){
return fac[n]*ifac[m]%MOD*ifac[n-m]%MOD;
}
ll dp[14][MAXN];
int main()
{
ll n,k,d;
scanf("%lld%lld%lld",&n,&k,&d);
if(k==2){
printf("%lld\n",(n-1)*(n-2)/2);
return 0;
}init();
dp[0][0]=1;
for(int i=1;i<=13;i++){
for(int j=0;j<=n-k;j++){
for(int m=0;(d+1)*m*(1<<(i-1))<=j&&(d+1)*m<=k/2;m++){
dp[i][j]=(dp[i][j]+dp[i-1][j-(d+1)*m*(1<<(i-1))]*C(k/2,m*(d+1))%MOD)%MOD;
}
}
}ll ans=0;
for(int i=0;i<=n-k;i++) ans=(ans+dp[13][i]*C(n-i-k/2,k/2))%MOD;
printf("%lld\n",((C(n,k)-ans)%MOD+MOD)%MOD);
}
需要尤其注意的一点就是:Nim-k 的结论不可以扩展到任意公平博弈上,仅限于 Nim 游戏。
结语
SG 积是不可能的,这辈子都不可能。
希望以后看到博弈论题不会再这么束手无策了。
总结一下就是如果可以暴力求 SG 就暴力求,不能的话必然是有一定的规律,或者能在一定规模下找到循环节。这个打个表就行了。这类题总是相对容易的。
关于建立模型只要多做题,然后大部分都可以归约到 Nim 博弈上来。不行就自己找有什么东西是独立变化的,以此作为一个单独的游戏,然后求 SG 和。
比较困难的题有两种,一种是需要一些人类智慧才能利用 SG 或者博弈论的相关知识,甚至不需要,就是纯粹的人类智慧博弈题;另一种是需要计算开局先手必胜的方案数,要能熟练掌握各种计数方式。
正因为如此,接下来准备去学学 FWT/FMT,从而能够解决这里唯一咕着的题。
已填坑,完结撒花!