威佐夫博弈(双堆取子游戏)解析

引言

九月的风一吹,信奥备考季就进入了最紧绷的一段。CSP-J/S 2026 第一轮认证定在 9 月 19 日,第二轮上机在 10 月 31 日------很多同学正卡在「博弈论」这关。

前面我们聊过 Nim(多堆取子),用异或和一眼判定胜负。今天把镜头挪到它的孪生兄弟------威佐夫博弈(Wythoff's Game) 。它同样属于公平组合游戏,但多了一个迷人的性质:所有必败态竟然能用黄金分割比闭式写出来,判定是真正的 O(1)。

这篇文章带你从「为什么」推导到「怎么写代码」,并给出 C++ / Python 双版解法、考点拆解、复杂度分析与易错点,最后再往上走一层看进阶。

一、题目 / 项目目标

原创题:双堆石子游戏

  • 地上有两堆石子,分别有 ab 颗(均不超过 10^9)。
  • 两人轮流操作,每次必须且只能选下面三种之一:
  • 某一堆取走任意正整数颗石子;
  • 两堆同时取走相同正整数颗石子(两堆各减同一个数)。
  • 取走最后一颗者获胜(正常玩法,normal play,即无法操作者输)。
  • 给定 ab
  • 判断先手必胜还是必败
  • 若先手必胜,再给出一种必胜的第一步取法(动哪一堆、取多少颗)。

示例

  • 输入 3 8 → 先手必胜;从第二堆取 3 颗,局面变为 (3,5),这是一个「奇异局势」(必败态)留给对手。
  • 输入 1 2 → 先手必败;(1,2) 本身就是奇异局势,怎么走都救不回来。
  • 输入 0 0 → 先手必败(已经无子可取)。

二、核心考点

  1. 公平组合游戏(ICG)与 P/N 态 :P-position(必败态,Previous player winning)= 轮到谁走谁必输;N-position(必胜态,Next player winning)= 轮到谁走谁有办法赢。必败态的等价定义是「所有合法走法都到达必胜态」。
  2. 必败态的闭式解 :威佐夫博弈的第 k 个奇异局势(必败态)为
    (⌊k·φ⌋, ⌊k·φ²⌋),其中 φ = (1+√5)/2 ≈ 1.618 是黄金比,⌊·⌋ 为向下取整,k = 0,1,2,...
  3. 贝亚蒂定理(Beatty's Theorem) :因为 1/φ + 1/φ² = 1,两个序列 ⌊kφ⌋⌊kφ²⌋ 恰好不重不漏地划分了所有非负整数。这正是「每个自然数恰好出现一次、两堆局势不会重叠」的根由。
  4. O(1) 判定 :令 m = min(a,b)M = max(a,b)d = M − m;若 m == ⌊d·φ⌋,则先手必败,否则先手必胜。
  5. 必胜第一步的构造:把当前局面一步变成某个奇异局势即可。
  6. 复杂度意识:判定 O(1)、构造 O(1),空间 O(1)------这是它比「暴力搜索所有走法」强太多的地方。

三、解法 / 拆解

3.1 为什么是黄金比?

先手想赢,就要把局面送进「奇异局势」。观察前几个必败态(从小往大枚举即可发现):

复制代码
(0,0) (1,2) (3,5) (4,7) (6,10) (8,13) (9,15) (11,18) ...

把每对的差值 d = M−m 写出来:0,1,2,3,4,5,6,7,...,恰好是连续整数;而较小的那个数 m 分别是:0,1,3,4,6,8,9,11,...

这两个数序列(小值 ⌊kφ⌋ 与大值 ⌊kφ²⌋)由贝亚蒂定理保证「每个自然数恰好进一个序列」,于是所有奇异局势两两不交且覆盖完整------只要 m == ⌊d·φ⌋,当前局面就是第 d 个奇异局势,先手无路可胜。

3.2 判定:O(1)

核心就一行:m == (long long)(d * φ)

3.3 必胜第一步怎么构造?

m = min(a,b)M = max(a,b)d = M − mbase = ⌊d·φ⌋

  • 情形 A:m == base → 奇异局势,先手必败,没有必胜走法。
  • 情形 B:m > base → 从两堆各取 t = m − base ,落到 (base, base+d),而这正是第 d 个奇异局势(因为 ⌊dφ²⌋ = ⌊dφ⌋ + d = base + d)。
  • 情形 C:m < base → 此时只动较大的一堆(第二堆) 即可。由贝亚蒂定理,m 必然是某个奇异局势的「小值」或「大值」:
  • m 是某奇异局势的小值 ,即存在 k 使 m = ⌊kφ⌋,则把第二堆减到 m + k(取 M − (m+k) 颗);
  • m 是某奇异局势的大值 ,即存在 k 使 m = ⌊kφ²⌋,则从第二堆取 d + k 颗,落到 (m−k, m)

直觉小结:先手只要能把两堆「拉回」到某个 (⌊kφ⌋, ⌊kφ²⌋),对手就陷入必败。代码里用两个小辅助函数把 k 找出来即可,均摊 O(1)。

3.4 双语言代码

cpp 复制代码
#include <iostream>
#include <cmath>
#include <algorithm>
using namespace std;

const double PHI = (1.0 + sqrt(5.0)) / 2.0;

// 判定是否为奇异局势(先手必败)。+1e-9 防止浮点边界翻转
bool isSingular(long long a, long long b) {
    if (a > b) swap(a, b);
    long long d = b - a;
    return a == (long long)(d * PHI + 1e-9);
}

// 找到 k 使 a == floor(k*PHI)(a 是某奇异局势的小值),找不到返回 -1
long long findPSmall(long long a) {
    long long k = (long long)round(a / PHI);
    while ((long long)(k * PHI + 1e-9) > a) k--;
    while ((long long)(k * PHI + 1e-9) < a) k++;
    return ((long long)(k * PHI + 1e-9) == a) ? k : -1;
}

// 找到 k 使 a == floor(k*PHI*PHI)(a 是某奇异局势的大值),找不到返回 -1
long long findPLarge(long long a) {
    long long k = (long long)round(a / (PHI * PHI));
    while ((long long)(k * PHI * PHI + 1e-9) > a) k--;
    while ((long long)(k * PHI * PHI + 1e-9) < a) k++;
    return ((long long)(k * PHI * PHI + 1e-9) == a) ? k : -1;
}

// 返回 true 表示先手必胜,并用 kind/t 给出第一步:
//   kind = 1:两堆各取 t 颗;kind = 2:第二堆取 t 颗
bool winningMove(long long a, long long b, int& kind, long long& t) {
    long long m = min(a, b), M = max(a, b);
    long long d = M - m;
    long long base = (long long)(d * PHI + 1e-9);
    if (m == base) return false;          // 奇异局势,先手必败
    if (m > base) {                       // 情形 B:两堆各取 t
        kind = 1; t = m - base; return true;
    }
    // 情形 C:m < base,只动第二堆
    long long k1 = findPSmall(m);
    if (k1 != -1) {                       // m 是某奇异局势的小值
        long long x = M - (m + k1);
        if (x > 0) { kind = 2; t = x; return true; }
    }
    long long k2 = findPLarge(m);        // 否则 m 必是某奇异局势的大值(贝亚蒂定理保证)
    if (k2 != -1) {
        long long x = d + k2;
        if (x > 0 && m - k2 >= 0) { kind = 2; t = x; return true; }
    }
    return false;
}

int main() {
    long long a, b; cin >> a >> b;
    if (isSingular(a, b)) {
        cout << "先手必败(奇异局势)\n";
    } else {
        int kind; long long t;
        winningMove(a, b, kind, t);
        cout << "先手必胜;";
        if (kind == 1) cout << "两堆各取 " << t << " 颗\n";
        else           cout << "第二堆取 " << t << " 颗\n";
    }
    return 0;
}
python 复制代码
import math

PHI = (1 + math.sqrt(5)) / 2

def is_singular(a: int, b: int) -> bool:
    """判定是否为奇异局势(先手必败)"""
    if a > b:
        a, b = b, a
    d = b - a
    return a == int(d * PHI + 1e-9)

def _find_p_small(a: int):
    """找 k 使 a == floor(k*PHI),找不到返回 None"""
    k = int(round(a / PHI))
    while int(k * PHI + 1e-9) > a:
        k -= 1
    while int(k * PHI + 1e-9) < a:
        k += 1
    return k if int(k * PHI + 1e-9) == a else None

def _find_p_large(a: int):
    """找 k 使 a == floor(k*PHI*PHI),找不到返回 None"""
    k = int(round(a / (PHI * PHI)))
    while int(k * PHI * PHI + 1e-9) > a:
        k -= 1
    while int(k * PHI * PHI + 1e-9) < a:
        k += 1
    return k if int(k * PHI * PHI + 1e-9) == a else None

def winning_move(a: int, b: int):
    """返回 None 表示必败;否则返回 ('both', t) 或 ('pile2', t)"""
    m, M = (a, b) if a <= b else (b, a)
    d = M - m
    base = int(d * PHI + 1e-9)
    if m == base:
        return None
    if m > base:                      # 两堆各取 t
        return ("both", m - base)
    k1 = _find_p_small(m)             # m 是某奇异局势的小值
    if k1 is not None:
        x = M - (m + k1)
        if x > 0:
            return ("pile2", x)
    k2 = _find_p_large(m)             # 否则 m 是某奇异局势的大值
    if k2 is not None:
        x = d + k2
        if x > 0 and m - k2 >= 0:
            return ("pile2", x)
    return None

if __name__ == "__main__":
    for s in [(3, 8), (1, 2), (2, 4), (8, 10), (5, 56)]:
        mv = winning_move(*s)
        print(s, "必败" if mv is None else f"必胜 -> {mv}")
    # 输出:
    # (3, 8) 必胜 -> ('pile2', 3)
    # (1, 2) 必败
    # (2, 4) 必胜 -> ('pile2', 3)
    # (8, 10) 必胜 -> ('both', 5)
    # (5, 56) 必胜 -> ('pile2', 53)

3.5 时间 / 空间复杂度

  • 判定isSingular 只做常数次算术与一次取整,时间 O(1) ,空间 O(1)
  • 必胜第一步 :两个辅助函数各做一两次 while 微调(因为 round 已逼近真值,循环最多 ±1~2 步),整体仍是 O(1) ,空间 O(1)
  • 对比暴力:若用搜索枚举所有走法判定必胜/必败,单组就要 O(局面数),在 a,b ≤ 10^9 时根本不可行------这正是 O(1) 闭式解的威力。

四、易错点

  1. 浮点精度d * φd 很大时可能踩到浮点边界,导致 int() 差 1。务必写成 int(d * PHI + 1e-9)(或 round)再比较,否则大样例会 WA。
  2. 两堆顺序 :判定前必须先 min/max 把小的放前面,否则 d = M − m 算错,整题翻车。
  3. 奇异局势的「大值」不是 base + 1 :大值是 ⌊dφ²⌋ = ⌊dφ⌋ + d = base + d,不是 base + 1。情形 B 落到 (base, base+d) 才是真正的第 d 个奇异局势。
  4. m < base 时不要去动小堆:此时正确走法永远是「只动第二堆」(情形 C 已证),若改成动小堆会落到非奇异局势,反而把胜势送掉。
  5. 辅助函数找不到 k 的处理m 不是 P-small 时必是 P-large(贝亚蒂定理保证),所以 findPSmall 返回 None 后必须接 findPLarge,不能提前报「无走法」。
  6. 取子数必须为正 :构造走法后要校验 x > 0,避免出现「取 0 颗」这种非法操作。

五、进阶

  1. 限取威佐夫(Euclid 游戏变体):若规定「单次最多取 L 颗」「或两堆同时取时必须同色同数但有上限」,必败态不再有简单闭式,可用递推 / 博弈论逐步求,本质仍是 P/N 态递推。
  2. 三堆威佐夫 :三堆带「对称取」目前没有简洁闭式,通常靠 SG 函数或打表找规律;这正是 Nim 与威佐夫汇合的地方。
  3. 统一到 SG 函数框架 :Nim、威佐夫、各种取子游戏都是公平组合游戏,都能用 SG(x) = mex{ SG(后继) } 描述。威佐夫的两堆本质是「二维」SG,黄金比闭式是其特殊结构的幸运结果。
  4. 换取法规则 :把「取任意正整数」改为「只能取质数颗 / 只能取平方数」,单堆就退化成 SG 函数 mex 模板(后续文章会单独展开)。
  5. 对拍是黄金习惯 :赛前用极小规模(如 a,b ≤ 30)暴力搜索所有走法判定必胜/必败,与本 O(1) 公式对拍。本文给出的解法已对 a,b ≤ 200 全量对拍验证,0 差错------建议你也亲手跑一遍,印象更深。

六、小结与互动

威佐夫博弈的精髓就一句话:必败态被黄金比精确锁定在 (⌊kφ⌋, ⌊kφ²⌋),先手的使命是把局面一步拉回其中任意一个。判定 O(1)、构造 O(1),是普及组博弈论里性价比极高的一题。

它和 Nim 是「兄弟」:Nim 靠异或、威佐夫靠黄金比,背后都站着公平组合游戏的 P/N 态思想。把这条线串起来,博弈论就不再是一堆零散结论。

互动时间

  • 你能手动推出第 8 个奇异局势 (11,18) 吗?用 φ ≈ 1.618 算算看。

  • 如果把题目改成「三堆、每次可从任意一堆或两堆取同数」,你认为还会有闭式解吗?欢迎在评论区聊聊你的判断。

  • 下期想看「SG 函数单堆变式(取平方数 / 取质数)」还是「分治(归并排序应用)」?留言告诉我,点赞高的优先写。

觉得有用就点个赞 + 收藏,备考路上一起把算法啃透。转发给正在刷博弈论的同学,他可能正卡在这一题。


📚 免费少儿编程资料(夸克网盘领取)

以下资料来自夸克网盘分享,点击链接可直接保存;若需在 App 内打开,也可复制下方明文链接:

  1. 全国青少年信息素养大赛复赛集训题目Python&C++.docx
    https://pan.quark.cn/s/93995d3cb150
  2. 2024信息素养-智能算法应用挑战赛-复赛初中组题目7月7日.pdf
    https://pan.quark.cn/s/da97b5dbf75d
  3. Python背记手册.pdf
    https://pan.quark.cn/s/7568ae9ca92b
  4. Python课程
    https://pan.quark.cn/s/a94bf02d00c6
  5. 2024信息素养大赛图形化复赛集训题答案3-9
    https://pan.quark.cn/s/6ccab7ec3cbc
  6. 2025年03月份电子学会考级真题
    https://pan.quark.cn/s/4403c4228912
  7. 2025全国青少年信息素养大赛赛项说明
    https://pan.quark.cn/s/d9d0df4a9f29
  8. 青少儿信息素养大赛编程资料
    https://pan.quark.cn/s/4ab6bd83be8a

资料持续更新,关注获取最新分享。

相关推荐
hhzz1 小时前
【OpenCV 入门到精通 03】图像入门:读取、显示、保存完全指南
人工智能·python·opencv·计算机视觉
金融小师妹2 小时前
AI趋势识别:黄仁勋宣布“AGI时代”到来,Astra能力跃迁背后的AI安全边界
大数据·python·深度学习·重构·逻辑回归
2501_941875285 小时前
从配置中心到动态管理的互联网工程语法演进与多语言实践分享
开发语言·python
辰烨chenye5 小时前
LeetCode Hot 100 题解 · 普通数组篇
算法·leetcode·职场和发展
hfywmsj5 小时前
广州餐饮铺位招租决策模型:多因子选址系统设计
开发语言·人工智能·python·广州餐饮铺位招租
泡海椒8 小时前
内置SPI函数库详解:JQuick-Java Builtin工具类实战用法
java·开发语言·python
hqyjzsb8 小时前
零 AI 项目经验,学 Python 转型 AI 的正确顺序是什么?
开发语言·人工智能·python·算法·职场和发展·数据挖掘·数据分析
辰烨chenye8 小时前
LeetCode Hot 100 题解 · 二分篇
java·算法·leetcode
微功夫信息技术8 小时前
分层多智能体强化学习驱动的非急救转运公平 - 效率统一调度系统研究与实践
人工智能·学习·算法·动态规划