909. Snakes and Ladders¶
Edited by Charles
Medium · Python · Graph · BFS · Matrix Coordinates
Problem
Read the original problem on LeetCode.
An n × n board is numbered from 1 at the bottom-left to n², reversing
direction on each row. Starting at 1, choose a roll from 1 to 6 without
overshooting n². If the landing square starts a snake or ladder, take it.
A board entry of -1 means no jump; any other entry is a destination label.
Take at most one jump per roll, even if its destination starts another jump.
Find the minimum number of rolls to reach n², or return -1 if unreachable.
Explanation, examples & other approaches
Approach and Core Idea¶
One complete dice roll is one graph edge
Use the square where a turn ends as the BFS state. For each possible roll, apply a snake or ladder once, then enqueue that final destination.
A square may lead to up to six next-turn states. Each transition costs one roll, regardless of how far a ladder moves us or how far a snake sends us backward. This is an unweighted shortest-path problem, so BFS visits states in increasing number of rolls.
The question asks for the best possible sequence of rolls, not the expected number of rolls of a random game. We explore every legal roll rather than simulating random dice.
Convert a Square Number to Board Coordinates¶
Working with labels is easier for movement: from square s, try labels
s + 1 through min(s + 6, n²). But we still need a matrix position to
check whether each landing square has a jump.
In getPosition(num), first subtract one from num to make it zero-based.
Then compute row = num // n and col = num % n.
At this point, row counts from the bottom:
- If
row % 2is true, reverse the column withcol = n - 1 - col. - Otherwise, leave
colunchanged. - Finally set
row = n - 1 - rowto convert to Python's top-based row index.
For a 3 × 3 board, the numbering is:
For square 6, num becomes 5, so row = 5 // 3 = 1 and
col = 5 % 3 = 2. The row is odd, so the column reverses:
col = 3 - 1 - 2 = 0, matching the middle-left cell.
Use the row counted from the bottom for parity. Using the top-indexed matrix row directly would give the wrong orientation for some board sizes.
Algorithm Steps¶
- Put
(1, 0)in adeque: square 1 reached with zero rolls. - Initialize
record = {1}to mark the starting square visited. - Pop
(cur, steps)from the front of the queue. - If
cur == n * n, returnsteps. - Try
dicevalues from 1 through 6:- Set
nxt = cur + dice; stop the loop if it exceedsn * n. - Convert
nxtto(row, col)usinggetPosition(nxt). - If the cell starts a jump, update
nxttoboard[row][col]once. - If this final
nxtis not inrecord, record it and enqueue(nxt, steps + 1).
- Set
- If the queue empties without reaching the goal, return
-1.
Hand Simulation¶
Consider this board:
The numbering diagram above shows that this represents a ladder 2 → 8
and a snake 7 → 3. The goal is square 9.
Start: queue = deque([(1, 0)]), record = {1}.
From square 1, inspect all six possible landing squares:
| Roll | Landing square | End-of-turn destination | Action |
|---|---|---|---|
| 1 | 2 | 8 | Take the ladder; enqueue (8, 1). |
| 2 | 3 | 3 | Enqueue (3, 1). |
| 3 | 4 | 4 | Enqueue (4, 1). |
| 4 | 5 | 5 | Enqueue (5, 1). |
| 5 | 6 | 6 | Enqueue (6, 1). |
| 6 | 7 | 3 | Take the snake; 3 is already visited. |
The queue is now [(8, 1), (3, 1), (4, 1), (5, 1), (6, 1)].
Popping (8, 1), the only legal next landing label is 9, so enqueue (9, 2).
BFS finishes processing earlier queued states before popping (9, 2) and
returning 2.
A shortest route is:
The ladder does not add a third turn.
Important Implementation Details¶
Follow only one jump per roll. If 2 → 8 and 8 → 4 are both present,
landing on 2 ends that turn at 8, not 4. Read the original landing cell once;
do not use a loop that follows jump chains.
Mark the final destination, not just the dice landing square. The queue models positions at the start of the next turn. Different rolls can lead to the same final position, which only needs to be explored once.
Mark on enqueue. BFS first discovers a final position using the fewest rolls. Scheduling it again cannot improve its answer, and snakes can otherwise create repeated visits or cycles.
Bound rolls by the goal. There is no transition for an overshoot. The
loop uses range(1, 7) to include all six dice values. Once nxt > n * n,
break is safe because every later dice value would overshoot too.
The start requires zero rolls. A snake or ladder reached during a turn
is part of that same turn. Every queued successor therefore uses steps + 1.
Do not mutate the board. The separate record set tracks visited end-of-turn squares;
the board continues to describe the original jumps.
Why BFS Gives the Minimum¶
Queue entries with zero rolls are processed first, then entries with one roll, then two, and so on. Each complete move has cost one, so the first discovered distance to a square is minimal. When the goal is popped, no path with fewer rolls can still be waiting to be explored.
If the queue empties, every reachable end-of-turn square has been processed. The absent goal is therefore unreachable. Dijkstra is unnecessary because these graph edges all have the same cost.
Time and Space Complexity¶
There are \(n^2\) possible square labels, each with at most six outgoing moves.
| Resource | Complexity | Reason |
|---|---|---|
| Time | \(O(n^2)\) | Each final square is enqueued at most once, with at most six constant-time transitions. |
| Auxiliary space | \(O(n^2)\) | The visited set and queue can store a linear number of board squares. |
Coordinate conversion uses constant time. We do not need to construct an explicit adjacency list or flatten the whole board.
Key Takeaways¶
- Model a complete turn as one edge, including its forced jump.
- Use BFS for minimum moves when each move has equal cost.
- Separate the rolled-to label from the final destination.
- Convert serpentine labels using the row counted from the bottom.
- Resolve a jump once per roll and deduplicate final states.
Python solution¶
from collections import deque
class Solution:
def snakesAndLadders(self, board: list[list[int]]) -> int:
n = len(board)
queue = deque([(1, 0)])
record = {1}
def getPosition(num):
num -= 1
row = num // n
col = num % n
if row % 2:
col = n - 1 - col
row = n - 1 - row
return row, col
while queue:
cur, steps = queue.popleft()
if cur == n * n:
return steps
for dice in range(1, 7):
nxt = cur + dice
if nxt > n * n:
break
row, col = getPosition(nxt)
if board[row][col] != -1:
# 跳转nxt
nxt = board[row][col]
if nxt not in record:
record.add(nxt)
queue.append((nxt, steps + 1))
return -1