LeetCode #752 Medium

Open the Lock

Open the Lock: A 4-wheel lock starts at 0000; find the minimum number of turns to reach target, avoiding any state in deadends.

Constraints
  • 1 <= deadends.length <= 500
  • deadends[i].length == 4
  • target.length == 4
  • target will not be in the list deadends.
  • target and deadends[i] consist of digits only.
bfsimplicit-graph
Open on LeetCode ↗
02

Intuition

Open the lock turns a four-wheel combination lock from "0000" to a target, avoiding a set of deadends, using the fewest single-wheel turns. The word fewest is the signal. Each lock state is a node, and turning one wheel one notch is an edge to another state. All turns cost the same, so this is a shortest path on an unweighted graph, which is BFS: - Every state has exactly eight neighbours — four wheels, each turned up or down one notch. The state space is bounded: 10⁴ = 10,000 combinations, small enough to explore exhaustively. Wheels wrap, so the digit after 9 is 0 and before 0 is 9. Modular arithmetic handles both — (d + 1) % 10 and (d + 9) % 10. Forgetting the wrap silently removes valid moves and can make a reachable target look unreachable. Deadends are simply states that may not be entered. The cleanest handling is to load them into the visited set before the search starts, so the traversal treats them as already-seen and never expands them. Checking them separately inside the loop works too but adds a branch on every neighbour. One case is easy to miss: if "0000" is itself a deadend, the answer is −1 even when the target is "0000". Seeding the visited set first handles this automatically, since the start is then never enqueued. Bidirectional BFS — searching from both start and target — cuts the explored frontier substantially and is worth knowing, though plain BFS is fast enough at this size.

How to spot this pattern

BFS over lock states, with deadends folded into the visited set at initialisation. That single trick means the expansion loop needs no separate deadend test — an unreachable state and an already-seen state are handled identically.

03

Approach

Try it first

Before reading on: price up what the direct approach costs here, then ask what one visit per cell or vertex would have to mark so nothing is counted twice. Aim for O(10^4) time and O(10^4) space.

1

Model states as graph nodes

Each four-digit combination is a node; a single wheel turn is an edge. Uniform move cost plus a shortest-path question means BFS, and the space is only 10,000 states.

2

Generate eight neighbours

For each of the four wheels, produce the state with that digit turned up and turned down. Every state has exactly eight successors, regardless of position.

3

Handle the wrap with modular arithmetic

Use (d + 1) % 10 and (d + 9) % 10 so 9 wraps to 0 and back. Forgetting the wrap removes valid moves and can make a reachable target appear unreachable.

4

Seed deadends into visited

Load every deadend into the visited set before the search begins. The traversal then skips them as already-seen, with no extra check on each neighbour.

5

Cover the blocked-start case

If "0000" is itself a deadend the answer is -1, even for target "0000". Seeding visited first handles this automatically — the start is never enqueued.

6

Expand level by level

Process the queue one level at a time, incrementing the turn count per level. The first time the target is dequeued, its level is the minimum number of turns.

7

Cost of the search

Each of the 10,000 states is visited once with eight neighbours generated, giving O(10^4 · 8) time — effectively constant for a fixed lock. Bidirectional BFS shrinks the frontier further.

04

Solution & live demo

▶1class Solution:
▶2 def openLock(self, deadends, target):
▶3 from collections import deque
▶4 dead = set(deadends)
▶5 if '0000' in dead:
▶6 return -1
▶7 visited = {'0000'} | dead
▶8 q = deque([('0000', 0)])
▶9 while q:
▶10 state, d = q.popleft()
▶11 if state == target:
▶12 return d
▶13 for i in range(4):
▶14 digit = int(state[i])
▶15 for delta in (1, -1):
▶16 nd = (digit + delta) % 10
▶17 cand = state[:i] + str(nd) + state[i+1:]
▶18 if cand not in visited:
▶19 visited.add(cand)
▶20 q.append((cand, d+1))
▶21 return -1
05

Common pitfalls

Not wrapping the digits

✗ Wrong
nd = digit + delta
✓ Right
nd = (digit + delta) % 10

The wheels are circular: 9 turns to 0 and 0 turns to 9. Without the modulo the digit goes out of range and the state string becomes invalid or throws.

Checking deadends separately in the loop

✗ Wrong
if cand not in visited and cand not in dead:
✓ Right
visited = {'0000'} | dead

Two conditions to keep in sync at every expansion. Seeding the visited set with the deadends makes them unreachable by construction, with one lookup instead of two.

Missing the case where the start is a deadend

✗ Wrong
q = deque([('0000', 0)])
✓ Right
if '0000' in dead:
    return -1

If the initial state is blocked the lock can never be turned, but the BFS would still expand from it since it was enqueued before any check. The guard has to come before the seed.

06

Edge cases

'0000' is itself a deadend

Return -1 before any traversal.

target is '0000'

Zero turns needed, found immediately.

Deadends block every path

BFS queue empties without reaching target; return -1.

Wheel wraps 0 to 9

Turning down from 0 must land on 9, not -1 or an invalid digit.

07

Complexity

Time
O(10^4)
Space
O(10^4)
At most 10000 4-digit states, each generating 8 neighbors.