Files
codetutor/backend/data/questions/cat-and-mouse.yaml
2025-05-25 10:16:13 +01:00

297 lines
14 KiB
YAML

title: Cat and Mouse
slug: cat-and-mouse
difficulty: hard
leetcode_id: 913
leetcode_url: https://leetcode.com/problems/cat-and-mouse/
categories:
- graphs
- dynamic-programming
patterns:
- dfs
- dynamic-programming
description: |
A game on an **undirected** graph is played by two players, Mouse and Cat, who alternate turns.
The graph is given as follows: `graph[a]` is a list of all nodes `b` such that `ab` is an edge of the graph.
The mouse starts at node `1` and goes first, the cat starts at node `2` and goes second, and there is a hole at node `0`.
During each player's turn, they **must** travel along one edge of the graph that meets where they are. For example, if the Mouse is at node `1`, it **must** travel to any node in `graph[1]`.
Additionally, it is not allowed for the Cat to travel to the Hole (node `0`).
Then, the game can end in three ways:
- If ever the Cat occupies the same node as the Mouse, the Cat wins.
- If ever the Mouse reaches the Hole, the Mouse wins.
- If ever a position is repeated (i.e., the players are in the same position as a previous turn, and it is the same player's turn to move), the game is a draw.
Given a `graph`, and assuming both players play optimally, return:
- `1` if the mouse wins the game,
- `2` if the cat wins the game, or
- `0` if the game is a draw.
constraints: |
- `3 <= graph.length <= 50`
- `1 <= graph[i].length < graph.length`
- `0 <= graph[i][j] < graph.length`
- `graph[i][j] != i`
- `graph[i]` is unique
- The mouse and the cat can always move
examples:
- input: "graph = [[2,5],[3],[0,4,5],[1,4,5],[2,3],[0,2,3]]"
output: "0"
explanation: "With optimal play from both sides, the game ends in a draw. Neither the mouse can guarantee reaching the hole, nor can the cat guarantee catching the mouse."
- input: "graph = [[1,3],[0],[3],[0,2]]"
output: "1"
explanation: "The mouse can reach the hole (node 0) before the cat can catch it, so the mouse wins."
explanation:
intuition: |
Imagine a chess match where both players can see every possible future move. This is a **two-player zero-sum game with perfect information** — what's good for the mouse is bad for the cat, and vice versa.
The key insight is that the game state can be fully described by three pieces of information: **where the mouse is**, **where the cat is**, and **whose turn it is**. From any state, we can determine the outcome if both players play optimally.
Think of it like this: the mouse is trying to reach the hole (node `0`) while avoiding the cat. The cat is trying to catch the mouse while being forbidden from entering the hole. Each player, on their turn, will choose the move that gives them the best possible outcome.
This is a classic application of **game theory** and **minimax reasoning**:
- On the mouse's turn, it picks the move that maximises its chances of winning (or at least drawing)
- On the cat's turn, it picks the move that maximises its chances of winning
We use **dynamic programming with memoisation** to avoid recalculating the same game states. The tricky part is handling draws — if we revisit a state, the game cycles forever.
approach: |
We solve this using **Memoised DFS with Game State Tracking**:
**Step 1: Define the game state**
- `mouse`: Current position of the mouse (node index)
- `cat`: Current position of the cat (node index)
- `turn`: Whose turn it is (`0` for mouse, `1` for cat)
&nbsp;
**Step 2: Identify base cases**
- If `mouse == 0`: Mouse wins (reached the hole) — return `1`
- If `mouse == cat`: Cat wins (caught the mouse) — return `2`
- If we've seen this state before or exceeded `2n` moves: Draw — return `0`
&nbsp;
**Step 3: Apply minimax logic**
- **Mouse's turn**: The mouse tries each adjacent node and picks the best outcome
- If any move leads to mouse winning, return `1`
- If no winning move but a draw exists, return `0`
- Otherwise, return `2` (cat wins)
- **Cat's turn**: The cat tries each adjacent node (except node `0`) and picks the best outcome
- If any move leads to cat winning, return `2`
- If no winning move but a draw exists, return `0`
- Otherwise, return `1` (mouse wins)
&nbsp;
**Step 4: Memoise results**
- Cache the result for each `(mouse, cat, turn)` state
- This prevents exponential recalculation
&nbsp;
The key to handling draws is limiting recursion depth to `2n` turns. If the game hasn't ended by then, it must be cycling through states, which means a draw.
common_pitfalls:
- title: Forgetting the Cat Cannot Enter the Hole
description: |
The cat is forbidden from moving to node `0` (the hole). When iterating through the cat's possible moves, you must skip node `0`.
Forgetting this constraint leads to incorrect results where the cat might "block" the hole.
wrong_approach: "Allowing cat to move to any adjacent node"
correct_approach: "Filter out node 0 from cat's valid moves"
- title: Infinite Recursion Without Cycle Detection
description: |
Without proper handling, the recursion can loop forever through repeating states. For example, mouse and cat might chase each other in circles.
The solution is to limit the recursion depth to `2n` moves (where `n` is the number of nodes). After this many moves without a winner, the game must be in a cycle, meaning it's a draw.
wrong_approach: "Relying only on visited set without depth limit"
correct_approach: "Limit recursion to 2n turns and return draw if exceeded"
- title: Incorrect Minimax Logic
description: |
A common mistake is not correctly implementing the minimax principle:
- The mouse should return "mouse wins" if **any** move leads to a mouse win
- The cat should return "cat wins" if **any** move leads to a cat win
Getting this backwards or not properly tracking "can draw" vs "must lose" leads to wrong answers.
wrong_approach: "Returning immediately on first move result"
correct_approach: "Evaluate all moves to find best possible outcome"
key_takeaways:
- "**Game theory problems**: Use minimax — each player picks their best move assuming the opponent plays optimally"
- "**State representation**: The state is `(mouse_pos, cat_pos, turn)` — memoising this avoids exponential blowup"
- "**Cycle detection**: Limit recursion depth to detect draws; `2n` moves is sufficient since each state involves at most `n^2` configurations"
- "**Graph games**: Many game problems on graphs use similar DFS + memoisation patterns"
time_complexity: "O(n^3). There are O(n^2) states (mouse position x cat position x turn), and each state explores O(n) neighbours."
space_complexity: "O(n^2). The memoisation cache stores results for all `(mouse, cat, turn)` combinations."
solutions:
- approach_name: Memoised DFS (Minimax)
is_optimal: true
code: |
def cat_mouse_game(graph: list[list[int]]) -> int:
n = len(graph)
# Constants for game outcomes
DRAW, MOUSE_WIN, CAT_WIN = 0, 1, 2
# Memoisation cache: (mouse, cat, turn) -> result
memo = {}
def dfs(mouse: int, cat: int, turn: int, moves: int) -> int:
# Base case: too many moves means a draw (cycle detected)
if moves >= 2 * n:
return DRAW
# Mouse reached the hole - mouse wins
if mouse == 0:
return MOUSE_WIN
# Cat caught the mouse - cat wins
if mouse == cat:
return CAT_WIN
# Check memo cache
state = (mouse, cat, turn)
if state in memo:
return memo[state]
if turn == 0: # Mouse's turn
# Mouse tries to win, or at least draw
can_draw = False
for next_mouse in graph[mouse]:
result = dfs(next_mouse, cat, 1, moves + 1)
if result == MOUSE_WIN:
memo[state] = MOUSE_WIN
return MOUSE_WIN
if result == DRAW:
can_draw = True
# Mouse couldn't win; return draw if possible, else cat wins
memo[state] = DRAW if can_draw else CAT_WIN
else: # Cat's turn
# Cat tries to win, or at least draw
can_draw = False
for next_cat in graph[cat]:
# Cat cannot move to the hole
if next_cat == 0:
continue
result = dfs(mouse, next_cat, 0, moves + 1)
if result == CAT_WIN:
memo[state] = CAT_WIN
return CAT_WIN
if result == DRAW:
can_draw = True
# Cat couldn't win; return draw if possible, else mouse wins
memo[state] = DRAW if can_draw else MOUSE_WIN
return memo[state]
# Start: mouse at 1, cat at 2, mouse's turn (0), 0 moves made
return dfs(1, 2, 0, 0)
explanation: |
**Time Complexity:** O(n^3) — There are O(n^2) unique states, each exploring O(n) edges.
**Space Complexity:** O(n^2) — Memoisation cache for all states plus O(n) recursion depth.
This solution uses depth-first search with memoisation to explore the game tree. The minimax logic ensures each player picks their optimal move. The `moves` counter prevents infinite loops by detecting cycles after `2n` moves.
- approach_name: BFS with Coloring (Bottom-Up)
is_optimal: true
code: |
from collections import deque
def cat_mouse_game(graph: list[list[int]]) -> int:
n = len(graph)
DRAW, MOUSE_WIN, CAT_WIN = 0, 1, 2
# color[mouse][cat][turn] = outcome
color = [[[DRAW] * 2 for _ in range(n)] for _ in range(n)]
# degree[mouse][cat][turn] = number of unprocessed children
degree = [[[0] * 2 for _ in range(n)] for _ in range(n)]
# Initialise degrees (how many moves each player can make)
for mouse in range(n):
for cat in range(n):
degree[mouse][cat][0] = len(graph[mouse]) # Mouse moves
degree[mouse][cat][1] = len(graph[cat]) # Cat moves
# Cat can't go to hole, so reduce cat's degree
if 0 in graph[cat]:
degree[mouse][cat][1] -= 1
# Queue of known states: (mouse, cat, turn, winner)
queue = deque()
# Base cases: cat wins when cat == mouse (except at hole)
for cat in range(1, n):
for turn in range(2):
color[cat][cat][turn] = CAT_WIN
queue.append((cat, cat, turn, CAT_WIN))
# Base cases: mouse wins when mouse reaches hole
for cat in range(1, n):
for turn in range(2):
color[0][cat][turn] = MOUSE_WIN
queue.append((0, cat, turn, MOUSE_WIN))
# Process queue, propagating known results backwards
while queue:
mouse, cat, turn, result = queue.popleft()
# Find parent states that lead to this state
if turn == 0: # Current is mouse's turn, parent was cat's turn
for prev_cat in graph[cat]:
if prev_cat == 0: # Cat can't be at hole
continue
if color[mouse][prev_cat][1] != DRAW:
continue # Already determined
if result == CAT_WIN:
# Cat found a winning move
color[mouse][prev_cat][1] = CAT_WIN
queue.append((mouse, prev_cat, 1, CAT_WIN))
else:
# This child doesn't help cat; decrement degree
degree[mouse][prev_cat][1] -= 1
if degree[mouse][prev_cat][1] == 0:
# All moves lead to mouse win/draw -> mouse wins
color[mouse][prev_cat][1] = MOUSE_WIN
queue.append((mouse, prev_cat, 1, MOUSE_WIN))
else: # Current is cat's turn, parent was mouse's turn
for prev_mouse in graph[mouse]:
if color[prev_mouse][cat][0] != DRAW:
continue # Already determined
if result == MOUSE_WIN:
# Mouse found a winning move
color[prev_mouse][cat][0] = MOUSE_WIN
queue.append((prev_mouse, cat, 0, MOUSE_WIN))
else:
# This child doesn't help mouse; decrement degree
degree[prev_mouse][cat][0] -= 1
if degree[prev_mouse][cat][0] == 0:
# All moves lead to cat win/draw -> cat wins
color[prev_mouse][cat][0] = CAT_WIN
queue.append((prev_mouse, cat, 0, CAT_WIN))
# Return result for initial state: mouse at 1, cat at 2, mouse's turn
return color[1][2][0]
explanation: |
**Time Complexity:** O(n^3) — We process each of the O(n^2) states at most once, with O(n) transitions.
**Space Complexity:** O(n^2) — Storage for the `color` and `degree` arrays.
This bottom-up approach works backwards from known terminal states. We start with states where we know the outcome (mouse at hole = mouse wins, cat catches mouse = cat wins) and propagate results backwards using the degree-counting technique. States that never get assigned a winner remain as draws.