博弈论Nim取子问题,困扰千年的问题一行代码解决
本文始发于个人公众号:TechFlow,原创不易,求个关注
今天是算法与数据结构专题26篇文章,我们来看看一个新的博弈论模型——Nim取子问题。
这个博弈问题非常古老,延续长度千年之久,一直到20世纪初才被哈佛大学的一个数学家找到解法,可见其思维的难度。但是这个问题本身却很有意思,推导的过程更是有趣,哪怕你没有多少数据基础也一定可以看明白。
Nim取子问题
这个问题的题面是这样的,我们有3堆石子,有A和B两个人轮流从其中的一堆取石子。规定每个人每次最少取1颗,最多可以取完当前堆,无法继续拿取石子的人落败。请问如果你是先手,你有必胜策略吗?
根据我们之前分析威佐夫博弈问题的套路,我们需要先来分析一下问题,找到一些典型的局面。比如说(0, 0, 0)对于先手来说一定是必败的,同理,对于一个(0, n, n)的局面,也一样是必败的。因为不论先手怎么取石子,后手只需要在另外一堆石子当中如法炮制,那么留给先手的依然是一个(0, n, n)的局面。在博弈论问题当中,我们通常会将先手必败的局面称为奇异局势。
那么这些奇异局势之间有没有什么关联呢?我们能不能找到这些局面之间的联系或者是公式呢?
我们光是靠脑子想或者是用纸笔去罗列我们所能想到的奇异局面是很难想出来的,不然也不会困扰人们长达一千多年了。但是这个问题的谜底却又如此简单,简单到让人不可思议。
首先,我们先来思考一个问题,这个问题之所以复杂,根本原因在于石子有3堆,而不是两堆。如果石子有两堆,那么就很容易了,先手除非面临两堆石子相等的情况,否则必胜。因为它可以通过拿取石子留下两堆一样的给后手,这样不论后手如何拿取,先手只需要在另一堆当中采取同样的操作,就必然可以给后手留下奇异局势。这和我们刚才分析的(0, n, n)的局面是一样的。
但是题目明确说了是3堆而不是两堆,我们不禁就开始设想起了一个问题,我们能不能想到一种策略,使得可以将三堆石子”转化“或者是看成是两堆石子呢?这样我们就可以非常容易地判断石子的输赢情况了。
解法分析
明明是3堆石子,怎么看成是两堆呢?怎么看都是自说自话,但如果你对二进制熟悉的话,你会发现这个问题可能并不是不可能的。
是的,二进制就是天生的二维“生物”,在二进制的世界当中,一切都只有两种,0和1。所以从直观上我们会觉得,也许可以将石子的数量和二进制取得关联。也许这样的关联会有助于我们找到解法。剩下的问题就成了,这个关联究竟是什么?
我们来思考另外一个问题,对于一堆石子来说,我们取走一个数量,石子的数量会减少,这是显而易见的。体现在石子的总数上,就是表示这堆石子数量的数字,减去了另外一个数字。这个是减法的操作,小学生都知道。但是小学生不知道的是,减法在二进制当中是怎么进行的,或者是它有什么规律呢?
我们先不急着回答,先来仔细分析一波。首先,减数和被减数都可以化作是二进制,也就是若干个1和0组成的数字。我们假设减数每一个为1的二进制位对应的被减数的值也是1,那么这个减法会进行得非常顺利。对应的就是从被减数当中移除掉若干个1的过程。
举个例子,被减数是9,减数是1。我们都知道9写成二进制是1001,而1的二进制是1。所以被减数减去减数的值为8,也就是1000,可以看成是1001移除了末尾的1。
如果减数存在二进制位被减数为0,比如10 - 3的情况,10的二进制是1010,3是11。很明显3的第0位是1,而10是0,这种情况下怎么办?首先,我们先把3和10当中都是1的二进制位去除。剩下的就是1000 减去 1,那么我们可以先把1000 减1 变成111,这样就回到了上面说的第一种情况,完成减法之后再加回来,所以得到的结果就是111,这其实就是一个向高位借位的过程。纵观整个减法的计算过程,其实就是被减数当中二进制位变化的过程,减去某一个数,等价于将被减数当中若干个0变成1,1变成0。
结合二进制,我们可以想到一种策略。就是统计这3个数所有的二进制位,由于我们有3个数,所以每一个二进制位最多有3个1,最少有0个1。如果每一位的1的数量和都是偶数,也就是不是0就是2的话,那么这一定是一个奇异局面。
举个例子,比如[10, 8, 2]是一个奇异局面,我们把它们写成二进制。10的二进制是1010,8的二进制是1000,2的二进制是10。所以我们可以发现这三个数的二进制位加起来,第1、2、3位都出现了两个1。这个时候先手不论如何操作,后手只需要保证剩下的三个数的二进制位维持这个特性即可。这样做可以保证最后一次拿取结束之后,给先手留下[0, 0, 0]的局面。本质上来说,它的原理和两堆石子的时候是一样的,只不过转化了一种形式。
举个例子,比如我们从10当中拿走3颗石子,得到(7, 8, 2),我们观察二进制位分别是111, 1000, 10。会发现每一位1的数量从低到高分别是[1, 2, 1, 1]。所以我们可以从1000拿取3个石子,保证留下的数量是101,也就是5。这样剩下的1的个数就是[2, 2, 2],依然是偶数。所以先手不论如何拿,后手都可以保证一定可以让留下的数字在二进制上保持偶数,先手一定必败。在不满足这个条件的局面当中先手一定必胜,因为先手可以在第一次通过拿取掉多余的1,保证留下一个必败的局面给后手。
这也是这题的解法,即通过二进制位来判断是否先手必胜。我们要判断每个二进制位当中出现的1的次数和是否是偶数,可以通过位运算的亦或来完成。在亦或操作当中,对每一个二进制位进行计算,奇数为1,偶数为0。所以我们只需要计算一下这三堆石子亦或之后的结果是否为0,就可以知道是否每一个二进制位的1的数量是否都是偶数了。
我们写成代码非常简单,我们通常用^这个符号表示亦或运算,那么代码只需要一行:
def win_or_lose(a, b, c):
return (a ^ b ^ c) == 0
推广以及证明
这里还没有结束,我们同样可以将3堆石子的局面推广到n堆,不管游戏当中玩家面临的是多少堆石子,这个结论依然都是成立的。这个成立的原因我们很容易想明白,为了严谨起见,我们可以用博弈问题常用的证明套路来证明一下。
在一个博弈问题当中,如果存在奇异局面,也就是必败局面,那么一定满足三个条件。第一个条件是无法进行任何操作的局面是奇异局面。第二个条件是可以移动到奇异局面的局面是非奇异局面。第三个条件是在奇异局面当中所作的任何操作得到的都是非奇异局面。
只要能够证明这三点,就可以证明我们的思路是正确的。
对于第一点毋庸置疑,所有石堆都没有石子的时候无法移动,这是必败状态。
我们来看第二个条件,我们假设这n堆石子的数量是a1, a2, ... an。如果当前局面是非奇异局面,根据我们的理论,那么a1 ^ a2 ^ a3 ^... ^an > 0。也就是说存在某个二进制位1的数量是奇数。
我们假设a1 ^ a2 ^ a3 ^... ^an = k,那么必然可以找到一个ai, 使得它的二进制表示在k的最高位上是1,因为k的所有二进制的1都是从这n个数当中来的,所以这样的ai一定存在。那么我们可以继续推导得到:ai ^ k < ai。因为最高位的1经过亦或之后变成了0,所以亦或操作之后一定是减小的。我们令p = ai ^ k,我们在a1^a2^a3^...^an = k 的等式两边同时亦或ai,可以得到a1 ^ a2 ^ ...^ai-1^ai+1^...^an = k ^ai,所以a1 ^ a2 ^ ...^ p ^...an = 0。
第三个条件也很好证明,因为如果当前是必败局面,也就是说a1 ^ a2 ^ ... ^ an=0。我们假设我们将an转变成了p之后依然有a1 ^ a2 ^ ... ^p=0, p < an。我们在等式两边同时亦或上p和an,可以得到:an ^ p = 0,也就是说p = an。这与p < an矛盾,所以不存在这样的转化使得奇异局面操作之后仍然是奇异局面。
这样我们就从数学上证明了这个推理的正确性,实际上已经有人对Nim取子问题有过深入的研究,这也是一个已经得到过证明的定理,叫做Bouton定理。定理的内容是先手可以在非平衡的Nim博弈中取胜,而后手可以在平衡的Nim博弈中取胜。这里的平衡就是指的是所有二进制位1的数量是偶数。
那么我们写出代码也非常简单:
def win_or_lose(nums):
ret = 0
for i in nums:
ret ^= i
return ret == 0
总结
到这里,关于Nim博弈的问题就讲完了。通过亦或操作去判断的解法真的是非常简单,但是这其中的推导过程想明白却不容易。我看过很多博客,都是直接给出的亦或这个结论,很少能够看到详细的推导过程。直接记住结论是简单的,但也很容易忘记,只有亲自推导一遍,才会明白亦或这个神奇的操作是怎么来的,为什么它可以解决Nim博弈的问题。
在整个思考推理和证明的过程当中,我们大量使用了亦或这个位运算操作,如果对它不熟悉的同学可能会看起来有些困扰。建议可以先了解学习一下二进制当中亦或的性质之后再来阅读本文,效果会更好。
目前为止,我们已经介绍完了巴什博奕、威佐夫博弈和Nim博弈这三种相对比较简单的博弈模型。在后续的文章当中,我们将会继续深入博弈论这个问题,一起去研究更加困难的博弈论问题,看看在复杂的场景当中,我们怎么样寻找奇异状态。
文章就到这里,如果喜欢本文,可以的话,请点个关注,给我一点鼓励,也方便获取更多文章。
本文使用 mdnice 排版
博弈论Nim取子问题,困扰千年的问题一行代码解决的更多相关文章
- dede取子栏目时重复显示同级栏目的终极解决方法
使用channelartlist标签时,当栏目没有子栏目是,会出现重复同级栏目的问题,解决方法如下: 先看下面的代码{dede:channelartlist typeid='2'} {dede:ty ...
- 萌新笔记之Nim取石子游戏
以下笔记摘自计算机丛书组合数学,机械工业出版社. Nim取石子游戏 Nim(来自德语Nimm!,意为拿取)取石子游戏. 前言: 哇咔咔,让我们来追寻娱乐数学的组合数学起源! 游戏内容: 有两个玩家面对 ...
- jquery 取子节点及当前节点属性值
分享下jquery取子节点及当前节点属性值的方法. <li class="menulink"><a href="#" rel="ex ...
- phpcms直接取子栏目的内容、调用点击量的方法
子栏目里面的内容可以直接取,而不需要通过循环. {$CATEGORYS[$catid][catname]}//取子栏目的栏目名称 {$CATEGORYS[$catid][image]}//取子栏目的栏 ...
- 51nod1069【Nim取石子游戏】
具体看:萌新笔记之Nim取石子游戏可以这么写: #include <bits/stdc++.h> using namespace std; typedef long long LL; in ...
- BZOJ.1299.[LLH邀请赛]巧克力棒(博弈论 Nim)
题目链接 \(Description\) 两人轮流走,每次可以从盒子(容量给定)中取出任意堆石子加入Nim游戏,或是拿走任意一堆中正整数个石子.无法操作的人输.10组数据. \(Solution\) ...
- 使用Python爬取淘宝两千款套套
各位同学们,好久没写原创技术文章了,最近有些忙,所以进度很慢,给大家道个歉. 警告:本教程仅用作学习交流,请勿用作商业盈利,违者后果自负!如本文有侵犯任何组织集团公司的隐私或利益,请告知联系猪哥删除! ...
- JAVA中取子字符串的几种方式
有这样一串字符串:String s = "共 100 页, 1 2 3 4..."; 假如我想把"100"给取出来,该如何做? 方法一: 采用split的方式 ...
- Jquery-获取子元素children,find
1.查找子元素方式1:> 例如:var aNods = $("ul > a");查找ul下的所有a标签 2.查找子元素方式2:children() 3.查找子元素方式3 ...
随机推荐
- Python——day2
学完今天我保证你自己可以至少写50行代码 明天,还在等你 回顾day1 小练习1: 小练习2: 小练习3: 好了激情的的一天已经过去了正式开始,day2的讲解 Day2 目录: 格式化 ...
- Linux (八)服务
个人博客网:https://wushaopei.github.io/ (你想要这里多有) 1.服务的概念 操作系统中在后台持续运行的程序,本身并没有操作界面,需要通过端口号访问和操作.CentO ...
- link和@import引入css的区别
@import是在CSS2.1提出的,低版本的浏览器不支持.link支持良好: link引用CSS时,在页面载入时同时加载: @import需要页面网页完全载入以后加载.如果页面内容过多,会产生不好的 ...
- Java实现 LeetCode 219 存在重复元素 II(二)
219. 存在重复元素 II 给定一个整数数组和一个整数 k,判断数组中是否存在两个不同的索引 i 和 j,使得 nums [i] = nums [j],并且 i 和 j 的差的绝对值最大为 k. 示 ...
- Java实现 LeetCode 97 交错字符串
97. 交错字符串 给定三个字符串 s1, s2, s3, 验证 s3 是否是由 s1 和 s2 交错组成的. 示例 1: 输入: s1 = "aabcc", s2 = " ...
- Java实现 LeetCode 49 字母异位词分组
49. 字母异位词分组 给定一个字符串数组,将字母异位词组合在一起.字母异位词指字母相同,但排列不同的字符串. 示例: 输入: ["eat", "tea", & ...
- java实现黄金队列
** 黄金队列** 黄金分割数0.618与美学有重要的关系.舞台上报幕员所站的位置大约就是舞台宽度的0.618处,墙上的画像一般也挂在房间高度的0.618处,甚至股票的波动据说也能找到0.618的影子 ...
- java实现第九届蓝桥杯全排列
全排列 对于某个串,比如:"1234",求它的所有全排列. 并且要求这些全排列一定要按照字母的升序排列. 对于"1234",应该输出(一共4!=24行): 12 ...
- 记一次Docker中Redis连接暴增的问题排查
周六生产服务器出现redis服务器不可用状态,错误信息为: 状态不可用,等待后台检查程序恢复方可使用.Unexpected end of stream; expected type 'Status' ...
- Python 中的类的继承
class parent(object): def override1(self): print("Parent") class child(parent): def overri ...