【学习笔记】博弈
这个其实更多其实是需要的运筹学知识吧。🤔
❔题目
1. Nim 游戏
解题思路
原始思路:
首先:只有最后一轮留下的是1~3,并且是自己的轮。
然后拿几个作为例子会发现,4的时候不可以,6、7的时候可以,8不可以,9又可以了。
所以猜想只要不是4的倍数就能赢。
严谨推断:
- 基础状态(基底)
- \(n \in \{1, 2, 3\}\):先手一次性拿光,先手必胜。
- \(n = 4\):先手无论拿 1、2 还是 3 块,剩下的石子数必然是 3、2 或 1 块。后手面对这个数量可以直接一次拿光。因此 \(n = 4\) 是先手必败态。
- 归纳与控制权锁定
- 当 \(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 的倍数”这个必败局甩给了对手,先手转化为控场者,先手必胜。
- 当 \(n\) 是 4 的倍数(\(n = 4k\))时:
实现
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. 猫和老鼠
解题思路
与其从起点向后正向搜索,不如从已知的终局(必胜/必败态)开始,往回倒推前一步的状态:
-
状态定义:
color[m][c][t]表示老鼠在m、猫在c、轮到t移动(0代表老鼠,1代表猫)时的结果(0未定/平局,1老鼠胜,2猫胜)。 -
确定基底状态(入队初始节点):
- 老鼠到达
0号洞(m = 0):老鼠必胜(1)。 - 猫抓到老鼠(
m = c):猫必胜(2)。
- 老鼠到达
-
逆向推导规则(状态转移):
- 能一步绝杀:如果上一步的玩家(例如老鼠)存在任意一种走法能走到对自己“必胜”的下一个状态,则上一步的状态直接确定为必胜,入队。
- 走投无路:如果上一步的玩家选择的所有走法,全都会走到对自己“必败”的状态(通过维护出度
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 辅助整理。

浙公网安备 33010602011771号