TL;DR
Backtracking with a start value and a βnot enough numbers leftβ prune β O(C(n,k) Β· k) time, O(k) space beyond the output.
Approach 1 β Brute force: filter all subsets
Enumerate every subset of {1..n} with a bitmask and keep the ones of size k. A bitmask enumeration reads each integer 0..2^n - 1 as a subset: bit i set means the number i + 1 is in.
from typing import List
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
result: List[List[int]] = []
for mask in range(1 << n):
if bin(mask).count("1") != k:
continue
combo = [v for v in range(1, n + 1) if mask >> (v - 1) & 1]
result.append(combo)
return result
Complexity: O(2^n Β· n) time, O(1) extra space. At n = 20 that is 2^20 β 10^6 masks β it squeaks by here, but it examines every subset to find the C(n,k) it wants; when k is small (say k = 2, 190 answers out of a million subsets) over 99.9% of the work is discarded.
Approach 2 β Backtracking with a start value
The insight: generate each combination only in its canonical increasing form. The recursion carries start β the smallest value still allowed β and appends one value per level; after choosing v it recurses with v + 1, so nothing is ever revisited and no duplicates can exist. This is the plain backtracking template: choose, recurse, un-choose.
from typing import List
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
result: List[List[int]] = []
path: List[int] = []
def backtrack(start: int) -> None:
if len(path) == k:
result.append(path.copy())
return
for v in range(start, n + 1):
path.append(v)
backtrack(v + 1)
path.pop()
backtrack(1)
return result
Walkthrough on n = 4, k = 2:
[1] β children [1,2], [1,3], [1,4] β three records.
[2] β [2,3], [2,4].
[3] β [3,4].
[4] β loop over range(5, 5) is empty; the path [4] dies without a record β wasted descent.
Result: all 6 pairs, each exactly once.
Complexity: O(C(n,k) Β· k) for the answers plus some dead descents like [4] above; O(k) recursion/path space.
Approach 3 β Add the counting prune
The insight: a partial path needs m = k - len(path) more values, all drawn from start..n, so it can only succeed if at least m values remain: n - start + 1 >= m, i.e. start <= n - m + 1. Bake that into the loop bound and every dead descent vanishes β the [4] branch in the walkthrough above is never entered.
from typing import List
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
result: List[List[int]] = []
path: List[int] = []
def backtrack(start: int) -> None:
if len(path) == k:
result.append(path.copy())
return
need = k - len(path)
last_useful = n - need + 1 # largest start that can still finish
for v in range(start, last_useful + 1):
path.append(v)
backtrack(v + 1)
path.pop()
backtrack(1)
return result
Walkthrough on n = 4, k = 2: at the root need = 2, so last_useful = 3 and the loop tries only 1, 2, 3 β the doomed [4] branch is pruned before it starts. One level down need = 1, last_useful = 4, and every leaf visited is a real answer.
Complexity: O(C(n,k) Β· k) time β now essentially all visited nodes lie on a path to an emitted answer β and O(k) space.
Approach 4 β Include/exclude binary recursion
The insight: instead of βwhich value fills the next slot,β decide per value: value v is either in or out. This mirrors the identity C(n,k) = C(nβ1,kβ1) + C(nβ1,k) and is the same decision shape youβd use for Subsets β worth knowing because many problems fit one framing more naturally than the other.
from typing import List
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
result: List[List[int]] = []
path: List[int] = []
def decide(v: int) -> None:
if len(path) == k:
result.append(path.copy())
return
if v > n or len(path) + (n - v + 1) < k:
return # not enough values left to reach k
path.append(v) # include v
decide(v + 1)
path.pop()
decide(v + 1) # exclude v
decide(1)
return result
Walkthrough on n = 4, k = 2: include 1 β include 2 β [1,2] recorded; exclude 2 β include 3 β [1,3]; β¦ exclude 1 β include 2 β include 3 β [2,3]; β¦ finally exclude 1,2 β [3,4]. Same 6 answers, generated as a binary tree of in/out decisions instead of an n-ary tree of slot fillings.
Complexity: O(C(n,k) Β· k) time with the feasibility check, O(n) recursion depth (one level per value rather than per slot).
Common pitfalls
- Appending
path instead of path.copy() at the leaf β all recorded answers later mutate to empty.
- Recursing with
start + 1 instead of v + 1 in the loop version β generates duplicates and permutation variants.
- Getting the prune bound wrong (
n - need vs n - need + 1) β test it on k = n, where the loop must still allow exactly one chain.
- In the include/exclude version, forgetting the
len(path) == k check before the exhaustion check β k = n answers arrive exactly when v = n + 1.
Pattern takeaway
Combinations is the skeleton under Combination Sum, Subsets, and friends: a start parameter makes increasing order the canonical form (no duplicates by construction), and a counting prune β βdo enough choices remain to fill my remaining slots?β β trims every hopeless branch at cost O(1). Also keep both framings in your pocket: slot-centric loops (which value goes next?) and value-centric binary decisions (is this value in?) generate the same set, and harder problems often yield to one framing far more cleanly than the other.