博弈论

博弈论题目解题的关键在于找到一个状态a,设它的否定为状态b,状态a满足:不论怎么操作对手的状态a一定会转化为状态b和一定存在一种从状态b转化到状态a的操作。满足这样两条性质的状态a为必败态,b为必胜态。
想求SG,需要对后续节点实行mex函数,想求是否为必胜必败态,需要求异或和
如果使用SG来做,不需要分析什么,直接对所有子游戏求异或和最后判断与0的关系即可(对应Nim游戏的推广);如果不使用SG,那么就需要分析出必败态和必胜态,(对应Nim游戏)

Nim游戏

给定n堆石子,两位玩家轮流操作,每次操作可以从任意一堆石子中拿走任意数量的石子(可以拿完,但不能不拿),最后无法进行操作的人视为失败。问如果两人都采用最优策略,先手是否必胜。

Nim定理

假设各堆石子数量为A1,A2,...,An,那么先手必胜的充分必要条件为 A1 xor A2 xor ... xor An  0

定理证明

前提说明
为何本题使用异或进行判断不是我们能想到的,那是数学家的工作,我们能做的就是理解相关定理的证明并记忆相关题目的特征。
首先必须明确一个必败局面是A1 xor A2 xor ... xor An = 0,该结论在《算法竞赛进阶指南》中并没有给出详细说明,书中的说法是当所有石子数量均为0时是先手必败局面,此时A1 xor A2 xor ... xor An = 0,但是这只是一种特殊情况,并非一般情况,所以这里我也不太清楚,不过由于后续证明必须用到这个结论,所以这里只能先暂时这么认定。
证明
若想要证明异或和不为0是先手必胜的条件,也就是证明异或和不为0的状态x是一个必胜态,需要从以下两个角度进行证明
一、通过某些操作可以使得状态x到达必败态,当先手处于必胜局面时,必须保证对手面对的一定是必败局面,即通过某个操作可以使得任意一个必胜局面转化为必败局面

对于任意一个局面,如果A1 xor A2 xor ... xor An = x  0,设x的二进制表示下最高位的1在第k位,那么至少存在一个Ai,它的k位是1,因为x xor x=0,所以我们只需要从Ai中拿走Ai(Ai xor x),使得Ai变为Aixorx,从而得到A1 xor A2 xor (Ai xor x) ... xor An =A1 xor A2 xor ... xor An = x xor x = 0的必败局面

二、不论采取什么操作必败态一定会转化为状态x,,当对手处于必败局面时,必须保证先手面对的一定是必胜局面,即通过任何操作可以使得任意一个必败局面转化为必胜局面

对于任意一个局面,如果A1 xor A2 xor Ai ... xor An = 0(1式),那么无论如何取石子,都可以得到所有石子数量异或和不为0的局面,即必胜局面。采用反证法,假设从Ai中取出一些石子后数量变为Ai,且A1 xor A2 xor Ai ... xor An = 0(2式),将1式和2式左右两侧分别进行异或,结果为Ai xor Ai = 0,即Ai = Ai,这显然和假设是矛盾的,所以对于一个必败局面,无论从哪堆石子中取出多少石子都一定得到一个必胜局面

注意上方两点中“某个”和“任意”的区别,某个是指并非所有操作都可以达到上方所说的效果,但是一定有一个可以,而且题目要求可以保证每次都选择最优方案,所以一定可以选择到那个特定的操作;任意是指无论如何操作都一定会有那个效果,即使按照每次选择最优方案的策略都会有这样的效果

代码实现

只需要按照Nim定理进行判断即可

Nim游戏的推广

名词和定理

公平组合游戏
上述的Nim游戏实际为一个公平组合游戏(ICG: Impartial Combinatorial Games),其满足几点特征(其实也就是了解一下,做题没啥用)

  • 由两名玩家交替进行
  • 在游戏进程的任意时刻,可以执行的合法行动与轮到哪名玩家无关
  • 不能行动的玩家判负(输)

有向图游戏
给定一个有向无环图,图中有唯一一个起点,在起点上放有一枚棋子。两名玩家交替将棋子沿着有向边移动,每次可以移动一次,无法移动者判定为输(判负)。
如果将游戏过程中每一个局面(状态)看为图中的一个节点,局面的变化看为节点沿着有向边的移动,任意一个公平组合游戏都可以看为是一个有向图游戏。

mex运算
设S为一个非负整数集合,mex(S)含义为求出不属于集合S的最小非负整数,即:
mex(S)=minx  N, x  Sx

SG函数
在有向图游戏中,对于每个节点x,设从x出发共有k条有向边,分别到达y1,y2,...,yk,定义SG(x)为x的后继节点y1,y2,...,yk的SG函数值构成的集合执行mex的结果,即:
SG(x) = mex({SG(y1),SG(y2), ... ,SG(yk)})
实际效果见下图:

有向图游戏G的SG函数值定义为游戏起点的SG函数值。

有向图游戏的和
有向图游戏的和的SG函数值等于各个子游戏SG函数值的异或和(游戏的SG函数值概念见SG函数最后一行)

定理

有向图游戏的某个局面必胜,当且仅当该局面对应节点的SG函数值>0
有向图游戏某个局面必败,当且仅当该局面对应节点的SG函数值=0

原因在于SG函数值为0代表到达了不能行动的局面,自然对应着必败局面;SG函数值非0表示后续节点中包含终点,即可以使得对手到达不能行动的局面,则对应必胜局面

注意定理提到的只是一个子问题,比如只有一堆石子,每次可以拿固定数量的石子,询问先手是否必胜。根据最初节点的SG函数值即可判断初始节点是否为必胜态。但是实际问题一定都是多个子问题,比如有多堆石子,每次可以拿固定数量的石子,询问先手是否必胜。对于多个子问题,是否先手必胜取决于各个子问题SG函数值的异或和,即取决于有向图游戏的和,书中并没有证明该结论,只是提到与上述Nim游戏的证明类似,但是我暂时并没有想懂。

举例

给定n堆石子以及一个由k个不同正整数构成的数字集合S。
现在有两位玩家轮流操作,每次操作可以从任意一堆石子中拿取石子,每次拿取的石子数量必须包含于集合S,最后无法进行操作的人视为失败。
问如果两人都采用最优策略,先手是否必胜。

分析
其实上面提到的Nim游戏可以看为是该题的特例,特殊在k=1,s中只有一个数据1。
可以看出本题就是定理中提到的多个子问题的情况,解题关键在于求解出每一个子游戏的SG函数值,我们考虑时只需要考虑一个子游戏,然后遍历求解即可。对于某一个节点的SG,取决于其后续节点的SG,所以代码实现是递归。同时考虑到不同子游戏中相同状态的节点(状态在本题中指堆中石子数量)的SG其实是相等的,因为对于该节点而言,无论处于哪个子游戏中,它延伸出的后续节点都是一样的,根据SG函数的本质求解方式,不同子游戏相同状态的节点SG显然是相等的,所以递归采用记忆化来降低复杂度。
对于记忆化的进一步解释可以查看下图,红色框的两部分目的都是计算石子数量为3对应节点的SG函数值,显然计算4会递归计算3,如果此前计算过3那么此时就没有必要再算一次了,能够记忆化的关键在于不同子游戏中相同状态节点的SG是相等的。

代码实现

/**
 * 在计算某节点的SG时需要先求解所有后续节点的SG,之后选择一个最小且不在这些SG值中的非负整数值
 * 实现方式很多,可以用数组存储然后从小到大排序并从小到大依次查看,第一个没有出现的数值就符合我们的要求,这样做时间复杂度的瓶颈在于排序,最快也要O(nlogn)
 * 但是我们在计算节点x的SG时,只需要判断某个值是否属于所有后续节点的SG值集合中,采用哈希是更好的方案
 * 如果使用STL,使用unordered_map和unordered_set均可,代码中注释部分为使用unordered_map的实现方式
 */
#include <iostream>
#include <unordered_map>
#include <unordered_set>
#include <cstring>

using namespace std;

const int N = 110, M = 10010;

int m, s[N];
int n, f[M]; // f[i]:石子数量为i对应的节点的SG函数值

int sg(int x)
{
    if (f[x] != -1) return f[x];
    
    unordered_set<int> S;
    // unordered_map<int, bool> S;
    for (int i = 0; i < m; ++ i)
        if (x >= s[i])
            S.insert(sg(x - s[i])); // 把x的所有后续节点的sg先存储下来
            // S[sg(x - s[i])] = true;
    
    // 找到不在集合中且最小的值作为x节点的SG值
    for (int i = 0; ; ++ i)
        if (!S.count(i)) // count:统计set中i的数量,由于set会去重,所以返回值为0或者1
        // if (!S[i]) 
            return f[x] = i;
}
int main()
{
    cin >> m;
    for (int i = 0; i < m; ++ i) cin >> s[i];
    cin >> n;
    
    memset(f, -1, sizeof f);
    int res = 0;
    while (n --)
    {
        int x;
        cin >> x;
        res ^= sg(x);
    }
    
    if (res) cout << "Yes" << endl;
    else cout << "No" << endl;
    
    return 0;
}
posted @   0x7F  阅读(153)  评论(0编辑  收藏  举报
编辑推荐:
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
阅读排行:
· 终于写完轮子一部分:tcp代理 了,记录一下
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
点击右上角即可分享
微信分享提示