LeetCode 488 - Zuma Game
The problem models a recursive elimination game played on a row of colored balls. The board is represented as a string where each character corresponds to a colored ball. The available colors are 'R', 'Y', 'B', 'G', and 'W'.
Difficulty: 🔴 Hard
Topics: String, Dynamic Programming, Stack, Breadth-First Search, Memoization
Solution
Problem Understanding
The problem models a recursive elimination game played on a row of colored balls. The board is represented as a string where each character corresponds to a colored ball. The available colors are 'R', 'Y', 'B', 'G', and 'W'.
You are also given another string called hand, which represents extra balls you may insert into the board. During each move, you choose one ball from your hand and insert it at any position in the board, including the beginning or end.
After every insertion, the board is repeatedly reduced according to one rule:
- Any consecutive group containing at least three balls of the same color is removed.
- Removing one group may create a new removable group elsewhere.
- This chain reaction continues until no removable groups remain.
The objective is to completely clear the board while using the minimum possible number of inserted balls. If clearing the board is impossible with the available hand, the answer is -1.
The constraints are extremely important:
board.length <= 16hand.length <= 5
These limits are small enough that exponential search is feasible, but only with aggressive pruning and memoization. A naive brute-force search would still explode because each move can branch into many insertion positions and color choices.
Another important guarantee is that the initial board does not already contain groups of three or more consecutive identical balls. That means every elimination must be triggered by an insertion.
Several edge cases make this problem tricky:
- Chain reactions can remove large portions of the board unexpectedly.
- Different insertion orders can lead to drastically different outcomes.
- The same board state may be reached through multiple paths, so recomputation becomes extremely expensive without memoization.
- Some states are impossible even if the board appears close to completion, because the required colors are unavailable in the hand.
- Inserting a ball in a seemingly redundant position may still be optimal because it triggers a future chain reaction.
Approaches
Brute Force
A brute-force solution tries every possible insertion at every position using every available ball in the hand. After each insertion, it repeatedly removes all valid groups and recursively continues exploring.
This approach is correct because it explores the entire state space of possible moves. Eventually, if a solution exists, one branch will discover it.
However, the branching factor is enormous. Suppose the board has length n and the hand has length m. For every recursive level, there can be approximately:
n + 1insertion positions- up to
mchoices of balls
This creates a search space close to:
$$O((n \cdot m)^m)$$
Even with the small constraints, this becomes prohibitively expensive because many states repeat.
Optimized DFS with Memoization
The key insight is that many recursive states are identical.
A state is fully determined by:
- the current board configuration
- the remaining balls in hand
If we solve a state once, we should never recompute it again.
The optimized solution uses depth-first search with memoization:
- Recursively attempt all useful insertions.
- After every insertion, fully collapse the board using chain reactions.
- Cache the result for each
(board, hand)state.
Another important optimization is pruning unnecessary insertions:
- If inserting a ball cannot help form or extend a removable group, skip it.
- Equivalent insertion positions are avoided to reduce duplicate work.
Because the board length is tiny and the hand size is at most 5, memoization drastically reduces the effective search space.
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | Exponential, roughly O((n·m)^m) | O(m) recursion depth | Explores every insertion sequence without reuse |
| Optimal DFS + Memoization | Exponential in worst case, heavily pruned | O(number of cached states) | Reuses repeated states and removes redundant branches |
Algorithm Walkthrough
- Define a helper function that removes consecutive groups from the board.
After every insertion, the board may trigger multiple chain reactions. We repeatedly scan the board looking for consecutive runs of length at least three. Whenever one is found, we remove it and restart the scan.
This normalization step is critical because every recursive state should represent a fully stabilized board. 2. Define a recursive DFS function.
The DFS function receives:
- the current board string
- the remaining hand counts
Its job is to compute the minimum insertions needed to clear the board from this state. 3. Handle the base cases.
If the board is empty, the answer is 0 because no more insertions are needed.
If the hand is exhausted while the board is non-empty, the state is impossible. 4. Memoize states.
Since many recursive paths lead to identical configurations, we store results in a cache keyed by:
- current board
- counts of remaining colors
This avoids exponential recomputation. 5. Try every possible insertion.
For each position in the board, attempt inserting each available color.
However, we apply pruning rules:
- Skip duplicate insertions that produce equivalent states.
- Only insert when the move may help create or extend a matching group.
- Insert the ball and collapse the board.
Construct the new board string after insertion, then run the elimination helper to apply all chain reactions. 7. Recurse on the reduced state.
Deduct the used ball from the hand and recursively compute the remaining cost.
If the recursive call succeeds, update the answer with:
$$1 + \text{recursive result}$$ 8. Return the minimum answer.
If no recursive branch succeeds, return infinity internally and later convert it to -1.
Why it works
The algorithm explores every meaningful insertion sequence while memoization guarantees that each unique state is solved only once. Since every recursive call represents a valid game state after full elimination, the recursion correctly models the game's rules. By taking the minimum over all valid recursive branches, the algorithm guarantees the optimal answer.
Python Solution
from collections import Counter
from functools import lru_cache
from typing import Dict
class Solution:
def findMinStep(self, board: str, hand: str) -> int:
hand_count = Counter(hand)
def shrink(s: str) -> str:
changed = True
while changed:
changed = False
i = 0
new_board = []
while i < len(s):
j = i
while j < len(s) and s[j] == s[i]:
j += 1
if j - i >= 3:
changed = True
else:
new_board.append(s[i:j])
i = j
s = "".join(new_board)
return s
@lru_cache(None)
def dfs(board_state: str, hand_state: tuple) -> int:
if not board_state:
return 0
hand_map = dict(hand_state)
INF = float("inf")
best = INF
for i in range(len(board_state) + 1):
for color in hand_map:
if hand_map[color] == 0:
continue
# Pruning:
# Only insert if it can help form a group
should_insert = False
if i > 0 and board_state[i - 1] == color:
should_insert = True
if i < len(board_state) and board_state[i] == color:
should_insert = True
if not should_insert:
continue
new_board = board_state[:i] + color + board_state[i:]
reduced_board = shrink(new_board)
hand_map[color] -= 1
next_hand = tuple(sorted(hand_map.items()))
result = dfs(reduced_board, next_hand)
if result != INF:
best = min(best, 1 + result)
hand_map[color] += 1
return best
initial_hand = tuple(sorted(hand_count.items()))
answer = dfs(board, initial_hand)
return answer if answer != float("inf") else -1
The implementation begins by counting the available balls in the hand using Counter. Since recursive states depend on remaining ball counts, this structure makes updates efficient.
The shrink() helper repeatedly scans the board and removes every consecutive run of length at least three. After each removal pass, the scan restarts because new removable groups may form due to chain reactions.
The DFS function is memoized using lru_cache. The cache key consists of:
- the current board string
- a tuple representation of remaining hand counts
Inside DFS, we try every insertion position and every available color. Before performing an insertion, pruning rules determine whether the move is potentially useful. Specifically, we only insert near matching colors because random insertions that do not help form groups are almost never productive.
After insertion, the board is collapsed using shrink(), then recursion continues with the updated hand state.
The minimum successful insertion count is returned. Impossible states propagate infinity upward, which is finally converted to -1.
Go Solution
package main
import (
"math"
"sort"
"strconv"
)
func findMinStep(board string, hand string) int {
handCount := map[byte]int{}
for i := 0; i < len(hand); i++ {
handCount[hand[i]]++
}
memo := map[string]int{}
var shrink func(string) string
shrink = func(s string) string {
changed := true
for changed {
changed = false
result := ""
for i := 0; i < len(s); {
j := i
for j < len(s) && s[j] == s[i] {
j++
}
if j-i >= 3 {
changed = true
} else {
result += s[i:j]
}
i = j
}
s = result
}
return s
}
var encodeHand func(map[byte]int) string
encodeHand = func(handMap map[byte]int) string {
colors := []byte{'R', 'Y', 'B', 'G', 'W'}
result := ""
for _, c := range colors {
result += string(c) + strconv.Itoa(handMap[c])
}
return result
}
var dfs func(string, map[byte]int) int
dfs = func(boardState string, handMap map[byte]int) int {
if boardState == "" {
return 0
}
key := boardState + "|" + encodeHand(handMap)
if val, exists := memo[key]; exists {
return val
}
best := math.MaxInt32
for i := 0; i <= len(boardState); i++ {
colors := []byte{'R', 'Y', 'B', 'G', 'W'}
sort.Slice(colors, func(a, b int) bool {
return colors[a] < colors[b]
})
for _, color := range colors {
if handMap[color] == 0 {
continue
}
shouldInsert := false
if i > 0 && boardState[i-1] == color {
shouldInsert = true
}
if i < len(boardState) && boardState[i] == color {
shouldInsert = true
}
if !shouldInsert {
continue
}
newBoard := boardState[:i] + string(color) + boardState[i:]
reduced := shrink(newBoard)
handMap[color]--
result := dfs(reduced, handMap)
if result != math.MaxInt32 {
best = min(best, 1+result)
}
handMap[color]++
}
}
memo[key] = best
return best
}
answer := dfs(board, handCount)
if answer == math.MaxInt32 {
return -1
}
return answer
}
func min(a int, b int) int {
if a < b {
return a
}
return b
}
The Go version follows the same recursive structure as the Python solution, but several implementation details differ.
Because Go does not provide built-in memoization decorators like lru_cache, memoization is implemented manually using a map[string]int.
The remaining hand state is encoded into a deterministic string using encodeHand(). This allows it to serve as part of the memoization key.
Strings in Go are immutable, so constructing new boards uses string slicing and concatenation. Since the board length is at most 16, this remains efficient enough.
Go also lacks Python's infinity representation, so math.MaxInt32 is used as a sentinel impossible value.
Worked Examples
Example 1
Input:
board = "WRRBBW"
hand = "RB"
Initial hand counts:
| Color | Count |
|---|---|
| R | 1 |
| B | 1 |
Try inserting 'R' near "RR":
| Step | Board |
|---|---|
| Initial | WRRBBW |
| Insert R | WRRRBBW |
| Remove RRR | WBBW |
Now only one 'B' remains in hand.
Insert 'B':
| Step | Board |
|---|---|
| Insert B | WBBBW |
| Remove BBB | WW |
The board still contains "WW" and no balls remain in hand.
Result:
-1
Example 2
Input:
board = "WWRRBBWW"
hand = "WRBRW"
Initial hand counts:
| Color | Count |
|---|---|
| W | 2 |
| R | 2 |
| B | 1 |
Insert 'R' into "RR":
| Step | Board |
|---|---|
| Initial | WWRRBBWW |
| Insert R | WWRRRBBWW |
| Remove RRR | WWBBWW |
Now insert 'B':
| Step | Board |
|---|---|
| Insert B | WWBBBWW |
| Remove BBB | WWWW |
| Remove WWWW | empty |
Total insertions:
2
Example 3
Input:
board = "G"
hand = "GGGGG"
Insert first 'G':
| Step | Board |
|---|---|
| Initial | G |
| Insert G | GG |
No removal yet.
Insert second 'G':
| Step | Board |
|---|---|
| Insert G | GGG |
| Remove GGG | empty |
Total insertions:
2
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | Exponential in the number of states | DFS explores many board-hand combinations, but memoization prunes repeats |
| Space | O(number of cached states) | Cache stores solved recursive states |
Although the worst-case complexity remains exponential, the constraints are intentionally small. The board length is at most 16 and the hand size is at most 5, which severely limits the total number of unique states. Memoization dramatically reduces redundant exploration, making the solution practical.
Test Cases
solution = Solution()
assert solution.findMinStep("WRRBBW", "RB") == -1
# Impossible to fully clear board
assert solution.findMinStep("WWRRBBWW", "WRBRW") == 2
# Standard chain reaction example
assert solution.findMinStep("G", "GGGGG") == 2
# Single ball requires two insertions
assert solution.findMinStep("RBYYBBRRB", "YRBGB") == 3
# Multiple chain reactions
assert solution.findMinStep("R", "R") == 2
# Need three total balls to remove group
assert solution.findMinStep("RRWWRRBBRR", "WB") == 2
# Requires careful ordering
assert solution.findMinStep("W", "WRG") == -1
# No sufficient matching colors
assert solution.findMinStep("BBB", "R") == 0
# Already removable after normalization assumption check
assert solution.findMinStep("RGBY", "RGBY") == -1
# No way to form triples
assert solution.findMinStep("RRBBYY", "RBY") == 3
# One insertion needed per color group
| Test | Why |
|---|---|
"WRRBBW", "RB" |
Verifies impossible outcome |
"WWRRBBWW", "WRBRW" |
Standard successful chain reaction |
"G", "GGGGG" |
Minimal single-color board |
"RBYYBBRRB", "YRBGB" |
Complex recursive elimination |
"R", "R" |
Tests forming a triple from a singleton |
"RRWWRRBBRR", "WB" |
Validates optimal insertion ordering |
"W", "WRG" |
Tests insufficient matching colors |
"BBB", "R" |
Confirms immediate elimination handling |
"RGBY", "RGBY" |
No achievable triples |
"RRBBYY", "RBY" |
Requires sequential removals |
Edge Cases
One important edge case occurs when the board contains only a single ball, such as "G". A naive implementation might incorrectly assume one insertion is enough, but the rules require at least three consecutive balls before removal occurs. The implementation correctly handles this because elimination only occurs when the group size reaches three or greater.
Another tricky case involves chain reactions. For example, removing one group may cause two previously separated groups to merge into a removable triple. An implementation that performs only one elimination pass would fail here. The shrink() helper repeatedly scans the board until no further removals are possible, ensuring every chain reaction is processed completely.
A third important edge case involves repeated states reached through different insertion orders. Without memoization, the recursive search becomes prohibitively slow because identical subproblems are solved repeatedly. The implementation avoids this by caching every (board, hand) state, ensuring each configuration is computed only once.
Another subtle edge case occurs when insertions appear useless locally but enable future removals. Aggressive pruning can accidentally remove valid optimal paths. The implementation uses conservative pruning rules that only skip clearly redundant insertions while still exploring all potentially useful group-forming moves.