AIGC标识 【学习笔记】博弈

这个其实更多其实是需要的运筹学知识吧。🤔

❔题目

1. Nim 游戏

解题思路

原始思路:
首先:只有最后一轮留下的是1~3,并且是自己的轮。
然后拿几个作为例子会发现,4的时候不可以,6、7的时候可以,8不可以,9又可以了。
所以猜想只要不是4的倍数就能赢。

严谨推断:

  1. 基础状态(基底)
    • \(n \in \{1, 2, 3\}\):先手一次性拿光,先手必胜。
    • \(n = 4\):先手无论拿 1、2 还是 3 块,剩下的石子数必然是 3、2 或 1 块。后手面对这个数量可以直接一次拿光。因此 \(n = 4\) 是先手必败态。
  2. 归纳与控制权锁定
    • \(n\) 是 4 的倍数(\(n = 4k\))时:
      无论先手第一步拿 \(x\) 块(\(x \in \{1, 2, 3\}\)),后手只需要跟着拿 \(4 - x\) 块(也必然在 \(\{1, 2, 3\}\) 范围内)。
      两人走完这一轮,石子总数刚好减少 4 块,变成 \(4(k-1)\)
      后手通过这个策略,能够永远把“4 的倍数”这个绝境丢回给先手。
      最终先手会被死死锁定在 \(n = 4\) 的局面,必败。
    • \(n\) 不是 4 的倍数(\(n = 4k + r\),其中 \(r \in \{1, 2, 3\}\))时:
      先手在第一回合直接拿掉余数 \(r\) 块石子,剩下的石子数瞬间变成了 \(4k\)(4 的倍数)。
      此时攻守势异,先手把“4 的倍数”这个必败局甩给了对手,先手转化为控场者,先手必胜。

实现

function canWinNim(n: number): boolean {
    if(n % 4 === 0) return false;
    return true;
};
public class Solution {
    public bool CanWinNim(int n) {
        return n % 4 != 0;
    }
}
class Solution {
public:
    bool canWinNim(int n) {
        // 也就是最后一轮是1~3,并且是自己,那就可以赢
        if(n <= 3) return true;
        if(n % 4 == 0) return false;
        return true;
    }
};

复杂度分析

  • 时间复杂度\(O(1)\)
  • 空间复杂度\(O(1)\)

2. 石子游戏

解题思路

原始思路:
只能拿队头或队尾,所以会选2种可能,
1:最大的数字
2:拿了后,可以保证下下个大数是自己的
所以就是,选择时,尽可能选暴露的数字是小数的一边

但是是不够严谨的。

思路一:区间 DP / 博弈 DP(通用求解)
定义 dp[i][j] 为当前玩家在区间 [i, j] 内能获得的净胜分(即“自己拿到的石子数”减去“对手后续能拿到的石子数”):

  • 拿队头 piles[i]:获得净收益 piles[i] - dp[i+1][j]
  • 拿队尾 piles[j]:获得净收益 piles[j] - dp[i][j-1]
  • 转移方程:\(\text{dp}[i][j] = \max(\text{piles}[i] - \text{dp}[i+1][j], \text{piles}[j] - \text{dp}[i][j-1])\)

⭐思路二:数学奇偶位锁定
题目限定了石子堆数 \(N\) 为偶数且总石子数为奇数:

  • 先手可以把所有石子堆按下标分为“奇数堆”和“偶数堆”。
  • 如果先手第一步拿 piles[0](奇数堆),后手就只能选择暴露出来的偶数堆;接着先手又可以继续拿奇数堆……先手可以控制自己拿走全部的奇数堆或拿走全部的偶数堆。
  • 由于总数为奇数,奇数堆之和与偶数堆之和必然不相等。先手只需开局前计算好哪一边更大,并全程锁定对应奇偶位的石子即可必胜(即 return true;)。

实现

function stoneGame(piles: number[]): boolean {
    const n = piles.length;
    const dp: number[][] = Array.from({ length: n }, () => new Array(n).fill(0));

    for (let i = 0; i < n; i++) dp[i][i] = piles[i];

    for (let len = 2; len <= n; len++) {
        for (let i = 0; i <= n - len; i++) {
            const j = i + len - 1;
            dp[i][j] = Math.max(piles[i] - dp[i + 1][j], piles[j] - dp[i][j - 1]);
        }
    }

    return dp[0][n - 1] > 0;
}
public class Solution {
    public bool StoneGame(int[] piles) {
        return true;
    }
}
class Solution {
public:
    bool stoneGame(vector<int>& piles) {
        return true;
    }
};

复杂度分析

动态规划

  • 时间复杂度\(O(N^2)\)
  • 空间复杂度\(O(N^2)\)

数学方法

  • 时间复杂度\(O(1)\)
  • 空间复杂度\(O(1)\)

3. 猫和老鼠

解题思路

与其从起点向后正向搜索,不如从已知的终局(必胜/必败态)开始,往回倒推前一步的状态:

  1. 状态定义color[m][c][t] 表示老鼠在 m、猫在 c、轮到 t 移动(0 代表老鼠,1 代表猫)时的结果(0 未定/平局,1 老鼠胜,2 猫胜)。

  2. 确定基底状态(入队初始节点)

    • 老鼠到达 0 号洞(m = 0):老鼠必胜(1)。
    • 猫抓到老鼠(m = c):猫必胜(2)。
  3. 逆向推导规则(状态转移)

    • 能一步绝杀:如果上一步的玩家(例如老鼠)存在任意一种走法能走到对自己“必胜”的下一个状态,则上一步的状态直接确定为必胜,入队。
    • 走投无路:如果上一步的玩家选择的所有走法,全都会走到对自己“必败”的状态(通过维护出度 degree,当可用走法扣减到 0 时),则上一步的状态被判定为必败,入队。
    • 平局判定:当队列为空,所有逆向倒推结束时,那些依然没有被标记输赢的状态,就属于双方在死循环中打转的平局(0)。

实现

function catMouseGame(graph: number[][]): number {
    const n = graph.length;

    // color[m][c][t] 存储状态结果:0=未知/平局, 1=老鼠胜, 2=猫胜
    // m: 老鼠位置, c: 猫位置, t: 0(轮到老鼠)/1(轮到猫)
    const color: number[][][] = Array.from({ length: n }, () =>
        Array.from({ length: n }, () => [0, 0])
    );

    // degree[m][c][t] 记录当前行动者还有多少种“未被证实为必败”的走法
    const degree: number[][][] = Array.from({ length: n }, () =>
        Array.from({ length: n }, () => [0, 0])
    );

    // 1. 初始化每个状态下玩家的合法走法数量
    for (let m = 0; m < n; m++) {
        for (let c = 1; c < n; c++) {
            degree[m][c][0] = graph[m].length; // 老鼠可选走法数
            degree[m][c][1] = graph[c].length; // 猫可选走法数
            // 猫不能移动到 0 号洞
            if (graph[c].includes(0)) {
                degree[m][c][1]--;
            }
        }
    }

    // 队列存储已经确定的终局状态:[老鼠位置, 猫位置, 轮到谁, 获胜方]
    const q: [number, number, number, number][] = [];

    // 2. 标记基础终局状态并入队
    // 终局 A: 老鼠进洞 (m = 0) -> 老鼠胜 (1)
    for (let c = 1; c < n; c++) {
        for (let t = 0; t < 2; t++) {
            color[0][c][t] = 1;
            q.push([0, c, t, 1]);
        }
    }
    // 终局 B: 猫抓到老鼠 (m = c) -> 猫胜 (2)
    for (let m = 1; m < n; m++) {
        for (let t = 0; t < 2; t++) {
            color[m][m][t] = 2;
            q.push([m, m, t, 2]);
        }
    }

    // 3. 逆向 BFS 倒推胜负
    while (q.length > 0) {
        const [m, c, t, w] = q.shift()!;

        // 如果推导到了起点 (老鼠在1, 猫在2, 轮到老鼠t=0),直接返回结果
        if (m === 1 && c === 2 && t === 0) return w;

        // prev_t 是上一步行动的玩家 (0和1互相切换)
        const prev_t = 1 - t;

        if (prev_t === 0) {
            // 上一步是【老鼠】在走,找所有能走到当前 m 的上一步位置 prev_m
            for (const prev_m of graph[m]) {
                if (color[prev_m][c][prev_t] !== 0) continue;

                if (w === 1) {
                    // 存在让老鼠必胜的走法 -> 上一步老鼠直接判定必胜
                    color[prev_m][c][prev_t] = 1;
                    q.push([prev_m, c, prev_t, 1]);
                } else if (--degree[prev_m][c][prev_t] === 0) {
                    // 所有走法都会输 -> 上一步老鼠认输,判定猫胜
                    color[prev_m][c][prev_t] = 2;
                    q.push([prev_m, c, prev_t, 2]);
                }
            }
        } else {
            // 上一步是【猫】在走,找所有能走到当前 c 的上一步位置 prev_c
            for (const prev_c of graph[c]) {
                if (prev_c === 0) continue; // 猫不能进洞
                if (color[m][prev_c][prev_t] !== 0) continue;

                if (w === 2) {
                    // 存在让猫必胜的走法 -> 上一步猫直接判定必胜
                    color[m][prev_c][prev_t] = 2;
                    q.push([m, prev_c, prev_t, 2]);
                } else if (--degree[m][prev_c][prev_t] === 0) {
                    // 猫的所有走法都会让老鼠赢 -> 上一步猫认输,判定老鼠胜
                    color[m][prev_c][prev_t] = 1;
                    q.push([m, prev_c, prev_t, 1]);
                }
            }
        }
    }

    // 队列清空后仍未确定起点的胜负,说明双方必踩环打转,返回平局 0
    return color[1][2][0];
}
using System.Collections.Generic;

public class Solution {
    public int CatMouseGame(int[][] graph) {
        int n = graph.Length;

        // 1. 三维状态数组:
        // color[m, c, t] 表示局势结果:
        // m: 老鼠位置 (0 ~ n-1)
        // c: 猫位置 (1 ~ n-1, 猫不能进 0 号洞)
        // t: 轮到谁 (0 为老鼠, 1 为猫)
        // 结果值:0 = 未知/平局, 1 = 老鼠胜, 2 = 猫胜
        int[,,] color = new int[n, n, 2];

        // 2. 出度计数器数组:
        // degree[m, c, t] 记录当前玩家还有多少种“未被证实为必败”的可尝试走法
        int[,,] degree = new int[n, n, 2];

        // 预处理每个状态下玩家的合法走法数量
        for (int m = 0; m < n; m++) {
            for (int c = 1; c < n; c++) {
                degree[m, c, 0] = graph[m].Length; // 老鼠可选走法数
                degree[m, c, 1] = graph[c].Length; // 猫可选走法数

                // 【特殊规则】猫不能移动到 0 号洞口
                foreach (int x in graph[c]) {
                    if (x == 0) {
                        degree[m, c, 1]--;
                        break;
                    }
                }
            }
        }

        // 3. BFS 队列,存储已确定输赢的状态节点
        // 元组结构:(m: 老鼠位置, c: 猫位置, t: 轮到谁, w: 获胜方)
        var q = new Queue<(int m, int c, int t, int w)>();

        // --- 放入基础已知结果(终局条件) ---

        // 条件 A:老鼠到达 0 号洞口 -> 老鼠必胜 ( w = 1 )
        for (int c = 1; c < n; c++) {
            for (int t = 0; t < 2; t++) {
                color[0, c, t] = 1;
                q.Enqueue((0, c, t, 1));
            }
        }

        // 条件 B:猫和老鼠在同一个节点 -> 猫必胜 ( w = 2 )
        for (int m = 1; m < n; m++) {
            for (int t = 0; t < 2; t++) {
                color[m, m, t] = 2;
                q.Enqueue((m, m, t, 2));
            }
        }

        // 4. 逆向 BFS 倒推胜负
        while (q.Count > 0) {
            var (m, c, t, w) = q.Dequeue();

            // 【提前退出】如果已经推算出了游戏起点 (老鼠在1, 猫在2, 轮到老鼠t=0)
            if (m == 1 && c == 2 && t == 0) {
                return w;
            }

            // 上一步行动的玩家 (0 和 1 互相切换)
            int prev_t = 1 - t;

            if (prev_t == 0) {
                // 【上一步是老鼠在走】:找所有能一步走到当前 m 的上一步老鼠位置 prev_m
                foreach (int prev_m in graph[m]) {
                    if (color[prev_m, c, prev_t] != 0) continue;

                    if (w == 1) {
                        // 存在让老鼠必胜的走法 -> 上一步老鼠直接判定必胜
                        color[prev_m, c, prev_t] = 1;
                        q.Enqueue((prev_m, c, prev_t, 1));
                    } else if (--degree[prev_m, c, prev_t] == 0) {
                        // 老鼠的所有走法都会输 -> 上一步老鼠认输,判定猫胜
                        color[prev_m, c, prev_t] = 2;
                        q.Enqueue((prev_m, c, prev_t, 2));
                    }
                }
            } else {
                // 【上一步是猫在走】:找所有能一步走到当前 c 的上一步猫位置 prev_c
                foreach (int prev_c in graph[c]) {
                    if (prev_c == 0) continue; // 猫不能进洞
                    if (color[m, prev_c, prev_t] != 0) continue;

                    if (w == 2) {
                        // 存在让猫必胜的走法 -> 上一步猫直接判定必胜
                        color[m, prev_c, prev_t] = 2;
                        q.Enqueue((m, prev_c, prev_t, 2));
                    } else if (--degree[m, prev_c, prev_t] == 0) {
                        // 猫的所有走法都会让老鼠赢 -> 上一步猫认输,判定老鼠胜
                        color[m, prev_c, prev_t] = 1;
                        q.Enqueue((m, prev_c, prev_t, 1));
                    }
                }
            }
        }

        // 队列清空后起点仍未确定胜负,说明双方必踩环打转,返回平局 0
        return color[1, 2, 0];
    }
}
class Solution {
public:
    int catMouseGame(vector<vector<int>>& graph) {
        int n = graph.size();

        // 1. 三维状态数组含义:
        // color[m][c][t] 表示在当前局势下的游戏结果:
        // m: 老鼠的位置 (0 ~ n-1)
        // c: 猫的位置 (1 ~ n-1, 猫不能进 0 号洞)
        // t: 轮到谁走 (0 表示轮到老鼠走,1 表示轮到猫走)
        // 结果值:0 = 未知/平局 (Draw), 1 = 老鼠获胜 (Mouse Win), 2 = 猫获胜 (Cat Win)
        int color[50][50][2] = {0};

        // 2. 出度/走法计数器数组:
        // degree[m][c][t] 记录在状态 (m, c, t) 下,当前行动的玩家还有多少种“可尝试”的走法。
        int degree[50][50][2] = {0};

        // 预处理计算每个状态下玩家的合法走法数量
        for (int m = 0; m < n; ++m) {
            for (int c = 1; c < n; ++c) {
                // 老鼠在位置 m 时,可选走法数量 = 图中 m 的邻居节点数量
                degree[m][c][0] = graph[m].size();
                // 猫在位置 c 时,可选走法数量 = 图中 c 的邻居节点数量
                degree[m][c][1] = graph[c].size();
                
                // 【特殊规则】猫不能移动到 0 号洞口,所以如果猫的邻居包含 0,走法数要减 1
                for (int x : graph[c]) {
                    if (x == 0) {
                        degree[m][c][1]--;
                        break;
                    }
                }
            }
        }

        // 3. BFS 队列,里面存的是【已经确定输赢的已知终局/节点】
        // 结构为:{老鼠位置 m, 猫位置 c, 轮到谁走 t, 谁赢了 winner}
        queue<tuple<int, int, int, int>> q;

        // --- 放入基础已知结果(终局条件) ---

        // 条件 A:老鼠到达 0 号洞口 -> 老鼠必胜 ( winner = 1 )
        // 不管当前轮到谁走 (t = 0 或 1),只要老鼠已经在洞里,老鼠就赢了
        for (int c = 1; c < n; ++c) {
            for (int t = 0; t < 2; ++t) {
                color[0][c][t] = 1; 
                q.push({0, c, t, 1}); // 放入队列作为倒推的起点
            }
        }

        // 条件 B:猫和老鼠在同一个节点 -> 猫必胜 ( winner = 2 )
        // 不管当前轮到谁走,只要猫抓住了老鼠,猫就赢了
        for (int m = 1; m < n; ++m) {
            for (int t = 0; t < 2; ++t) {
                color[m][m][t] = 2; 
                q.push({m, m, t, 2}); // 放入队列作为倒推的起点
            }
        }

        // 4. 开始逆向 BFS (从已知的胜负终局往回推算上一步)
        while (!q.empty()) {
            auto [m, c, t, w] = q.front();
            q.pop();

            // 【提前退出/剪枝】如果已经倒推算出了游戏的“初始起点”状态 (老鼠在1,猫在2,轮到老鼠走 t=0)
            // 那么这个 w 就是全盘的最佳结果,可以直接返回!
            if (m == 1 && c == 2 && t == 0) {
                return w;
            }

            // 计算“上一步”是谁在走:如果当前 t=0 (轮到老鼠),那上一步必然是 prev_t=1 (猫刚走完)
            int prev_t = 1 - t;

            if (prev_t == 0) {
                // 【上一步是老鼠在走】:我们需要找到所有能一步走到当前状态 (m, c) 的上一步老鼠位置 prev_m
                for (int prev_m : graph[m]) {
                    // 如果上一步的状态已经被确定过胜负了,跳过不重复处理
                    if (color[prev_m][c][prev_t] != 0) continue;

                    // 情况 1:当前状态是老鼠赢 (w == 1)
                    // 老鼠是聪明的:上一步的老鼠只要发现“存在至少一种走法”能让自己赢,
                    // 他就一定会选择这种走法!因此上一步状态立刻判定为【老鼠必胜 (1)】。
                    if (w == 1) {
                        color[prev_m][c][prev_t] = 1;
                        q.push({prev_m, c, prev_t, 1});
                    } 
                    // 情况 2:当前状态是猫赢 (w == 2)
                    // 这意味着上一步老鼠选这条路会输。老鼠会尝试避开,所以把可用走法数 minus 1。
                    // 只有当 degree 减少到 0(老鼠尝试了所有可能走法,结果全是猫赢)时,
                    // 才能判定上一步老鼠是【走投无路,只能认输 (猫胜 2)】!
                    else if (--degree[prev_m][c][prev_t] == 0) {
                        color[prev_m][c][prev_t] = 2;
                        q.push({prev_m, c, prev_t, 2});
                    }
                }
            } else {
                // 【上一步是猫在走】:找到所有能一步走到当前状态 (m, c) 的上一步猫位置 prev_c
                for (int prev_c : graph[c]) {
                    if (prev_c == 0) continue; // 猫不可能从 0 号洞出来
                    if (color[m][prev_c][prev_t] != 0) continue;

                    // 情况 1:当前状态是猫赢 (w == 2)
                    // 猫也是聪明的:只要上一步猫存在至少一种走法能抓到老鼠/赢,
                    // 猫就一定会选!因此上一步立刻判定为【猫必胜 (2)】。
                    if (w == 2) {
                        color[m][prev_c][prev_t] = 2;
                        q.push({m, prev_c, prev_t, 2});
                    } 
                    // 情况 2:当前状态是老鼠赢 (w == 1)
                    // 猫会避开这条路。只有当猫的所有走法都被堵死 (degree == 0),
                    // 猫才被迫认输,判定上一步为【老鼠必胜 (1)】。
                    else if (--degree[m][prev_c][prev_t] == 0) {
                        color[m][prev_c][prev_t] = 1;
                        q.push({m, prev_c, prev_t, 1});
                    }
                }
            }
        }

        // 如果 BFS 队列跑完了,起点 (1, 2, 0) 依然没有被标记为老鼠胜或猫胜,
        // 说明双方都找不到必胜策略,只能陷入无限死循环,故返回 0 (平局)
        return color[1][2][0];
    }
};

复杂度分析

  • 时间复杂度\(O(N^3)\)
    • 状态总数:游戏中一共有 \(2 \times N \times N\) 种可能的状态(\(m\)\(N\) 种可能,\(c\)\(N\) 种可能,\(t\) 轮到谁有 2 种可能),即 \(O(N^2)\) 个状态。
    • 状态转移开销:每个状态在出队进行逆向推导时,需要遍历邻居节点找“上一步”的可能性。图中的节点最大度数为 \(N\),即每次转移最多循环 \(N\) 次。
    • 总开销:\(O(N^2) \text{ 个状态} \times O(N) \text{ 的前驱节点遍历} = O(N^3)\)。(更精确地说为 \(O(N \cdot E)\),其中 \(E\) 为图中的边数)。
  • 空间复杂度\(O(N^2)\)
    • 状态数组 color[N, N, 2]:大小为 \(2N^2\),占用 \(O(N^2)\) 空间。
    • 出度数组 degree[N, N, 2]:大小为 \(2N^2\),占用 \(O(N^2)\) 空间。
    • BFS 队列 q:每个状态最多只会入队一次,队列最大长度不超过状态总数 \(2N^2\),占用 \(O(N^2)\) 空间。

引用

[1] 力扣探索模式


注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

posted @ 2026-09-04 16:02  SEHOD  阅读(6)  评论(0)    收藏  举报