0/23in Data Structures & Algorithms
Data Structures & Algorithms

Recursion & Backtracking

Trust the recursion. Build the decision tree for subsets, permutations, and combinations, and prune it to stay fast.

~17 minLesson 22 of 60
Your progressNot started

Recursion is what you use when a problem is naturally defined in terms of smaller copies of itself. A tree’s height is one plus the height of its taller subtree. A factorial is n times the factorial of n - 1. The code mirrors the idea directly: solve the tiny case, then reduce the big case until it becomes tiny.

Backtracking is recursion with memory of a partial choice. You build one candidate, explore everything that follows from it, then undo the choice so the next branch starts clean. That choose → explore → un-choose rhythm is the core of subsets, permutations, combinations, and many constraint-search problems.

The mental model

Every recursive function needs two pieces:

  • Base case — the smallest input you can answer immediately.
  • Recursive case — shrink the input and trust that the recursive call solves the smaller problem.
def factorial(n):
    if n <= 1:                 # base case
        return 1
    return n * factorial(n - 1) # recursive case

The runtime keeps unfinished calls on the call stack. factorial(4) waits for factorial(3), which waits for factorial(2), which waits for factorial(1). When the base case returns, the stack unwinds in reverse order. That is why missing base cases crash as recursion-depth errors: the stack never gets a chance to unwind.

The most useful mindset is trust the recursion. Do not mentally inline every call forever. Define what the function promises to return for a smaller input, assume it keeps that promise, and write the one layer that combines those answers.

Backtracking adds a decision tree. Each level represents one decision, each edge is one choice, and each root-to-leaf path is a candidate answer.

def backtrack(state, choices):
    if is_complete(state):
        answers.append(state.copy())
        return

    for choice in choices:
        if not is_valid(choice, state):
            continue

        make(choice, state)       # choose
        backtrack(state, next_choices(choice, choices))
        undo(choice, state)       # un-choose

Pruning is just refusing to walk branches that cannot lead to a valid answer. If the remaining sum is negative, stop. If a permutation already used a number, skip it. Good backtracking is not brute force with recursion syntax; it is systematic search plus early exits.

Recursion tree · fib(5)15 calls
543210121032101
computedcache hit (pruned)Toggle memoize: repeated subproblems collapse to cache hits, turning exponential work into linear.

Pattern recognition

Variations

Worked problems

Each solution below is the same skeleton wearing a different set of choices. Name the decision at each level before you write code.

LeetCode 78Medium
  • Backtracking
  • Subsets

Subsets

Given distinct integers, return every subset of the array. The empty subset and the full array both count, and the answer can be in any order.

Approach. At index i, the decision is binary: either skip nums[i] or take it. A cleaner iterative-feeling backtrack is to append the current path as an answer at every node, then try adding each later element. Copy the path when you record it because the same list is mutated throughout the search.

Show solution
def subsets(nums):
    ans = []
    path = []

    def backtrack(start):
        ans.append(path.copy())
        for i in range(start, len(nums)):
            path.append(nums[i])
            backtrack(i + 1)
            path.pop()

    backtrack(0)
    return ans

Complexity. O(n · 2^n) time and O(n) auxiliary stack/path space, not counting the output. There are 2^n subsets, and copying each path costs up to O(n).

LeetCode 46Medium
  • Backtracking
  • Permutations

Permutations

Given distinct integers, return every ordering that uses each integer exactly once.

Approach. Each level chooses the next unused number. Track used positions so you never place the same element twice in one permutation. When the path length matches len(nums), it is a complete ordering and should be copied into the answer.

Show solution
def permute(nums):
    ans = []
    path = []
    used = [False] * len(nums)

    def backtrack():
        if len(path) == len(nums):
            ans.append(path.copy())
            return

        for i, x in enumerate(nums):
            if used[i]:
                continue
            used[i] = True
            path.append(x)
            backtrack()
            path.pop()
            used[i] = False

    backtrack()
    return ans

Complexity. O(n · n!) time and O(n) auxiliary space, excluding output. There are n! permutations, and each complete one is copied.

LeetCode 39Medium
  • Backtracking
  • Pruning

Combination Sum

Given distinct positive candidates and a target, return every combination whose sum equals the target. You may reuse the same candidate any number of times, and combinations with the same numbers in a different order should not be duplicated.

Approach. Sort first so you can stop a loop once a candidate is larger than the remaining sum. Pass a start index to keep combinations nondecreasing; that prevents [2, 3, 2] from appearing separately from [2, 2, 3]. Reuse is allowed by recursing with i, not i + 1.

Show solution
def combination_sum(candidates, target):
    candidates.sort()
    ans = []
    path = []

    def backtrack(start, remaining):
        if remaining == 0:
            ans.append(path.copy())
            return

        for i in range(start, len(candidates)):
            x = candidates[i]
            if x > remaining:
                break
            path.append(x)
            backtrack(i, remaining - x)
            path.pop()

    backtrack(0, target)
    return ans

Complexity. Exponential in the target and candidates because the output can be exponential. The recursion depth is at most target // min(candidates) for positive candidates; sorting costs O(n log n) and enables pruning.

The classic pitfall

Your progressNot started