17. Letter Combinations of a Phone Number
On LeetCode ->Reformulated question¶
Given a string of digits 2 to 9, return every string formed by choosing one phone-keypad letter for each digit.
Example:
Key trick¶
Build the answer one position at a time.
- Each digit gives a small set of choices.
- Use backtracking/DFS:
- pick one letter for the current digit,
- recurse to the next digit,
- when the built string has length
len(digits), save it.
This is the recursive version of nested loops where the number of loops is unknown in advance.
Trap¶
- Forgetting the empty-input case.
- LeetCode expects
[]for"", not[""].
- LeetCode expects
- Appending too early.
- Only append when all digits were processed.
- Mutating shared state incorrectly in iterative/backtracking code.
- If you use one string/list path, you must undo the last choice when returning.
- Hardcoding nested loops.
- Works only for fixed length, but input length is variable.
Why this question is interesting¶
It is a tiny, clean backtracking problem.
- It shows how recursion replaces an arbitrary number of nested loops.
- It is easy to explain, easy to test, and exposes base-case/state-management mistakes quickly.
Solve the problem with idiomatic Python¶
class Solution:
def letterCombinations(self, digits: str) -> list[str]:
# LeetCode usually expects [] for empty input.
if not digits:
return []
phone = {
"2": "abc",
"3": "def",
"4": "ghi",
"5": "jkl",
"6": "mno",
"7": "pqrs",
"8": "tuv",
"9": "wxyz",
}
result = []
path = []
def dfs(i: int) -> None:
# If we chose one letter for every digit, we built one answer.
if i == len(digits):
result.append("".join(path))
return
# Try every possible letter for the current digit.
for ch in phone[digits[i]]:
path.append(ch) # choose
dfs(i + 1) # explore
path.pop() # un-choose
dfs(0)
return result
Complexity:
- Time: \(O(n \cdot 4^n)\)
- Space: \(O(n)\) auxiliary, ignoring output
Also valid direct-library style solution¶
from itertools import product
class Solution:
def letterCombinations(self, digits: str) -> list[str]:
if not digits:
return []
phone = {
"2": "abc",
"3": "def",
"4": "ghi",
"5": "jkl",
"6": "mno",
"7": "pqrs",
"8": "tuv",
"9": "wxyz",
}
return ["".join(chars) for chars in product(*(phone[d] for d in digits))]
Pytest test¶
import pytest
@pytest.mark.parametrize(
("digits", "expected"),
[
("", []),
("2", ["a", "b", "c"]),
("23", ["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]),
("7", ["p", "q", "r", "s"]),
("79", [
"pw", "px", "py", "pz",
"qw", "qx", "qy", "qz",
"rw", "rx", "ry", "rz",
"sw", "sx", "sy", "sz",
]),
],
)
def test_letterCombinations(digits, expected):
assert Solution().letterCombinations(digits) == expected
Comment your solution¶
Your recursive solution is correct and already interview-good.
- Good:
- Clear mapping.
- Correct DFS structure.
- Easy base case.
- Small improvements:
- Return
[]for empty input. - Use strings in the map instead of lists; simpler.
- Avoid
nonlocal combinations; appending to a captured list does not need it. - Using a
pathlist plusappend/popis slightly more idiomatic for backtracking thans + ch.
- Return
## Solution
# WORKS
class Solution:
def letterCombinations(self, digits: str) -> list[str]:
d_to_chars = {
"2": ["a", "b", "c"],
"3": ["d", "e", "f"],
"4": ["g", "h", "i"],
"5": ["j", "k", "l"],
"6": ["m", "n", "o"],
"7": ["p", "q", "r", "s"],
"8": ["t", "u", "v"],
"9": ["w", "x", "y", "z"],
}
combinations = []
# `i` index in digits
# `s` the string we're building
def dfs(i, s):
nonlocal combinations
if i >= len(digits):
combinations.append(s)
return
digit = digits[i]
chars = d_to_chars[digit]
for ch in chars:
dfs(i + 1, s + ch)
dfs(0, "")
return combinations
# Easier problem/question to help me answer the original one
# how to convert following into backtracking?
#
# for i in ["a", "b", "c"]:
# for j in ["d", "e", "f"]:
# for k in ["g", "h", "i"]:
# combinations.append(i + j + k)
# adg
# adh
# adi
# --- backtracking
# aeg
# aeh
# aei
# --- backtracking
# afg
# afh
# afi
# --- backtracking
# --- backtracking
# bdg
# bdh
# bdi
# --- backtracking
# beg
# ...
# DON'T WORK - iterative
class Solution:
def letterCombinations(self, digits: str) -> list[str]:
d_to_chars = {
"2": ["a", "b", "c"],
"3": ["d", "e", "f"],
"4": ["g", "h", "i"],
"5": ["j", "k", "l"],
"6": ["m", "n", "o"],
"7": ["p", "q", "r", "s"],
"8": ["t", "u", "v"],
"9": ["w", "x", "y", "z"],
}
combinations = []
i = 0
chars = d_to_chars[digits[0]].copy()
stack = [(0, chars)] # digit, chars
s = ""
while stack:
print(f"stack: {stack}")
if i >= len(digits):
combinations.append(s)
i = stack[len(stack) - 1][0]
s = s[:i]
continue
j, chars = stack.pop()
ch = chars.pop() # reverse order (different from recursive dfs I wrote)
s = s + ch
if chars:
stack.append((j, chars))
i += 1
if i < len(digits):
stack.append((i, d_to_chars[digits[i]].copy()))
return combinations
# DON'T WORK - we're missing one pair 'ad'
Solution().letterCombinations("23") # ['cf', 'ce', 'cd', 'bf', 'be', 'bd', 'af', 'ae']
A cleaned-up version close to yours:
class Solution:
def letterCombinations(self, digits: str) -> list[str]:
if not digits:
return []
phone = {
"2": "abc",
"3": "def",
"4": "ghi",
"5": "jkl",
"6": "mno",
"7": "pqrs",
"8": "tuv",
"9": "wxyz",
}
result = []
def dfs(i: int, s: str) -> None:
if i == len(digits):
result.append(s)
return
for ch in phone[digits[i]]:
dfs(i + 1, s + ch)
dfs(0, "")
return result
About your "how does this become backtracking?" note¶
This:
for i in ["a", "b", "c"]:
for j in ["d", "e", "f"]:
for k in ["g", "h", "i"]:
combinations.append(i + j + k)
becomes:
choices = [
["a", "b", "c"],
["d", "e", "f"],
["g", "h", "i"],
]
result = []
path = []
def dfs(level: int) -> None:
if level == len(choices):
result.append("".join(path))
return
for ch in choices[level]:
path.append(ch)
dfs(level + 1)
path.pop()
Idea:
levelsays which loop depth you are at.- The recursive call enters the next loop.
- The
pop()is the backtracking step.
Why your iterative attempt misses "ad"¶
Your stack version mixes two different states into globals:
- current depth
i - current built string
s
Those belong to each stack frame, but you keep only one shared i and one shared s.
So when you pop one branch and continue another, the string/depth restoration is incomplete, which skips combinations like "ad".
If you want iterative DFS, each stack item should carry the full state for that branch, for example:
class Solution:
def letterCombinations(self, digits: str) -> list[str]:
if not digits:
return []
phone = {
"2": "abc",
"3": "def",
"4": "ghi",
"5": "jkl",
"6": "mno",
"7": "pqrs",
"8": "tuv",
"9": "wxyz",
}
result = []
stack = [(0, "")] # (index in digits, built string)
while stack:
i, s = stack.pop()
if i == len(digits):
result.append(s)
continue
# Push in reverse so pop() processes letters in normal order.
for ch in reversed(phone[digits[i]]):
stack.append((i + 1, s + ch))
return result
This works because every pending branch stores its own full state.
Extra¶
Complexity¶
Explain how you get this time complexity. I thought it would be \(O(4^n)\). I don't understand why we mutiply it by \(n\).
You are not missing much - the subtle part is what counts as work per result.
If you only count the shape of the recursion tree, then yes:
- Branching factor is at most
4 - Depth is
n - Number of leaves is about
4^n
So the tree itself suggests:
- \(O(4^n)\) recursive states/leaves, roughly
But to produce the actual answers, each answer is a string of length n, and building that string costs work.
Where the extra n comes from¶
In this version:
at every leaf:
pathhas lengthn"".join(path)must copyncharacters into a new string
So:
- number of leaves: up to \(4^n\)
- work per leaf to build the final string: \(O(n)\)
Therefore:
- total = \(O(n \cdot 4^n)\)
Why \(O(4^n)\) feels natural¶
Because the algorithm explores combinations like a tree:
- each digit gives up to 4 choices
- so total combinations is at most \(4^n\)
That is correct for:
- the number of outputs
- the number of leaves
- the recursion-tree growth up to constant factors
But if the output strings themselves must be constructed, the total runtime includes that cost too.
A concrete example¶
If n = 4 and every digit had 4 letters:
- number of combinations = \(4^4 = 256\)
Each combination has length 4, for example:
Even if finding each leaf were free, you still must write 4 characters per result.
So just materializing the outputs already costs:
- \(256 \cdot 4\)
That is the same pattern as:
- \(4^n \cdot n\)
Another way to see it¶
The output itself has size:
- number of strings: about \(4^n\)
- length of each string: \(n\)
So the total output size is:
- \(O(n \cdot 4^n)\)
An algorithm cannot be asymptotically faster than the size of the output it must return.
Why some people still say \(O(4^n)\)¶
Sometimes interview explanations simplify and say:
- "There are \(4^n\) combinations"
This is often used as a shorthand.
It is acceptable if they are focusing on:
- number of combinations generated
But the more precise runtime for returning all strings is:
- \(O(n \cdot 4^n)\)
Tiny nuance about your exact recursive version¶
If you build strings like this:
then s + ch also creates a new string each time.
That means there is string-copying work not only at leaves, but along the path too.
Still, the usual clean complexity stated is:
- Time: \(O(n \cdot 4^n)\)
- Space: \(O(n)\) auxiliary, ignoring output
because the dominant idea is still:
- \(4^n\) results
- each result has length \(n\)
So your intuition of \(O(4^n)\) was close - it counts combinations, but not the cost of constructing each length-\(n\) answer.
Backtracking with iterative DFS¶
Write an iterative DFS solution that build a
pathlike in DFS recursive instead of concatenating string at each step due to the use of thestack = [(0, "")]in your improvement above.
Yes - to mimic recursive DFS more closely, the stack should store enough state to let you:
- know which digit you are exploring
- know which choice you are trying next at that digit
- mutate one shared
path - backtrack with
path.pop()
That is the iterative equivalent of the call stack.
class Solution:
def letterCombinations(self, digits: str) -> list[str]:
if not digits:
return []
phone = {
"2": "abc",
"3": "def",
"4": "ghi",
"5": "jkl",
"6": "mno",
"7": "pqrs",
"8": "tuv",
"9": "wxyz",
}
result = []
path = []
# Stack frame:
# (digit_index, next_char_index_to_try_at_this_digit)
stack = []
i = 0
while True:
# Go down as far as possible, like recursive DFS.
while i < len(digits):
letters = phone[digits[i]]
# We are about to choose letters[0] now.
# If we come back later, we should resume from index 1.
stack.append((i, 1))
path.append(letters[0])
i += 1
# Reached a leaf: one full combination is built.
result.append("".join(path))
# Backtrack until we find a frame with another choice left.
while stack:
i, next_j = stack.pop()
path.pop()
letters = phone[digits[i]]
if next_j < len(letters):
# Take the next choice at this level.
path.append(letters[next_j])
# If we come back again to this same level,
# continue from the following choice.
stack.append((i, next_j + 1))
# Then continue DFS on the next digit.
i += 1
break
else:
# No frame left: DFS is finished.
return result
How to read it:
pathis the current partial answer, exactly like recursive backtracking.stackstores suspended frames, exactly like recursion would.- each frame says:
- which digit it belongs to
- which letter index should be tried next when we return to that digit
What about using path list in iterative DFS non-backtracking?¶
Ok but can't we just replace
(i, s)by(i, [])or something similar in the iterative DFS (non-backtracking)?
Yes, you can, but there is an important Python gotcha:
- if you put a mutable list in the stack,
- you must not reuse the same list object for multiple branches,
- otherwise all stack entries will point to the same
path.
What goes wrong with (i, path_list)¶
This is wrong:
because then:
- one branch modifies
path - all other branches see the same modification
So if you use lists in stack frames, each pushed branch needs its own copy.
Safe version with lists¶
class Solution:
def letterCombinations(self, digits: str) -> list[str]:
if not digits:
return []
phone = {
"2": "abc",
"3": "def",
"4": "ghi",
"5": "jkl",
"6": "mno",
"7": "pqrs",
"8": "tuv",
"9": "wxyz",
}
result = []
stack = [(0, [])] # (index, path as list of chars)
while stack:
i, path = stack.pop()
if i == len(digits):
result.append("".join(path))
continue
for ch in reversed(phone[digits[i]]):
stack.append((i + 1, path + [ch]))
return result
This works because:
path + [ch]creates a new list- each stack entry owns its own partial path
But note the tradeoff¶
This does not behave like recursive backtracking with one shared path.
It behaves more like the string version:
(i, s + ch)- replaced by
(i, path + [ch])
So:
- it is correct
- it is simple
- but it still copies the whole partial path at every push
Cost of that copying¶
If current path length is k, then:
costs:
- \(O(k)\)
So this is not avoiding copying in the same way true backtracking does.
Difference from real backtracking¶
Stack-of-state approach¶
- every branch stores its own full partial path
- easy to write
- copies partial path many times
True backtracking approach¶
- one shared
path append- explore
pop- stack stores only control state
This is closer to recursive DFS and uses less temporary copying.
So the short answer¶
Yes, you can replace (i, s) with (i, path_list) if you do:
and not:
But that is still a "copy-the-state-per-branch" solution, not a true iterative backtracking simulation with one shared path.
Tiny rule of thumb¶
- immutable state like
s:- safe to store directly
- mutable state like
path:- either copy it for each branch
- or use one shared object and explicitly backtrack
That is the key distinction.
Backtracking is really about do -> recurse -> undo on shared state¶
Ok I get it.
So backtracking is really about
do + (recurse / go deep one path) + undowithdo/undomodifying the same state. Am I understanding correctly?So, the stack version
stack = [(0, "")]with a string is not backtracking because we merge two kind of state in one:
do/undostate which build the thing we want(recurse / go deep one path)which traverses the graphAm I correct?
In practice (real code base and LeetCode interview questions), is backtracking done with recursive DFS or imperative DFS? imperative DFS is a bit ugly but recursive DFS might overflow the stack.
Yes - that is the right idea.
Backtracking is usually:
- do
- go deeper
- undo
And the important part is:
doandundomutate the same shared state- recursion or an explicit stack only controls where to return next
So your summary is good.
About the stack = [(0, "")] version¶
Yes, that version is usually DFS, but not really what people mean by backtracking.
Why:
- each branch carries its own independent state:
s - there is no shared mutable state to undo
- you do not need a real
undostep
So it is better described as:
- iterative DFS over states
rather than:
- backtracking with shared state
Your distinction is correct¶
You said there are two kinds of state:
- traversal state
- constructed-solution state
That is exactly the useful mental model.
In recursive backtracking¶
These are separated:
- traversal state is handled by the call stack
- solution-building state is the shared
path, modified byappend/pop
In stack = [(i, s)]¶
They are bundled together:
- stack stores both where you are and what you built
- no undo is needed because every branch has its own copy
That is why it feels different.
Is backtracking recursive DFS or imperative DFS in practice?¶
Usually:
- recursive DFS
Most interview backtracking solutions are recursive because:
- shorter
- clearer
- matches the idea naturally
- easier to reason about
Typical interview examples:
- subsets
- permutations
- combinations
- N-Queens
- word search
- phone number letters
are almost always written recursively.
When would imperative DFS be used?¶
Usually when:
- recursion depth may be very large
- the language has a small recursion limit
- the team prefers explicit control
- you need to avoid call-stack risk
In Python specifically:
- recursion is elegant
- but deep recursion can hit the recursion limit
So for very deep traversals, iterative DFS is often safer.
For LeetCode / interviews¶
In practice:
- use recursive backtracking unless depth can be large
For this problem:
digits.length <= 4
so recursive DFS is absolutely the normal and best answer.
No interviewer will prefer an iterative version here unless they explicitly ask for it.
Good rule of thumb¶
Use recursive backtracking when¶
- depth is small or moderate
- the problem is naturally "choose, explore, unchoose"
- clarity matters most
Use iterative DFS when¶
- depth can be huge
- recursion limit is a real risk
- you are doing plain graph/tree traversal more than "choose/unchoose"
One subtle point¶
People often use "backtracking" loosely to mean:
- DFS that explores possibilities
even if the code actually copies state per branch instead of mutating and undoing one shared state.
So in casual discussion:
stack = [(i, s)]might still be called "backtracking"
But if you want the precise distinction:
- backtracking = shared mutable state + undo
- DFS over copied states = DFS/state-space search, but not strict choose-unchoose backtracking
Short answer¶
Yes, you understand it correctly.
- Backtracking is really about
do -> recurse -> undoon shared state. - Recursive DFS naturally handles traversal state.
- The
(i, s)stack version is DFS over copied states, not true backtracking. - In interviews and most real code for these problems, recursive DFS is the standard choice unless recursion depth is dangerous.