Skip to main content

Open the Lock

MEDIUMProblemSolveExternal Links

Description

You have a lock with 4 circular wheels. Each wheel has 10 slots labeled '0' through '9'. The wheels wrap around: turning '9' forward gives '0', and turning '0' backward gives '9'. Each move consists of turning one wheel by one slot (either forward or backward).

The lock starts at the combination "0000".

You are given a list of deadends — if the lock ever displays any of these codes, the wheels jam and you can no longer turn them.

Given a target combination, return the minimum total number of single-wheel turns needed to reach the target from "0000". If it is impossible to reach the target without hitting a deadend, return -1.

Examples

Example 1

Input: deadends = ["0201","0101","0102","1212","2002"], target = "0202"

Output: 6

Explanation: One valid sequence of moves is:
"0000" → "1000" → "1100" → "1200" → "1201" → "1202" → "0202"
Each arrow represents turning one wheel by one slot. A path like "0000" → "0001" → "0002" → "0102" → "0202" is invalid because "0102" is a deadend — the wheels would jam before reaching the target. The shortest valid path requires 6 turns.

Example 2

Input: deadends = ["8888"], target = "0009"

Output: 1

Explanation: We turn the last wheel backward once: "0000" → "0009". The wheel wraps from '0' to '9'. The deadend "8888" is never encountered, so a single turn suffices.

Example 3

Input: deadends = ["8887","8889","8878","8898","8788","8988","7888","9888"], target = "8888"

Output: -1

Explanation: Every combination that is exactly one turn away from "8888" is a deadend. To reach "8888", we would have to pass through one of these blocked states. Since all 8 neighbors of the target are deadends, it is completely surrounded and impossible to reach.

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

Editorial

Brute Force

Intuition

The most natural first instinct is to try every possible sequence of moves using a depth-first search with backtracking. Think of it like navigating a maze by always picking one direction, going as far as you can, and then retracing your steps when you hit a wall.

From the starting code "0000", we have 8 possible moves: each of the 4 wheels can be turned either up or down by one slot. From each resulting state, we again have 8 moves, and so on. We recursively explore all of these paths, and whenever we reach the target, we record the number of moves taken. After exploring all possible paths, we return the minimum.

To avoid getting stuck in infinite loops (e.g., turning a wheel up and then back down forever), we maintain a visited set. Before visiting a state, we add it to the set; after fully exploring it, we remove it (backtracking) so other paths can still pass through it. We also check each state against the deadends set and prune immediately if a state is blocked.

Step-by-Step Explanation

Let's trace through a simplified DFS on Example 1: deadends = ["0201","0101","0102","1212","2002"], target = "0202".

We show a subgraph of the full state space, focusing on the key paths and dead-ends.

Step 1: Start at "0000". Mark it visited. We need to explore all reachable neighbors depth-first.

Step 2: Pick first neighbor: turn wheel 1 up → "0100". Mark visited. Depth = 1.

Step 3: From "0100", try neighbor "0101" — it is a DEADEND. Skip. Try "0102" — also a DEADEND. No more useful neighbors. Dead end reached.

Step 4: Backtrack to "0000". Unmark "0100" from visited.

Step 5: Try next neighbor from "0000": turn wheel 3 up → "0001". Mark visited. Depth = 1.

Step 6: From "0001", no neighbors lead closer to target without excessive depth. Backtrack to "0000".

Step 7: Try neighbor: turn wheel 0 up → "1000". Mark visited. Depth = 1.

Step 8: From "1000" → "1100" (turn wheel 1 up). Depth = 2.

Step 9: From "1100" → "1200" (turn wheel 1 up). Depth = 3.

Step 10: From "1200" → "1201" (turn wheel 3 up). Depth = 4.

Step 11: From "1201", try "0201" — DEADEND! Skip.

Step 12: From "1201" → "1202" (turn wheel 3 up). Depth = 5.

Step 13: From "1202" → "0202" (turn wheel 0 down). This IS the target! Record min_moves = 6.

Step 14: Backtrack all the way. Continue exploring other branches to check if a shorter path exists. After exhaustive search, no path shorter than 6 is found. Return 6.

DFS Backtracking — Exploring the Lock State Space — Watch how DFS explores paths depth-first, hitting deadends and backtracking before eventually finding the target. The visited set grows and shrinks as we backtrack.

Algorithm

  1. Place all deadends in a hash set for O(1) lookup
  2. If "0000" is a deadend, return -1 immediately
  3. Start DFS from "0000" with depth = 0 and a visited set containing "0000"
  4. At each state, if it equals the target, record depth as a candidate minimum
  5. If depth ≥ current minimum, prune (no point going deeper)
  6. For each of the 4 wheels, try turning it up (+1) and down (-1):
    • If the new state is not in deadends and not in visited, recurse with depth + 1
    • Add state to visited before recursing; remove it after (backtracking)
  7. After all paths are explored, return the recorded minimum (or -1 if target was never reached)

Code

class Solution {
public:
    int minMoves;

    void dfs(string& state, string& target,
             unordered_set<string>& dead,
             unordered_set<string>& visited, int depth) {
        if (state == target) {
            minMoves = min(minMoves, depth);
            return;
        }
        if (depth >= minMoves) return;

        for (int i = 0; i < 4; i++) {
            for (int delta : {1, -1}) {
                string next = state;
                next[i] = '0' + (state[i] - '0' + delta + 10) % 10;
                if (!dead.count(next) && !visited.count(next)) {
                    visited.insert(next);
                    dfs(next, target, dead, visited, depth + 1);
                    visited.erase(next);
                }
            }
        }
    }

    int openLock(vector<string>& deadends, string target) {
        unordered_set<string> dead(deadends.begin(), deadends.end());
        if (dead.count("0000")) return -1;
        if (target == "0000") return 0;

        minMoves = INT_MAX;
        unordered_set<string> visited;
        visited.insert("0000");
        string start = "0000";
        dfs(start, target, dead, visited, 0);

        return minMoves == INT_MAX ? -1 : minMoves;
    }
};
class Solution:
    def openLock(self, deadends: list[str], target: str) -> int:
        dead = set(deadends)
        if "0000" in dead:
            return -1
        if target == "0000":
            return 0

        min_moves = [float('inf')]

        def dfs(state: str, depth: int, visited: set) -> None:
            if state == target:
                min_moves[0] = min(min_moves[0], depth)
                return
            if depth >= min_moves[0]:
                return

            for i in range(4):
                d = int(state[i])
                for delta in [1, -1]:
                    new_digit = (d + delta) % 10
                    new_state = state[:i] + str(new_digit) + state[i + 1:]
                    if new_state not in visited and new_state not in dead:
                        visited.add(new_state)
                        dfs(new_state, depth + 1, visited)
                        visited.remove(new_state)

        visited = {"0000"}
        dfs("0000", 0, visited)
        return min_moves[0] if min_moves[0] != float('inf') else -1
import java.util.*;

class Solution {
    private int minMoves = Integer.MAX_VALUE;

    private void dfs(String state, String target,
                     Set<String> dead, Set<String> visited, int depth) {
        if (state.equals(target)) {
            minMoves = Math.min(minMoves, depth);
            return;
        }
        if (depth >= minMoves) return;

        for (int i = 0; i < 4; i++) {
            for (int delta : new int[]{1, -1}) {
                char[] chars = state.toCharArray();
                chars[i] = (char) ('0' + (chars[i] - '0' + delta + 10) % 10);
                String next = new String(chars);
                if (!dead.contains(next) && !visited.contains(next)) {
                    visited.add(next);
                    dfs(next, target, dead, visited, depth + 1);
                    visited.remove(next);
                }
            }
        }
    }

    public int openLock(String[] deadends, String target) {
        Set<String> dead = new HashSet<>(Arrays.asList(deadends));
        if (dead.contains("0000")) return -1;
        if (target.equals("0000")) return 0;

        minMoves = Integer.MAX_VALUE;
        Set<String> visited = new HashSet<>();
        visited.add("0000");
        dfs("0000", target, dead, visited, 0);

        return minMoves == Integer.MAX_VALUE ? -1 : minMoves;
    }
}

Complexity Analysis

Time Complexity: O(N! / (N - d)!) in the worst case, where N = 10^4 (total possible states) and d is the solution depth

DFS with backtracking potentially explores all simple paths in the state graph. Each state has 8 neighbors (4 wheels × 2 directions). Without the depth pruning, the algorithm could enumerate an exponential number of paths. The pruning (depth >= minMoves) helps in practice, but worst-case performance remains exponential.

Space Complexity: O(d)

The recursion stack depth is bounded by the depth of the deepest path explored. The visited set also grows and shrinks, but at any point holds at most d entries (the current path). The deadends set uses O(D) space where D is the number of deadends.

Why This Approach Is Not Efficient

The DFS backtracking approach has fundamental flaws for finding the shortest path:

1. No shortest-path guarantee on first discovery. DFS dives deep into one direction before trying alternatives. It might find a path of length 20 before discovering a path of length 6 existed all along. To guarantee finding the shortest path, DFS must explore ALL possible paths and compare lengths — massively wasteful.

2. Exponential branching. Each state has 8 neighbors. At depth d, the DFS tree can have up to 8^d branches. Even with visited tracking and pruning, the number of paths explored can be enormous. For our lock problem with 10,000 possible states and a target at depth 6, the DFS might explore thousands of paths before verifying the answer.

3. Backtracking overhead. Every dead-end requires unwinding the recursion stack and undoing visited marks. This constant backtracking is pure overhead — the DFS is doing and then undoing work repeatedly.

The critical insight: Every turn costs exactly 1 move — this is an unweighted graph. For unweighted shortest-path problems, Breadth-First Search (BFS) is provably optimal: it explores all states at distance d before any state at distance d+1. The FIRST time BFS reaches the target, it has found the shortest path. No backtracking, no exhaustive verification, no wasted exploration of longer paths.

Optimal Approach - BFS (Breadth-First Search)

Intuition

Model the problem as a graph. Each of the 10,000 possible 4-digit codes ("0000" through "9999") is a node. Two nodes are connected by an edge if they differ in exactly one digit, and that digit differs by exactly 1 (with wrapping: '0' and '9' are adjacent). Deadends are nodes that we simply remove from the graph.

Now the problem becomes: find the shortest path from node "0000" to node "target" in this unweighted graph, avoiding removed nodes. The classic algorithm for shortest path in an unweighted graph is Breadth-First Search (BFS).

BFS explores nodes in order of their distance from the start:

  • First, all nodes 1 turn away from "0000"
  • Then, all nodes 2 turns away
  • Then 3 turns, and so on

The moment we reach the target, we know the distance is minimal — because BFS guarantees every shorter distance was already checked. This is fundamentally different from DFS, which has no such guarantee.

Concept diagram showing the lock state space as a graph with nodes connected by single-wheel turns, BFS levels radiating outward from 0000
Concept diagram showing the lock state space as a graph with nodes connected by single-wheel turns, BFS levels radiating outward from 0000

Step-by-Step Explanation

Let's trace with deadends = ["0201","0101","0102","1212","2002"], target = "0202".

We show a subgraph of key states along the optimal path.

Step 1: Initialize. Place deadends in a set. Enqueue "0000". Mark visited. moves = 0.

Step 2: Level 0 — Dequeue "0000". Generate all 8 neighbors by turning each wheel ±1: {"1000", "9000", "0100", "0900", "0010", "0090", "0001", "0009"}. None is the target. Enqueue all. Mark visited.

Step 3: Level 0 complete. moves = 1. Queue has 8 states.

Step 4: Level 1 — Process "1000": neighbor "1100" is new, enqueue. Process "0100": neighbor "0101" is a DEADEND → skip! Process remaining Level 1 states.

Step 5: Level 1 complete. moves = 2. Among new states enqueued: "1100".

Step 6: Level 2 — Process "1100": neighbor "1200" is new, enqueue.

Step 7: Level 2 complete. moves = 3. "1200" is in the queue.

Step 8: Level 3 — Process "1200": neighbor "1201" is new, enqueue.

Step 9: Level 3 complete. moves = 4.

Step 10: Level 4 — Process "1201": neighbor "0201" is a DEADEND → skip! Neighbor "1202" is valid, enqueue.

Step 11: Level 4 complete. moves = 5.

Step 12: Level 5 — Process "1202": neighbor "0202" equals the TARGET! Return moves + 1 = 6.

Step 13: Path found: "0000" → "1000" → "1100" → "1200" → "1201" → "1202" → "0202". Total: 6 turns.

BFS — Level-by-Level Exploration of Lock States — Watch how BFS systematically explores all states at distance d before moving to distance d+1, guaranteeing the shortest path is found first.

Algorithm

  1. Place all deadends in a hash set for O(1) lookup
  2. If "0000" is a deadend, return -1 immediately
  3. If target is "0000", return 0
  4. Initialize a queue with "0000" and a visited set containing "0000"
  5. Set moves = 0
  6. While the queue is not empty:
    a. Record current queue size (number of states at this BFS level)
    b. For each state in this level:
    • Dequeue the state
    • For each of 4 wheels, try turning up (+1) and down (-1):
      • If the new state equals target, return moves + 1
      • If not visited and not a deadend, mark visited and enqueue
        c. Increment moves
  7. Queue empty and target not found → return -1

Code

class Solution {
public:
    int openLock(vector<string>& deadends, string target) {
        unordered_set<string> dead(deadends.begin(), deadends.end());
        if (dead.count("0000")) return -1;
        if (target == "0000") return 0;

        queue<string> q;
        unordered_set<string> visited;
        q.push("0000");
        visited.insert("0000");
        int moves = 0;

        while (!q.empty()) {
            int size = q.size();
            for (int k = 0; k < size; k++) {
                string state = q.front();
                q.pop();
                for (int i = 0; i < 4; i++) {
                    for (int delta : {1, -1}) {
                        string next = state;
                        next[i] = '0' + (state[i] - '0' + delta + 10) % 10;
                        if (next == target) return moves + 1;
                        if (!dead.count(next) && !visited.count(next)) {
                            visited.insert(next);
                            q.push(next);
                        }
                    }
                }
            }
            moves++;
        }

        return -1;
    }
};
from collections import deque

class Solution:
    def openLock(self, deadends: list[str], target: str) -> int:
        dead = set(deadends)
        if "0000" in dead:
            return -1
        if target == "0000":
            return 0

        queue = deque(["0000"])
        visited = {"0000"}
        moves = 0

        while queue:
            size = len(queue)
            for _ in range(size):
                state = queue.popleft()
                for i in range(4):
                    d = int(state[i])
                    for delta in [1, -1]:
                        new_digit = (d + delta) % 10
                        new_state = state[:i] + str(new_digit) + state[i + 1:]
                        if new_state == target:
                            return moves + 1
                        if new_state not in visited and new_state not in dead:
                            visited.add(new_state)
                            queue.append(new_state)
            moves += 1

        return -1
import java.util.*;

class Solution {
    public int openLock(String[] deadends, String target) {
        Set<String> dead = new HashSet<>(Arrays.asList(deadends));
        if (dead.contains("0000")) return -1;
        if (target.equals("0000")) return 0;

        Queue<String> queue = new LinkedList<>();
        Set<String> visited = new HashSet<>();
        queue.offer("0000");
        visited.add("0000");
        int moves = 0;

        while (!queue.isEmpty()) {
            int size = queue.size();
            for (int k = 0; k < size; k++) {
                String state = queue.poll();
                for (int i = 0; i < 4; i++) {
                    for (int delta : new int[]{1, -1}) {
                        char[] chars = state.toCharArray();
                        chars[i] = (char) ('0' + (chars[i] - '0' + delta + 10) % 10);
                        String next = new String(chars);
                        if (next.equals(target)) return moves + 1;
                        if (!dead.contains(next) && !visited.contains(next)) {
                            visited.add(next);
                            queue.offer(next);
                        }
                    }
                }
            }
            moves++;
        }

        return -1;
    }
}

Complexity Analysis

Time Complexity: O(N × D + D_set)

Where N = 10^4 = 10,000 (total possible 4-digit states), D = 8 (neighbors per state: 4 wheels × 2 directions), and D_set is the number of deadends.

Each of the N states is visited at most once, and for each we check 8 neighbors — this gives O(N × 8) = O(N) for the BFS. Building the deadends set costs O(D_set). The total is O(N + D_set), which is at most O(10,000 + 500) = O(10,500). This is extremely fast.

Space Complexity: O(N)

The visited set can hold up to N states. The queue can hold up to N states in the worst case. The deadends set holds D_set entries. Total space: O(N + D_set) = O(N).