Hi👋SpeedAlgo • Deliberate Practice & Cognitive Ergonomics for Software Engineers

An interactive algorithm mastery and technical interview preparation platform published by Hi👋WebEnterprise. Built for senior and staff software engineers preparing for rigorous coding screens at top tech companies (FAANG/MAMAA).

13 Core Algorithmic Patterns & 225 Practice Problems

  • 1. Two Pointers (16 Paradigms, 43 Problems): Opposite ends, sorted pair sums, palindrome verification, trapping rain water, 3Sum, container with most water — includes the Sliding Window and Fast & Slow Pointers paradigms (contiguous subarray invariants, longest substrings, Floyd's cycle detection, linked list middle).
  • 2. Binary Search (9 Paradigms, 14 Problems): Monotonic predicate partitioning (feasible(x)), boundary search, rotated sorted arrays, square roots, minimum ship capacity, median of two sorted arrays, matrix median on value range.
  • 3. Bit Manipulation (5 Paradigms, 11 Problems): Counting set bits, XOR cancellation, bit reversal, bitmask pairing, maximum XOR with a bitwise trie.
  • 4. Math & Geometry (8 Paradigms, 14 Problems): Sieve of Eratosthenes, integer/roman conversions, modular arithmetic, geometric simulation.
  • 5. Tree/Graph Depth-First Search (15 Paradigms, 24 Problems): Path sums, lowest common ancestor, tree diameter, validating BSTs, tree DP (House Robber III, Binary Tree Cameras).
  • 6. Tree/Graph Breadth-First Search (5 Paradigms, 12 Problems): Level-order traversals, shortest path in unweighted graphs, zig-zag traversals, rotting oranges, word ladders.
  • 7. Graphs (12 Paradigms, 22 Problems): Topological sort, cycle detection, Dijkstra's algorithm, bipartite graph validation, network delay.
  • 8. Backtracking (6 Paradigms, 14 Problems): Subsets, permutations, combinations, constraint satisfaction, pruning, N-Queens, Sudoku solver.
  • 9. Dynamic Programming (13 Paradigms, 28 Problems): Memoization vs tabulation, 0/1 knapsack, unbounded knapsack, coin change, edit distance, longest common subsequence.
  • 10. Heap / Priority Queue (9 Paradigms, 11 Problems): Running medians, top-k elements, interval scheduling, IPO, k-way merges.
  • 11. Advanced Data Structures (7 Paradigms, 17 Problems): Trie (prefix tree), Union-Find (Disjoint Set Union), LRU Cache, LFU Cache, range-sum queries with updates.
  • 12. Stack (5 Paradigms, 9 Problems): Next greater element, largest rectangle in histogram, remove k digits, decode string, min stack.
  • 13. Intervals & Greedy (2 Paradigms, 6 Problems): Merge intervals, interval scheduling, meeting rooms by sweep line, jump game.

4-Stage Deliberate Practice Framework

  1. Stage 1 (Compare & Learn): Read the pattern's mental model, loop invariant and Big-O proof, compare the pattern's template with the problem's solution side by side, and use the step-by-step debugger. The solution can also be shown in C#, Java, TypeScript, C++, Go or Rust for almost every problem.
  2. Stage 2 (Active Recall): Rebuild the solution from memory in a code editor, with an invariant checklist and optional progressive hints, and check it against a judged test suite.
  3. Stage 3 (AI Interview Coach, coming soon): A mock-interview stage still in development; for now, students go straight from Stage 2 to Stage 4.
  4. Stage 4 (Solve on Your Own): Solve a new problem on your own against a 15-minute timer; your code is graded against a test suite. Python and TypeScript run in your browser and C# runs on our server (design problems grade in Python and TypeScript).

Equipped with a spaced-repetition review hub on a fixed 7-step schedule (1 day up to 90 days), a two-pane Studio workspace, and study notes.

Pricing, Access & Commercial Terms

  • Core Curriculum: 100% Free. No credit card required.
  • Coins: New accounts get 40 free coins once, the daily claim adds +20 coins once a day, and inviting a friend earns you +25 coins when they complete their first practice step. These are the only ways to earn coins.
  • AI Coach: Each AI coach question costs 1-5 coins, priced by the server according to its size.
  • BYOK (Bring Your Own Key): AI coaching is free with your own Google Gemini, OpenAI or OpenRouter API key (BYOK).
  • Extras: Coins also unlock extras for 24 hours: a Recall hint level (1 coin), a Solve solution reveal (2), a LeetCode problem card's solutions (1) and a pattern's extra canonical problems (1).
  • Payments & Refunds: Nothing on the site is sold for money: coins can only be earned, never bought. There are no subscriptions or charges, so there is nothing to refund.
  • Platform Operator: Hi👋WebEnterprise. Support & policies at hispeedalgo.com.
Skip to main content
Hi👋SpeedAlgo

Invariant-First Algorithmic Mastery

Arena
238Items
Theory Context•Breadth-First Search (BFS)

Builds on Shortest-Path BFS, which you haven’t finished yet.

P2HardBuilt on Level-Order BFS~5 min speedrun

BFS on an Implicit State Graph (incl. Bidirectional BFS)

Built on
Shortest-Path BFSA node is a whole configuration, and its neighbours are built by changing it a little.
The problem

Open the Lock (LC 752)(opens leetcode.com)

A lock has 4 wheels side by side, and each wheel shows one digit from '0' to '9'. One move turns a single wheel by one position, up or down, and the wheels wrap around: turning up from '9' shows '0', and turning down from '0' shows '9'. The lock begins at "0000", and what it displays is always a 4-character string.

deadends lists codes that jam the lock: as soon as the display shows one of them, the wheels can't be turned any more and the lock can't be opened. Return the smallest number of moves that makes the display show target without ever showing a code from deadends, or -1 when no sequence of moves can do it.

Example 1
Input:deadends = ["0201","0101","0102","1212","2002"], target = "0202"
Output:6
The two neighbours of "0202" that are only 3 turns from the start, "0102" and "0201", are both dead ends, so the lock has to take a detour. One 6-turn route: "0000" -> "0010" -> "0110" -> "0210" -> "0211" -> "0212" -> "0202".

State-Space BFS

start configuration âž” build neighbour configurations âž” BFS depth = fewest moves
Spot it when: You need the fewest moves from one configuration to another, where a configuration is a whole lock code, word, board or (cell, keys) pair and each move changes it a little: there is no grid to walk, so you build the next configurations yourself.
State-Space BFS
Python
1from collections import deque
2from typing import Callable, Hashable, Iterable, List
3 
4 
5def fewest_moves(start: Hashable, is_goal: Callable[[Hashable], bool], next_states: Callable[[Hashable], Iterable[Hashable]], blocked: Iterable[Hashable] = ()) -> int:
6 """State-Space BFS: the fewest moves from start to a goal state, on a graph built one state at a time, or -1.
7 Precondition: every move costs one step, and a state holds everything that decides the moves ahead.
8 next_states(state) yields the states one move away. blocked states may never be entered.
9 V = reachable states. T = the cost of one next_states or is_goal call (it yields at most T states).
10 B = the cost of putting the blocked states into seen.
11 Time: O(B + V * T) | Space: O(B + V) states
12 """
13 seen = set(blocked) # a blocked state may never be entered, so it is treated as already seen
14 # 1. Check the Start State Itself:
15 # Nothing else in the loop tests start, since it is pushed before the loop
16 # begins, so this guard is the only thing standing between a blocked start and
17 # a wrong answer.
18 if start in seen:
19 return -1
20 seen.add(start)
21 queue = deque([(start, 0)]) # (state, steps taken to reach it)
22 
23 while queue:
24 # 2. Pop and Check for the Goal:
25 # Every move costs exactly 1, so the first time is_goal is true, steps is
26 # the fewest moves needed — no later pop can report less.
27 state, steps = queue.popleft()
28 if is_goal(state):
29 return steps
30 
31 # 3. Enqueue Every Unseen Neighbour:
32 # Marking a neighbour seen at enqueue time, not at pop time, stops it from
33 # being queued twice while it is still waiting in line.
34 for neighbor in next_states(state):
35 if neighbor not in seen:
36 seen.add(neighbor)
37 queue.append((neighbor, steps + 1))
38 
39 return -1
Invariant: seen holds every dead end and every state ever pushed, so each state enters queue at most once, and queue pops states in non-decreasing order of steps.

Write each configuration as one hashable state (here the 4-digit code) and run BFS on a graph that is never stored: pop a state, build each neighbor on the spot by turning one wheel one slot with (digit + move) % 10, and push every neighbor that is not in seen. Every move costs one step, so BFS pops states in order of steps, and the first time target comes off queue its steps is the fewest; putting the dead ends into seen before the search means no move can ever enter one. The trap is a partial key: seen must hold the whole state, here all four wheels, and (row, col, keys) or (row, col, removals_left) when a maze adds keys or wall removals. A later visit that differs only in the left-out part is a different state, and a key without that part skips it as already seen.

See it run

State-Space BFS on a lock, step by step

state 0000 · steps 0 · queue 1 · seen 2

state 0000 · steps 0 · queue 0 · seen 2

state 0000 · steps 0 · queue 8 · seen 10

state 1000 · steps 1 · queue 7 · seen 10

state 1000 · steps 1 · queue 13 · seen 16

state 9000 · steps 1 · queue 12 · seen 16

state 9000 · steps 1 · queue 19 · seen 23

state 0100 · steps 1 · queue 18 · seen 23

state 0100 · steps 1 · queue 23 · seen 28

state 0900 · steps 1 · queue 22 · seen 28

state 0900 · steps 1 · queue 27 · seen 33

state 0010 · steps 1 · queue 26 · seen 33

state 0010 · steps 1 · queue 29 · seen 36

state 0090 · steps 1 · queue 28 · seen 36

state 0090 · steps 1 · queue 31 · seen 39

state 0001 · steps 1 · queue 30 · seen 39

state 0001 · steps 1 · queue 31 · seen 40

state 0009 · steps 1 · queue 30 · seen 40

state 0009 · steps 1 · queue 31 · seen 41

state 1100 · steps 2 · queue 30 · seen 41

state - · steps 2 · queue 30 · seen 41 · answer 2

Each column is one wheel. The first row is the popped state. The other rows are the states made by turning that wheel up or down by one slot.

  • pushed a new state, added to seen and to the queue
  • seen a state that seen already holds
  • dead end a state that may never be entered

Start: seen holds the dead ends "2000" and "0000". The queue holds ("0000", 0).

Pop "0000" with steps = 0. It is not target "1100", so build its 8 neighbours.

Pushed 8 new states with steps = 1.

Pop "1000" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 6 new states with steps = 2. Skipped 2: "2000" is a dead end and "0000" is in seen.

Pop "9000" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 7 new states with steps = 2. Skipped 1: "0000" is in seen.

Pop "0100" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 5 new states with steps = 2. "1100" and "9100" are still waiting in the queue but seen already holds them, so they are not pushed again.

Pop "0900" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 5 new states with steps = 2. "1900" and "9900" are still waiting in the queue but seen already holds them, so they are not pushed again.

Pop "0010" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 3 new states with steps = 2. "1010" and "9010" and "0110" and "0910" are still waiting in the queue but seen already holds them, so they are not pushed again.

Pop "0090" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 3 new states with steps = 2. "1090" and "9090" and "0190" and "0990" are still waiting in the queue but seen already holds them, so they are not pushed again.

Pop "0001" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 1 new states with steps = 2. "1001" and "9001" and "0101" and "0901" and "0011" and "0091" are still waiting in the queue but seen already holds them, so they are not pushed again.

Pop "0009" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 1 new states with steps = 2. "1009" and "9009" and "0109" and "0909" and "0019" and "0099" are still waiting in the queue but seen already holds them, so they are not pushed again.

Pop "1100" with steps = 2. It equals target "1100", so return 2.

Done: target "1100" was popped first with steps = 2, so the answer is 2.

state
wheel 10
wheel 20
wheel 30
wheel 40
turn up
turn down
1 / 21
Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Keying seen on part of the state

    Mistake
    if (nr, nc) not in seen:
        seen.add((nr, nc))
    Fix
    if (nr, nc, nrem) not in seen:
        seen.add((nr, nc, nrem))

    What happens: seen.add(neighbor) must store the whole state. Here that is all four wheels. In Shortest Path in a Grid with Obstacles Elimination (LC 1293), the state is (row, col, removals_left). A later visit to the same cell with more removals left is a different state. Keyed on the cell alone, seen skips it, and the answer is lost.

    Example: Shortest Path in a Grid with Obstacles Elimination (LC 1293): grid = [[0,0],[1,1],[1,1],[1,0]], k = 2
    Mistake: returns -1Fix: returns 4
  2. 2

    Gotcha 2: Not checking whether "0000" is a dead end

    Mistake
    seen.add("0000")
    Fix
    if "0000" in seen:
        return -1
    seen.add("0000")

    What happens: The search pushes "0000" before the loop starts. The loop only tests the neighbours it builds, never the start. If "0000" is a dead end, the lock can never turn. Without the check, the search walks out of the dead end anyway.

    Example: deadends = ["0000"], target = "8888"
    Mistake: returns 8Fix: returns -1
  3. 3

    Gotcha 3: Building the next digit without % 10

    Mistake
    neighbor = state[:i] + str(digit + move) + state[i + 1:]
    Fix
    neighbor = state[:i] + str((digit + move) % 10) + state[i + 1:]

    What happens: A wheel wraps around. 9 turns up to 0, and 0 turns down to 9. Without % 10, turning 0 down builds the text "-1". The state "0000" then gets the neighbour "-1000", which has five characters. When that state is popped, int(state[i]) reads "-", and Python raises a ValueError. In Java, C++ and C#, -1 % 10 is -1, so write (digit + move + 10) % 10 there.

    Example: deadends = [], target = "0009"
    Mistake: raises ValueErrorFix: returns 1
  4. 4

    Gotcha 4: Searching from one side when the goal is known

    What happens: In Word Ladder (LC 127), each state has many neighbours, and the goal state is known. A search from the start alone builds states level by level up to the full depth. Bidirectional BFS grows one frontier from each end and always expands the smaller one. It stops when a new neighbor is in the other frontier. Each side goes only about half the depth, so far fewer states are built.

Not this one if…
  • Where you are is just a cell of a given grid, as in Shortest Path in Binary Matrix (LC 1091): the neighbours are read off the grid, and no state has to be encoded or built. See Shortest-Path BFS
  • The states are still built on the spot, but only some moves count, as in Minimum Moves to Move a Box to Their Target Location (LC 1263), where walking is free and each push costs 1: BFS levels no longer equal the answer. See Zero-One BFS

Deep Dive

A state is everything that decides the moves ahead

Shortest-Path BFS already shows that the first pop of the goal is the fewest moves. This card changes only what a node is. A node is a state, and the graph is never stored. The code builds the neighbours of one state at the moment it pops it.

In Open the Lock (LC 752), the state is the four-digit text such as "0000". There are 10^4 = 10,000 states. Each wheel can turn up or down by one slot, so every state has 8 neighbours. The wheel wraps, so (digit + move) % 10 gives 0 after 9 and 9 before 0.

To choose the state, ask this question: if two situations have the same state, do they have the same moves and the same future? If not, a part of the situation is missing from the key.

Example: in Shortest Path in a Grid with Obstacles Elimination (LC 1293), the cell (row, col) is not enough. A visit with 2 removals left can go where a visit with 0 removals left cannot. The state is (row, col, removals_left). For grid = [[0,0],[1,1],[1,1],[1,0]] and k = 2, the full key returns 4 and the key (row, col) returns -1.

One trace: dead ends in seen, and a state that waits in the queue

The board replays openLock(["2000"], "1100"). The answer is 2. The first row is the popped state, and the next two rows are the 8 neighbours, two for each wheel.

seen starts with "2000", so no move can enter that dead end. Then the start "0000" is added. The pop of "0000" pushes 8 states with steps = 1, in this order: "1000", "9000", "0100", "0900", "0010", "0090", "0001", "0009".

The pop of "1000" builds "2000", which is a dead end, and "0000", which is in seen. It pushes 6 new states with steps = 2, and "1100" is the first of them.

Later, the pop of "0100" builds "1100" and "9100". Both states are still waiting in queue, but seen already holds them, so neither is pushed a second time. This is the first step marked as the trap on the board. The pops of "0900", "0010", "0090", "0001" and "0009" each repeat it with two more states.

After all 8 states with steps = 1 are popped, "1100" comes to the front. It equals target, so the function returns 2.

Dead ends and the start state

A dead end is a state that may never be entered. The blueprint puts the dead ends into seen before the search. A move to a dead end then fails the test neighbor not in seen, the same test that skips a visited state. No extra check is needed in the loop.

The start is different. The search adds "0000" to seen and to queue before the loop, so the loop never tests it. The guard if "0000" in seen: return -1 must run first. For deadends = ["0000"] and target = "8888", the right answer is -1. Without the guard, the search leaves the dead end and returns 8.

Example: for deadends = [] and target = "0000", the guard passes, the first pop equals target, and the answer is 0.

Wrapping needs % 10. Without it, turning 0 down builds the text "-1000". When that state is popped, int(state[i]) reads "-", and Python raises ValueError. In Java, C++ and C#, write (digit + move + 10) % 10, because -1 % 10 is -1 there.

The cost: states times work per state, and bidirectional search

The card's complexity line is O(B + V * T). V is the number of reachable states, T is the cost of one next_states call, and B is the cost of putting the blocked states into seen.

For openLock, W = 4 wheels. There are at most 10^W states. One state builds 2 * W neighbours, and each neighbour is a new text of W characters, so T = O(W^2). With D dead ends, the total is O(10^W * W^2 + D * W) time.

Example: for the LeetCode example with dead ends ["0201","0101","0102","1212","2002"] and target "0202", the answer is 6, and 822 states are popped. For deadends = [] and target = "5555", the answer is 20, and all 10,000 states are popped.

When the goal state is known and the moves can be undone, bidirectional BFS searches from both ends. It keeps one set of states from start and one from target. Each round it expands the smaller set. It stops when a new neighbor is in the other set. Each side goes about half the depth, so far fewer states are built. This is the method for Word Ladder (LC 127).

60-Second Pitch

"The lock has 10,000 states, and a move turns one wheel by one slot. So I treat each four-digit text as a node and build its 8 neighbours when I pop it, with (digit + move) % 10. I put the dead ends into seen first, so no move can enter one. I also check that "0000" is not a dead end, because the loop never tests the start. Every move costs one step, so BFS pops states in order of steps. The first pop of target is the fewest turns, and an empty queue means -1. The time is O(10^W * W^2 + D * W). If the goal is known, I can search from both ends and expand the smaller side."

Common Questions

How do you decide what goes into the state?

Put in every value that changes which moves are possible or what the answer is later. Test it with two situations that have the same state: they must have the same future. In Shortest Path in a Grid with Obstacles Elimination (LC 1293), the cell is not enough, and removals_left must be in the key.

Why are the dead ends put into seen and not checked in the loop?

The loop already skips every neighbor that is in seen. A dead end is a state that may never be entered, which is the same rule for the loop. One set and one test cover both cases. The start needs its own check, because it is pushed before the loop.

What are the time and space complexity of openLock?

O(10^W * W^2 + D * W) time and O(10^W * W + D * W) space, where W is the number of wheels and D is the number of dead ends. There are at most 10^W states. Each builds 2 * W neighbours of W characters, and seen stores the states and the dead ends.

When is bidirectional BFS worth using, and how does it stop?

Use it when the goal state is known and a move can be undone, so the search can also start from target. Keep one set of states from each end, and always expand the smaller set. Stop when a new neighbor is in the other set. Add the steps of both sides for the answer.

What changes when the moves have different costs?

The first pop of target is then not always the cheapest. A path with more moves can cost less. For general positive costs, use a cheapest-first search with a heap, as in Network Delay Time (LC 743). The state idea stays the same.

SWE Reasoning

Spot it

  • The question asks for the fewest moves between two configurations, such as a lock code, a word, a board or a pair of values.
  • There is no grid or graph in the input. The code has to build the next configurations from the current one.
  • Each move changes the configuration a little: one wheel, one letter, one tile.
  • Every move costs the same, and some configurations are forbidden, such as dead ends.
  • The number of configurations is limited, for example 10^4 lock codes, so a search over all of them is affordable.

The invariant, step by step

  1. seen starts with every dead end. If "0000" is in seen, the function returns -1.
  2. Otherwise "0000" is added to seen, and queue holds ("0000", 0).
  3. The loop pops (state, steps). If state == target, it returns steps.
  4. It builds the 8 neighbours by turning one wheel one slot with (digit + move) % 10.
  5. A neighbor that is not in seen is added to seen and pushed with steps + 1. A dead end and a visited state are both in seen, so both are skipped.
  6. Each state enters queue at most once, and the pops come out in non-decreasing order of steps. The first pop of target is the fewest moves. If queue empties first, the function returns -1.

Traps

  • Key is a part of the state. In Shortest Path in a Grid with Obstacles Elimination (LC 1293), a key of (row, col) skips a later visit with more removals left. For [[0,0],[1,1],[1,1],[1,0]] and k = 2, the answer is -1 instead of 4. Put every value that changes the future moves into the key.
  • Start in seen is not checked. The loop never tests "0000". For deadends = ["0000"], the search returns 8 instead of -1 for target = "8888".
  • Missing % 10. Turning 0 down builds "-1000", and the next pop raises ValueError. The same code with % 10 returns 1 for target = "0009".
  • Mark at pop time. A state waits in queue without being in seen, so other states push it again. For target = "5555", marking at pop time with a skip for popped states makes 40,001 pushes, and marking at push time makes 10,000.
  • One-sided search with a known goal. In Word Ladder (LC 127), a search from the start alone builds every level to the full depth. Search from both ends and expand the smaller set.

Practice

Canonical LeetCode:Open the Lock (LC 752)Word Ladder (LC 127)Minimum Genetic Mutation (LC 433)Sliding Puzzle (LC 773)Shortest Path to Get All Keys (LC 864)Shortest Path in a Grid with Obstacles Elimination (LC 1293)Shortest Path with Alternating Colors (LC 1129)The Maze (LC 490)
Theory Context•Breadth-First Search (BFS)

Builds on Shortest-Path BFS, which you haven’t finished yet.

P2HardBuilt on Level-Order BFS~5 min speedrun

BFS on an Implicit State Graph (incl. Bidirectional BFS)

Built on
Shortest-Path BFSA node is a whole configuration, and its neighbours are built by changing it a little.
The problem

Open the Lock (LC 752)(opens leetcode.com)

A lock has 4 wheels side by side, and each wheel shows one digit from '0' to '9'. One move turns a single wheel by one position, up or down, and the wheels wrap around: turning up from '9' shows '0', and turning down from '0' shows '9'. The lock begins at "0000", and what it displays is always a 4-character string.

deadends lists codes that jam the lock: as soon as the display shows one of them, the wheels can't be turned any more and the lock can't be opened. Return the smallest number of moves that makes the display show target without ever showing a code from deadends, or -1 when no sequence of moves can do it.

Example 1
Input:deadends = ["0201","0101","0102","1212","2002"], target = "0202"
Output:6
The two neighbours of "0202" that are only 3 turns from the start, "0102" and "0201", are both dead ends, so the lock has to take a detour. One 6-turn route: "0000" -> "0010" -> "0110" -> "0210" -> "0211" -> "0212" -> "0202".

State-Space BFS

start configuration âž” build neighbour configurations âž” BFS depth = fewest moves
Spot it when: You need the fewest moves from one configuration to another, where a configuration is a whole lock code, word, board or (cell, keys) pair and each move changes it a little: there is no grid to walk, so you build the next configurations yourself.
State-Space BFS
Python
1from collections import deque
2from typing import Callable, Hashable, Iterable, List
3 
4 
5def fewest_moves(start: Hashable, is_goal: Callable[[Hashable], bool], next_states: Callable[[Hashable], Iterable[Hashable]], blocked: Iterable[Hashable] = ()) -> int:
6 """State-Space BFS: the fewest moves from start to a goal state, on a graph built one state at a time, or -1.
7 Precondition: every move costs one step, and a state holds everything that decides the moves ahead.
8 next_states(state) yields the states one move away. blocked states may never be entered.
9 V = reachable states. T = the cost of one next_states or is_goal call (it yields at most T states).
10 B = the cost of putting the blocked states into seen.
11 Time: O(B + V * T) | Space: O(B + V) states
12 """
13 seen = set(blocked) # a blocked state may never be entered, so it is treated as already seen
14 # 1. Check the Start State Itself:
15 # Nothing else in the loop tests start, since it is pushed before the loop
16 # begins, so this guard is the only thing standing between a blocked start and
17 # a wrong answer.
18 if start in seen:
19 return -1
20 seen.add(start)
21 queue = deque([(start, 0)]) # (state, steps taken to reach it)
22 
23 while queue:
24 # 2. Pop and Check for the Goal:
25 # Every move costs exactly 1, so the first time is_goal is true, steps is
26 # the fewest moves needed — no later pop can report less.
27 state, steps = queue.popleft()
28 if is_goal(state):
29 return steps
30 
31 # 3. Enqueue Every Unseen Neighbour:
32 # Marking a neighbour seen at enqueue time, not at pop time, stops it from
33 # being queued twice while it is still waiting in line.
34 for neighbor in next_states(state):
35 if neighbor not in seen:
36 seen.add(neighbor)
37 queue.append((neighbor, steps + 1))
38 
39 return -1
Invariant: seen holds every dead end and every state ever pushed, so each state enters queue at most once, and queue pops states in non-decreasing order of steps.

Write each configuration as one hashable state (here the 4-digit code) and run BFS on a graph that is never stored: pop a state, build each neighbor on the spot by turning one wheel one slot with (digit + move) % 10, and push every neighbor that is not in seen. Every move costs one step, so BFS pops states in order of steps, and the first time target comes off queue its steps is the fewest; putting the dead ends into seen before the search means no move can ever enter one. The trap is a partial key: seen must hold the whole state, here all four wheels, and (row, col, keys) or (row, col, removals_left) when a maze adds keys or wall removals. A later visit that differs only in the left-out part is a different state, and a key without that part skips it as already seen.

See it run

State-Space BFS on a lock, step by step

state 0000 · steps 0 · queue 1 · seen 2

state 0000 · steps 0 · queue 0 · seen 2

state 0000 · steps 0 · queue 8 · seen 10

state 1000 · steps 1 · queue 7 · seen 10

state 1000 · steps 1 · queue 13 · seen 16

state 9000 · steps 1 · queue 12 · seen 16

state 9000 · steps 1 · queue 19 · seen 23

state 0100 · steps 1 · queue 18 · seen 23

state 0100 · steps 1 · queue 23 · seen 28

state 0900 · steps 1 · queue 22 · seen 28

state 0900 · steps 1 · queue 27 · seen 33

state 0010 · steps 1 · queue 26 · seen 33

state 0010 · steps 1 · queue 29 · seen 36

state 0090 · steps 1 · queue 28 · seen 36

state 0090 · steps 1 · queue 31 · seen 39

state 0001 · steps 1 · queue 30 · seen 39

state 0001 · steps 1 · queue 31 · seen 40

state 0009 · steps 1 · queue 30 · seen 40

state 0009 · steps 1 · queue 31 · seen 41

state 1100 · steps 2 · queue 30 · seen 41

state - · steps 2 · queue 30 · seen 41 · answer 2

Each column is one wheel. The first row is the popped state. The other rows are the states made by turning that wheel up or down by one slot.

  • pushed a new state, added to seen and to the queue
  • seen a state that seen already holds
  • dead end a state that may never be entered

Start: seen holds the dead ends "2000" and "0000". The queue holds ("0000", 0).

Pop "0000" with steps = 0. It is not target "1100", so build its 8 neighbours.

Pushed 8 new states with steps = 1.

Pop "1000" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 6 new states with steps = 2. Skipped 2: "2000" is a dead end and "0000" is in seen.

Pop "9000" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 7 new states with steps = 2. Skipped 1: "0000" is in seen.

Pop "0100" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 5 new states with steps = 2. "1100" and "9100" are still waiting in the queue but seen already holds them, so they are not pushed again.

Pop "0900" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 5 new states with steps = 2. "1900" and "9900" are still waiting in the queue but seen already holds them, so they are not pushed again.

Pop "0010" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 3 new states with steps = 2. "1010" and "9010" and "0110" and "0910" are still waiting in the queue but seen already holds them, so they are not pushed again.

Pop "0090" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 3 new states with steps = 2. "1090" and "9090" and "0190" and "0990" are still waiting in the queue but seen already holds them, so they are not pushed again.

Pop "0001" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 1 new states with steps = 2. "1001" and "9001" and "0101" and "0901" and "0011" and "0091" are still waiting in the queue but seen already holds them, so they are not pushed again.

Pop "0009" with steps = 1. It is not target "1100", so build its 8 neighbours.

Pushed 1 new states with steps = 2. "1009" and "9009" and "0109" and "0909" and "0019" and "0099" are still waiting in the queue but seen already holds them, so they are not pushed again.

Pop "1100" with steps = 2. It equals target "1100", so return 2.

Done: target "1100" was popped first with steps = 2, so the answer is 2.

state
wheel 10
wheel 20
wheel 30
wheel 40
turn up
turn down
1 / 21
Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Keying seen on part of the state

    Mistake
    if (nr, nc) not in seen:
        seen.add((nr, nc))
    Fix
    if (nr, nc, nrem) not in seen:
        seen.add((nr, nc, nrem))

    What happens: seen.add(neighbor) must store the whole state. Here that is all four wheels. In Shortest Path in a Grid with Obstacles Elimination (LC 1293), the state is (row, col, removals_left). A later visit to the same cell with more removals left is a different state. Keyed on the cell alone, seen skips it, and the answer is lost.

    Example: Shortest Path in a Grid with Obstacles Elimination (LC 1293): grid = [[0,0],[1,1],[1,1],[1,0]], k = 2
    Mistake: returns -1Fix: returns 4
  2. 2

    Gotcha 2: Not checking whether "0000" is a dead end

    Mistake
    seen.add("0000")
    Fix
    if "0000" in seen:
        return -1
    seen.add("0000")

    What happens: The search pushes "0000" before the loop starts. The loop only tests the neighbours it builds, never the start. If "0000" is a dead end, the lock can never turn. Without the check, the search walks out of the dead end anyway.

    Example: deadends = ["0000"], target = "8888"
    Mistake: returns 8Fix: returns -1
  3. 3

    Gotcha 3: Building the next digit without % 10

    Mistake
    neighbor = state[:i] + str(digit + move) + state[i + 1:]
    Fix
    neighbor = state[:i] + str((digit + move) % 10) + state[i + 1:]

    What happens: A wheel wraps around. 9 turns up to 0, and 0 turns down to 9. Without % 10, turning 0 down builds the text "-1". The state "0000" then gets the neighbour "-1000", which has five characters. When that state is popped, int(state[i]) reads "-", and Python raises a ValueError. In Java, C++ and C#, -1 % 10 is -1, so write (digit + move + 10) % 10 there.

    Example: deadends = [], target = "0009"
    Mistake: raises ValueErrorFix: returns 1
  4. 4

    Gotcha 4: Searching from one side when the goal is known

    What happens: In Word Ladder (LC 127), each state has many neighbours, and the goal state is known. A search from the start alone builds states level by level up to the full depth. Bidirectional BFS grows one frontier from each end and always expands the smaller one. It stops when a new neighbor is in the other frontier. Each side goes only about half the depth, so far fewer states are built.

Not this one if…
  • Where you are is just a cell of a given grid, as in Shortest Path in Binary Matrix (LC 1091): the neighbours are read off the grid, and no state has to be encoded or built. See Shortest-Path BFS
  • The states are still built on the spot, but only some moves count, as in Minimum Moves to Move a Box to Their Target Location (LC 1263), where walking is free and each push costs 1: BFS levels no longer equal the answer. See Zero-One BFS

Deep Dive

A state is everything that decides the moves ahead

Shortest-Path BFS already shows that the first pop of the goal is the fewest moves. This card changes only what a node is. A node is a state, and the graph is never stored. The code builds the neighbours of one state at the moment it pops it.

In Open the Lock (LC 752), the state is the four-digit text such as "0000". There are 10^4 = 10,000 states. Each wheel can turn up or down by one slot, so every state has 8 neighbours. The wheel wraps, so (digit + move) % 10 gives 0 after 9 and 9 before 0.

To choose the state, ask this question: if two situations have the same state, do they have the same moves and the same future? If not, a part of the situation is missing from the key.

Example: in Shortest Path in a Grid with Obstacles Elimination (LC 1293), the cell (row, col) is not enough. A visit with 2 removals left can go where a visit with 0 removals left cannot. The state is (row, col, removals_left). For grid = [[0,0],[1,1],[1,1],[1,0]] and k = 2, the full key returns 4 and the key (row, col) returns -1.

One trace: dead ends in seen, and a state that waits in the queue

The board replays openLock(["2000"], "1100"). The answer is 2. The first row is the popped state, and the next two rows are the 8 neighbours, two for each wheel.

seen starts with "2000", so no move can enter that dead end. Then the start "0000" is added. The pop of "0000" pushes 8 states with steps = 1, in this order: "1000", "9000", "0100", "0900", "0010", "0090", "0001", "0009".

The pop of "1000" builds "2000", which is a dead end, and "0000", which is in seen. It pushes 6 new states with steps = 2, and "1100" is the first of them.

Later, the pop of "0100" builds "1100" and "9100". Both states are still waiting in queue, but seen already holds them, so neither is pushed a second time. This is the first step marked as the trap on the board. The pops of "0900", "0010", "0090", "0001" and "0009" each repeat it with two more states.

After all 8 states with steps = 1 are popped, "1100" comes to the front. It equals target, so the function returns 2.

Dead ends and the start state

A dead end is a state that may never be entered. The blueprint puts the dead ends into seen before the search. A move to a dead end then fails the test neighbor not in seen, the same test that skips a visited state. No extra check is needed in the loop.

The start is different. The search adds "0000" to seen and to queue before the loop, so the loop never tests it. The guard if "0000" in seen: return -1 must run first. For deadends = ["0000"] and target = "8888", the right answer is -1. Without the guard, the search leaves the dead end and returns 8.

Example: for deadends = [] and target = "0000", the guard passes, the first pop equals target, and the answer is 0.

Wrapping needs % 10. Without it, turning 0 down builds the text "-1000". When that state is popped, int(state[i]) reads "-", and Python raises ValueError. In Java, C++ and C#, write (digit + move + 10) % 10, because -1 % 10 is -1 there.

The cost: states times work per state, and bidirectional search

The card's complexity line is O(B + V * T). V is the number of reachable states, T is the cost of one next_states call, and B is the cost of putting the blocked states into seen.

For openLock, W = 4 wheels. There are at most 10^W states. One state builds 2 * W neighbours, and each neighbour is a new text of W characters, so T = O(W^2). With D dead ends, the total is O(10^W * W^2 + D * W) time.

Example: for the LeetCode example with dead ends ["0201","0101","0102","1212","2002"] and target "0202", the answer is 6, and 822 states are popped. For deadends = [] and target = "5555", the answer is 20, and all 10,000 states are popped.

When the goal state is known and the moves can be undone, bidirectional BFS searches from both ends. It keeps one set of states from start and one from target. Each round it expands the smaller set. It stops when a new neighbor is in the other set. Each side goes about half the depth, so far fewer states are built. This is the method for Word Ladder (LC 127).

60-Second Pitch

"The lock has 10,000 states, and a move turns one wheel by one slot. So I treat each four-digit text as a node and build its 8 neighbours when I pop it, with (digit + move) % 10. I put the dead ends into seen first, so no move can enter one. I also check that "0000" is not a dead end, because the loop never tests the start. Every move costs one step, so BFS pops states in order of steps. The first pop of target is the fewest turns, and an empty queue means -1. The time is O(10^W * W^2 + D * W). If the goal is known, I can search from both ends and expand the smaller side."

Common Questions

How do you decide what goes into the state?

Put in every value that changes which moves are possible or what the answer is later. Test it with two situations that have the same state: they must have the same future. In Shortest Path in a Grid with Obstacles Elimination (LC 1293), the cell is not enough, and removals_left must be in the key.

Why are the dead ends put into seen and not checked in the loop?

The loop already skips every neighbor that is in seen. A dead end is a state that may never be entered, which is the same rule for the loop. One set and one test cover both cases. The start needs its own check, because it is pushed before the loop.

What are the time and space complexity of openLock?

O(10^W * W^2 + D * W) time and O(10^W * W + D * W) space, where W is the number of wheels and D is the number of dead ends. There are at most 10^W states. Each builds 2 * W neighbours of W characters, and seen stores the states and the dead ends.

When is bidirectional BFS worth using, and how does it stop?

Use it when the goal state is known and a move can be undone, so the search can also start from target. Keep one set of states from each end, and always expand the smaller set. Stop when a new neighbor is in the other set. Add the steps of both sides for the answer.

What changes when the moves have different costs?

The first pop of target is then not always the cheapest. A path with more moves can cost less. For general positive costs, use a cheapest-first search with a heap, as in Network Delay Time (LC 743). The state idea stays the same.

SWE Reasoning

Spot it

  • The question asks for the fewest moves between two configurations, such as a lock code, a word, a board or a pair of values.
  • There is no grid or graph in the input. The code has to build the next configurations from the current one.
  • Each move changes the configuration a little: one wheel, one letter, one tile.
  • Every move costs the same, and some configurations are forbidden, such as dead ends.
  • The number of configurations is limited, for example 10^4 lock codes, so a search over all of them is affordable.

The invariant, step by step

  1. seen starts with every dead end. If "0000" is in seen, the function returns -1.
  2. Otherwise "0000" is added to seen, and queue holds ("0000", 0).
  3. The loop pops (state, steps). If state == target, it returns steps.
  4. It builds the 8 neighbours by turning one wheel one slot with (digit + move) % 10.
  5. A neighbor that is not in seen is added to seen and pushed with steps + 1. A dead end and a visited state are both in seen, so both are skipped.
  6. Each state enters queue at most once, and the pops come out in non-decreasing order of steps. The first pop of target is the fewest moves. If queue empties first, the function returns -1.

Traps

  • Key is a part of the state. In Shortest Path in a Grid with Obstacles Elimination (LC 1293), a key of (row, col) skips a later visit with more removals left. For [[0,0],[1,1],[1,1],[1,0]] and k = 2, the answer is -1 instead of 4. Put every value that changes the future moves into the key.
  • Start in seen is not checked. The loop never tests "0000". For deadends = ["0000"], the search returns 8 instead of -1 for target = "8888".
  • Missing % 10. Turning 0 down builds "-1000", and the next pop raises ValueError. The same code with % 10 returns 1 for target = "0009".
  • Mark at pop time. A state waits in queue without being in seen, so other states push it again. For target = "5555", marking at pop time with a skip for popped states makes 40,001 pushes, and marking at push time makes 10,000.
  • One-sided search with a known goal. In Word Ladder (LC 127), a search from the start alone builds every level to the full depth. Search from both ends and expand the smaller set.

Practice

Canonical LeetCode:Open the Lock (LC 752)Word Ladder (LC 127)Minimum Genetic Mutation (LC 433)Sliding Puzzle (LC 773)Shortest Path to Get All Keys (LC 864)Shortest Path in a Grid with Obstacles Elimination (LC 1293)Shortest Path with Alternating Colors (LC 1129)The Maze (LC 490)
© 2026 Hi👋WebEnterprise. All rights reserved.
Sitemap•llms.txt•