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)   **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`   **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)   **Step 4: Memoise results** - Cache the result for each `(mouse, cat, turn)` state - This prevents exponential recalculation   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.