title: Combinations
slug: combinations
difficulty: medium
leetcode_id: 77
leetcode_url: https://leetcode.com/problems/combinations/
categories:
  - arrays
  - recursion
patterns:
  - backtracking

description: |
  Given two integers `n` and `k`, return *all possible combinations of* `k` *numbers chosen from the range* `[1, n]`.

  You may return the answer in **any order**.

constraints: |
  - `1 <= n <= 20`
  - `1 <= k <= n`

examples:
  - input: "n = 4, k = 2"
    output: "[[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]"
    explanation: "There are 4 choose 2 = 6 total combinations. Note that combinations are unordered, i.e., [1,2] and [2,1] are considered to be the same combination."
  - input: "n = 1, k = 1"
    output: "[[1]]"
    explanation: "There is 1 choose 1 = 1 total combination."

explanation:
  intuition: |
    Imagine you're picking a team of `k` players from a pool of `n` candidates numbered 1 through `n`. The order you pick them doesn't matter — selecting player 1 then player 3 is the same team as selecting player 3 then player 1.

    The key insight is that we can **build combinations incrementally** by making a series of choices. At each step, we decide: "Should I include this number in my combination?" If we include it, we move on to consider the next number. If not, we skip it and move to the next.

    To avoid duplicates like `[1,3]` and `[3,1]`, we enforce a rule: **only consider numbers greater than the last number we picked**. This ensures each combination is built in ascending order, so `[1,3]` is generated but `[3,1]` never is.

    This naturally leads to a *backtracking* approach: we explore one path (include a number), and if it doesn't lead to a valid combination of size `k`, we "backtrack" by removing that number and trying the next option.

  approach: |
    We solve this using **Backtracking**:

    **Step 1: Define the recursive function**

    - Create a helper function `backtrack(start, current_combination)` that builds combinations
    - `start`: the smallest number we can still pick (ensures ascending order)
    - `current_combination`: the combination being built

    &nbsp;

    **Step 2: Base case — combination is complete**

    - If `len(current_combination) == k`, we've found a valid combination
    - Add a **copy** of `current_combination` to results (important: we need a copy because we'll modify it later)
    - Return to explore other paths

    &nbsp;

    **Step 3: Recursive case — try each remaining number**

    - Loop through numbers from `start` to `n`
    - For each number `i`:
      - **Choose**: Add `i` to `current_combination`
      - **Explore**: Recursively call `backtrack(i + 1, current_combination)`
      - **Unchoose**: Remove `i` from `current_combination` (backtrack)

    &nbsp;

    **Step 4: Optimisation — pruning**

    - If there aren't enough numbers left to complete a combination, stop early
    - We need `k - len(current_combination)` more numbers
    - We have `n - start + 1` numbers remaining
    - If remaining < needed, prune this branch

    &nbsp;

    **Step 5: Return all combinations**

    - Call `backtrack(1, [])` to start building from number 1
    - Return the collected results

  common_pitfalls:
    - title: Generating Duplicate Combinations
      description: |
        Without enforcing order, you might generate both `[1,3]` and `[3,1]`. Since combinations are unordered, these are duplicates.

        The fix is to only consider numbers **greater than** the last number added. By always building in ascending order, each unique set of numbers appears exactly once.
      wrong_approach: "Starting each recursion from 1"
      correct_approach: "Starting from last_picked + 1"

    - title: Forgetting to Copy the Combination
      description: |
        A common bug is adding `current_combination` directly to results:
        ```python
        result.append(current_combination)  # Bug!
        ```

        Since `current_combination` is modified during backtracking, all entries in `result` end up pointing to the same (eventually empty) list.

        Always append a **copy**: `result.append(current_combination[:])` or `result.append(list(current_combination))`.
      wrong_approach: "result.append(current_combination)"
      correct_approach: "result.append(current_combination[:])"

    - title: Missing the Pruning Optimisation
      description: |
        Without pruning, the algorithm explores paths that can never lead to valid combinations. For example, if `n = 4`, `k = 3`, and we've picked `[4]`, there's no way to pick 2 more numbers greater than 4.

        Adding a check at the start of the loop — `if n - i + 1 < k - len(current)` — skips these futile branches and significantly speeds up execution.
      wrong_approach: "Exploring all paths regardless of remaining elements"
      correct_approach: "Pruning when remaining elements < needed elements"

  key_takeaways:
    - "**Backtracking template**: The choose-explore-unchoose pattern is the foundation for generating all permutations, combinations, and subsets"
    - "**Avoiding duplicates**: Enforcing an ordering (only picking larger numbers) is a common technique to prevent duplicate combinations"
    - "**Pruning**: Early termination when a path cannot lead to a solution dramatically improves performance"
    - "**Copy before storing**: When collecting mutable objects during recursion, always store copies, not references"

  time_complexity: "O(k * C(n,k)). We generate C(n,k) combinations, and copying each combination of length k takes O(k) time."
  space_complexity: "O(k). The recursion depth is at most k (the size of each combination), and we use O(k) space for the current combination being built. The output space O(k * C(n,k)) is not counted as auxiliary space."

solutions:
  - approach_name: Backtracking
    is_optimal: true
    code: |
      def combine(n: int, k: int) -> list[list[int]]:
          result = []

          def backtrack(start: int, current: list[int]) -> None:
              # Base case: combination is complete
              if len(current) == k:
                  result.append(current[:])  # Append a copy
                  return

              # Try each number from start to n
              for i in range(start, n + 1):
                  # Pruning: not enough numbers left to complete combination
                  if n - i + 1 < k - len(current):
                      break

                  # Choose: add current number
                  current.append(i)
                  # Explore: recurse with next starting point
                  backtrack(i + 1, current)
                  # Unchoose: remove current number (backtrack)
                  current.pop()

          backtrack(1, [])
          return result
    explanation: |
      **Time Complexity:** O(k * C(n,k)) — We generate all C(n,k) combinations, each of length k.

      **Space Complexity:** O(k) — Recursion depth and current combination size are bounded by k.

      The backtracking approach systematically explores all valid combinations by making choices, recursing, and undoing choices. Pruning ensures we skip branches that cannot yield valid combinations.

  - approach_name: Iterative with Lexicographic Generation
    is_optimal: false
    code: |
      def combine(n: int, k: int) -> list[list[int]]:
          result = []
          # Start with the lexicographically smallest combination
          current = list(range(1, k + 1))

          while True:
              result.append(current[:])

              # Find the rightmost element that can be incremented
              i = k - 1
              while i >= 0 and current[i] == n - k + 1 + i:
                  i -= 1

              # If no such element exists, we've generated all combinations
              if i < 0:
                  break

              # Increment this element and reset all elements to its right
              current[i] += 1
              for j in range(i + 1, k):
                  current[j] = current[j - 1] + 1

          return result
    explanation: |
      **Time Complexity:** O(k * C(n,k)) — Same as backtracking, we generate all combinations.

      **Space Complexity:** O(k) — Only the current combination is stored.

      This approach generates combinations in lexicographic order without recursion. It starts with `[1, 2, ..., k]` and repeatedly finds the rightmost element that can be incremented, then resets all elements after it. More memory-efficient than recursion but harder to understand.
