题目描述
两位玩家分别扮演猫和老鼠,在一张 无向 图上进行游戏,两人轮流行动。
图的形式是:graph[a] 是一个列表,由满足 ab 是图中的一条边的所有节点 b 组成。
老鼠从节点 1 开始,第一个出发;猫从节点 2 开始,第二个出发。在节点 0 处有一个洞。
在每个玩家的行动中,他们 必须 沿着图中与所在当前位置连通的一条边移动。例如,如果老鼠在节点 1 ,那么它必须移动到 graph[1] 中的任一节点。
此外,猫无法移动到洞中(节点 0)。
然后,游戏在出现以下三种情形之一时结束:
- 如果猫和老鼠出现在同一个节点,猫获胜。
- 如果老鼠到达洞中,老鼠获胜。
- 如果某一位置重复出现(即,玩家的位置和移动顺序都与上一次行动相同),游戏平局。
给你一张图 graph ,并假设两位玩家都都以最佳状态参与游戏:
- 如果老鼠获胜,则返回 1;
- 如果猫获胜,则返回 2;
- 如果平局,则返回 0
示例 1:
输入:graph = [[2,5],[3],[0,4,5],[1,4,5],[2,3],[0,2,3]]
输出:0
示例 2:
输入:graph = [[1,3],[0],[3],[0,2]]
输出:1
提示:
3 <= graph.length <= 50
1 <= graph[i].length < graph.length
0 <= graph[i][j] < graph.length
graph[i][j] != i
graph[i] 互不相同
猫和老鼠在游戏中总是移动
解题思路
博弈知识介绍
这道题是博弈问题,猫和老鼠都按照最优策略参与游戏。
在阐述具体解法之前,首先介绍博弈问题中的三个概念:必胜状态、必败状态与必和状态。
- 对于特定状态,如果游戏已经结束,则根据结束时的状态决定必胜状态、必败状态与必和状态。
如果分出胜负,则该特定状态对于获胜方为必胜状态,对于落败方为必败状态。
如果是平局,则该特定状态对于双方都为必和状态。
-
从特定状态开始,如果存在一种操作将状态变成必败状态,则当前玩家可以选择该操作,将必败状态留给对方玩家,因此该特定状态对于当前玩家为必胜状态。
-
从特定状态开始,如果所有操作都会将状态变成必胜状态,则无论当前玩家选择哪种操作,都会将必胜状态留给对方玩家,因此该特定状态对于当前玩家为必败状态。
-
从特定状态开始,如果任何操作都不能将状态变成必败状态,但是存在一种操作将状态变成必和状态,则当前玩家可以选择该操作,将必和状态留给对方玩家,因此该特定状态对于双方玩家都为必和状态。
对于每个玩家,最优策略如下:
-
争取将必胜状态留给自己,将必败状态留给对方玩家。
-
在自己无法到达必胜状态的情况下,争取将必和状态留给自己。
自顶向下动态规划解法求解
博弈问题通常可以使用动态规划求解。这道题由于数据规模的原因,动态规划方法不适用(时间复杂度为 O ( n 5 ) O(n^5) O(n5))。不过官方还是给出了动态规划求解的代码,想了解一下动态规划思路的可以戳这里。
拓扑排序
自顶向下的动态规划由于判定平局的标准和轮数有关,因此时间复杂度较高。为了降低时间复杂度,需要使用自底向上的方法实现,消除结果和轮数之间的关系。
使用自底向上的方法实现时,游戏中的状态由老鼠的位置、猫的位置和轮到移动的一方三个因素确定。初始时,只有边界情况的胜负结果已知,其余所有状态的结果都初始化为平局。边界情况为直接确定胜负的情况,包括两类情况:老鼠躲入洞里,无论猫位于哪个结点,都是老鼠获胜;猫和老鼠占据相同的节点,无论占据哪个结点,都是猫获胜。
从边界情况出发遍历其他情况。对于当前状态,可以得到老鼠的位置、猫的位置和轮到移动的一方,根据当前状态可知上一轮的所有可能状态,其中上一轮的移动方和当前的移动方相反,上一轮的移动方在上一轮状态和当前状态所在的节点不同。假设当前状态是老鼠所在节点是 m o u s e mouse mouse,猫所在节点是 c a t cat cat,则根据当前的移动方,可以得到上一轮的所有可能状态:
- 如果当前的移动方是老鼠,则上一轮的移动方是猫,上一轮状态中老鼠所在节点是 m o u s e mouse mouse,猫所在节点可能是 g r a p h ( c a t ) graph(cat) graph(cat) 中的任意一个节点(除了节点 0);
- 如果当前的移动方是猫,则上一轮的移动方是老鼠,上一轮状态中老鼠所在节点可能是 g r a p h ( m o u s e ) graph(mouse) graph(mouse)中的任意一个节点,猫所在节点是 c a t cat cat。
对于上一轮的每一种可能的状态,如果该状态的结果已知不是平局,则不需要重复计算该状态的结果,只有对结果是平局的状态,才需要计算该状态的结果。对于上一轮的移动方,只有当可以确定上一轮状态是必胜状态或者必败状态时,才更新上一轮状态的结果。
- 如果上一轮的移动方和当前状态的结果的获胜方相同,由于当前状态为上一轮的移动方的必胜状态,因此上一轮的移动方一定可以移动到当前状态而获胜,上一轮状态为上一轮的移动方的必胜状态。
-
如果上一轮的移动方和当前状态的结果的获胜方不同,则上一轮的移动方需要尝试其他可能的移动,可能有以下三种情况:
-
如果存在一种移动可以到达上一轮的移动方的必胜状态,则上一轮状态为上一轮的移动方的必胜状态;
-
如果所有的移动都到达上一轮的移动方的必败状态,则上一轮状态为上一轮的移动方的必败状态;
-
如果所有的移动都不能到达上一轮的移动方的必胜状态,但是存在一种移动可以到达上一轮的移动方的必和状态,则上一轮状态为上一轮的移动方的必和状态。
-
其中,对于必败状态与必和状态的判断依据为上一轮的移动方可能的移动是都到达必败状态还是可以到达必和状态。为了实现必败状态与必和状态的判断,需要记录每个状态的度,初始时每个状态的度为当前玩家在当前位置可以移动到的节点数。对于老鼠而言,初始的度为老鼠所在的节点的相邻节点数;对于猫而言,初始的度为猫所在的节点的相邻且非节点 0 的节点数。
遍历过程中,从当前状态出发遍历上一轮的所有可能状态,如果上一轮状态的结果是平局且上一轮的移动方和当前状态的结果的获胜方不同,则将上一轮状态的度减 1。如果上一轮状态的度减少到 0,则从上一轮状态出发到达的所有状态都是上一轮的移动方的必败状态,因此上一轮状态也是上一轮的移动方的必败状态。
在确定上一轮状态的结果(必胜或必败)之后,即可从上一轮状态出发,遍历其他结果是平局的状态。当没有更多的状态可以确定胜负结果时,遍历结束,此时即可得到初始状态的结果。
细心的读者可以发现,上述遍历的过程其实是拓扑排序。
证明
必胜状态和必败状态都符合博弈中的最优策略,需要证明的是必和状态的正确性。
遍历结束之后,如果一个状态的结果是平局,则该状态满足以下两个条件:
-
从该状态出发,任何移动都无法到达该状态的移动方的必胜状态;
-
从该状态出发,存在一种移动可以到达必和状态。
对于标记结果是平局的状态,如果其实际结果是该状态的移动方必胜,则一定存在一个下一轮状态,为当前状态的移动方的必胜状态,在根据下一轮状态的结果标记当前状态的结果时会将当前状态标记为当前状态的移动方的必胜状态,和标记结果是平局矛盾。
对于标记结果是平局的状态,如果其实际结果是该状态的移动方必败,则所有的下一轮状态都为当前状态的移动方的必败状态,在根据下一轮状态的结果标记当前状态的结果时会将当前状态标记为当前状态的移动方的必败状态,和标记结果是平局矛盾。
因此,如果标记的状态是必和状态,则实际结果一定是必和状态。
代码:
class Solution {
static final int MOUSE_TURN = 0, CAT_TURN = 1;
static final int DRAW = 0, MOUSE_WIN = 1, CAT_WIN = 2;
int[][] graph;
int[][][] degrees;
int[][][] results;
public int catMouseGame(int[][] graph) {
int n = graph.length;
this.graph = graph;
this.degrees = new int[n][n][2];//存储每种状态的度
this.results = new int[n][n][2];//存储每种状态的结果(必胜、必败、平局)
Queue<int[]> queue = new ArrayDeque<>();
//初始化degree
for (int i = 0; i < n; i++) {
for (int j = 1; j < n; j++) {
degrees[i][j][MOUSE_TURN] = graph[i].length;
degrees[i][j][CAT_TURN] = graph[j].length;
}
}
//猫不能进入0节点,对应的度减1
for (int node : graph[0]) {
for (int i = 0; i < n; i++) {
degrees[i][node][CAT_TURN]--;
}
}
//老鼠在0节点的状态为老鼠的必胜状态
for (int j = 1; j < n; j++) {
results[0][j][MOUSE_TURN] = MOUSE_WIN;
results[0][j][CAT_TURN] = MOUSE_WIN;
queue.offer(new int[]{0, j, MOUSE_TURN});
queue.offer(new int[]{0, j, CAT_TURN});
}
//猫和老鼠在同一节点的状态为猫的必胜状态
for (int i = 1; i < n; i++) {
results[i][i][MOUSE_TURN] = CAT_WIN;
results[i][i][CAT_TURN] = CAT_WIN;
queue.offer(new int[]{i, i, MOUSE_TURN});
queue.offer(new int[]{i, i, CAT_TURN});
}
//拓扑排序
while (!queue.isEmpty()) {
int[] state = queue.poll();
int mouse = state[0], cat = state[1], turn = state[2];
int result = results[mouse][cat][turn];
List<int[]> prevStates = getPrevStates(mouse, cat, turn);
//遍历当前状态的每一种前继状态
for (int[] prevState : prevStates) {
int prevMouse = prevState[0], prevCat = prevState[1], prevTurn = prevState[2];
if (results[prevMouse][prevCat][prevTurn] == DRAW) {
//上一轮的移动方和当前轮的必胜方一致,则上一轮改为必胜状态
boolean canWin = (result == MOUSE_WIN && prevTurn == MOUSE_TURN) || (result == CAT_WIN && prevTurn == CAT_TURN);
if (canWin) {
results[prevMouse][prevCat][prevTurn] = result;
queue.offer(new int[]{prevMouse, prevCat, prevTurn});
} else { //上一轮的移动方和当前轮的必胜方不一致,则上一轮的移动方必不可能选择移动至当前状态(这样会导致它必败)
degrees[prevMouse][prevCat][prevTurn]--;
if (degrees[prevMouse][prevCat][prevTurn] == 0) {
int loseResult = prevTurn == MOUSE_TURN ? CAT_WIN : MOUSE_WIN;
results[prevMouse][prevCat][prevTurn] = loseResult;
queue.offer(new int[]{prevMouse, prevCat, prevTurn});
}
}
}
}
}
return results[1][2][MOUSE_TURN];
}
//得到所有前继状态
public List<int[]> getPrevStates(int mouse, int cat, int turn) {
List<int[]> prevStates = new ArrayList<>();
int prevTurn = turn == MOUSE_TURN ? CAT_TURN : MOUSE_TURN;
if (prevTurn == MOUSE_TURN) {
for (int prev : graph[mouse]) {
prevStates.add(new int[]{prev, cat, prevTurn});
}
} else {
for (int prev : graph[cat]) {
if (prev != 0) {
prevStates.add(new int[]{mouse, prev, prevTurn});
}
}
}
return prevStates;
}
}