LeetCode #39 — Medium

Combination Sum

A complete visual walkthrough of backtracking with element reuse — from brute force to optimal pruning.

Patterns Used

Roadmap

  1. Brute Force — Why It Doesn't Scale
  2. The Core Insight — Backtracking with Reuse
  3. Decision Tree Walkthrough
  4. Edge Cases
  5. Full Annotated Code
  6. Interactive Demo
  7. Complexity Analysis
Step 01

Brute Force — Why It Doesn't Scale

The naive approach would generate all possible subsets of every length, with repetition, then check which ones sum to the target. For candidates = [2, 3, 6, 7] and target = 7, we would enumerate an enormous number of combinations — most of them useless.

The Problem

candidates
2
3
6
7
target = 7

With unlimited reuse, the search space is infinite unless we prune intelligently. For example, we could keep picking 2 forever: [2, 2, 2, 2, ...] and never stop.

We need a systematic way to explore combinations without duplicates and with early termination when the running sum exceeds the target.

Step 02

The Core Insight — Backtracking with Reuse

The trick is a classic backtracking pattern: at each step, we choose a candidate, add it to our current path, then recurse with a reduced target. After the recursive call returns, we undo the choice (backtrack) and try the next candidate.

The Key Difference: i, Not i + 1

In standard combination problems (no reuse), the recursive call starts from i + 1 to avoid picking the same element again. Here, we pass i (the same index) because the same number can be reused unlimited times.

WITH REUSE (#39)
backtrack(..., i, ...)
Can pick candidates[i] again in the next recursive call
NO REUSE (typical)
backtrack(..., i + 1, ...)
Moves past candidates[i], never picks it again
💡

Sorting first lets us prune early: once candidates[i] exceeds the remaining target, all subsequent candidates (which are larger) will too, so we can break immediately instead of continuing the loop.

Three rules govern the backtracking:

remain == 0
Found a valid combination! Add a copy of the current path to results.
candidates[i] > remain
This candidate (and all after it, since sorted) overshoot the target. Break.
otherwise
Pick candidates[i], recurse with remain - candidates[i], then undo.
Step 03

Decision Tree Walkthrough

Let's trace the backtracking for candidates = [2, 3, 6, 7], target = 7. The sorted order is already [2, 3, 6, 7]. Each node shows the current path and remaining target.

Backtracking Tree (Abridged)

[] remain=7
pick 2
[2] remain=5
pick 3
[3] remain=4
pick 6
[6] remain=1
pick 7
[7] remain=0
pick 2
[2,2] remain=3
pick 3
[2,3] remain=2
pick 6
[2,6] 6>5
[3] branch
[3,3] remain=1
[6] branch
[6,2] 2>1
pick 2
[2,2,2] remain=1
pick 3
[2,2,3] remain=0
from [2,3]
[2,3,2] 2=2 ok but only 2 left, pick 2
pick 2
[2,2,2,2] 2>1
found Valid combination
pruned Exceeds target, prune
explore Still exploring
🎯

Two solutions found: [2, 2, 3] and [7]. Notice how sorting + the break on overshoot aggressively prunes the tree. Without sorting, we would waste time exploring many more dead-end branches.

The start index parameter prevents generating duplicate combinations like [2, 3, 2] and [3, 2, 2] — once we move past candidate 2, we never go back to it. We only consider candidates at index start or later.

Step 04

Edge Cases

Single candidate equals target
candidates = [7], target = 7 → [[7]]
Target unreachable
candidates = [3], target = 2 → [] (3 > 2, immediate pruning)
All-ones reuse
candidates = [1], target = 3 → [[1, 1, 1]] (max reuse depth = target)
Large candidate set
Multiple candidates where some are multiples of others. Sorting ensures we explore smallest first and prune efficiently.
Step 05

Full Annotated Code

class Solution {
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        List<List<Integer>> result = new ArrayList<>();
        Arrays.sort(candidates);           // Sort for early termination
        backtrack(candidates, target, 0, new ArrayList<>(), result);
        return result;
    }

    private void backtrack(int[] candidates, int remain,
                           int start, List<Integer> path,
                           List<List<Integer>> result) {

        if (remain == 0) {               // Target reached — record solution
            result.add(new ArrayList<>(path));
            return;
        }

        for (int i = start; i < candidates.length; i++) {

            if (candidates[i] > remain)    // Prune: too large (and all after)
                break;

            path.add(candidates[i]);       // Choose

            backtrack(candidates,
                      remain - candidates[i],
                      i,                         // ← NOT i+1: allow reuse
                      path, result);

            path.remove(path.size() - 1);  // Un-choose (backtrack)
        }
    }
}
Step 06

Interactive Demo

Enter candidates and a target, then step through the backtracking tree to see how combinations are built and pruned.

⚙ Backtracking Visualizer



Press "Step →" or "Run All" to begin.
Step 07

Complexity Analysis

Time
O(n^(T/M))
Space
O(T / M)

Where n is the number of candidates, T is the target value, and M is the minimum candidate value. The maximum depth of the recursion tree is T/M (picking the smallest element repeatedly). At each level, we branch up to n ways.

📝

In practice, pruning makes this much faster. Sorting the candidates and breaking when a candidate exceeds the remaining target eliminates huge subtrees. The worst case is theoretical — real inputs rarely hit it.