目录

题目描述

292. Nim 游戏

题意分析

桌上有一堆共 n 块石头,你和朋友轮流拿,每次可以拿走 1 块、2 块或 3 块,拿走最后一块石头的人获胜。你先手,双方都采取最优策略,问你能否获胜。

「双方都发挥最佳水平」是博弈题的标准前提,它的含义很具体:胜负完全由局面本身决定,不存在运气或对手失误,因此答案是关于 n 的一个确定函数。

「拿走最后一块的人获胜」等价于「轮到自己时面对 0 块石头的人失败」。把胜负条件翻译成「面对某个数量的一方是赢是输」,是分析这类问题的第一步。

数据范围是最强的信号:n 可以取到 $2^{31} - 1$。这直接排除了逐项递推打表和任何模拟走子的方案,说明必须找到一个闭式结论。

边界是最小的几个取值:n 为 1、2、3 时一次就能拿完,必胜;n = 4 是第一个必败的局面,也是整个规律的起点。

解法:数学结论

核心思路

先按博弈题的标准套路建立递推。记 f[i] 表示「轮到自己且面前有 i 块石头时,自己是否必胜」。终止态是 f[0] = false——没有石头可拿,说明上一步对手已经拿走了最后一块,自己输。对于 i >= 1,只要能拿走 1、2 或 3 块之后把一个必败局面丢给对手,自己就赢,于是 f[i] = !f[i-1] || !f[i-2] || !f[i-3](下标为负的项视为不可选)。

按这个式子手工推前几项:f[0] = falsef[1] = f[2] = f[3] = true(一口气拿完),f[4] = !f[3] || !f[2] || !f[1] = falsef[5] = f[6] = f[7] = true(先拿掉余数,把 4 丢给对手),f[8] = false。规律已经浮现:f[i] 为假当且仅当 i 是 4 的倍数。

递推本身的瓶颈在于必须从 0 一路算到 n,而 n 高达二十亿,无论用数组还是滚动变量都跑不完。

把规律提炼成不变量就能一步到位:面对 4 的倍数且轮到自己行动的一方必败。理由是对手可以维持这个性质——自己拿走 $k$ 块($k \in {1,2,3}$),对手立刻拿走 $4 - k$ 块,两人合计恰好消耗 4 块,于是自己再次面对一个 4 的倍数。石头总量严格递减,最终必然落到面对 0 块的局面,也就是输。

反过来,若 n 不是 4 的倍数,先手只要第一步拿掉余数 n % 4(余数只可能是 1、2、3,都在允许范围内),就把「面对 4 的倍数」这个必败身份转嫁给对手,此后按同样的补足策略应对即可。因此答案就是 n % 4 != 0

解题步骤

  • 计算 n 对 4 取余。模数取 4 是因为每次可拿的最大数量是 3,双方一来一回可以稳定凑出 $3 + 1 = 4$ 这个固定步长,4 正是这个游戏的周期。
  • 若余数为 0,说明先手一上来就站在必败位置:无论拿几块,对手都能补足到 4 把局面原样还回来,返回 false
  • 若余数不为 0,先手第一步拿走这个余数即可让对手陷入必败位置,返回 true。余数落在 1 到 3 之间,恰好是合法的取法,这一步永远可行。
  • 整个判断压缩成一次取模,不需要任何循环或递归。

n = 7 走一遍:$7 \bmod 4 = 3$,非 0,返回 true。对应的实际下法是:先手拿 3 块,剩 4 块交给对手。此时对手若拿 1 块剩 3,先手拿 3 块取胜;对手若拿 2 块剩 2,先手拿 2 块取胜;对手若拿 3 块剩 1,先手拿 1 块取胜。三条分支都由先手拿到最后一块,结论成立。再看 n = 8:$8 \bmod 4 = 0$,返回 false。先手无论拿 $k$ 块,剩下 $8 - k \in {5, 6, 7}$,对手补拿 $4 - k$ 块使剩余变成 4;先手再拿 $k'$ 块,对手再补 $4 - k'$ 块,剩余归零,最后一块被对手拿走,先手确实必败。

代码实现

class Solution {
    // 如果先手面对 4 的倍数,无论拿 1、2、3 块,对手都能补到 4,让先手再次面对 4 的倍数。
    public boolean canWinNim(int n) {
        return n % 4 != 0;
    }
}
func canWinNim(n int) bool {
    // 如果先手面对 4 的倍数,无论拿 1、2、3 块,对手都能补到 4,让先手再次面对 4 的倍数。
    return n%4 != 0
}

复杂度分析

  • 时间复杂度:$O(1)$,只做一次取模和一次比较,与 n 的大小无关。
  • 空间复杂度:$O(1)$,没有数组、没有递归栈,连临时变量都不需要。

关键点总结

  • 博弈题的通用起手式是从终止态倒推:先明确「什么局面轮到谁就是输」,再用「能否一步走到必败局面」定义必胜局面。有了这个框架,规律是被推出来的而不是猜出来的。
  • 每轮取 1 到 $m$ 个、取走最后一个者胜的取石子游戏(Bash 博弈)通解是 n mod (m + 1):余数非 0 时先手必胜,且第一步就是拿掉余数。本题是 $m = 3$ 的特例。
  • 「补足到固定步长」是维持不变量的经典手段。对手每次的应对都由自己的动作唯一确定,这种成对策略保证了必败性质在整局中一直成立。
  • 数据范围到 $2^{31}$ 是「必须找闭式解」的明确信号。看到范围就该放弃递推,转而打小表找周期。
  • 面试视角:千万不要上来就甩出 n % 4 != 0,那会被认为是背题。正确的表达顺序是:写出递推式、手推前八项、指出周期为 4、给出「补足到 4」的策略证明,最后才落到一行代码。过程远比结论值钱。
  • 面试视角:常见追问有两类。改成「每次取 1 到 $m$ 个」,答 n mod (m + 1);改成「每次可取的数量限定在某个集合内」,就要说明一般情形需要 Sprague-Grundy 定理计算 SG 值,简单取模不再适用。能划出结论的适用边界,比会用结论更重要。

易错点总结

  • 错误写法:把判据方向写反,返回 n % 4 == 0。用例 n = 4 → 返回 true,而先手面对 4 的倍数必败,正确答案是 false
  • 错误写法:老老实实开数组做递推。用例 n = 2147483647 → 需要长度二十一亿的布尔数组,内存直接溢出;即便换成滚动变量,循环次数同样跑不完。
  • 错误写法:用递归实现 f[i] = !f[i-1] || !f[i-2] || !f[i-3] 且不加记忆化。用例 n = 50 → 搜索树呈指数级膨胀,直接超时。
  • 错误写法:把胜负条件理解反,当成「拿走最后一块的人输」。用例 n = 1 → 按反向规则先手被迫拿走唯一一块而告负,返回 false,本题的正确答案是 true
  • 错误写法:凭直觉认为石头越多先手越有利,写成 n > 3。用例 n = 1 → 返回 false,正确答案是 true;用例 n = 8 → 返回 true,正确答案是 false
  • 错误写法:把模数取成 3,误以为「每次最多拿 3 个」就该模 3。用例 n = 33 % 3 == 0 使函数返回 false,而先手一次拿完即胜,正确答案是 true
  • 错误写法:把结论记成「偶数必败、奇数必胜」。用例 n = 2 → 返回 false,正确答案是 true;周期是 4 而不是 2。
  • 错误写法:在代码里真的模拟双方轮流取石子的过程。用例 n = 1000000 → 即使每一步都是常数时间,也要循环上百万次;更要命的是模拟时还得自己写对「补足到 4」的应对策略,一旦写错就会得到与结论不一致的答案。

相似题目

题目 难度 考察点
877. 石子游戏 中等 从两端取石子且石子有数值,需要区间动态规划或先手占位的奇偶论证
486. 预测赢家 中等 同为两端取数博弈,但要计算最终分差而非单纯胜负
1690. 石子游戏 VII 中等 得分由剩余石子总和决定,区间 DP 的状态是分差,需要前缀和配合
1227. 飞机座位分配概率 中等 同样是把一个看似复杂的随机过程压成常数时间结论,靠对称性而非博弈分析