博弈及其应用——组合游戏
博弈,在一般人的脑海中多半是个高深的词汇,我们常常将它与一个人联系起来——纳什均衡的发现者John Forbes Nash,以及他的那部经典的传记电影——《美丽人生》。而对这位诺贝尔经济学家最好的尊敬,就是懂一些博弈论的知识。
博弈,先不管它到底是运筹学还是经济学还是数学,我们从最原始的角度去定义它,它就是——下棋。下过象棋、围棋、国际象棋的人也许会对这一过程(下棋)有更深的理解,那是一种通过双方的脑力活动(参加博弈者是理性的假设是研究博弈论的前提),最终引导出一种客观的结果(输或赢),由于这个过程考验着游戏者的逻辑性以及最终引导出某种逻辑必然性(这是数理世界很重要的美感之一),它们与组合数学、数论等是经久不衰的数学游戏和美学消遣,以无穷的魅力激发着人们的聪明才智和数学兴趣。
作为博弈论的一个小分支,组合博弈有着以下的定义。
①有且仅有两个玩家
②游戏双方轮流操作
③游戏操作状态是个有限的集合(比如:取石子游戏,石子是有限的,棋盘中的棋盘大小的有限的)
④游戏必须在有限次内结束
⑤当一方无法操作时,游戏结束。
与我们熟知的一些棋类进行对照,发现十分的吻合。
那么我们今天就来逐步的介绍一些其他简单而有妙趣的博弈模型。
Bash博弈:(Problem source:hdu 1846)
数理分析:其实有关博弈的一些分析思路和在解决数论、组合数学的一些问题非常类似。
我们现在一个最简单的模型开始,假设n = m + 1,那么先手无论取多少个([1,m]),都取不完,而且留下的石子数小于等于m个,这意味着后手肯定能拿完,所以这种情况下,后手必胜。
基于上面这个简单的模型,我们发现其引导出了逻辑依然性,那么就从这个简单模型进行推广。
假设有n = (m + 1)*r(r是大于1的整数),我们将其分成r组,先手无论取多少,后手都机智的将改组剩下的石子取走,这样后手刚好取完某一组中剩下的石子,这一规律延续到最后,后手必胜。
那么如果n = (m + 1)*r + s(s∈[1,m) )呢?这里不妨拿n = m + 2举个栗子,先手拿走1,不论后手拿走多少,先手将拿走剩下的石子,先手赢。推广起来,把n分成r组,但是第一组前面还有s个石子,这样先手只要机智的拿走一定数量的石子,使得这组剩下m+1个石子(介于s的取值范围,这个操作一定可以完成),这样这里的先手其实就成为了上面那个模型的后手,必赢。
因此总结起来不难发现:如果n对m+1取余等于0,那么后手必胜;如果n对m + 1取余不等于零,那么先手必胜。
编程实现:有了上述的数理分析,编程实现就非常的简单。
参考代码如下:
#include<stdio.h> using namespace std; int main() { int t; int n , m; scanf("%d",&t); while(t--) { scanf("%d %d",&n ,&m); if(n % (m + 1)) printf("first\n"); else printf("second\n"); } }
我们再来看一道关于Bash博弈的变形题(Problem source:hdu2149)
数理分析:这道题其实就是很简单的Bash博弈,不过要在其基础上进行一定的分类讨论。
如果这里n>=m,显然先手必胜,枚举出所有情况即可。
如果n<m:
①m % (n+1) = 0,通过Bash博弈的模型,我们可以知道这种情况先手必败。
②如果m%(n+1) = s,s∈[1,m],此时先手存在必胜策略,即第一次拿走s个即可
参考代码如下。
#include<stdio.h> int main() { int m , n; while(scanf("%d%d",&m,&n) != EOF) { if(m <= n) for(int i = m;i <= n;i++) { if(i == n) printf("%d\n",i); else printf("%d ",i); } else if(m%(n+1) == 0) printf("none\n"); else printf("%d\n",m%(n+1)); } }
让我们再来看一道有关简易的Bash博弈的问题(Problem source :hdu2188)
很明显的Bash博弈的模型,这里与上题类似,在没有给出n、m的关系的情况下,还是需要单独分出一种情况来讨论。
参考代码如下。
#include<stdio.h> int main() { int t; scanf("%d",&t); while(t--) { int m , n; scanf("%d%d",&n,&m); if(n <= m) printf("Grass\n"); else { if(n%(m+1) == 0) printf("Rabbit\n"); else printf("Grass\n"); } } }
我们再来看一道类Bash博弈的题目。(Problem source : hdu 1517)
题目大意:给定一个数n,从p = 1开始,两个游戏玩家交替从[2,9]中取一个整数,然后用这个数乘以p,谁操作最先使得p大于或等于n,谁就是胜者。那么现在给你一个数字n,让你判断谁会赢。
数理分析:之所以说它是Bash博弈,不是说它只是简单的在原Bash博弈的基础上进行数据的改动,而是采用Bash博弈的分析思想,这其实是数学领域真正的迁移、或者说举一反三的能力。
回想起对原Bash博弈的分析,我们是采用"先特例,后推广"的分析方法,即先分析简单的几个模型,然后将他们推广到整个整数集从而找到规律。
那么我们这里也采用类似的方法,如果这里n属于[2,9],显然先手赢,那么如何找到一个最简单的区间,使得后手必胜呢?我们关注到这里的p大于或等于n是符合胜利条件的,为了分析的方便以及后面规律的推广,我们显然不喜欢不等号的存在,即我们想要找到一个临界的确定值来分析。
如果先手取了最小的2,那么后手最大能取9,此时p = 18,,1就是一个临界性质的数值。为什么呢?此时n如果小于18,那么先手不管取什么,后手一定能先构造出大于n的p,因为先手即使取了最小的2,后手都能取9来构造出18,何况你取比2更大的数,只会使后手构造出更大的数。如果n > 18,后手则无法一次构造出符合要求的p,假设n = 19,先手取2,后手不管取什么都无法构造出超过19的p,此时先手则一定赢。我们观察到,n=18时胜负的必然性就已经发生了交替,证明它就是一个临界点。
那么我们得出简单的结论。n取[2,9],先手必胜;n取[10,18],后手必胜。
那么如何将这两个模型推广出去呢?我们基于n取[2,9]这种情况,这是先手必胜的区间,我们现在要关注的是,下一个先手必胜区间是是什么?这中间必须要让后手取一次,并且满足后手无法完成构造,且无论后手取什么,都能帮助先手完成构造。那么假设后手取了2,先手取9,那么基于[2,9]乘18的新区间一定是先手必胜区间。因为此时后手取2都能导致先手胜利,取大于2的数字就更不用说了。
所以我们看到,基于[2,9]区间,乘以18得到的新区间,是先手的必胜区间。那么我们再基于[2 * 18 , 9 * 18]的区间,重复上述分析。我们则得到推广后的结论——基于[2,9],乘以18的整数倍形成的区间,都是先手的必胜区间。
同理分析基于[10,18],乘以18的整数倍,是后手的整数区间。
这里要注意的是,推广后形成的区间,已经不是数轴上连续的点了,但是取在这个区间的整数,依然是对应的必胜态。
基于上面的数理分析,我们很容易看到,只需将n多次除以18,当其小于等于18后,我们即可判断谁有必胜策略。
参考代码如下。
#include<stdio.h> int main() { double n; while(scanf("%lf",&n)!=EOF) { while(n>18) n/=18; if(n <= 9) printf("Stan wins.\n"); else printf("Ollie wins.\n"); } return 0; }
我们再来看一道有关Bash博弈变式分析的问题。(Problem source:hdu 1847)
数理分析:通过读题我们发现,这道题目与Bash博弈有那么一点联系但是又有区别,问题基于的大的背景是Bash博弈给出的背景(一个石子堆),但是一次可以取的石子发生了变化,这里不再是[1,m],而是2^k(k >=0)。
基于我们已经所熟知的关于Bash博弈模型的分析,我们在这里可以灵活的变通来继续使用哪个证明过程。这里如果一次只能取1or2个石子,那么这将是标准的Bash博弈,分析的时候我们将石子堆3个一组来分堆。
我们假设n%3=0,现在取的石子变为2^k,我们可以将其看成2 * 2^(k-1),如果我们还从3个一组的石子堆看这个过程,我们会发现,先手每次相当于在2^(k-1)组中每组拿走了2个,这样在那2^(k-1)组中,每组都剩余了1个,则共剩余了2^(k-1)个,后手是可以拿完的。这时剩下了n/3 - 2^(k-1) 组石子,石子总数又是3的倍数,因此有重复了上述过程,由此可见,对于n%3 = 0的情况,先手必败。
那么如果n%3 不等于0呢?有了上面的分析过程我们就很好得出答案了,此时先后手的胜负发生置换。
有了以上的数理思维,我们就可以编程实现了。
参考代码很简单。
#include<stdio.h> int main() { int n; while(scanf("%d",&n) != EOF) { if(n % 3 == 0) printf("Cici\n"); else printf("Kiki\n"); } }
我们在来看一个Bash博弈的推广。(Problem source:hdu2897)
数理分析:显然这道题目是上面那道简易Bash博弈的推广,在上题中取石子的下限是1,而这里是p。
我们还是先找到一个简单的模型,假设n = p + q ,那么先手拿走q,后手就必败了。
紧接着我们再分析n = (p + q)*r的情况,这里我们还是将n分成r组,每组数是p+q,不过,将第1组拆开,q放在最前面,p放在最后面,然后先手拿走q,后手开始取第2组的硬币,其拿走硬币个数的范围是[p,q],所以剩余硬币的范围是[p,q],也就是说,后手不论拿多少(假设为k),先手只需再拿走(p+q-k)个(通过上面的分析,一定在可拿的范围之内),就可以保证第2组。然后循环往复,最终后手将面临只剩下p个的硬币堆,后手必败。
那么如果n = (p+q)*r + s呢?(s∈[1,p+q]),显然这里我们要分情况讨论。
①如果s≤p,那么后手采取上述先手的策略,最后先手将面临一个s个硬币的堆,先手必败。
②如果p<s≤q,先手采取先拿走s-p,然后采取n = (p+q)*r情况下的策略,后手拿k,先手就拿p+q-k,后手最终将面临大小为p的堆,后手必输。
③如果q<s,情况和②类似,只不过先手上去先拿走q个即可,这样后手最终将面临一个小于p的堆,后手必输。
有了以上分析,就很好编程了。
参考代码如下。
#include<stdio.h> int main() { int n , p , q; while(scanf("%d%d%d",&n,&p,&q) != EOF) { if(n%(p + q) == 0) printf("WIN\n"); else if(n%(p + q) <= p) printf("LOST\n"); else if(n%(p + q)> p && n%(p + q) <= q) printf("WIN\n"); else printf("WIN\n"); } }
我们再介绍另外一个博弈Nim博弈。
与Bash博弈有着类似的规则,这里给出n堆大小为Ai的石子,游戏参与者仍然是两个人,这种博弈类型,我们如何分析呢?对于Nim博弈的分析,论文资料可以说是浩如烟海。关于这个博弈最早给出详尽证明的是哈佛数学系教授Chales Leonard Bouton,那么我们今天就来简单的探索一下这个优美的游戏。
其实我们完全可以跳过一些分析中对于(0,n,n),(1,2,3)这些具体状态的分析,对他们的分析对之后结论的推广没有什么用处。这里我们就假设有n堆大小分别为Ai的石子。我们进行博弈,最终的目的是导出逻辑必然性,即诸如先手必胜或者后手必胜等结论。而当我们面临越少的选择项,就越容易导出逻辑必然性——最少的选择项,Bouton教授想到了二进制数。
我们假设Ai表示成二进制数最高位数是s,那么我们可以讲n个十进制数均进行二进制数。
A1=a1 a2 a3 a4 a5 a6……as
A2=b1 b2 b3 b4 b5 b6……bs
A3=c1 c2 c3 c4 c5 c6……cs
……
Ai=……
这里的小写字母a、b、c是二进制数,非0即1。这里我们再将a1、b1、c1……求和,记作λ1,同理,我们可以求出λ2、λ3……λi。我们这里给出一个定义——所有λ为偶数,记作平衡态 ,否则,记作非平衡态。
那我们现在开始开始分析。
①先手遇到平衡态,进行操作后必然打破,此时后手如果足够聪明,他可以看到,如果他对对应位的二进制数进行操作,将整个石子堆还原成平衡态,那么先手下次又将打破平衡态,依次往复,先手总是面临平衡态,因为聪明的后手总是给他留下平衡态,那么这样——先手将会面临他最后一个平衡态,所有的A = 0——先手必输。
②先手遇到非平衡态,与bash博弈的分析非常类似,这次聪明的先手开始给后手留平衡态——后手必输。
而在这里我们如何表征所谓的平衡态呢?——A1^A2^……^Ai^……=0!(符号^表示异或运算,很多资料关于这一点的引出很搪塞,觉得很神奇,但是它确实有存在的逻辑性) 这就是Nim博弈的分析。
下面我们结合一道题目来应用一下Nim博弈。(Problem source:hdu1907)
题目大意:题目的意思和Nim博弈很相似但是又有些区别,题目给出的要求是吃最后一个糖的人输。
数理分析:这本质上表达了和Nim博弈一样的意思,在Nim博弈中的赢家只需最后留一颗糖给另外一个玩家,就可以赢。但是这里考虑到剩下一个糖的特殊性,我们需要单独考虑这个盒子里全部只有一颗糖的特殊情况。
编程实现上没有什么难度。
参考代码如下。
#include<stdio.h> using namespace std; int main() { int t; scanf("%d",&t); while(t--) { int n , x; scanf("%d",&n); int ans = 0 , flag = 0; for(int i = 0;i < n;i++) { scanf("%d",&x); ans ^= x; if(x > 1) flag = 1; } if(flag) { if(ans == 0) printf("Brother\n"); else printf("John\n"); } else { if(n&1) printf("Brother\n"); else printf("John\n"); } } }
我们再来看一道需要进行转化成Nim博弈的问题(Problem soure : hdu 1730)
数理分析:这道题表面上看与Nim博弈唯一的联系就是有n行,这其实对应了Nim博弈中的多堆石子。
我们注意到,当某个玩家遇到两颗石子相邻时,单单针对这一行,这个人是必败的。为什么呢?因为在这种情况下,如果这个玩家可以移动这个棋子,另一个玩家只需要再次贴住这个玩家移动后的棋子,那么最终这个玩家一定会面临无法移动棋子的局面。
我们注意到,这种情形下,两个棋子之间的距离是0,其实对应着原始的Nim博弈中的”平衡态“,那么我们这里就可以将每一行中的两棋子之间的距离看成Nim博弈中每堆的石子数。我们看到,游戏的最终形态:n行两棋子之间的距离的值均为0,那么进行异或运算的结果也是0,这是平衡状态。那么如果先手面对非平衡状态,即n行两棋子之间的距离的异或结果非零,先手必有相应的操作(从二进制的角度看待两棋子间的距离),使得游戏状态变成平衡态,那么后手就开始面临平衡状态,后手的操作一定再次打破平衡状态,先手将其恢复成平衡状态,反复操作,后手将面临最终无法移动棋子的平衡状态,先手必胜。
如果起始状态是平衡状态,那么后手必胜,分析过程和上面是一样的。
有了上面对问题与原Nim博弈模型的对照,我们就可以很好的编程实现了。
参考代码如下。
#include<stdio.h> using namespace std; int main() { int t; scanf("%d",&t); while(t--) { int n , x; scanf("%d",&n); int ans = 0 , flag = 0; for(int i = 0;i < n;i++) { scanf("%d",&x); ans ^= x; if(x > 1) flag = 1; } if(flag) { if(ans == 0) printf("Brother\n"); else printf("John\n"); } else { if(n&1) printf("Brother\n"); else printf("John\n"); } } }
我们再来看一道基于Nim博弈的问题。(Problem source : hdu1850)
数理分析:这道题是在Nim博弈判断胜负的基础上,计算先手必胜的情况下,第一次可行的方案。
我们依然从二进制的角度来看所给出的m个数,我们基于对Nim博弈的认知,可知只有m个数进行异或运算在非零的情况下先手才有必胜策略。
而在这种情况下,先手第一手必须达成一个目的——是整个游戏达到平衡态。这里我们假设a1^a2^a3……^ai^……an = k,那么一定存在一个操作,使得ai' = ai^k,这使得操作后整个游戏的状态变成a1^a2^a3^……ai'……^an = k^k = 0,这就达到了目的。
而我们现在就是来枚举每一个石子堆(ai),来判断是否存在这样一种方案来满足我们达成必胜策略。(^k操作,使游戏变成平衡态)
假设我们现在再判断取石子堆ai是否有可行方案,我们对剩余的m - 1堆石子做异或运算:
1.如果得到了平衡态,那么显然m-1个石子堆异或运算的结果是0,那么说明ai所对应的二进制数导致了整个游戏局面的不平衡,所以此时只要全部拿走ai堆里的石子即可。
2.如果没有得到平衡态,那么拿ai这个石子堆对应的二进制数与之进行异或运算,会存在某一位或某几位是非平衡状态,此时如果二进制数ai在该位上对应1,那么我们将其变为0即可。如果二进制数ai的某一位是0,显然m - 1堆石子异或运算的结果在该位上是1,那么此时对于二进制数ai,只有比该位高的位有1才可以实现将游戏转化成平衡状态。如果两个位置同时存在,必须是1在高位,0在低位,这使得m-1异或运算的结果必须是0在高位,1在低位。
综合起来看,我们会得出结论,ai必须大于m-1堆石子异或的结果,对第i堆石子操作,是存在必胜策略的。
有了这层数理思维,编程不难实现。
参考代码如下。
#include<stdio.h> using namespace std; int main() { int num[105]; int m; while(scanf("%d",&m) != EOF && m) { int ans = 0; for(int i = 0;i < m;i++) { scanf("%d",&num[i]); ans ^= num[i]; } if(ans == 0) printf("0\n"); else { int cnt = 0; for(int i = 0; i < m;i++) { ans = 0; for(int j = 0;j < m;j++) { if(i == j) continue; ans ^= num[j]; } if(num[i] > ans) cnt++; } printf("%d\n",cnt); } } }
基于上面我们对Nim博弈获胜策略的方案数模型的探讨,我们再来看一道一模一样的题目。(Problem source : pku 2975)
题目大意:给出Nim博弈中n个石子堆的石子数目,让你给出先手的必胜策略有多少种。
数理分析:基于上文的探讨,我们知道当第i堆石子数目a[i]小于n-1堆石子数目异或运算的结果时,对于第i堆石子,先手就将存在一个必胜策略,依次遍历下去,我们就可以知道先手必胜策略的总是是多少。
参考代码如下。
#include<cstdio> using namespace std; const int maxn = 1005; int a[maxn]; int main() { int n; while(~scanf("%d",&n),n) { int ans = 0; for(int i = 1;i <= n;i++) { scanf("%d",&a[i]); ans ^= a[i]; } int num = 0; for(int i = 1;i <= n;i++) if((ans^a[i]) < a[i]) num++; printf("%d\n",num); } }
基于这道题对于Nim博弈先手如何操作引导出必胜策略的分析,我们再来看一道类似的题目。(Problem source: hdu2176)
数理分析:与上面那道题相似,这道题不仅仅基于对胜负态的分析,还要求对先手必胜时第一手策略的分析。
在这里通过上面的问题我们已经明白了方案的可行性,或者说是存在性,而在这道题目则需要我们一一找出所有的方案。
通过上面的题目,我们可以知道,我们比较第i堆石子大小和除了第i堆剩余m-1堆异或运算的结果的大小,为的就是判断能否通过第i堆石子来使得整个游戏变为平衡态,而在这个过程,如果可以,操作是唯一的。即让第i堆石子大小(记作a[i]),变为剩余m-1堆石子的大小异或运算的结果(记作s),显然,完成操作后,第i堆剩下的石子数是s。
有了以上的数理逻辑,编程实现就很容易了。
#include<stdio.h> const int maxn = 200000 + 5; using namespace std; int main() { int m , a[maxn]; int ans; int i; while(scanf("%d",&m) != EOF && m) { ans = 0; for(i = 0;i < m;i++) { scanf("%d",&a[i]); ans ^= a[i]; } if(ans == 0) printf("No\n"); else { printf("Yes\n"); for(i = 0;i < m;i++) { int s = (ans^a[i]); if(s < a[i]) printf("%d %d\n",a[i],s); } } } }
下面我们再来看一道关于Nim博弈的简单应用。(Problem source:hdu1849)
这里其实就是还在Nim博弈起始模型(分石子)的基础上,对游戏内容进行了改变,但是换汤不换药,本质还是和分石子游戏一样的。这里m个棋子相当于有m堆石子,每个棋子的位置则相当于每堆石子的大小。
值得注意的是,题设中提到两个女孩都是冰雪聪明的,这其实就暗示了博弈模型中参加者是理性的假设,通过这一点也应该使我们往博弈上面联想。
知道了联系Nim博弈模型,这道题就非常的简单了。
#include<stdio.h> using namespace std; int main() { int m , x; while(scanf("%d",&m) != EOF && m) { int ans = 0 ; for(int i = 0;i < m;i++) { scanf("%d",&x); ans ^= x; } if(ans == 0) printf("Grass Win!\n"); else printf("Rabbit Win!\n"); } }
那么我们结合一个题目来迁移应用一下这个阶梯博弈(Problem source : hdu 4315)。
有了以上数理分析,代码就很好实现了,参考代码如下。
#include<stdio.h> using namespace std; const int maxn = 1005; int n , k; int hill[maxn]; void solve() { int ans = 0; for(int i = 1;i <= n;i++) scanf("%d",&hill[i]); if(k == 1) { printf("Alice\n"); return; } else if(k == 2 && n & 1) { hill[1]--; } else { for(int i = n;i >= 1;i -= 2) { ans ^= (hill[i] - hill[i-1] - 1); } if(n&1) ans ^= hill[1]; } printf(ans ? "Alice" : "Bob"); printf("\n"); return; } int main() { while(~scanf("%d%d",&n,&k)) { solve(); } }
我们今天将来介绍一种新的组合游戏——Fibonacci Game。(Problem source :hdu2516)
数理分析:既然我们要介绍的是典型的斐波那契博弈,那么肯定与著名的斐波那契数(1,1,2,3,5,8……)有关。
这里我们先提到一个结论,对于斐波那契数列,有2F[i-1] > F[i],这个结论是比较好证的。
那么我们先从n是斐波那契数来开始分析。
① n = 2,那么此时先手必败,这是显然的。
②n为大于2的斐波那契数,那么,这意味着n = F[k] = F[k-1]+F[k-2]。这里我们一定可以将大小为n的石子分成两堆,因为如果先手拿走了超过F[k-1]个石子,再加上上面2F[k-1] > F[k]的结论,那么后手必赢了。所以先手一定不敢取完F[k - 1]个石子,这就演化成了一个新的子问题。所以对于n = F[k]的石子堆,可以变成取n1 = F[k - 1]的石子堆和取n2 = F[k-2]的石子堆两个子问题。随后我们一次分解下去,会发现,对于F[5] = F[3] + F[4]这种最基元的情况,当n = 2,后手赢;当n = 3还是后手赢。有了对最基元情况的分析,然后开始递归回去。我们不难得出结论,如果n是斐波那契数,那么先手必败。
那么我们在分析一下n为非斐波那契数的情况。这就像就像Wythoff博弈需要Beatty定理来帮忙一样,这里需要借助Zeckendorf定理:任何正整数可以表示为若干个不连续的Fibonacci数之和。这里时间缘故不给出Zeckendorf定理的详细证明。
那么n可以表示成F[a1] + F[a2] …… +F[ap]。要注意的是,这里我们将一个整数分解成斐波那契数加和的时候,我们每次要分解出可行的最大的斐波那契数,这就导致F[a1] + F[a2] …… +F[ap](a1 > a2 ……>ap)中的就不会有连续的斐波那契数出现,即a(p-1) > ap + 1,那么此时先手只要拿走F[ap]个石子,后手一定拿不完F[a(p-1)]的所有石子,就开始多次进行上文n为斐波那契数的游戏,只不过此时先后手已经颠倒,所以我们可以得出结论,后手必败。
有了以上结论,编程就很好实现了。(编程时注意控制斐波那契数不要溢出,否则会导致错误)
参考代码如下。
#include<stdio.h> using namespace std; int F[50]; void make() { F[0] = 1 , F[1] = 2; for(int i = 2;i < 45;i++) F[i] = F[i-1] + F[i-2]; } int main() { make(); int n,i; while(~scanf("%d",&n),n) { for(i = 1;i < 45;i++) if(n == F[i]) break; if(i < 45) printf("Second win\n"); else printf("First win\n"); } }
参考系:百度百科