1. 问题背景与题目解析
LeetCode 913题"猫和老鼠"是一道经典的博弈论问题,属于图论与动态规划的结合题型。题目给定一个无向图表示老鼠和猫的活动空间,老鼠从节点1出发,猫从节点2出发,洞口在节点0。双方轮流移动,老鼠的目标是到达洞口,猫的目标是抓住老鼠(到达同一节点),如果超过100回合未分胜负则判定平局。
这道题之所以被广泛讨论,是因为它涉及:
- 博弈论中的必胜态、必败态分析
- 状态转移图的构建
- 记忆化搜索与动态规划的结合应用
- 复杂状态空间的剪枝优化
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心算法思路
2.1 状态定义与转移
我们需要定义三维状态dp[t][x][y],其中:
- t表示当前回合数(0-100)
- x表示老鼠的位置
- y表示猫的位置
每个状态可能有三种结果:
- 1:老鼠必胜
- 2:猫必胜
- 0:平局
状态转移规则:
- 如果老鼠到达洞口(x=0),返回1
- 如果猫抓到老鼠(x=y),返回2
- 如果t=100,返回0
- 否则根据当前移动方(t为偶数老鼠移动,奇数猫移动)进行下一步转移
2.2 记忆化搜索实现
采用DFS+记忆化的方式实现:
java复制int[][][] memo = new int[2*N][N][N]; // N为节点数
public int catMouseGame(int[][] graph) {
int N = graph.length;
return dfs(graph, 0, 1, 2);
}
private int dfs(int[][] graph, int t, int x, int y) {
if (t == 2 * graph.length) return 0;
if (x == y) return 2;
if (x == 0) return 1;
if (memo[t][x][y] != 0) return memo[t][x][y];
boolean isMouseTurn = (t % 2 == 0);
int res = isMouseTurn ? 2 : 1; // 默认当前玩家处于不利位置
if (isMouseTurn) {
for (int next : graph[x]) {
int tmp = dfs(graph, t+1, next, y);
if (tmp == 1) { // 老鼠找到必胜策略
res = 1;
break;
}
if (tmp == 0) { // 至少可以平局
res = 0;
}
}
} else {
for (int next : graph[y]) {
if (next == 0) continue; // 猫不能进洞
int tmp = dfs(graph, t+1, x, next);
if (tmp == 2) { // 猫找到必胜策略
res = 2;
break;
}
if (tmp == 0) { // 至少可以平局
res = 0;
}
}
}
memo[t][x][y] = res;
return res;
}
3. 关键优化技巧
3.1 回合数限制优化
题目规定超过100回合判平局,但实际可以优化为2N回合(N为节点数),因为:
- 最坏情况下,老鼠和猫可能遍历所有节点组合
- 超过2N回合必然出现重复状态
3.2 移动策略优化
对于当前玩家:
- 如果发现任何移动能直接获胜,立即返回
- 否则优先选择能迫使对手处于不利位置的移动
- 最后才考虑平局选项
3.3 剪枝策略
- 猫不能移动到洞口(节点0)
- 当老鼠距离洞口很近时,可以提前判断
- 对称状态的合并处理
4. 复杂度分析
时间复杂度:O(N^3)
- 状态总数:2N * N * N
- 每个状态处理时间:O(N)(邻接节点遍历)
空间复杂度:O(N^3)
- 三维记忆化数组存储
5. 实际编码注意事项
-
边界条件处理:
- 初始状态检查
- 节点编号范围验证
- 空图处理
-
记忆化数组初始化:
- Java中int数组默认值为0,与平局状态冲突
- 建议使用Integer数组或额外标记
-
移动顺序影响:
- 遍历邻接节点时,按特定顺序可能更快找到解
- 可以按节点编号排序或根据距离启发式排序
6. 测试用例设计
有效测试用例应包括:
- 老鼠直接可以到达洞口的情况
- 猫初始位置就能抓住老鼠的情况
- 复杂迷宫式图结构
- 完全图(所有节点互相连接)
- 链式图(线性结构)
- 环形图结构
示例测试:
java复制@Test
public void testCases() {
// 用例1:老鼠直接获胜
int[][] graph1 = {{1,2},{0,2},{0,1}};
assertEquals(1, catMouseGame(graph1));
// 用例2:猫直接获胜
int[][] graph2 = {{1,3},{0,2},{1,3},{0,2}};
assertEquals(2, catMouseGame(graph2));
// 用例3:平局
int[][] graph3 = {{1,2,3},{0},{0},{0}};
assertEquals(0, catMouseGame(graph3));
}
7. 常见错误与调试
-
栈溢出:
- 忘记设置回合数限制
- 缺少基础条件判断
-
错误结果:
- 猫移动时未排除洞口节点
- 状态转移逻辑错误(胜负判断颠倒)
-
性能问题:
- 未使用记忆化导致重复计算
- 状态表示不完整(缺少回合信息)
调试建议:
- 打印关键状态转移路径
- 对小规模图手动验证状态转移
- 使用可视化工具绘制状态空间
8. 算法扩展思考
- 多猫情况下的扩展
- 带有障碍物的图结构
- 移动速度不同的情况
- 概率性移动模型
- 不完全信息博弈版本
这类博弈问题在实际中有广泛应用:
- 网络安全中的攻防对抗模拟
- 机器人路径规划与避障
- 游戏AI中的对手行为预测
9. 其他实现方式对比
9.1 广度优先搜索(BFS)
可以从终止状态逆向推导:
- 标记所有直接获胜状态
- 逆向传播必胜/必败信息
- 直到初始状态被确定
优点:
- 避免递归栈深度问题
- 更适合某些图结构
缺点:
- 实现较复杂
- 需要维护额外队列
9.2 动态规划(DP)
按回合顺序递推计算:
java复制int[][][] dp = new int[2*N][N][N];
// 初始化终止状态
for (int t = 0; t < 2*N; t++) {
for (int x = 0; x < N; x++) {
for (int y = 0; y < N; y++) {
if (x == 0) dp[t][x][y] = 1;
else if (x == y) dp[t][x][y] = 2;
else if (t >= 2*N) dp[t][x][y] = 0;
}
}
}
// 逆向递推
for (int t = 2*N-1; t >= 0; t--) {
for (int x = 0; x < N; x++) {
for (int y = 0; y < N; y++) {
if (x == 0 || x == y || t >= 2*N) continue;
boolean isMouseTurn = (t % 2 == 0);
int res = isMouseTurn ? 2 : 1;
if (isMouseTurn) {
for (int next : graph[x]) {
int tmp = dp[t+1][next][y];
if (tmp == 1) {
res = 1;
break;
}
if (tmp == 0) {
res = 0;
}
}
} else {
for (int next : graph[y]) {
if (next == 0) continue;
int tmp = dp[t+1][x][next];
if (tmp == 2) {
res = 2;
break;
}
if (tmp == 0) {
res = 0;
}
}
}
dp[t][x][y] = res;
}
}
}
10. 性能优化实战
对于大规模图(N>50),可以考虑以下优化:
-
状态压缩:
- 使用位运算表示状态
- 减少内存占用
-
并行计算:
- 不同回合的状态计算可以并行
- 使用多线程加速
-
启发式搜索:
- 优先探索更可能获胜的路径
- 基于距离启发式评估
-
对称性利用:
- 识别对称状态避免重复计算
- 旋转/镜像对称优化
实际编码中,我发现对于N≤30的图,记忆化DFS已经足够高效。当N>50时,建议使用BFS+剪枝的组合策略。
