Skip to content

39. Combination Sum

class Solution:
    """🎲 Combination Sum.

    Problem:
    --------
    Given an array of distinct integers `candidates` and an integer `target`, return all
    unique combinations of candidates that sum to `target`, in any order. Each candidate
    may be chosen an unlimited number of times.

    Approach:
    ---------
    Backtracking over the "choose the next element" tree, as in Combinations (77), except
    that the recursion restarts from the same index instead of the next one, so the current
    candidate can be reused. Never going back to an earlier index means each combination is
    built in non-decreasing order, so different orderings of it are never generated.
    - Complete: when the path sums to `target`, record a copy.
    - Make move: append `candidates[idx]`, then recurse from `idx`.
    - Unmake move: pop it.
    - Prune: with `candidates` sorted, once `sum_of_path + candidates[idx] > target` every
      later candidate is too large as well, so `break` instead of `continue`.

    Complexity:
    -----------
    Let m = min(candidates) and d = target // m, the maximum depth of the recursion.
    - Time: O(n^d) as a loose upper bound, with up to n branches per level and depth d;
      each valid combination is copied in O(d). The O(n log n) sort is dominated.
    - Space: O(d) auxiliary for the path and the recursion stack; the output depends on the
      number of valid combinations and their lengths.
    """

    def combinationSum(self, candidates: list[int], target: int) -> list[list[int]]:
        results: list[list[int]] = []
        path: list[int] = []
        candidates.sort()

        def backtrack(start: int, sum_of_path: int) -> None:
            if sum_of_path == target:
                results.append(path[:])
                return

            for idx in range(start, len(candidates)):
                candidate = candidates[idx]

                # Sorted, so every later candidate overshoots as well
                if sum_of_path + candidate > target:
                    break

                sum_of_path += candidate
                path.append(candidate)
                backtrack(idx, sum_of_path)
                sum_of_path -= candidate
                path.pop()

        backtrack(start=0, sum_of_path=0)
        return results