261 lines
11 KiB
YAML
261 lines
11 KiB
YAML
title: Open the Lock
|
||
slug: open-the-lock
|
||
difficulty: medium
|
||
leetcode_id: 752
|
||
leetcode_url: https://leetcode.com/problems/open-the-lock/
|
||
categories:
|
||
- strings
|
||
- hash-tables
|
||
- graphs
|
||
patterns:
|
||
- slug: bfs
|
||
is_optimal: true
|
||
|
||
function_signature: "def open_lock(deadends: list[str], target: str) -> int:"
|
||
|
||
test_cases:
|
||
visible:
|
||
- input: { deadends: ["0201", "0101", "0102", "1212", "2002"], target: "0202" }
|
||
expected: 6
|
||
- input: { deadends: ["8888"], target: "0009" }
|
||
expected: 1
|
||
- input: { deadends: ["8887", "8889", "8878", "8898", "8788", "8988", "7888", "9888"], target: "8888" }
|
||
expected: -1
|
||
hidden:
|
||
- input: { deadends: ["0000"], target: "1111" }
|
||
expected: -1
|
||
- input: { deadends: [], target: "0000" }
|
||
expected: 0
|
||
- input: { deadends: [], target: "1111" }
|
||
expected: 4
|
||
- input: { deadends: ["1111"], target: "1112" }
|
||
expected: 5
|
||
- input: { deadends: ["0001", "0010", "0100", "1000"], target: "9999" }
|
||
expected: 4
|
||
|
||
description: |
|
||
You have a lock in front of you with 4 circular wheels. Each wheel has 10 slots: `'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'`. The wheels can rotate freely and wrap around: for example we can turn `'9'` to be `'0'`, or `'0'` to be `'9'`. Each move consists of turning **one wheel one slot**.
|
||
|
||
The lock initially starts at `'0000'`, a string representing the state of the 4 wheels.
|
||
|
||
You are given a list of `deadends` dead ends, meaning if the lock displays any of these codes, the wheels of the lock will stop turning and you will be unable to open it.
|
||
|
||
Given a `target` representing the value of the wheels that will unlock the lock, return *the minimum number of turns required to open the lock*, or `-1` if it is impossible.
|
||
|
||
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
|
||
|
||
examples:
|
||
- input: 'deadends = ["0201","0101","0102","1212","2002"], target = "0202"'
|
||
output: "6"
|
||
explanation: 'A sequence of valid moves would be "0000" → "1000" → "1100" → "1200" → "1201" → "1202" → "0202". Note that "0000" → "0001" → "0002" → "0102" → "0202" would be invalid because the lock becomes stuck at dead end "0102".'
|
||
- input: 'deadends = ["8888"], target = "0009"'
|
||
output: "1"
|
||
explanation: 'We can turn the last wheel in reverse to move from "0000" → "0009".'
|
||
- input: 'deadends = ["8887","8889","8878","8898","8788","8988","7888","9888"], target = "8888"'
|
||
output: "-1"
|
||
explanation: "We cannot reach the target without getting stuck. All paths to 8888 are blocked by deadends."
|
||
|
||
explanation:
|
||
intuition: |
|
||
Imagine standing in front of a combination lock with 4 wheels, each showing a digit from 0-9. You start at `"0000"` and need to reach the target code, but certain combinations will jam the lock forever.
|
||
|
||
Think of it like this: each 4-digit code is a **node** in a graph, and from any code, you can reach exactly **8 neighbouring codes** — one for each wheel turned up or down. Some nodes (deadends) are "blocked" and cannot be visited. Your goal is to find the **shortest path** from `"0000"` to the target.
|
||
|
||
When you need the shortest path in an unweighted graph where each edge has equal cost (one turn), **BFS is the optimal choice**. BFS explores nodes level by level, guaranteeing that the first time we reach the target, we've found the minimum number of moves.
|
||
|
||
The key insight is recognising this as a graph traversal problem in disguise. The state space is all 10,000 possible codes (`"0000"` to `"9999"`), and we're doing a shortest-path search with obstacles.
|
||
|
||
approach: |
|
||
We solve this using **BFS on the State Space Graph**:
|
||
|
||
**Step 1: Handle edge cases**
|
||
|
||
- If `"0000"` is a deadend, return `-1` immediately — we can't even start
|
||
- If target is `"0000"`, return `0` — we're already there
|
||
|
||
|
||
|
||
**Step 2: Initialise BFS structures**
|
||
|
||
- `visited`: A set containing all deadends (states we cannot enter) plus states we've already explored
|
||
- `queue`: Start with `("0000", 0)` where `0` is the number of turns taken
|
||
- Add `"0000"` to visited
|
||
|
||
|
||
|
||
**Step 3: BFS traversal**
|
||
|
||
- Dequeue the current state and its turn count
|
||
- If it equals the target, return the turn count
|
||
- Generate all 8 neighbours (turn each of 4 wheels up or down by 1)
|
||
- For each neighbour not in visited, add it to visited and enqueue with `turns + 1`
|
||
|
||
|
||
|
||
**Step 4: Generate neighbours**
|
||
|
||
- For each wheel position (0 to 3):
|
||
- Turn up: digit becomes `(d + 1) % 10` (wraps 9 → 0)
|
||
- Turn down: digit becomes `(d - 1) % 10` (wraps 0 → 9)
|
||
- This produces exactly 8 neighbours per state
|
||
|
||
|
||
|
||
**Step 5: Return result**
|
||
|
||
- If BFS completes without finding target, return `-1`
|
||
|
||
|
||
|
||
This works because BFS explores states in order of increasing distance from the start. The first time we reach the target, we've found the shortest path.
|
||
|
||
common_pitfalls:
|
||
- title: Using DFS Instead of BFS
|
||
description: |
|
||
DFS will find *a* path to the target, but not necessarily the *shortest* path. Since we need the minimum number of turns, we must use BFS.
|
||
|
||
BFS guarantees that when we first reach a node, we've taken the fewest steps to get there. DFS would need to explore all paths and track the minimum, which is much less efficient.
|
||
wrong_approach: "DFS to find any path"
|
||
correct_approach: "BFS to find shortest path"
|
||
|
||
- title: Forgetting to Check if Start is a Deadend
|
||
description: |
|
||
If `"0000"` itself is in the deadends list, we can never begin. Always check this before starting BFS.
|
||
|
||
Similarly, if the target is `"0000"`, return `0` immediately — no moves needed.
|
||
wrong_approach: "Starting BFS without checking initial state"
|
||
correct_approach: "Check if '0000' in deadends before BFS"
|
||
|
||
- title: Adding to Visited Too Late
|
||
description: |
|
||
A common bug is marking states as visited when **dequeuing** rather than when **enqueuing**. This causes the same state to be added to the queue multiple times from different paths, wasting time and memory.
|
||
|
||
For example, both `"1000"` and `"0100"` can reach `"1100"`. If we mark visited on dequeue, both will add `"1100"` to the queue.
|
||
wrong_approach: "Mark visited when popping from queue"
|
||
correct_approach: "Mark visited immediately when adding to queue"
|
||
|
||
- title: Incorrect Wrap-Around Logic
|
||
description: |
|
||
The wheels wrap around: `9 + 1 = 0` and `0 - 1 = 9`. Using `(d + 1) % 10` handles the forward wrap correctly, but `(d - 1) % 10` in Python needs care.
|
||
|
||
In Python, `(-1) % 10 = 9`, so `(d - 1) % 10` works correctly. In some languages, you may need `(d + 9) % 10` instead.
|
||
wrong_approach: "d - 1 without handling negative (in some languages)"
|
||
correct_approach: "(d - 1) % 10 or (d + 9) % 10"
|
||
|
||
key_takeaways:
|
||
- "**Implicit graph recognition**: Many problems involve state spaces that form graphs — codes, configurations, game states"
|
||
- "**BFS for shortest path in unweighted graphs**: When all edges have equal weight, BFS finds the optimal path"
|
||
- "**Deadends as blocked nodes**: Preloading obstacles into the visited set is a clean way to handle forbidden states"
|
||
- "**State generation**: Being able to enumerate all neighbours of a state is key to graph-based solutions"
|
||
|
||
time_complexity: "O(10<sup>4</sup> × 4) = O(1). At most 10,000 states, each generating 8 neighbours. Effectively O(10<sup>4</sup>) which is constant."
|
||
space_complexity: "O(10<sup>4</sup>) = O(1). The visited set and queue can hold at most all 10,000 possible codes."
|
||
|
||
solutions:
|
||
- approach_name: BFS
|
||
is_optimal: true
|
||
code: |
|
||
from collections import deque
|
||
|
||
def open_lock(deadends: list[str], target: str) -> int:
|
||
# Convert deadends to set for O(1) lookup
|
||
dead = set(deadends)
|
||
|
||
# Edge case: can't even start
|
||
if "0000" in dead:
|
||
return -1
|
||
|
||
# Edge case: already at target
|
||
if target == "0000":
|
||
return 0
|
||
|
||
# BFS setup
|
||
queue = deque([("0000", 0)]) # (state, turns)
|
||
visited = {"0000"}
|
||
|
||
while queue:
|
||
state, turns = queue.popleft()
|
||
|
||
# Generate all 8 neighbours (4 wheels × 2 directions)
|
||
for i in range(4):
|
||
digit = int(state[i])
|
||
|
||
# Turn wheel up (+1) and down (-1)
|
||
for delta in [1, -1]:
|
||
new_digit = (digit + delta) % 10
|
||
# Build new state string
|
||
new_state = state[:i] + str(new_digit) + state[i+1:]
|
||
|
||
# Check if we found target
|
||
if new_state == target:
|
||
return turns + 1
|
||
|
||
# Add unvisited, non-dead states to queue
|
||
if new_state not in visited and new_state not in dead:
|
||
visited.add(new_state)
|
||
queue.append((new_state, turns + 1))
|
||
|
||
# Target unreachable
|
||
return -1
|
||
explanation: |
|
||
**Time Complexity:** O(10<sup>4</sup>) — We visit each of the 10,000 possible states at most once. Generating 8 neighbours per state is O(1).
|
||
|
||
**Space Complexity:** O(10<sup>4</sup>) — The visited set and queue together hold at most all possible states.
|
||
|
||
BFS explores states level by level, where each level represents one more turn. When we first reach the target, we've found the minimum number of turns. The deadends are implicitly handled by never adding them to the queue.
|
||
|
||
- approach_name: Bidirectional BFS
|
||
is_optimal: true
|
||
code: |
|
||
def open_lock(deadends: list[str], target: str) -> int:
|
||
dead = set(deadends)
|
||
|
||
if "0000" in dead:
|
||
return -1
|
||
if target == "0000":
|
||
return 0
|
||
|
||
# Two frontiers expanding toward each other
|
||
front = {"0000"}
|
||
back = {target}
|
||
visited = {"0000", target}
|
||
turns = 0
|
||
|
||
while front and back:
|
||
# Always expand the smaller frontier
|
||
if len(front) > len(back):
|
||
front, back = back, front
|
||
|
||
next_front = set()
|
||
turns += 1
|
||
|
||
for state in front:
|
||
for i in range(4):
|
||
digit = int(state[i])
|
||
for delta in [1, -1]:
|
||
new_digit = (digit + delta) % 10
|
||
new_state = state[:i] + str(new_digit) + state[i+1:]
|
||
|
||
# Frontiers meet — found shortest path
|
||
if new_state in back:
|
||
return turns
|
||
|
||
if new_state not in visited and new_state not in dead:
|
||
visited.add(new_state)
|
||
next_front.add(new_state)
|
||
|
||
front = next_front
|
||
|
||
return -1
|
||
explanation: |
|
||
**Time Complexity:** O(10<sup>4</sup>) — Same worst case, but often faster in practice due to reduced search space.
|
||
|
||
**Space Complexity:** O(10<sup>4</sup>) — Same as standard BFS.
|
||
|
||
Bidirectional BFS searches from both start and target simultaneously. When the two frontiers meet, we've found the shortest path. By always expanding the smaller frontier, we minimise the search space. This optimisation is especially effective when the branching factor is high.
|