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•Backtracking & State Search

Builds on Subsets, which you haven’t finished yet.

P1MediumBuilt on Subsets~3 min speedrun

Cartesian Product Backtracking (One Choice per Slot)

Built on
SubsetsEach position has its own list of options, so the loop reads that list and there is no start and no used array.
Unlocks
Count and Skip
The problem

Letter Combinations of a Phone Number (LC 17)(opens leetcode.com)

Given a string of digits from 2 to 9, return every letter combination the digit string could spell out under a telephone-keypad mapping, in any order.

Each digit key 2-9 maps to a fixed group of 3 or 4 letters, exactly like on an old telephone keypad; the digit 1 has no letters mapped to it.

Example 1
Input:digits = "23"
Output:["ad","ae","af","bd","be","bf","cd","ce","cf"]
2 maps to abc and 3 to def, so there are 3 * 3 = 9 letter pairs.
12 abc3 def4 ghi5 jkl6 mno7 pqrs8 tuv9 wxyz

One Choice per Slot

slots in order ➔ one option per slot ➔ record at full length
Spot it when: You must build every string (or list) of a fixed length where each position has its own short list of options and any mix of choices is allowed: phone-key letters, the two cases of a letter, the options inside braces.
One Choice per Slot
Python
1from typing import Callable, Iterable, List, TypeVar
2 
3Item = TypeVar("Item")
4 
5 
6def one_per_slot(slots: int, choices: Callable[[int], Iterable[Item]]) -> List[List[Item]]:
7 """One Choice per Slot: every way to pick one option for each slot 0..slots-1, in slot order.
8 choices(slot) lists the options of that slot. No choice limits the options of another slot.
9 An empty slot gives no full combination, so the result is [].
10 G = the number of full combinations. T = the cost of one choices call.
11 Time: O(slots * G * T) | Space: O(slots) path + recursion stack, output not counted
12 """
13 results: List[List[Item]] = []
14 path: List[Item] = [] # path[k] is the option chosen for slot k
15 
16 def backtrack(index: int) -> None:
17 # 1. Save a Complete Combination:
18 # Every slot has an option chosen only once index reaches slots; a shorter
19 # path still has empty slots, so nothing is saved before that.
20 if index == slots:
21 results.append(list(path))
22 return
23 
24 # 2. Choose, Explore, Un-choose, One Slot's Options Only:
25 # choices(index) lists only the options of slot index, and no other slot
26 # limits them, so trying each in turn builds every combination once, one
27 # slot at a time.
28 for option in choices(index):
29 path.append(option)
30 backtrack(index + 1)
31 path.pop()
32 
33 backtrack(0)
34 return results
Invariant: path holds exactly one option for each of the slots digits[0..index-1], in order, so len(path) == index in every call.

Every position of the answer is its own slot with its own list of options, so the recursion depth is the slot: backtrack(index) tries each letter of keypad[digits[index]], appends it to path, and moves on to backtrack(index + 1). The slots never share options and no choice limits another, so there is no start index, no used set and nothing to prune: every branch reaches a full combination, and each combination is built exactly once, one letter per slot in slot order, which is why the answer has exactly the product of the slots' option counts. The trap is where to save: append "".join(path) only when index == len(digits), because a shorter path still has empty slots; saving at every node, as Subsets does, adds partial strings such as "a".

See it run

One Choice per Slot, step by step

index 0 · path [] · saved 0

index 1 · path ['a'] · saved 0

index 1 · path ['a'] · saved 0

index 2 · path ['a', 'd'] · saved 1

index 1 · path ['a', 'd'] · saved 1

index 2 · path ['a', 'e'] · saved 2

index 1 · path ['a', 'e'] · saved 2

index 2 · path ['a', 'f'] · saved 3

index 1 · path ['a', 'f'] · saved 3

index 0 · path ['a'] · saved 3

index 1 · path ['b'] · saved 3

index 2 · path ['b', 'd'] · saved 4

index 1 · path ['b', 'd'] · saved 4

index 2 · path ['b', 'e'] · saved 5

index 1 · path ['b', 'e'] · saved 5

index 2 · path ['b', 'f'] · saved 6

index 1 · path ['b', 'f'] · saved 6

index 0 · path ['b'] · saved 6

index 1 · path ['c'] · saved 6

index 2 · path ['c', 'd'] · saved 7

index 1 · path ['c', 'd'] · saved 7

index 2 · path ['c', 'e'] · saved 8

index 1 · path ['c', 'e'] · saved 8

index 2 · path ['c', 'f'] · saved 9

index 1 · path ['c', 'f'] · saved 9

index 0 · path ['c'] · saved 9

index 0 · path [] · saved 9 · results ad,ae,af,bd,be,bf,cd,ce,cf

The top row is digits and the bottom row is path. Each column of path is one slot.

  • choose the slot that path.append just filled
  • undo the slot that path.pop is about to empty
  • index the digit whose letters backtrack(index) is trying

digits = "23" has 2 slots. path starts empty and backtrack(0) begins.

path.append("a") fills slot 0, then backtrack(1) runs.

path = ['a'] has 1 of 2 slots filled. Nothing is saved yet, because "a" is not a full string.

path.append("d") fills slot 1. index reaches 2, so "ad" is saved.

path.pop() removes "d" from slot 1. The next letter of this slot can be tried.

path.append("e") fills slot 1. index reaches 2, so "ae" is saved.

path.pop() removes "e" from slot 1. The next letter of this slot can be tried.

path.append("f") fills slot 1. index reaches 2, so "af" is saved.

path.pop() removes "f" from slot 1. The next letter of this slot can be tried.

path.pop() removes "a" from slot 0. The next letter of this slot can be tried.

path.append("b") fills slot 0, then backtrack(1) runs.

path.append("d") fills slot 1. index reaches 2, so "bd" is saved.

path.pop() removes "d" from slot 1. The next letter of this slot can be tried.

path.append("e") fills slot 1. index reaches 2, so "be" is saved.

path.pop() removes "e" from slot 1. The next letter of this slot can be tried.

path.append("f") fills slot 1. index reaches 2, so "bf" is saved.

path.pop() removes "f" from slot 1. The next letter of this slot can be tried.

path.pop() removes "b" from slot 0. The next letter of this slot can be tried.

path.append("c") fills slot 0, then backtrack(1) runs.

path.append("d") fills slot 1. index reaches 2, so "cd" is saved.

path.pop() removes "d" from slot 1. The next letter of this slot can be tried.

path.append("e") fills slot 1. index reaches 2, so "ce" is saved.

path.pop() removes "e" from slot 1. The next letter of this slot can be tried.

path.append("f") fills slot 1. index reaches 2, so "cf" is saved.

path.pop() removes "f" from slot 1. The next letter of this slot can be tried.

path.pop() removes "c" from slot 0. The next letter of this slot can be tried.

Done: 9 strings were saved: ad, ae, af, bd, be, bf, cd, ce, cf.

digits
slot 02index
slot 13
path
1 / 27
Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Saving path before every slot has its letter

    Mistake
    results.append("".join(path))
    if index == len(digits):
        return
    Fix
    if index == len(digits):
        results.append("".join(path))
        return

    What happens: path[k] holds the letter for digits[k]. While index < len(digits), some slots are still empty. Saving at every node, as Subsets (LC 78) does, also saves those shorter strings, such as "" and "a".

    Example: digits = "23" (the first 4 results)
    Mistake: returns ['', 'a', 'ad', 'ae']Fix: returns ['ad', 'ae', 'af', 'bd']
  2. 2

    Gotcha 2: Picking from a shared pool with range(index, ...)

    Mistake
    for i in range(index, len(digits)):
        for letter in keypad[digits[i]]:
            path.append(letter)
            backtrack(i + 1)
            path.pop()
    Fix
    for letter in keypad[digits[index]]:
        path.append(letter)
        backtrack(index + 1)
        path.pop()

    What happens: The loop of Combinations (LC 77) picks any later index from one shared pool. Here each depth is one slot, and it must use only the letters of digits[index]. The shared-pool loop can skip a slot. So strings such as "d" appear, with no letter for the digit 2.

    Example: digits = "23" (the last 3 results)
    Mistake: returns ['d', 'e', 'f']Fix: returns ['cd', 'ce', 'cf']
  3. 3

    Gotcha 3: Leaving out path.pop() after the recursive call

    Mistake
    backtrack(index + 1)
    Fix
    backtrack(index + 1)
    path.pop()

    What happens: path.pop() removes the letter of this slot before the next letter is tried. Without it, the next letter is appended after the old one. path grows longer than len(digits), and the saved strings are too long.

    Example: digits = "23" (the first 3 results)
    Mistake: returns ['ad', 'ade', 'adef']Fix: returns ['ad', 'ae', 'af']
  4. 4

    Gotcha 4: Giving a digit two options, lower case and upper case

    Mistake
    options = [ch.lower(), ch.upper()]
    Fix
    options = [ch.lower(), ch.upper()] if ch.isalpha() else [ch]

    What happens: In Letter Case Permutation (LC 784), each character of s is one slot. A letter has two options. A digit has only one option, itself. For a digit, ch.lower() and ch.upper() are the same character, so every string is built twice.

    Example: Letter Case Permutation (LC 784): s = "a1"
    Mistake: returns ['a1', 'a1', 'A1', 'A1']Fix: returns ['a1', 'A1']
Not this one if…
  • Every choice comes from one shared pool and a chosen item can't be picked again, as in Combinations (LC 77): the positions have no option lists of their own. See Combinations
  • Each position has the same few options, but a choice is only legal given the ones before it, as in Generate Parentheses (LC 22): some branches must be cut before they reach full length. See Constraint Backtracking

Deep Dive

Why the depth is the slot

Every position of the answer is a slot. Slot 0 comes from digits[0], slot 1 comes from digits[1], and so on. Each slot has its own list of options. For the digit 2 the options are a, b, c. For the digit 3 they are d, e, f.

backtrack(index) works on slot index only. It loops over keypad[digits[index]]. For each letter it does three things: path.append(letter), then backtrack(index + 1), then path.pop(). The append is the choice, the recursive call fills the later slots, and the pop undoes the choice.

Example: digits = "23". backtrack(0) appends a, so path = ['a']. backtrack(1) appends d, so path = ['a', 'd']. Now index == 2 == len(digits), so "ad" is saved. The pop removes d. The loop then appends e, and "ae" is saved, and so on.

After the three letters of slot 1 are done, backtrack(1) returns. The pop removes a from slot 0, and the loop appends b. The strings come out in this order: ad, ae, af, bd, be, bf, cd, ce, cf. That is 9 strings, which is 3 times 3.

The invariant is len(path) == index in every call. The path always holds one option for each slot before index, and no option for the slots from index onward.

Why each combination is built exactly once

A full combination is one option for each slot. In the recursion, slot 0 picks first, then slot 1, and so on. Two different combinations differ in at least one slot. At the first slot where they differ, the loop took a different letter, so they come from different branches.

No branch is ever cut. No choice in one slot limits the options of another slot. So every branch reaches index == slots, and every full combination appears. The number of saved strings is the product of the option counts of the slots.

I checked this with the card's code on 500 random digit strings. The saved strings were the same as itertools.product gives, in the same order. For "79" the result has 16 strings, because 7 and 9 have 4 options each.

This is the difference from Combinations (LC 77). There, all choices come from one shared pool, and an item cannot be used twice. Here, the slots do not share options, so there is no start index and no used set.

The cost: one path per combination

Let G be the number of full combinations. Each one is built in slots appends. Saving a copy with list(path) or "".join(path) costs slots as well. So the time is O(slots * G * T), where T is the cost of one choices call. The blueprint states this.

For the phone number, a slot has at most 4 options, so G is at most 4^N and the time is O(N * 4^N). The space is O(N) for path and the recursion stack. The saved strings are the output and are not counted.

The number of backtrack calls is smaller than the cost of saving. For 3 slots with 3 options each, I counted 40 calls. That matches (3^4 - 1) / 2 = 40.

The output is the cost. If the answer has G strings, no method can be faster than writing them. So the card has no pruning step to add.

Other problems with one option list per slot

Letter Case Permutation (LC 784): each character of s is a slot. A letter has two options, its lower case and its upper case. A digit has one option, itself. For s = "a1b2" the result is a1b2, a1B2, A1b2, A1B2.

Brace Expansion (LC 1087): each plain letter is a slot with one option, and each brace group is a slot with the listed options. Sort the options of a group first, because the answer must be in sorted order. For {a,b}c{d,e}f the result is acdf, acef, bcdf, bcef.

In both problems only the function choices(index) changes. The saving rule and the choose, recurse, undo loop stay the same.

One edge case: an empty slot gives no full combination, so the result is []. LeetCode's Letter Combinations of a Phone Number (LC 17) also asks for [] when digits is empty. The loop alone would return [""] for that input, so a solution must handle an empty digits before it starts.

60-Second Pitch

"Each position of the answer is a slot with its own list of options, and no choice limits another. So the recursion depth is the slot index. In backtrack(index) I loop over the options of slot index. I append one to path, call backtrack(index + 1), and pop it. I save a copy of path only when index reaches the number of slots, because before that some slots are still empty. Each full combination is built exactly once, so the answer has the product of the option counts. With at most 4 letters per digit, the time is O(N * 4^N), and the space is O(N) for the path and the recursion stack, not counting the output."

Common Questions

What are the time and space complexity of letterCombinations?

O(N * 4^N) time, because a digit has at most 4 letters and each full string costs N to build and save. The space is O(N) for path and the recursion stack. The output is not counted.

Why is there no start index or used set?

Each slot has its own options, and no choice limits another slot. A start index or a used set is needed only when the choices come from one shared pool, as in Combinations (LC 77).

Why save only when index == len(digits)?

Before that, path has fewer letters than there are digits, so some slots are empty. Saving at every call, as Subsets (LC 78) does, would add strings such as "a" that are not full combinations.

How would you solve the problem without recursion?

Start with a list that holds the empty string. For each digit, build a new list: for every string in the list and every letter of the digit, add the string plus the letter. This does the same work, and the memory is the size of the list. The recursive version keeps only one path in memory at a time.

How do you change the code for Letter Case Permutation (LC 784)?

Only the options change. For a letter, use its lower case and upper case. For a digit, use the digit alone. The choose, recurse, undo loop and the base case stay the same.

SWE Reasoning

Spot it

  • The answer is a string or list of fixed length, and each position has its own short list of options.
  • Any mix of choices is allowed. No choice makes another option illegal.
  • The statement says "all possible" and N is small, because the output size is the product of the option counts.
  • The options come from the position itself: the digit on a key, the two cases of a letter, the words inside braces.
  • If every choice must be legal given the earlier choices, as in Generate Parentheses (LC 22), the signal points to a different card.

The invariant, step by step

  1. backtrack(0) starts with an empty path. len(path) == 0 == index.
  2. In backtrack(index), the loop takes one option of slot index only, using choices(index).
  3. path.append(option) makes len(path) == index + 1. The call backtrack(index + 1) starts with len(path) == index + 1, as the invariant requires.
  4. When backtrack(index + 1) returns, path.pop() makes len(path) == index again. The next option of this slot can start from the same state.
  5. When index == slots, every slot has an option. Only then does the code save a copy of path.
  6. The loop visits each option of each slot, so every full combination is saved once.

Traps

  • Saving before every slot is filled. If you save path at every call, as in Subsets (LC 78), you also save "" and "a". For "23" the first four results are ['', 'a', 'ad', 'ae'] instead of ['ad', 'ae', 'af', 'bd'].
  • Looping over a shared pool with range(index, len(digits)). The loop can skip a slot, so strings such as "d" appear. They have no letter for the digit 2.
  • Forgetting path.pop(). The next letter is appended after the old one. For "23" the first three results are ['ad', 'ade', 'adef'] instead of ['ad', 'ae', 'af'].
  • Giving a digit two options in Letter Case Permutation (LC 784). For a digit, ch.lower() and ch.upper() are the same character, so every string is built twice. For "a1" the result is ['a1', 'a1', 'A1', 'A1'].
  • Not handling an empty input. For digits = "", the base case index == len(digits) is true at once and the blueprint returns [""]. LeetCode expects [].

Practice

Canonical LeetCode:Letter Combinations of a Phone Number (LC 17)Letter Case Permutation (LC 784)Brace Expansion (LC 1087)Generalized Abbreviation (LC 320)
Theory Context•Backtracking & State Search

Builds on Subsets, which you haven’t finished yet.

P1MediumBuilt on Subsets~3 min speedrun

Cartesian Product Backtracking (One Choice per Slot)

Built on
SubsetsEach position has its own list of options, so the loop reads that list and there is no start and no used array.
Unlocks
Count and Skip
The problem

Letter Combinations of a Phone Number (LC 17)(opens leetcode.com)

Given a string of digits from 2 to 9, return every letter combination the digit string could spell out under a telephone-keypad mapping, in any order.

Each digit key 2-9 maps to a fixed group of 3 or 4 letters, exactly like on an old telephone keypad; the digit 1 has no letters mapped to it.

Example 1
Input:digits = "23"
Output:["ad","ae","af","bd","be","bf","cd","ce","cf"]
2 maps to abc and 3 to def, so there are 3 * 3 = 9 letter pairs.
12 abc3 def4 ghi5 jkl6 mno7 pqrs8 tuv9 wxyz

One Choice per Slot

slots in order ➔ one option per slot ➔ record at full length
Spot it when: You must build every string (or list) of a fixed length where each position has its own short list of options and any mix of choices is allowed: phone-key letters, the two cases of a letter, the options inside braces.
One Choice per Slot
Python
1from typing import Callable, Iterable, List, TypeVar
2 
3Item = TypeVar("Item")
4 
5 
6def one_per_slot(slots: int, choices: Callable[[int], Iterable[Item]]) -> List[List[Item]]:
7 """One Choice per Slot: every way to pick one option for each slot 0..slots-1, in slot order.
8 choices(slot) lists the options of that slot. No choice limits the options of another slot.
9 An empty slot gives no full combination, so the result is [].
10 G = the number of full combinations. T = the cost of one choices call.
11 Time: O(slots * G * T) | Space: O(slots) path + recursion stack, output not counted
12 """
13 results: List[List[Item]] = []
14 path: List[Item] = [] # path[k] is the option chosen for slot k
15 
16 def backtrack(index: int) -> None:
17 # 1. Save a Complete Combination:
18 # Every slot has an option chosen only once index reaches slots; a shorter
19 # path still has empty slots, so nothing is saved before that.
20 if index == slots:
21 results.append(list(path))
22 return
23 
24 # 2. Choose, Explore, Un-choose, One Slot's Options Only:
25 # choices(index) lists only the options of slot index, and no other slot
26 # limits them, so trying each in turn builds every combination once, one
27 # slot at a time.
28 for option in choices(index):
29 path.append(option)
30 backtrack(index + 1)
31 path.pop()
32 
33 backtrack(0)
34 return results
Invariant: path holds exactly one option for each of the slots digits[0..index-1], in order, so len(path) == index in every call.

Every position of the answer is its own slot with its own list of options, so the recursion depth is the slot: backtrack(index) tries each letter of keypad[digits[index]], appends it to path, and moves on to backtrack(index + 1). The slots never share options and no choice limits another, so there is no start index, no used set and nothing to prune: every branch reaches a full combination, and each combination is built exactly once, one letter per slot in slot order, which is why the answer has exactly the product of the slots' option counts. The trap is where to save: append "".join(path) only when index == len(digits), because a shorter path still has empty slots; saving at every node, as Subsets does, adds partial strings such as "a".

See it run

One Choice per Slot, step by step

index 0 · path [] · saved 0

index 1 · path ['a'] · saved 0

index 1 · path ['a'] · saved 0

index 2 · path ['a', 'd'] · saved 1

index 1 · path ['a', 'd'] · saved 1

index 2 · path ['a', 'e'] · saved 2

index 1 · path ['a', 'e'] · saved 2

index 2 · path ['a', 'f'] · saved 3

index 1 · path ['a', 'f'] · saved 3

index 0 · path ['a'] · saved 3

index 1 · path ['b'] · saved 3

index 2 · path ['b', 'd'] · saved 4

index 1 · path ['b', 'd'] · saved 4

index 2 · path ['b', 'e'] · saved 5

index 1 · path ['b', 'e'] · saved 5

index 2 · path ['b', 'f'] · saved 6

index 1 · path ['b', 'f'] · saved 6

index 0 · path ['b'] · saved 6

index 1 · path ['c'] · saved 6

index 2 · path ['c', 'd'] · saved 7

index 1 · path ['c', 'd'] · saved 7

index 2 · path ['c', 'e'] · saved 8

index 1 · path ['c', 'e'] · saved 8

index 2 · path ['c', 'f'] · saved 9

index 1 · path ['c', 'f'] · saved 9

index 0 · path ['c'] · saved 9

index 0 · path [] · saved 9 · results ad,ae,af,bd,be,bf,cd,ce,cf

The top row is digits and the bottom row is path. Each column of path is one slot.

  • choose the slot that path.append just filled
  • undo the slot that path.pop is about to empty
  • index the digit whose letters backtrack(index) is trying

digits = "23" has 2 slots. path starts empty and backtrack(0) begins.

path.append("a") fills slot 0, then backtrack(1) runs.

path = ['a'] has 1 of 2 slots filled. Nothing is saved yet, because "a" is not a full string.

path.append("d") fills slot 1. index reaches 2, so "ad" is saved.

path.pop() removes "d" from slot 1. The next letter of this slot can be tried.

path.append("e") fills slot 1. index reaches 2, so "ae" is saved.

path.pop() removes "e" from slot 1. The next letter of this slot can be tried.

path.append("f") fills slot 1. index reaches 2, so "af" is saved.

path.pop() removes "f" from slot 1. The next letter of this slot can be tried.

path.pop() removes "a" from slot 0. The next letter of this slot can be tried.

path.append("b") fills slot 0, then backtrack(1) runs.

path.append("d") fills slot 1. index reaches 2, so "bd" is saved.

path.pop() removes "d" from slot 1. The next letter of this slot can be tried.

path.append("e") fills slot 1. index reaches 2, so "be" is saved.

path.pop() removes "e" from slot 1. The next letter of this slot can be tried.

path.append("f") fills slot 1. index reaches 2, so "bf" is saved.

path.pop() removes "f" from slot 1. The next letter of this slot can be tried.

path.pop() removes "b" from slot 0. The next letter of this slot can be tried.

path.append("c") fills slot 0, then backtrack(1) runs.

path.append("d") fills slot 1. index reaches 2, so "cd" is saved.

path.pop() removes "d" from slot 1. The next letter of this slot can be tried.

path.append("e") fills slot 1. index reaches 2, so "ce" is saved.

path.pop() removes "e" from slot 1. The next letter of this slot can be tried.

path.append("f") fills slot 1. index reaches 2, so "cf" is saved.

path.pop() removes "f" from slot 1. The next letter of this slot can be tried.

path.pop() removes "c" from slot 0. The next letter of this slot can be tried.

Done: 9 strings were saved: ad, ae, af, bd, be, bf, cd, ce, cf.

digits
slot 02index
slot 13
path
1 / 27
Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Saving path before every slot has its letter

    Mistake
    results.append("".join(path))
    if index == len(digits):
        return
    Fix
    if index == len(digits):
        results.append("".join(path))
        return

    What happens: path[k] holds the letter for digits[k]. While index < len(digits), some slots are still empty. Saving at every node, as Subsets (LC 78) does, also saves those shorter strings, such as "" and "a".

    Example: digits = "23" (the first 4 results)
    Mistake: returns ['', 'a', 'ad', 'ae']Fix: returns ['ad', 'ae', 'af', 'bd']
  2. 2

    Gotcha 2: Picking from a shared pool with range(index, ...)

    Mistake
    for i in range(index, len(digits)):
        for letter in keypad[digits[i]]:
            path.append(letter)
            backtrack(i + 1)
            path.pop()
    Fix
    for letter in keypad[digits[index]]:
        path.append(letter)
        backtrack(index + 1)
        path.pop()

    What happens: The loop of Combinations (LC 77) picks any later index from one shared pool. Here each depth is one slot, and it must use only the letters of digits[index]. The shared-pool loop can skip a slot. So strings such as "d" appear, with no letter for the digit 2.

    Example: digits = "23" (the last 3 results)
    Mistake: returns ['d', 'e', 'f']Fix: returns ['cd', 'ce', 'cf']
  3. 3

    Gotcha 3: Leaving out path.pop() after the recursive call

    Mistake
    backtrack(index + 1)
    Fix
    backtrack(index + 1)
    path.pop()

    What happens: path.pop() removes the letter of this slot before the next letter is tried. Without it, the next letter is appended after the old one. path grows longer than len(digits), and the saved strings are too long.

    Example: digits = "23" (the first 3 results)
    Mistake: returns ['ad', 'ade', 'adef']Fix: returns ['ad', 'ae', 'af']
  4. 4

    Gotcha 4: Giving a digit two options, lower case and upper case

    Mistake
    options = [ch.lower(), ch.upper()]
    Fix
    options = [ch.lower(), ch.upper()] if ch.isalpha() else [ch]

    What happens: In Letter Case Permutation (LC 784), each character of s is one slot. A letter has two options. A digit has only one option, itself. For a digit, ch.lower() and ch.upper() are the same character, so every string is built twice.

    Example: Letter Case Permutation (LC 784): s = "a1"
    Mistake: returns ['a1', 'a1', 'A1', 'A1']Fix: returns ['a1', 'A1']
Not this one if…
  • Every choice comes from one shared pool and a chosen item can't be picked again, as in Combinations (LC 77): the positions have no option lists of their own. See Combinations
  • Each position has the same few options, but a choice is only legal given the ones before it, as in Generate Parentheses (LC 22): some branches must be cut before they reach full length. See Constraint Backtracking

Deep Dive

Why the depth is the slot

Every position of the answer is a slot. Slot 0 comes from digits[0], slot 1 comes from digits[1], and so on. Each slot has its own list of options. For the digit 2 the options are a, b, c. For the digit 3 they are d, e, f.

backtrack(index) works on slot index only. It loops over keypad[digits[index]]. For each letter it does three things: path.append(letter), then backtrack(index + 1), then path.pop(). The append is the choice, the recursive call fills the later slots, and the pop undoes the choice.

Example: digits = "23". backtrack(0) appends a, so path = ['a']. backtrack(1) appends d, so path = ['a', 'd']. Now index == 2 == len(digits), so "ad" is saved. The pop removes d. The loop then appends e, and "ae" is saved, and so on.

After the three letters of slot 1 are done, backtrack(1) returns. The pop removes a from slot 0, and the loop appends b. The strings come out in this order: ad, ae, af, bd, be, bf, cd, ce, cf. That is 9 strings, which is 3 times 3.

The invariant is len(path) == index in every call. The path always holds one option for each slot before index, and no option for the slots from index onward.

Why each combination is built exactly once

A full combination is one option for each slot. In the recursion, slot 0 picks first, then slot 1, and so on. Two different combinations differ in at least one slot. At the first slot where they differ, the loop took a different letter, so they come from different branches.

No branch is ever cut. No choice in one slot limits the options of another slot. So every branch reaches index == slots, and every full combination appears. The number of saved strings is the product of the option counts of the slots.

I checked this with the card's code on 500 random digit strings. The saved strings were the same as itertools.product gives, in the same order. For "79" the result has 16 strings, because 7 and 9 have 4 options each.

This is the difference from Combinations (LC 77). There, all choices come from one shared pool, and an item cannot be used twice. Here, the slots do not share options, so there is no start index and no used set.

The cost: one path per combination

Let G be the number of full combinations. Each one is built in slots appends. Saving a copy with list(path) or "".join(path) costs slots as well. So the time is O(slots * G * T), where T is the cost of one choices call. The blueprint states this.

For the phone number, a slot has at most 4 options, so G is at most 4^N and the time is O(N * 4^N). The space is O(N) for path and the recursion stack. The saved strings are the output and are not counted.

The number of backtrack calls is smaller than the cost of saving. For 3 slots with 3 options each, I counted 40 calls. That matches (3^4 - 1) / 2 = 40.

The output is the cost. If the answer has G strings, no method can be faster than writing them. So the card has no pruning step to add.

Other problems with one option list per slot

Letter Case Permutation (LC 784): each character of s is a slot. A letter has two options, its lower case and its upper case. A digit has one option, itself. For s = "a1b2" the result is a1b2, a1B2, A1b2, A1B2.

Brace Expansion (LC 1087): each plain letter is a slot with one option, and each brace group is a slot with the listed options. Sort the options of a group first, because the answer must be in sorted order. For {a,b}c{d,e}f the result is acdf, acef, bcdf, bcef.

In both problems only the function choices(index) changes. The saving rule and the choose, recurse, undo loop stay the same.

One edge case: an empty slot gives no full combination, so the result is []. LeetCode's Letter Combinations of a Phone Number (LC 17) also asks for [] when digits is empty. The loop alone would return [""] for that input, so a solution must handle an empty digits before it starts.

60-Second Pitch

"Each position of the answer is a slot with its own list of options, and no choice limits another. So the recursion depth is the slot index. In backtrack(index) I loop over the options of slot index. I append one to path, call backtrack(index + 1), and pop it. I save a copy of path only when index reaches the number of slots, because before that some slots are still empty. Each full combination is built exactly once, so the answer has the product of the option counts. With at most 4 letters per digit, the time is O(N * 4^N), and the space is O(N) for the path and the recursion stack, not counting the output."

Common Questions

What are the time and space complexity of letterCombinations?

O(N * 4^N) time, because a digit has at most 4 letters and each full string costs N to build and save. The space is O(N) for path and the recursion stack. The output is not counted.

Why is there no start index or used set?

Each slot has its own options, and no choice limits another slot. A start index or a used set is needed only when the choices come from one shared pool, as in Combinations (LC 77).

Why save only when index == len(digits)?

Before that, path has fewer letters than there are digits, so some slots are empty. Saving at every call, as Subsets (LC 78) does, would add strings such as "a" that are not full combinations.

How would you solve the problem without recursion?

Start with a list that holds the empty string. For each digit, build a new list: for every string in the list and every letter of the digit, add the string plus the letter. This does the same work, and the memory is the size of the list. The recursive version keeps only one path in memory at a time.

How do you change the code for Letter Case Permutation (LC 784)?

Only the options change. For a letter, use its lower case and upper case. For a digit, use the digit alone. The choose, recurse, undo loop and the base case stay the same.

SWE Reasoning

Spot it

  • The answer is a string or list of fixed length, and each position has its own short list of options.
  • Any mix of choices is allowed. No choice makes another option illegal.
  • The statement says "all possible" and N is small, because the output size is the product of the option counts.
  • The options come from the position itself: the digit on a key, the two cases of a letter, the words inside braces.
  • If every choice must be legal given the earlier choices, as in Generate Parentheses (LC 22), the signal points to a different card.

The invariant, step by step

  1. backtrack(0) starts with an empty path. len(path) == 0 == index.
  2. In backtrack(index), the loop takes one option of slot index only, using choices(index).
  3. path.append(option) makes len(path) == index + 1. The call backtrack(index + 1) starts with len(path) == index + 1, as the invariant requires.
  4. When backtrack(index + 1) returns, path.pop() makes len(path) == index again. The next option of this slot can start from the same state.
  5. When index == slots, every slot has an option. Only then does the code save a copy of path.
  6. The loop visits each option of each slot, so every full combination is saved once.

Traps

  • Saving before every slot is filled. If you save path at every call, as in Subsets (LC 78), you also save "" and "a". For "23" the first four results are ['', 'a', 'ad', 'ae'] instead of ['ad', 'ae', 'af', 'bd'].
  • Looping over a shared pool with range(index, len(digits)). The loop can skip a slot, so strings such as "d" appear. They have no letter for the digit 2.
  • Forgetting path.pop(). The next letter is appended after the old one. For "23" the first three results are ['ad', 'ade', 'adef'] instead of ['ad', 'ae', 'af'].
  • Giving a digit two options in Letter Case Permutation (LC 784). For a digit, ch.lower() and ch.upper() are the same character, so every string is built twice. For "a1" the result is ['a1', 'a1', 'A1', 'A1'].
  • Not handling an empty input. For digits = "", the base case index == len(digits) is true at once and the blueprint returns [""]. LeetCode expects [].

Practice

Canonical LeetCode:Letter Combinations of a Phone Number (LC 17)Letter Case Permutation (LC 784)Brace Expansion (LC 1087)Generalized Abbreviation (LC 320)
© 2026 Hi👋WebEnterprise. All rights reserved.
Sitemap•llms.txt•