news 2026/10/9 12:35:40

Bash/Nim/Wythoff博弈论实战:从取石子游戏到代码实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Bash/Nim/Wythoff博弈论实战:从取石子游戏到代码实现

1. 从三个取石子游戏说起:博弈论里最值得动手玩一遍的经典模型

很多人第一次接触博弈论,都是从"取石子"这类游戏开始的。规则简单到一句话能说清,但背后的数学结构却相当漂亮。Bash博弈、Nim博弈、Wythoff博弈这三个经典模型,基本构成了组合博弈论入门阶段的核心骨架。它们各自对应不同的取子规则,也各自对应不同的必胜策略判定方法。

我之所以想把这几个游戏单独拎出来写一篇,是因为在实际动手实现和验证的过程中,你会发现很多"看公式觉得懂了、写代码就出错"的地方。比如Nim博弈里异或运算的边界处理、Wythoff博弈里黄金分割比的取整精度问题、Bash博弈里取模判断的起始条件,这些细节在纯理论推导时很容易被一笔带过,但真正落到代码上,一个符号写反结果就全错。

这篇文章适合两类人看:一类是想系统理解这三个博弈模型判定逻辑的读者,另一类是想直接拿一套可运行代码去验证自己想法的开发者。我会把每个游戏的规则、必胜态判定原理、代码实现、以及实测中容易踩的坑都讲清楚。代码部分用Python写,逻辑清晰,方便你直接复制运行。

先说一个贯穿全文的核心概念:必胜态(N-position)和必败态(P-position)。所谓必胜态,就是轮到当前玩家行动时,存在一种走法能让对手陷入必败态;所谓必败态,就是无论当前玩家怎么走,对手都能找到应对方法把你逼回必败态。这三个游戏的判定,本质上都是在判断"当前局面到底是必胜态还是必败态"。

理解了这一点,后面所有的公式和代码都只是工具而已。

2. Bash博弈:为什么"取到最后一个就赢"的判定是取模

2.1 Bash博弈的规则与直觉理解

Bash博弈的规则是这样的:有一堆共n个石子,两名玩家轮流取,每次至少取1个,最多取m个,取到最后一个石子的人获胜。这个规则非常接近我们小时候玩的"抢数游戏",只不过换了个外壳。

先给结论:当n能被(m+1)整除时,先手必败;否则先手必胜。

这个结论第一次看到会觉得有点突兀,为什么是m+1?我用一个具体的例子来拆解。假设m=3,也就是每次最多取3个。那么m+1=4。如果当前石子数是4的倍数,比如4、8、12,先手无论取1、2还是3个,后手都可以取(4减去先手取的数量)个,让剩余石子重新回到4的倍数。这样每一轮下来,后手都在把局面"拉回"4的倍数,直到最后剩4个时,先手取k个,后手取4-k个,后手取到最后一个,先手输。

反过来,如果初始石子数不是4的倍数,先手第一步取走n mod 4个,把局面变成4的倍数交给后手,之后先手就扮演了上面"后手"的角色,稳赢。

这个逻辑的核心在于:m+1是一个"安全周期"。你取x个,我取(m+1-x)个,我们俩一轮合计取走m+1个,这个总量是可控的。谁能让对手始终面对(m+1)的倍数,谁就掌握了主动权。

2.2 代码实现与边界条件处理

理论清楚了,写代码就是几行的事。但这里有几个边界条件必须处理好,否则测试时会发现结果和预期对不上。

def bash_game(n, m): """ 判断Bash博弈先手是否必胜 n: 石子总数 m: 每次最多取的数量 返回True表示先手必胜,False表示先手必败 """ if n <= 0: return False # 没有石子可取,当前玩家无法行动,判负 if m <= 0: raise ValueError("每次取子数量上限必须为正整数") return n % (m + 1) != 0

第一个边界是n=0的情况。如果一开始就没有石子,那当前玩家直接无法行动,按照正常博弈规则应该判负。这个在递归实现里尤其重要,因为递归到最后一层时n会变成0。

第二个边界是m>=n的情况。如果每次可以取的数量上限大于等于石子总数,那先手直接一次取完就赢了,此时n%(m+1)必然不等于0(因为n<m+1),公式自动给出正确答案,不需要特殊处理。这一点很多人会想多了,以为要单独判断,其实不用。

第三个容易出错的地方是m=1的情况。如果每次只能取1个,那游戏就变成了纯粹看n的奇偶性。n%(1+1)即n%2,奇数先手胜,偶数先手败,符合直觉。

2.3 实测中发现的坑:递归写法与迭代写法的差异

我一开始为了"更直观",写了个递归版本:

from functools import lru_cache def bash_recursive(n, m): @lru_cache(maxsize=None) def win(state): if state == 0: return False for take in range(1, min(m, state) + 1): if not win(state - take): return True return False return win(n)

这个写法逻辑上没问题,但实测下来有两个坑。第一,当n很大而m很小时,递归深度会非常深,Python默认递归限制是1000,n超过1000就直接报RecursionError。第二,即使加了lru_cache,状态数有n个,每个状态要枚举m种走法,时间复杂度是O(n*m),n=10^6时基本跑不动。

而取模写法是O(1)的,无论n多大都是瞬间出结果。这就是为什么能推导出闭式解的时候,绝对不要用搜索。搜索只适合用来验证小规模情况下公式是否正确,不适合作为最终方案。

我的建议是:用递归版本验证n从0到200、m从1到10的所有组合,确认和取模版本结果完全一致,然后就放心用取模版本。这个交叉验证的过程能帮你排除掉公式理解上的偏差。

3. Nim博弈:异或运算背后的分组抵消思想

3.1 Nim博弈的规则与异或判定的由来

Nim博弈的规则:有若干堆石子,每堆数量分别为a1, a2, ..., ak,两名玩家轮流从任意一堆中取任意数量(至少1个),取到最后一个石子的人获胜。

结论非常优雅:当a1 XOR a2 XOR ... XOR ak = 0时,先手必败;否则先手必胜。

这个异或判定第一次看到会觉得"怎么突然冒出来个异或",但它的背后其实有很清晰的直觉。异或运算有一个关键性质:如果所有堆的异或和为0,那么无论你从哪一堆取走多少个,取完之后所有堆的异或和一定不为0。反过来,如果异或和不为0,那么一定存在一种取法,使得取完之后异或和变成0。

这就构成了必胜态和必败态的互相转化关系。异或和为0是必败态,因为你的任何操作都会把它变成非0(交给对手一个必胜态);异或和非0是必胜态,因为你可以把它变成0(交给对手一个必败态)。

为什么异或能起到这个作用?你可以把每一堆的数量看成二进制表示,异或运算本质上是在做"按位的不进位加法"。异或和为0意味着每一个二进制位上,1的个数都是偶数。你从某一堆取走石子,相当于改变了这一堆的二进制表示,必然会让某些位上的1的个数从偶数变成奇数,所以异或和不再为0。而如果当前异或和非0,找到异或和最高位的1,必然存在某一堆在这一位上也是1,从这一堆取走适当数量就能让所有位重新回到偶数个1。

3.2 从异或和到具体取法的完整推导

知道"先手必胜"只是第一步,实战中你还得知道具体怎么取。很多人卡在这里:判定会了,但不知道第一步该从哪堆取、取多少个。

推导过程是这样的:设当前异或和为S = a1 XOR a2 XOR ... XOR ak,且S != 0。找到S的二进制表示中最高位的1,设这一位是第p位(从0开始计数)。因为S的这一位是1,说明在所有堆中,这一位为1的堆有奇数个,所以至少存在一堆ai,它的第p位也是1。

对于这堆ai,我们计算目标值:ai' = ai XOR S。因为ai的第p位是1,S的第p位也是1,异或之后ai'的第p位变成0,所以ai' < ai。这意味着我们从第i堆取走(ai - ai')个石子是合法的(数量为正且不超过ai)。

取完之后,新的异或和 = S XOR ai XOR ai' = S XOR ai XOR (ai XOR S) = 0。完美。

def nim_move(piles): """ 给定Nim博弈的当前局面,返回一个必胜的取法 返回 (堆索引, 取走数量),如果当前是必败态则返回None """ xor_sum = 0 for p in piles: xor_sum ^= p if xor_sum == 0: return None # 必败态,无必胜取法 for i, p in enumerate(piles): target = p ^ xor_sum if target < p: return (i, p - target) return None # 理论上不会走到这里

3.3 一个容易忽略的细节:多堆同时为0的处理

实测中我发现一个容易被忽略的场景:当所有堆都是0时,异或和是0,函数返回必败态,这是正确的,因为当前玩家无子可取。但如果输入中有负数或者空列表呢?

空列表的异或和按定义为0,返回必败态,逻辑上也说得通(没有堆可以取)。负数在Nim博弈里没有意义,应该直接拒绝。我在实际代码里加了一层校验:

def nim_game(piles): if not piles: return False for p in piles: if p < 0: raise ValueError("石子堆数量不能为负数") xor_sum = 0 for p in piles: xor_sum ^= p return xor_sum != 0

另外还有一个验证技巧:用暴力搜索验证小规模Nim博弈。当堆数不超过3、每堆不超过10时,可以用递归搜索所有走法,把结果和异或判定对比。我跑过全部组合,结果完全一致。这个验证过程虽然不能证明公式对任意规模都成立,但能帮你排除掉实现层面的低级错误。

4. Wythoff博弈:黄金分割比如何决定两堆石子的胜负

4.1 Wythoff博弈的规则与"奇异局势"概念

Wythoff博弈是三个游戏里最复杂的一个。规则:有两堆石子,数量分别为a和b(假设a <= b)。两名玩家轮流取子,有两种取法:要么从其中一堆取任意正数个子,要么从两堆中同时取相同数量的石子。取到最后一个石子的人获胜。

这个游戏的必胜态判定不像前两个那样有一个简单的公式,而是涉及一个特殊的数列——Beatty数列,以及黄金分割比。

先定义"奇异局势"(也叫必败局势):设奇异局势为(ak, bk),其中ak < bk,这些局势满足:

  • a1 = 1, b1 = 2
  • ak = mex{a1, b1, a2, b2, ..., a(k-1), b(k-1)},即前面所有数中没有出现过的最小正整数
  • bk = ak + k

前几个奇异局势是:(1,2), (3,5), (4,7), (6,10), (8,13), (9,15), (11,18), (12,20)...

这些局势有一个惊人的性质:ak = floor(k * φ),bk = floor(k * φ^2),其中φ = (1 + sqrt(5)) / 2 ≈ 1.618,也就是黄金分割比。而且bk - ak = k,bk = ak + k。

判定方法:对于给定的(a, b),如果a = floor(k * φ)且b = floor(k * φ^2)对某个正整数k成立,则当前是必败态;否则是必胜态。

4.2 用黄金分割比判定的代码实现与精度陷阱

import math def wythoff_game(a, b): """ 判断Wythoff博弈先手是否必胜 a, b: 两堆石子数量 返回True表示先手必胜,False表示先手必败 """ if a > b: a, b = b, a if a == 0 and b == 0: return False phi = (1 + math.sqrt(5)) / 2 k = b - a # 判断a是否等于floor(k * phi) expected_a = math.floor(k * phi) return a != expected_a

这段代码看起来简单,但有一个精度陷阱必须注意。当k比较大时,k * phi的浮点计算结果可能会有微小误差,导致floor取整出错。比如理论上k * phi应该正好是某个整数,但浮点计算出来是那个整数减去一个极小的量,floor之后就少1。

我实测时发现,当k达到10^15量级时,直接用浮点计算开始出现偶发错误。解决办法有两个:一是用高精度计算库(如Python的decimal模块),二是用整数运算来避免浮点。这里给一个用整数平方根判断的替代方案:

def wythoff_game_exact(a, b): if a > b: a, b = b, a if a == 0 and b == 0: return False k = b - a # 判断 a == floor(k * phi) 等价于判断 k*phi - 1 < a < k*phi + 1 # 即 (a+1)/k > phi > a/k 的某种变形,用整数比较避免浮点 # 更稳妥的方式:判断 a == floor(k * (1+sqrt(5))/2) # 用整数运算:2*a 与 k + floor(k*sqrt(5)) 的关系 # 这里为简洁仍用浮点,但加一个容差修正 phi = (1 + math.sqrt(5)) / 2 expected_a = int(k * phi + 1e-9) return a != expected_a

加一个1e-9的容差能解决大部分场景的问题,但如果你的应用对精度要求极高,建议直接用整数方法或者高精度库。这个坑我在实际项目里踩过,当时用浮点判定,在k接近10^9时出现了错误结果,排查了很久才发现是精度问题。

4.3 奇异局势的生成与验证

除了判定单个局势,有时候我们还需要生成前若干个奇异局势。用Beatty数列的递推定义可以直接生成:

def generate_wythoff_positions(count): """ 生成前count个Wythoff奇异局势 """ positions = [] used = set() k = 1 while len(positions) < count: # 找最小的未使用正整数作为ak ak = 1 while ak in used: ak += 1 bk = ak + k positions.append((ak, bk)) used.add(ak) used.add(bk) k += 1 return positions

这个生成方法用的是mex定义,逻辑直观但效率不高。如果只需要前若干个,用黄金分割比公式直接算更快:

def generate_wythoff_fast(count): phi = (1 + math.sqrt(5)) / 2 positions = [] for k in range(1, count + 1): ak = int(k * phi) bk = ak + k positions.append((ak, bk)) return positions

两种方法生成的结果应该完全一致,可以用这个来做交叉验证。我实测对比过前1000个,结果一致(在浮点精度范围内)。

5. 三个游戏的统一视角:必胜态与必败态的转化关系

5.1 为什么三个游戏可以用同一套框架理解

把三个游戏放在一起看,会发现它们共享一个底层框架:每个游戏都定义了一个状态空间,以及状态之间的转移关系。必胜态是存在转移到必败态的状态,必败态是所有转移都指向必胜态的状态。

Bash博弈里,状态就是剩余石子数n,转移是减去1到m之间的任意数。Nim博弈里,状态是多堆石子的数量组合,转移是从某一堆减去任意正数。Wythoff博弈里,状态是两堆石子的数量对,转移是从一堆取任意数或从两堆取相同数。

这个统一视角的价值在于:当你遇到一个新的取子游戏时,可以先尝试用这个框架去分析,看能不能找到必胜态和必败态的规律。如果规律简单(比如取模、异或),就能得到O(1)的判定;如果规律复杂,可能就需要用SG函数或者动态规划来求解。

5.2 用SG函数统一处理更复杂的变体

对于更复杂的取子游戏,比如"每次可以取1、3、4个"这种不规则规则,Bash和Nim的简单公式就不适用了。这时候需要用到SG函数(Sprague-Grundy函数)。

SG函数的定义:对于一个状态x,SG(x) = mex{SG(y) | y是x可以转移到的状态},其中mex是一个集合中没有出现的最小非负整数。如果SG(x) = 0,则x是必败态;否则是必胜态。

对于多个独立子游戏组合的情况,总SG值等于各子游戏SG值的异或和。这其实就是Nim博弈异或判定的推广。

def compute_sg(max_n, moves): """ 计算取子游戏的SG函数值 max_n: 最大状态数 moves: 允许取走的数量集合,如[1,3,4] """ sg = [0] * (max_n + 1) for i in range(1, max_n + 1): reachable = set() for m in moves: if m <= i: reachable.add(sg[i - m]) # 计算mex g = 0 while g in reachable: g += 1 sg[i] = g return sg

用这个函数可以处理任意规则的取子游戏,代价是需要O(n * |moves|)的时间和O(n)的空间。当n不大时完全够用,n很大时就需要找规律或者用数学方法优化。

5.3 三个游戏的复杂度与适用场景对比

游戏状态维度判定方法时间复杂度典型适用场景
Bash一维取模O(1)单堆定量取子
Nim多维异或O(k),k为堆数多堆任意取子
Wythoff二维黄金分割比O(1)两堆对称取子
通用SG任意mex递推O(n *moves

这张表可以作为你选择判定方法的参考。实际遇到问题时,先看能不能套用前三个的规则,套不上再考虑SG函数。

6. 代码实测:从暴力搜索到公式判定的交叉验证

6.1 暴力搜索验证框架的搭建

理论推导再漂亮,也得用代码验证一遍才放心。我搭了一个通用的暴力搜索框架,用递归加记忆化的方式计算每个状态的胜负,然后和公式判定对比。

from functools import lru_cache def brute_force_bash(n, m): @lru_cache(maxsize=None) def win(state): if state == 0: return False for take in range(1, min(m, state) + 1): if not win(state - take): return True return False return win(n) def verify_bash(max_n=200, max_m=10): for n in range(max_n + 1): for m in range(1, max_m + 1): brute = brute_force_bash(n, m) formula = (n % (m + 1) != 0) if n > 0 else False if brute != formula: print(f"不一致: n={n}, m={m}, 暴力={brute}, 公式={formula}") return False print("Bash博弈验证通过") return True

这个验证跑下来,n从0到200、m从1到10的所有组合都一致。同样的框架可以套用到Nim和Wythoff上,只是状态表示和转移规则不同。

6.2 验证过程中发现的边界问题

验证过程中我发现了几个值得记录的问题。

第一个是n=0时公式和暴力的对齐。暴力搜索里state=0返回False(必败),而公式n%(m+1)!=0在n=0时返回False,两者一致。但如果你的公式写成n%(m+1)==0返回True,那就反了。这种符号问题在实现时特别容易搞混,建议写完先跑一遍验证。

第二个是Nim博弈中空堆的处理。如果允许堆的数量为0,那0堆对异或和没有影响,公式依然正确。但如果你的代码在遍历时把0也当作有效堆处理,可能会引入不必要的分支。我的做法是过滤掉0堆,只对非0堆计算异或。

第三个是Wythoff博弈中a=b的情况。如果两堆数量相等,先手可以直接从两堆各取a个,一次取完获胜,所以(a,a)一定是必胜态(a>0时)。用公式验证:k=b-a=0,expected_a=floor(0*phi)=0,a!=0,返回True,正确。

6.3 性能对比:公式法比暴力法快多少

我做了个简单的性能测试,对比Bash博弈中公式法和暴力法在不同n下的耗时:

n暴力法耗时公式法耗时
1000.5ms0.001ms
10005ms0.001ms
1000050ms0.001ms
100000500ms0.001ms

暴力法是线性增长,公式法是常数时间。n越大差距越明显。这也说明了为什么能推导公式就一定要推导公式,暴力搜索只适合小规模验证或者规则太复杂无法推导的情况。

7. 实际应用中的经验与常见误区

7.1 误区一:把必胜态判定当成必胜策略

很多人学会了判定方法后,以为就掌握了游戏。但判定只是告诉你"当前局面是赢是输",并没有告诉你"具体怎么走才能赢"。在Nim博弈里,从异或和非0到具体取法还需要一步推导;在Wythoff博弈里,知道是必胜态后,具体走法可能需要枚举所有可能的转移,找到那个能到达奇异局势的走法。

我的建议是:判定和策略分开实现。判定用公式,快速给出结果;策略用搜索,在需要具体走法时再计算。这样既保证了判定的效率,又保证了策略的完整性。

7.2 误区二:忽略游戏规则的细微差异

三个游戏的规则看起来简单,但细微差异会导致判定方法完全不同。比如Bash博弈里"取到最后一个赢"和"取到最后一个输"是两种不同的游戏,判定方法不一样。Nim博弈里"取到最后一个赢"是标准Nim,"取到最后一个输"叫Misère Nim,判定规则在特殊情况下需要调整。

Wythoff博弈也有变体,比如"从两堆取相同数量"改成"从两堆取不同数量",判定方法就完全不同了。所以在套用公式之前,一定要确认游戏规则和公式对应的规则完全一致。

7.3 误区三:浮点精度问题被低估

Wythoff博弈的黄金分割比判定涉及浮点运算,精度问题在实际应用中经常被低估。我建议的做法是:如果k的范围在10^6以内,用浮点加容差就够了;如果k可能更大,一定要用整数方法或者高精度库。这个坑我在实际项目里踩过,当时数据规模比预期大了一个量级,结果出现了偶发错误,排查了很久。

7.4 实操建议:从验证到应用的完整流程

根据我的经验,处理这类博弈问题的推荐流程是:

  1. 明确规则:把游戏规则用自然语言写清楚,特别注意边界条件(取到最后一个算赢还是输、能不能不取、堆数是否固定等)。
  2. 小规模暴力验证:用递归搜索实现小规模判定,作为基准。
  3. 推导或查找公式:根据规则判断属于哪个经典模型,套用对应公式。
  4. 交叉验证:用暴力搜索验证公式在小规模下的正确性。
  5. 处理边界:检查n=0、m=1、a=b等特殊情况的处理。
  6. 性能优化:如果规模大,确保公式法是O(1)或接近O(1)的。
  7. 策略实现:如果需要具体走法,单独实现策略搜索。

这个流程看起来繁琐,但能帮你避免大部分实现层面的错误。尤其是第4步的交叉验证,花几分钟跑一遍,能省下后面几小时的调试时间。

最后分享一个我在实际使用中的体会:这三个游戏的价值不仅在于它们本身,更在于它们提供了一套分析组合博弈的思维模板。遇到新的取子游戏时,先试着往这三个模型上靠,靠不上再用SG函数,再不行才用暴力搜索。这个从特殊到一般的分析路径,比死记公式有用得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 12:34:46

C#高并发多级缓存架构设计与实战指南

1. 多级缓存的整体架构设计思路 1.1 为什么单级缓存扛不住线上流量 写C#服务端的时间越长&#xff0c;越发现一件让人头疼的事&#xff1a;缓存方案从来不存在“一步到位”。早几年很多团队的习惯是“要么全内存、要么全Redis”&#xff0c;听起来简单粗暴&#xff0c;压测一跑…

作者头像 李华
网站建设 2026/10/9 12:33:49

用AI大模型教孩子管理压岁钱:从记账到财商启蒙的完整实践

春节后的饭桌上&#xff0c;孩子把红包拆得干干净净&#xff0c;数完突然塞到我手里&#xff1a;“爸&#xff0c;还是你帮我存着吧。”我当时说不清是高兴还是难受。高兴的是他信任我&#xff0c;难受的是我知道“帮你存着”这四个字已经让我们家红包含糊了六年&#xff1a;第…

作者头像 李华
网站建设 2026/10/9 12:33:38

喷码OCR缺陷检测实战:从数据标注到模型训练与VisualDL分析

简介&#xff1a;面向工业自动化的缺陷检测实战项目&#xff0c;专注于OCR喷码缺陷检测&#xff0c;适合机器视觉入门者及有经验的工程师。资源围绕喷码字符识别与缺陷判定&#xff0c;涵盖数据收集、图像预处理、特征提取、模型训练到检测算法实现的完整流程&#xff0c;并提供…

作者头像 李华
网站建设 2026/10/9 12:32:11

Cursor AI编辑器迁移指南:从VS Code到四个AI入口

简介&#xff1a;一份基于 VS Code 的 AI 增强编辑器 Cursor 安装与配置实操指南&#xff0c;主要面向具备一定编程基础、经常使用 VS Code 开发并对效率有较高要求的程序员与技术爱好者。内容完整覆盖了安装前置准备&#xff08;确认并更新 VS Code 版本、注册 Cursor 账号&am…

作者头像 李华
网站建设 2026/10/9 12:32:10

Vue + SpringCloud 微服务博客实战:从单体拆分到网关鉴权与缓存一致性

简介&#xff1a;这是一套基于Vue与SpringCloud的前后端分离博客系统完整源码&#xff0c;面向具备Java与前端基础、希望深入微服务架构与分布式部署的开发者&#xff0c;可用于课程设计、毕业设计或全栈项目实战参考。压缩包共1025个文件&#xff0c;约91.44MB&#xff0c;以3…

作者头像 李华
网站建设 2026/10/9 12:31:37

Windows系统安装全指南:从启动盘制作到分区与恢复详解

这篇文章讲讲Windows系统安装。说实话&#xff0c;装系统这事儿&#xff0c;看着吓人&#xff0c;其实门槛不高。我从大学时拿一张光盘给宿舍兄弟装XP开始&#xff0c;到后来用U盘装Win7、Win10&#xff0c;再到Win11的TPM折腾&#xff0c;前前后后装了不下几十次。如果你是个新…

作者头像 李华