Permutations & Combinations

Generate all possible arrangements and selections through systematic backtracking

Last generated

Lesson 10 of 23 available20 practice questions

SPACED REPETITION · 20 practice questions

Make this lesson stick.

Try 3 questions now. No account needed. Sample answers aren't saved.

Build each answer exactly once

A coach has 20 players and wants a list of every possible 10-player squad. Here is a plan that sounds reasonable: generate every ordered line-up of 10 different players, sort each one, and let a set throw away the repeats.

That plan builds P(20, 10) = 670,442,572,800 line-ups. There are only C(20, 10) = 184,756 different squads, so every squad gets built 10! = 3,628,800 times before the set discards the copies. No hash set is fast enough to rescue that.

The fix is not a faster way to remove duplicates. It is a way of building answers in which every answer has exactly one construction, so there is nothing to remove.

Every problem in this lesson runs the same machine. Walk a decision tree depth-first, add one choice per level, record the path when it is complete, then undo the choice and try the next one. Only two knobs change from family to family:

  1. Which choices are allowed at the next level.
  2. When a path counts as an answer.

Set both knobs correctly and the tree contains each answer once. This lesson covers six settings.

# Smell in the problem Move Signature problem
1 Order matters, each item used once Pick any unused item Permutations
2 Order doesn't matter, exactly k items Only look forward Combinations
3 Any number of items Record every node Subsets
4 The input repeats values Sort, then skip an equal twin Subsets II, Permutations II
5 The picks must add up to a target Carry the remainder Combination Sum
6 One pick from each of several groups Take the cross product Letter Combinations

This lesson builds on Advanced Recursion & Backtracking: the call stack, the choose/explore/unchoose template, why a finished path is copied before it is stored, and pruning. Here that template is applied to the families that generate arrangements and selections. The count vectors from Foundation: Arrays & Strings come back in Move 4. All code is Python 3, and itertools appears only as a trusted reference for testing.

The machine, in outline

result = []
path = []                                  # the one live list of choices, root to here

backtrack(state):
    if path is a finished answer:          # knob 2
        result.append(copy of path)        # path keeps changing, so store a snapshot
        return                             # subsets skip this return: record, then keep going
    for choice in allowed(state):          # knob 1
        path.append(choice)                # choose
        backtrack(next state)              # explore
        path.pop()                         # unchoose

Each move below fills in allowed, next state and "finished" differently. Keep the outline in mind and the six moves are one idea.

Before any code: count the answers

A generator has to write out every answer, so (number of answers) × (length of each answer) is a floor on its running time. Count first. The count tells you whether the problem is feasible, and it gives your tests a number to check.

What you build Count Small example
Arrangements of n distinct items n! [1, 2, 3]: 3 · 2 · 1 = 6
Ordered picks of k from n (k-permutations) P(n, k) = n! / (n − k)! 2 from 4: 4 · 3 = 12
Unordered picks of k from n (combinations) C(n, k) = n! / (k! (n − k)!) 2 from 4: 12 / 2! = 6
Subsets of n items 2ⁿ [1, 2, 3]: 8
Distinct arrangements when values repeat c₁, c₂, … times n! / (c₁! · c₂! · …) [1, 1, 2, 2]: 24 / (2! · 2!) = 6
Multisets: k picks from n kinds, repeats allowed C(n + k − 1, k) 3 scoops from 4 flavours: 20
One pick from each group product of the group sizes keypad "23": 3 · 3 = 9

Every row except the multisets row comes from filling slots: n choices for the first seat, n − 1 for the second, and so on. The multisets row needs a different argument ("stars and bars"). Dividing the 4³ ordered scoop sequences by 3! fails (it gives 10.67), because a sequence with a repeated flavour has fewer than 3! orderings. The answer is C(6, 3) = 20. A combination is a k-permutation with the order forgotten. Each group of k appears in k! orders, so C(n, k) = P(n, k) / k!. The same "divide out what you can't tell apart" argument gives the repeated-values row: swapping two equal 1s gives back the same list, so the c₁! orderings of the 1s count once. Python has these built in: math.factorial(n), math.perm(n, k) and math.comb(n, k).

Predict first: one problem says n ≤ 12 and asks you to return every arrangement. Another says n ≤ 20 and asks for every subset. Which one is hopeless?

Check your answer

The arrangements. 12! = 479,001,600. For scale, generating the 10! = 3,628,800 permutations of 10 items with the Move 1 code took about 2.8 seconds in CPython, and each stored 10-item list takes 136 bytes. 12! is 132 times more: several minutes of CPU and tens of gigabytes of lists. The subsets are fine: 2²⁰ = 1,048,576.

n n! 2ⁿ C(n, n/2)
8 40,320 256 70
10 3,628,800 1,024 252
12 479,001,600 4,096 924
15 1,307,674,368,000 32,768 6,435
20 about 2.4 × 10¹⁸ 1,048,576 184,756

A working rule: listing every permutation is practical up to about n = 10, and listing every subset up to about n = 20. LeetCode's limits sit safely inside these: Permutations caps n at 6, Permutations II at 8, and Subsets at 10. Read the constraint as a hint about the family.

⚠️ Count, don't generate. When a problem asks only how many, don't list anything. The number of 5-card hands from a 52-card deck is math.comb(52, 5) = 2,598,960, computed instantly. When the count comes with conditions attached, dynamic programming usually counts without listing. See Dynamic Programming Mastery.

🧠 Why not memoize the generator? The subproblem "all arrangements of the remaining items" does repeat. For n = 4 the recursion makes 65 calls but meets only 16 different remaining sets, and it reaches the empty set 24 times. Caching still doesn't help, because every one of the n! answers must be written out in full. A cache can only skip work the output doesn't force on you. When the question is a count, that repetition is exactly what DP exploits.

Move 1: Pick any unused item (permutations)

Smell: the expected output treats [1, 2] and [2, 1] as different answers, and every item appears exactly once in each answer.

LeetCode 46: Permutations asks for every arrangement of up to 6 distinct integers. Level d of the tree decides what goes in position d. The root has n children, each of those has n − 1, and so on down to n! leaves:

                     []
          /           |           \
        [1]          [2]          [3]
       /   \        /   \        /   \
    [1,2] [1,3]  [2,1] [2,3]  [3,1] [3,2]
      |     |      |     |      |     |
 [1,2,3][1,3,2][2,1,3][2,3,1][3,1,2][3,2,1]

Predict first: the code below tries unused indices smallest first. For [1, 2, 3], which arrangement does it record fourth? And how many times is backtrack called in total?

Check your answer

It records the leaves left to right, so the fourth is [2, 3, 1]. There is one call per node of the tree: 1 + 3 + 6 + 6 = 16.

The slow version tries every tuple of n picks and keeps those with no repeated item, for example itertools.product(nums, repeat=n) plus a filter. For n = 8 that is 8⁸ = 16,777,216 tuples, of which 40,320 survive, so 99.76% are thrown away. The waste comes from picking an item that is already in the path and only noticing at the very end. The fix is to check before you go deeper.

def permute(nums):
    n = len(nums)
    result, path = [], []
    used = [False] * n

    def backtrack():
        if len(path) == n:              # every position is filled
            result.append(path[:])      # store a snapshot, not the live list
            return
        for i in range(n):
            if used[i]:
                continue
            used[i] = True              # choose
            path.append(nums[i])
            backtrack()                 # explore
            path.pop()                  # unchoose, in reverse order
            used[i] = False

    backtrack()
    return result

The first twelve events for permute([1, 2, 3]), printed by an instrumented copy:

step event path used
1 choose 1 [1] T F F
2 choose 2 [1, 2] T T F
3 choose 3 [1, 2, 3] T T T
4 record [1, 2, 3] T T T
5 undo 3 [1, 2] T T F
6 undo 2 [1] T F F
7 choose 3 [1, 3] T F T
8 choose 2 [1, 3, 2] T T T
9 record [1, 3, 2] T T T
10 undo 2 [1, 3] T F T
11 undo 3 [1] T F F
12 undo 1 [] F F F

Then 2 goes first and the pattern repeats. The full output is [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]].

Why it works

Invariant: at every call, used[i] is True exactly for the indices already in path. Each choose sets one flag and each unchoose clears it in reverse order, so the invariant holds before and after every call. The loop therefore offers exactly the unused items.

Every arrangement p comes out once: at depth d the loop reaches the index of p[d] exactly once, and that index is still unused because p has no repeats. Nothing else comes out, because a path can reach length n only with n different indices.

Cost. Depth d of the tree has n!/(n − d)! nodes, which adds up to n! · (1 + 1/1! + 1/2! + …), less than e · n! ≈ 2.72 · n!. For n = 3 that is 16 nodes. Each node runs an O(n) loop and each leaf copies n items, so the time is O(n · n!). Saying O(n!) misses that each of the n! answers is n numbers long. Extra space besides the output is O(n): path, used, and at most n + 1 stack frames. The output itself holds n · n! numbers. Recursion depth never comes near Python's default limit of 1,000, because the output explodes long before the stack does.

⚠️ A hidden quadratic. Many tutorials pass shrinking copies instead of a used array: backtrack(path, remaining[:i] + remaining[i+1:]). That version is correct, but every call builds a new list, and the lists along the current path (sizes n − 1, n − 2, …, 1) are all alive at once. That costs O(n²) extra space instead of O(n), plus O(n) extra time per call.

Variation: swap in place

def permute_swap(nums):
    nums = nums[:]                      # work on a copy; the caller's list stays as it was
    n = len(nums)
    result = []

    def backtrack(first):
        if first == n:
            result.append(nums[:])
            return
        for i in range(first, n):
            nums[first], nums[i] = nums[i], nums[first]   # move nums[i] into position first
            backtrack(first + 1)
            nums[first], nums[i] = nums[i], nums[first]   # swap back

    backtrack(0)
    return result

Invariant: before backtrack(first), nums[:first] is the fixed prefix of the current arrangement and nums[first:] holds exactly the items not yet placed. The first swap chooses, and the second swap undoes the choice.

permute_swap([1, 2, 3]) returns [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 2, 1], [3, 1, 2]]. The last two are not in sorted order: swapping 1 and 3 leaves the suffix as [2, 1]. LeetCode accepts any order, but a test that compares lists position by position would not.

You may have heard that "permutations use a used array and combinations use a start index". That is half true. The swap version has an index too. first means "the next position to fill", and the swaps keep every unused item to its right. What a permutation generator must never do is refuse the items to the left of a pointer forever. That refusal is what makes combinations.

used array swap in place
Extra state path + used the array itself is the path
Output order sorted, if the input is not sorted
Handles duplicates (Move 4) sort and skip a per-level seen set (swaps break sort-and-skip)
Stops after k picks base case len(path) == k base case first == k, store nums[:k]

Edge case: permute([]) returns [[]], one arrangement of nothing, which matches 0! = 1. LeetCode's n ≥ 1 means it won't test this, but your own tests should agree on it.

Your turn: write arrangements(nums, k), which returns every ordered pick of k distinct positions. Predict the count for [1, 2, 3, 4] with k = 2, name the one line that changes, and say what k > n returns.

Check your answer

There are 4 · 3 = 12: [1, 2], [1, 3], [1, 4], [2, 1], …, [4, 3]. Only the base case changes. For k > n no path ever reaches length k, so the result is [].

def arrangements(nums, k):
    n = len(nums)
    result, path = [], []
    used = [False] * n

    def backtrack():
        if len(path) == k:              # the only change: stop after k picks
            result.append(path[:])
            return
        for i in range(n):
            if used[i]:
                continue
            used[i] = True
            path.append(nums[i])
            backtrack()
            path.pop()
            used[i] = False

    backtrack()
    return result

Move 2: Only look forward (combinations)

Smell: the problem says "choose k", "team" or "group", and [1, 2] and [2, 1] count as the same answer. If the example output shows only one of them, you need combinations.

LeetCode 77: Combinations asks for every group of k numbers from 1 to n (n ≤ 20).

Predict first: run arrangements([1, 2, 3, 4], 2) from Move 1. How many lists come out? How many different groups of two are there, and how many times does each group appear?

Check your answer

12 lists, 6 groups, each group twice ([1, 2] and [2, 1]). In general every group of k appears k! times. That is the hook's waste on a small scale.

A group of k items can be written in k! orders, but only one of them lists the indices in increasing order. So build only that one: after picking index i, the next pick must come from index i + 1 or later.

def combine(items, k):
    result, path = [], []

    def backtrack(start):
        if len(path) == k:
            result.append(path[:])
            return
        for i in range(start, len(items)):
            path.append(items[i])
            backtrack(i + 1)            # the next pick must come from a later index
            path.pop()

    backtrack(0)
    return result

For LeetCode 77, call combine(list(range(1, n + 1)), k). Keeping one convention, where i is always an index and items[i] is always a value, avoids the classic off-by-one of mixing 1-based values with 0-based indices.

Every call of combine([1, 2, 3, 4], 2), in order:

call path on entry what happens
backtrack(0) [] tries indices 0–3
backtrack(1) [1] tries indices 1–3
backtrack(2) [1, 2] record
backtrack(3) [1, 3] record
backtrack(4) [1, 4] record
backtrack(2) [2] tries indices 2–3
backtrack(3) [2, 3] record
backtrack(4) [2, 4] record
backtrack(3) [3] tries index 3
backtrack(4) [3, 4] record
backtrack(4) [4] empty loop: a dead end

The output is [[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]]. [2, 1] can never be built, because once the path holds 2, index 0 is behind start.

Why it works. Invariant: the indices in path strictly increase, and every index still to be picked is at least start. Each group has exactly one increasing listing, and at every depth the loop reaches that listing's next index exactly once. So each group appears once and no reordering is ever built.

One argument, four families

The argument you pass to the recursive call decides what you generate. Here is what each choice prints on [1, 2, 3] with k = 2:

Loop Next call gets You generate Output Count
range(start, n) i + 1 combinations [1,2] [1,3] [2,3] 3 = C(3, 2)
range(start, n) i combinations with repetition [1,1] [1,2] [1,3] [2,2] [2,3] [3,3] 6 = C(4, 2)
range(start, n) start, unchanged every ordered pair, repeats allowed [1,1] [1,2] [1,3] [2,1] … [3,3] 9 = 3²
range(n), skip used nothing, used tracks it k-permutations [1,2] [1,3] [2,1] [2,3] [3,1] [3,2] 6 = P(3, 2)

The third row is a classic bug. Forgetting to advance start doesn't only allow repeats. It also brings back the reorderings, such as [2, 1]. The second row is exactly what Move 5 needs.

Pruning: leave room for the remaining picks

If you still need need more items (this pick included), the current pick can't come later than index len(items) − need. Otherwise too few items are left to finish. Put that bound in the loop:

def combine_pruned(items, k):
    result, path = [], []

    def backtrack(start):
        if len(path) == k:
            result.append(path[:])
            return
        need = k - len(path)
        for i in range(start, len(items) - need + 1):   # leave room for the other picks
            path.append(items[i])
            backtrack(i + 1)
            path.pop()

    backtrack(0)
    return result

Measured call counts on items 1..20:

k answers C(20, k) calls without pruning calls with pruning
3 1,140 1,351 1,330
10 184,756 616,666 352,716
18 190 1,048,555 1,330

With small k, pruning barely matters, because almost every branch can still finish. With k close to n it matters enormously: the unpruned search walks nearly the whole 2²⁰ tree to find 190 answers. It never beats the output, though.

Cost. With pruning, every node lies on the way to at least one answer. Each answer's root-to-leaf path has k + 1 nodes, so the tree has at most (k + 1) · C(n, k) nodes, and time is O(k · C(n, k)) and extra space is O(k). C(n, k) peaks at k = n/2, where C(20, 10) = 184,756.

Edge cases: k = 0 gives [[]] (one empty group), and k > n gives [] (the pruned loop is empty immediately). LeetCode 77 promises 1 ≤ k ≤ n.

Your turn: with the pruned loop, which values can be the first pick in combine_pruned([1, 2, 3, 4, 5], 3)? The unpruned version makes 26 calls. Which nodes does pruning remove, and how many calls remain?

Check your answer

Only 1, 2 and 3 can come first, because the first index must be at most 5 − 3 = 2. Pruning removes the six dead ends [4], [5], [1, 5], [2, 5], [3, 5] and [4, 5], leaving 20 calls. The ten answers are the same.

Move 3: Record every node (subsets)

Smell: "all subsets", "power set", any size including empty.

LeetCode 78: Subsets asks for every subset of up to 10 distinct integers.

Predict first: the combination code records a path only when it holds k items. Suppose it recorded every path, as soon as each call starts. What would it print for [1, 2, 3], in order?

Check your answer

[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]: 2³ = 8 subsets. They come out in depth-first order, each node before its children. Each subset appears once for the same reason as in Move 2: it has one increasing listing.

def subsets(nums):
    result, path = [], []

    def backtrack(start):
        result.append(path[:])          # every node is an answer
        for i in range(start, len(nums)):
            path.append(nums[i])
            backtrack(i + 1)
            path.pop()

    backtrack(0)
    return result

There is no base case. When start == len(nums) the loop is empty and the call simply returns.

The other tree: take it or leave it

You can also make one decision per item: take it or leave it. Answers sit only at the leaves.

def subsets_binary(nums):
    result, path = [], []

    def decide(i):
        if i == len(nums):              # a decision has been made for every item
            result.append(path[:])
            return
        path.append(nums[i])            # take nums[i]
        decide(i + 1)
        path.pop()                      # leave nums[i]
        decide(i + 1)

    decide(0)
    return result
take 1?            yes                          no
take 2?       yes        no               yes        no
take 3?     yes   no   yes   no         yes   no   yes   no
         [1,2,3] [1,2] [1,3]  [1]      [2,3]  [2]   [3]   []

It returns the same 8 subsets in the order shown along the bottom row. The binary tree has 2ⁿ⁺¹ − 1 = 15 nodes for n = 3. The start-index tree has 8, one per answer.

Without recursion

def subsets_iterative(nums):
    result = [[]]
    for x in nums:
        result += [s + [x] for s in result]   # every subset so far, now also with x
    return result

def subsets_bitmask(nums):
    n = len(nums)
    return [[nums[i] for i in range(n) if mask >> i & 1] for mask in range(1 << n)]

The doubling version builds [], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]. The bitmask version maps bit i of mask to nums[i], and the two produce the same order: after the loop, result[m] is the subset whose mask is m.

Cost. Every version produces 2ⁿ answers whose sizes add up to n · 2ⁿ⁻¹, so every version takes O(n · 2ⁿ) time. The recursive versions need O(n) extra space for the path and stack. Don't expect the bitmask version to be the fast one in Python. In a CPython run at n = 16 and n = 18 it was 3 to 4 times slower than subsets, because it tests all n bits of every mask, and the doubling version was the fastest of the four.

Subsets are just all the combination sizes together: 2ⁿ = C(n, 0) + C(n, 1) + … + C(n, n). For n = 3 that is 1 + 3 + 3 + 1 = 8.

Your turn: without running anything, what is subsets_iterative(['a', 'b', 'c'])[6], and which mask gives the same subset? Which subset does mask 5 (binary 101) mean?

Check your answer

result[6] is ['b', 'c'], and mask 6 is binary 110: bits 1 and 2 are set, so it means nums[1] and nums[2]. Mask 5 = 101 sets bits 0 and 2, which gives ['a', 'c'].

Move 4: Sort, then skip an equal twin (duplicates)

Smell: the input may repeat values, but the output must not repeat answers. See LeetCode 90: Subsets II and LeetCode 47: Permutations II.

Predict first: subsets([1, 2, 2]) from Move 3 returns 8 lists. How many different subsets are there, and which lists are repeats?

Check your answer

There are 6 different subsets: [], [1], [1, 2], [1, 2, 2], [2] and [2, 2]. The code prints [], [1], [1, 2], [1, 2, 2], [1, 2], [2], [2, 2], [2], so [1, 2] and [2] each appear twice. The second [1, 2] picked the second 2 at a level where the first 2 had already been tried.

Duplicates come from one place: two equal values tried at the same level. Choosing the second 2 there starts the path with the same value as choosing the first 2, and what it can add afterwards (nums[3:], nothing) is a suffix of what the first 2 can add (nums[2:], one more 2). So the second 2's branch can only rebuild answers the first 2's branch already built: [2] here, while [2, 2] comes from the first branch alone. So the rule is: at each level, try each distinct value once. Sorting puts equal values next to each other, so "same value as the previous iteration" becomes a single comparison.

def subsets_with_dup(nums):
    nums = sorted(nums)                 # equal values side by side; the caller's list is untouched
    result, path = [], []

    def backtrack(start):
        result.append(path[:])
        for i in range(start, len(nums)):
            if i > start and nums[i] == nums[i - 1]:
                continue                # this value was already tried at this level
            path.append(nums[i])
            backtrack(i + 1)
            path.pop()

    backtrack(0)
    return result

Every loop step for [1, 2, 2]:

path start i nums[i] action
[] 0 0 1 take
[1] 1 1 2 take
[1, 2] 2 2 2 take (i == start, the first try at this level)
[1] 1 2 2 skip (2 was already tried at this level)
[] 0 1 2 take
[2] 2 2 2 take
[] 0 2 2 skip

The recorded answers are [], [1], [1, 2], [1, 2, 2], [2], [2, 2].

Why i > start and not i > 0. The first iteration of a level is never a repeat at that level. In the third row, nums[2] == nums[1], but that earlier 2 was never an alternative tried at this level. It is already in the path. With i > 0 that row would be skipped, [1, 2, 2] and [2, 2] would vanish, and the output would be [[], [1], [1, 2], [2]].

Permutations with repeated values

The used-array loop in Move 1 starts at index 0 on every level, so i > start has nothing to compare against. The rule becomes: equal values must be placed in left-to-right order. Call the two 1s 1a and 1b. 1b may be placed only when 1a is already in the path.

def permute_unique(nums):
    nums = sorted(nums)
    n = len(nums)
    result, path = [], []
    used = [False] * n

    def backtrack():
        if len(path) == n:
            result.append(path[:])
            return
        for i in range(n):
            if used[i]:
                continue
            if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
                continue                # the left twin must be placed first
            used[i] = True
            path.append(nums[i])
            backtrack()
            path.pop()
            used[i] = False

    backtrack()
    return result

Why it works. Any arrangement of the values can be labelled in exactly one way that keeps each value's copies in left-to-right order (1a before 1b). The rule allows only that labelling, so each distinct arrangement appears exactly once, and there are n! / (c₁! · c₂! · …) of them.

Every loop decision for [1, 1, 2]:

path step reason
[] i=0: take 1
[1] i=1: take 1 its twin (index 0) is in the path
[1, 1] i=2: take 2, record [1, 1, 2]
[1] i=2: take 2
[1, 2] i=1: take 1, record [1, 2, 1] its twin is in the path
[] i=1: skip its twin is not in the path
[] i=2: take 2
[2] i=0: take 1
[2, 1] i=1: take 1, record [2, 1, 1]
[2] i=1: skip index 0 was just unchosen, so it is no longer in the path

That is 9 calls for the 3 answers.

⚠️ Borrowing the subsets rule breaks permutations. if i > 0 and nums[i] == nums[i - 1]: continue, without the used check, blocks 1b forever, even after 1a has been placed. No path ever reaches length 3, so [1, 1, 2] returns [].

⚠️ Forgetting to sort. The rule compares neighbours only. [1, 2, 1] then returns all six lists, repeats included.

⚠️ Sorting the caller's list. nums.sort() inside the function reorders the list the caller passed in. nums = sorted(nums) sorts a copy.

🧠 Plot twist. Flip the rule to and used[i - 1], which skips when the twin is in the path, and the output is still correct. The copies are now forced right to left. But this version rejects bad branches much later. On [1, 1, 1, 1, 2, 2, 2] it makes 1,192 calls instead of 125. Keep not used[i - 1].

A counter instead of a sort

from collections import Counter

def permute_unique_counter(nums):
    counts = Counter(nums)
    n = len(nums)
    result, path = [], []

    def backtrack():
        if len(path) == n:
            result.append(path[:])
            return
        for value in counts:            # each distinct value at most once per level
            if counts[value] == 0:
                continue
            counts[value] -= 1
            path.append(value)
            backtrack()
            path.pop()
            counts[value] += 1

    backtrack()
    return result

Each level loops over distinct values, so it can't pick "a 1" twice at the same level. There is no sort and no skip rule. This is the count vector from the Foundation lesson again: when identities don't matter, remember counts. Only the values change, never the keys, so iterating over counts while updating it is safe.

Cost. Let U = n! / (c₁! · c₂! · …) be the number of answers. Every node is a distinct prefix that can still be completed, so there are at most (n + 1) · U nodes, each with an O(n) loop. That gives O(n² · U) time as a safe bound, of which n · U is writing the output. Extra space is O(n). The dedupe-afterwards alternative, set(itertools.permutations(nums)), builds all n! tuples. On [1, 1, 2, 2, 3, 3, 4, 4, 5, 5] that is 3,628,800 tuples built to keep 113,400, so 32 are built for every one kept.

Your turn: predict how many lists permute_unique([2, 2, 1, 1]) returns, and the first three in order.

Check your answer

4! / (2! · 2!) = 6. The function sorts its copy to [1, 1, 2, 2], so the first three are [1, 1, 2, 2], [1, 2, 1, 2] and [1, 2, 2, 1].

Move 5: Carry the remainder (target sums)

Smell: the picks must add up to a target, order doesn't matter, and items may or may not be reused.

LeetCode 39: Combination Sum gives distinct candidates between 2 and 40, lets you use each one any number of times, and caps the target at 40. The statement promises fewer than 150 answers.

Predict first: with candidates [2, 3, 6, 7] and target 7, what is the answer? And what would the unchanged combination template (next call gets i + 1) return?

Check your answer

The answer is [[2, 2, 3], [7]]. With i + 1, each candidate can be used at most once, so only [[7]] survives, because 2 + 2 + 3 needs the 2 twice. Reuse is row 2 of the four-families table: pass i.

def combination_sum(candidates, target):
    candidates = sorted(candidates)
    result, path = [], []

    def backtrack(start, remaining):
        if remaining == 0:
            result.append(path[:])
            return
        for i in range(start, len(candidates)):
            if candidates[i] > remaining:
                break                   # sorted, so every later candidate is too big as well
            path.append(candidates[i])
            backtrack(i, remaining - candidates[i])    # i, not i + 1: the same item may repeat
            path.pop()

    backtrack(0, target)
    return result

Every loop step for candidates [2, 3] and target 5:

path remaining step
[] 5 take 2
[2] 3 take 2
[2, 2] 1 2 > 1: break, so 3 is never tried
[2] 3 take 3, giving [2, 3] with remaining 0: record
[] 5 take 3
[3] 2 3 > 2: break

The output is [[2, 3]]. [3, 2] can't appear, because after taking 3 the loop starts at 3's index.

Why break is safe. The candidates are sorted and all positive, so once candidates[i] > remaining, every later candidate is too big as well. continue would give the same output more slowly. On [2, 3, 6, 7] with target 7, the break version makes 10 calls, while a version that recurses first and returns on remaining < 0 makes 28. Without sorting, break is a bug. Candidates [5, 2] with target 4 stop at the 5 and return [] instead of [[2, 2]].

Why it stops at all. Every call subtracts a positive amount, so the path is at most target / min(candidates) long. LeetCode guarantees candidates ≥ 2. If a candidate could be 0, passing i would call backtrack(i, remaining) with the same arguments forever, and Python would raise RecursionError after about 1,000 frames. Every recursive call must shrink the problem.

Cost. Let T be the target and m the smallest candidate. Extra space is O(T/m) for the path and stack, which is 20 levels at most under LeetCode's limits. A loose time bound: at most n branches per node and depth at most T/m, so O(n^(T/m)) nodes, plus copying each answer. Sorting and break keep the real tree far smaller, as the 10 calls above show.

Each item at most once: Combination Sum II

In LeetCode 40: Combination Sum II the candidates may repeat, each may be used at most once, and the output must not repeat a combination. That is Subsets II plus a remainder: sort, skip twins with i > start, pass i + 1, and break when a candidate is too big.

def combination_sum2(candidates, target):
    candidates = sorted(candidates)
    result, path = [], []

    def backtrack(start, remaining):
        if remaining == 0:
            result.append(path[:])
            return
        for i in range(start, len(candidates)):
            if i > start and candidates[i] == candidates[i - 1]:
                continue                # this value was already tried at this level
            if candidates[i] > remaining:
                break
            path.append(candidates[i])
            backtrack(i + 1, remaining - candidates[i])  # each item at most once
            path.pop()

    backtrack(0, target)
    return result

For candidates [10, 1, 2, 7, 6, 1, 5] and target 8 it returns [[1, 1, 6], [1, 2, 5], [1, 7], [2, 6]]. [1, 1, 6] uses both 1s, taken at different levels, and [1, 7] appears once even though there are two 1s to start it with.

Your turn: LeetCode 216: Combination Sum III asks for every set of k distinct numbers from 1 to 9 that adds up to n. Which row of the four-families table is it? Write it, then predict the output for k = 3, n = 9 and for k = 4, n = 1.

Check your answer

It is row 1 (i + 1, no reuse), plus a remainder and a length cap. For k = 3, n = 9 it returns [[1, 2, 6], [1, 3, 5], [2, 3, 4]]. For k = 4, n = 1 it returns []: 1 + 2 + 3 + 4 = 10 already exceeds 1.

def combination_sum3(k, n):
    result, path = [], []

    def backtrack(start, remaining):
        if len(path) == k:
            if remaining == 0:
                result.append(path[:])
            return
        for x in range(start, 10):
            if x > remaining:
                break                   # x only grows from here
            path.append(x)
            backtrack(x + 1, remaining - x)
            path.pop()

    backtrack(1, n)
    return result

Move 6: Take the cross product (one from each group)

Smell: there are several groups, you pick exactly one option from each, and the order of the groups is fixed by the input. Nothing is rearranged and nothing is left out.

LeetCode 17: Letter Combinations of a Phone Number gives 1 to 4 digits from 2 to 9 and asks for every string the keypad letters could spell.

Predict first: how many strings do the digits "79" give? And "234"? Which string comes fifth for "29"?

Check your answer

"79" gives 4 · 4 = 16 and "234" gives 3 · 3 · 3 = 27. For "29" the strings run aw, ax, ay, az, bw.

KEYPAD = {"2": "abc", "3": "def", "4": "ghi", "5": "jkl",
          "6": "mno", "7": "pqrs", "8": "tuv", "9": "wxyz"}

def letter_combinations(digits):
    result, path = [], []

    def backtrack(pos):
        if pos == len(digits):
            result.append("".join(path))
            return
        for letter in KEYPAD[digits[pos]]:   # depth = position, branching = that digit's letters
            path.append(letter)
            backtrack(pos + 1)
            path.pop()

    backtrack(0)
    return result

letter_combinations("23") returns ['ad', 'ae', 'af', 'bd', 'be', 'bf', 'cd', 'ce', 'cf']. Joining builds a new string, so no copy of path is needed here.

Cost. With n digits there are at most 4ⁿ strings, each joined in O(n), so time is O(n · 4ⁿ) and extra space is O(n). Under LeetCode's limit of 4 digits that is at most 256 strings.

Edge case: the empty string. The constraints rule it out, but your contract might not. This code returns [''] for "": one way to pick nothing from no groups, just as 0! = 1. Earlier versions of this problem allowed "" and expected [], which takes one line: if not digits: return []. Decide it; don't discover it.

The title misleads. The problem is called "letter combinations", but the answer is neither combinations nor permutations: the groups are fixed and in order. It is itertools.product, which makes a handy test reference:

from itertools import product

def letter_combinations_reference(digits):
    return ["".join(p) for p in product(*(KEYPAD[d] for d in digits))]

Your turn: generalize the idea to cross(groups), which takes a list of option lists and picks one option from each. Predict the output for [[1, 2], [3], [4, 5, 6]] and for [[1, 2], [], [3]].

Check your answer

The first gives 2 · 1 · 3 = 6 lists: [1, 3, 4], [1, 3, 5], [1, 3, 6], [2, 3, 4], [2, 3, 5], [2, 3, 6]. The second gives [], because an empty group leaves no complete path. The product of the sizes is 0.

def cross(groups):
    result, path = [], []

    def backtrack(pos):
        if pos == len(groups):
            result.append(path[:])
            return
        for option in groups[pos]:
            path.append(option)
            backtrack(pos + 1)
            path.pop()

    backtrack(0)
    return result

Debugging clinic: read the symptom

A broken generator usually still returns something, and the shape of that something points at the bug. Every symptom below came from running the broken version:

Symptom Cause Fix
permute([1, 2, 3]) → [[], [], [], [], [], []] result.append(path) stored the live list six times append path[:]
permute([1, 2, 3]) → [[1, 2, 3]] used[i] = False missing after the call undo every change, in reverse order
a combination list contains [2, 1] the recursive call got start instead of i + 1 pass i + 1
subsets_with_dup([1, 2, 2]) → [[], [1], [1, 2], [2]] i > 0 instead of i > start compare with start
permute_unique([1, 1, 2]) → [] the subsets skip rule used for permutations add and not used[i - 1]
permute_unique([1, 2, 1]) → six lists with repeats input not sorted sort a copy first
combination_sum([5, 2], 4) → [] break on unsorted candidates sort first
RecursionError a call that doesn't shrink the problem, e.g. a 0 candidate with reuse make every call make progress

The first row is aliasing. result.append(path) puts a reference to the one path list into result, so all six entries are the same object, which the final pops leave empty. It has nothing to do with how Python passes arguments; it's two names for one list. Advanced Recursion & Backtracking covers state bugs like this in depth.

Test against a trusted reference

A generator is easy to check, because itertools already knows the right answer. Compare the outputs as multisets of tuples:

import itertools
import random

def same_answers(got, want):
    return sorted(map(tuple, got)) == sorted(map(tuple, want))

def check(trials=300):
    for _ in range(trials):
        nums = [random.randint(0, 2) for _ in range(random.randint(0, 6))]
        want = set(itertools.permutations(nums))      # distinct arrangements
        got = permute_unique(nums)
        assert same_answers(got, want), nums          # also fails if got has repeats
    print("all matched")

The values are drawn from 0–2 on purpose, so duplicates are common. Lengths start at 0, so [] → [[]] gets tested too. The references for the other families are itertools.permutations(nums), itertools.combinations(items, k), combinations of every size r from 0 to n for subsets, and itertools.product(*groups).

⚠️ Don't sort the inner lists before comparing. With sorted(sorted(p) for p in got), the order inside each answer is erased, and that order is exactly what a permutation test must check. A broken permute that returns [[1, 2], [1, 2]] would pass against [[1, 2], [2, 1]]. Sort the outer list of tuples only.

Final round: no label on the problem

Real problems don't say which move they want. For each one, decide: what is chosen at each level, which choices are allowed, and when is a path an answer?

Challenge 1: the case switch

LeetCode 784: Letter Case Permutation. Given a string of letters and digits (length ≤ 12), return every string you can make by switching the case of each letter. "a1b2" gives ["a1b2", "a1B2", "A1b2", "A1B2"], in any order. Which move is this, and how many strings does a string with L letters produce?

Check your answer

Move 6, despite the title. Each position is a group with two options (a letter) or one option (a digit), and nothing is rearranged. A string with L letters gives 2ᴸ results. Time is O(n · 2ᴸ) for length n, and extra space is O(n).

def letter_case_permutation(s):
    result, path = [], []

    def backtrack(pos):
        if pos == len(s):
            result.append("".join(path))
            return
        ch = s[pos]
        options = [ch.lower(), ch.upper()] if ch.isalpha() else [ch]
        for option in options:
            path.append(option)
            backtrack(pos + 1)
            path.pop()

    backtrack(0)
    return result

"12" has no letters and gives ['12']: one string, not an empty list.

Challenge 2: cut into palindromes

LeetCode 131: Palindrome Partitioning. Split a string (length ≤ 16) into pieces that are all palindromes, and return every way to do it. "aab" gives [["a", "a", "b"], ["aa", "b"]].

Check your answer

It is Move 2's forward-only loop, where a choice means "where the next piece ends". A partition is a subset of the n − 1 gaps between letters, so there are at most 2ⁿ⁻¹ answers, and the palindrome test prunes every piece that fails.

def partition(s):
    result, path = [], []

    def backtrack(start):
        if start == len(s):
            result.append(path[:])
            return
        for end in range(start + 1, len(s) + 1):
            piece = s[start:end]
            if piece == piece[::-1]:    # only palindromic pieces may be chosen
                path.append(piece)
                backtrack(end)          # the next piece starts where this one ended
                path.pop()

    backtrack(0)
    return result

The worst case is a string of one repeated letter, where every piece is a palindrome: "aaaa" has 2³ = 8 partitions. Then the search visits 2ⁿ nodes, and each tries up to n pieces with an O(n) slice-and-reverse test, so time is O(n² · 2ⁿ). Extra space is O(n) besides the output. Precomputing which substrings are palindromes removes the inner O(n); that is a dynamic programming table.

Challenge 3: keep the order, drop the repeats

LeetCode 491: Non-decreasing Subsequences. Return every different non-decreasing subsequence with at least two elements (length ≤ 15, values from −100 to 100). [4, 6, 7, 7] gives 8 answers, among them [4, 6, 7, 7] and [7, 7].

Hint

It looks like Subsets II, but sorting would destroy the very order the problem is about. What else can tell you "this value was already tried at this level"?

Check your answer

It is Move 4's rule without the sort. Keep a seen set inside each call, holding the values already tried at that level.

def find_subsequences(nums):
    result, path = [], []

    def backtrack(start):
        if len(path) >= 2:
            result.append(path[:])
        seen = set()                    # values already tried at THIS level
        for i in range(start, len(nums)):
            if nums[i] in seen:
                continue
            if path and nums[i] < path[-1]:
                continue                # would break the non-decreasing order
            seen.add(nums[i])
            path.append(nums[i])
            backtrack(i + 1)
            path.pop()

    backtrack(0)
    return result

[4, 6, 7, 7] returns [[4, 6], [4, 6, 7], [4, 6, 7, 7], [4, 7], [4, 7, 7], [6, 7], [6, 7, 7], [7, 7]]. The set is created fresh in each call because it describes one level: a deeper level may still take the second 7, which is how [7, 7] appears. It is the same principle as i > start, without needing equal values to be neighbours. Time is O(n · 2ⁿ). The sets along the current path hold O(n²) values at worst.

Cheat sheet: problem → knobs

Problem says Loop over Recursive call gets Record when Repeated input values
every arrangement all i, skip used nothing (used tracks it) len(path) == n sort; skip if the left twin is unused
choose k range(start, n) i + 1 len(path) == k sort; skip twins when i > start
every subset range(start, n) i + 1 on entry to every call sort; skip twins when i > start
sum to target, reuse sorted, range(start, n) i remaining == 0 (LeetCode 39 has none)
sum to target, once each sorted, range(start, n) i + 1 remaining == 0 skip twins when i > start
one from each group the options of group pos pos + 1 pos == len(groups) none
order must be kept range(start, n) i + 1 as the problem says a seen set per call

And before any of it: count the answers, check the count against the constraints, and ask whether the problem wants the list, the count, or just one answer.

Before moving on, pick one family and write it from a blank editor. Say what each level chooses, which choices are allowed, and when a path is an answer. Then state the invariant, the number of answers, the time and extra space, and the bug you would expect to make. If you can do that for permutations with duplicates and for Combination Sum, the rest are variations.

Next: String Permutations for next permutation, permutation-in-string and strings with repeated letters. Constraint Satisfaction takes the same machine to N-Queens, Sudoku and Word Search, where pruning does the heavy lifting. Dynamic Programming Mastery counts answers without listing them.