这篇文章上次修改于 507 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

ACM程序课算法笔记5——组合博弈

问题引入

问题:一共有10张牌,你与一个人进行游戏,每次只能取走1或2或3张牌,谁取走最后一张,你是先手,如何获胜。

题解:根据以前的经验,我们能保证一轮取走4张牌,而且设定每一轮我们是后手,对手取走1张,我取走3张。对手取走2张,我也取走2张。对手取走3张,我取走1张。易得结果。

思考总结

但在进行这个游戏的时候,我们心中就有个概念,只要我这么走,无论对方怎么走,我的结果就是赢,我们称这个状态为必胜点,同理,对于除了必胜点意外的其它点,对方就会必胜,此状态我们称为必败点

从这个角度上再来看这道题,我们可以让剩余的卡片数量作为状态,并且我们设必胜为N必败为P

剩余卡片 0 1 2 3 4 5 6 7 8 9 10
状态情况 P N N N P N N N P N N

根据此次标记,我们可以找到一些规律:

  • 终结位置可以直接标记为必败点(没有牌给对面选意味着对面输了)
  • 所有能进入必败点的前置点都为必胜点(我能进入让对方必输的状态)
  • 如果该点进行所有操作能进入的点都为必胜,则改点必败(无论怎么下都会让对方必胜)

按照该规律,我们还可以进行变版游戏,每次只能取走1或3或4张牌,这时我们难以找到像开始4的倍数规律,但根据必败必胜点规律,我们依旧可以打表

剩余卡片 0 1 2 3 4 5 6 7 8 9 10
状态情况 P N P N N N N P N P N

就又可以判断如何必胜

按照此规律不能只局限于线性表,类似于二维表,甚至图的结构都可以解决。

Nim游戏(Nim-Sum)

游戏规则

  • 有两个玩家,三堆扑克牌(比如:分别是5, 7, 9张),双方轮流操作
  • 玩家的每次操作是选择其中某一堆牌,然后从中取走任意张
  • 最后一次取牌的一方为获胜方

初次尝试

按照我们原本的思路来看,这道题再根据必胜必败点,我们将(a1, a2, a3)为三个三堆扑克牌的剩余牌数量

可得(0, 0, 0)为必败点,(0, 0, x)为必胜点,(0, 1, 1)为必败点,n(0, k, k)为必败点。

依据这样来计算,将会花费大量空间,我们需要更好的规律来计算这类问题。

引入概念: Nim-Sum

定义

Nim-Sum的运算对象(x1, x2, x3 … xn)

(x1, x2, x3 … xn)和(y1, y2, y3 … yn)的Nim-Sum是(z1, z2, z3 … zn)

其中对于所有 zk = xk + yk (mod 2) (k=0…m)

定理

对于本游戏,每个游戏状态都有一个Nim-Sum,就是对所有牌堆的牌数量进行Nim-Sum

对于nim游戏的某个位置(x1,x2,x3),当且仅当它各部分的nim-sum等于0时(即x1⊕x2⊕x3=0),则当前位于必败点。

例如点(0, 0, 0)属于必败点,(3, 5, 6) 即b11^b101^b110 = 0,即必败点。

证明

对于每次取牌,都可以取任意张,唯一的局限性就是,只能取其中的一堆的牌,只有一堆的牌的数量会变化。

同时,假如此时状态的Nim-Sum是0,那么修改其中一堆牌的二进制必定会影响nim-sum而使其不为0,所以nim-sum=0的点(除了最终状态)永远无法一步到达最终状态,故nim-sum=0的点为必败点。

同时,又因为nim-sum不为0的状态点,我们要去除除二进制的多个1,当最高位有1的时候,说明三堆牌中也有最高位为1的,修改它,并将之后所有的1位变为0,也就是nim-sum不为0的状态点都能一步到达nim-sum=0的点,能到达必败点的点就是必胜点,一旦nim-sum不为0,其必胜。

例子:

13 = 1101b  ->  0100b
12 = 1100b  ->  0101b
 8 = 1000b  ->  0001b
 ____________异或运算
     1001b  ->  0000b(目的)

此时,修改任意一堆牌以符合要求,见以上修改,即可进入必败点。

bool isPointWin(const vector<int> & nims){
    int nimSum = 0;
    for(int x: nims){
		nimSum ^= x;
    }
    return nimSum != 0;
}

SG函数(Sprague-Grundy Function)

定义

SG函数同样为每个状态建立了一个值

g(x) =min{n ≥ 0 : n != g(y) for y ∈ F(x)}

即X节点的SG值是除X的后继节点的SG值后的最小的非负整数。

定理

必败点: 当节点x的 sg(x) = 0
必胜点: 当节点x的 sg(x) > 0

证明

  • 终止状态的SG函数值为0。
  • 如果SG(x)不为0,根据SG函数的定义,x的后继中一定存在某一个状态的SG函数值为0,我们称之为y。那么,我们一定可以通过操作将x转移到y,从而留给对手一个SG函数值为0的状态。
  • 如果SG(x)为0,根据SG函数的定义,x的后继中不存在SG函数值为0的状态。所以,无论如何操作,都一定会留给对手一个SG函数值不为0的状态。

因此,如果玩家处于一个SG函数值不等于0的状态,那么他是必胜的,否则他是必败的。SG函数确实可以作为必胜点进行使用。

组合型组合博弈

题目

有三堆扑克牌,分别为5,7,9张;
双方轮流取牌,每次可以选择任意一堆牌取走1~3张;
最后取牌的一方获胜。

定理

如果图游戏G由若干子图游戏Gi组成,即:G = G1 +···+ Gn ,假设gi是Gi(i = 1, …,n)的SG函数值,那么,图游戏G的SG值计算如下:
g(x1, ...,xn) = g1(x1)⊕...⊕gn(xn)

题解

按照本题套定理,三堆扑克牌也就属于三个子游戏,每个点都有自己的sg值

int calculateSG(const vector<int>& moves, int state, unordered_set<int>& memo) {
    if (memo.find(state) != memo.end()) {
        return memo[state];
    }

    unordered_set<int> reachableStates;

    for (int move : moves) {
        int nextState = state - move;

        if (nextState >= 0) {
            int nextSG = calculateSG(moves, nextState, memo);
            reachableStates.insert(nextSG);
        }
    }

    int currentSG = 0;
    while (reachableStates.find(currentSG) != reachableStates.end()) {
        currentSG++;
    }

    memo[state] = currentSG;  // 记忆化
    return currentSG;
}

int main() {
    // 定义游戏的可行动集合
    vector<int> moves = {1, 2, 3};

    // 输入初始状态
    int initial_state;
    cout << "输入博弈的初始状态(正整数):";
    cin >> initial_state;

    unordered_set<int> memo;

    int sgValue = calculateSG(moves, initial_state, memo);

    cout << "SG值为:" << sgValue << endl;

    return 0;
}

证明

这里简单说明一下

我们将sg用于三堆牌的行为,同时,一次操作只能修改一堆牌。

同步到这个问题,三堆牌都有自己的sg值来代表必胜点和必败点

sg值是根据后继节点判断的,一个sg的值为5,代表它的后继必有0,1,2,3,4。类似于取牌游戏,5可以任意变成5以下的数字,sg值的变化和取牌等价,取牌也可以取任意张使得sg值随意变小,直到三堆牌的sg都变为0,便可直接结合应用。

严谨证明 参考