|
| 1 | +from typing import List, Optional |
| 2 | + |
| 3 | + |
| 4 | +class Solution: |
| 5 | + # candidates = [2,3,6,7], target = 7 |
| 6 | + # Straight Forward Backtracking. |
| 7 | + # Time Complexity: Exponential. |
| 8 | + # Space Complexity: Additional storage required is the maximum storage required for |
| 9 | + # current_combination, which is upper bounded by 500 according to problem |
| 10 | + # constraints. (Case: 1 repeating 500 times for a maximum target of 500) |
| 11 | + def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]: |
| 12 | + n = len(candidates) |
| 13 | + |
| 14 | + def backtrack( |
| 15 | + current_index: int = 0, |
| 16 | + current_sum: int = 0, |
| 17 | + current_combination: Optional[List[int]] = None, |
| 18 | + combinations: Optional[List[int]] = None, |
| 19 | + ): |
| 20 | + if current_combination is None: |
| 21 | + current_combination = [] |
| 22 | + |
| 23 | + if combinations is None: |
| 24 | + combinations = [] |
| 25 | + |
| 26 | + if current_sum == target: |
| 27 | + combinations.append(current_combination.copy()) |
| 28 | + else: |
| 29 | + for index in range(current_index, n): |
| 30 | + if current_sum + candidates[index] <= target: |
| 31 | + current_combination.append(candidates[index]) |
| 32 | + backtrack( |
| 33 | + current_index=index, |
| 34 | + current_sum=current_sum + candidates[index], |
| 35 | + current_combination=current_combination, |
| 36 | + combinations=combinations, |
| 37 | + ) |
| 38 | + current_combination.pop() |
| 39 | + |
| 40 | + return combinations |
| 41 | + |
| 42 | + return backtrack() |
| 43 | + |
| 44 | + |
| 45 | +class OfficialSolution: |
| 46 | + """ |
| 47 | + == Overview == |
| 48 | + This is one of the problems in the series of combination sum. They all can be solved |
| 49 | + with the same algorithm, i.e. backtracking. |
| 50 | + Before tackling this problem, we would recommend one to start with another almost |
| 51 | + identical problem called Combination Sum III, which is arguably easier and once can |
| 52 | + tweak the solution a bit to solve this problem. |
| 53 | + For the sake of this article, we will present the backtracking algorithm. |
| 54 | + """ |
| 55 | + |
| 56 | + def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]: |
| 57 | + """ |
| 58 | + == Approach 1: Backtracking == |
| 59 | + == Intuition == |
| 60 | + As a reminder, backtracking is a general algorithm for finding all (or some) |
| 61 | + solutions to some computational problems. The idea is that it incrementally |
| 62 | + builds candidates to the solutions, and abandons a candidate ("backtrack") as |
| 63 | + soon as it determines that this candidate cannot lead to a final solution. |
| 64 | + Specially, to our problem, we could incrementally build the combination, and |
| 65 | + once we find the current combination is not valid, we backtrack and try another |
| 66 | + option. |
| 67 | +
|
| 68 | + To demonstrate the idea, we showcase how it works with a concrete example: |
| 69 | + For the given list of candidates [3,4,5] and a target sum 8, we start off from |
| 70 | + empty combination [] as indicated as the root node in the above graph. |
| 71 | + - Each node represents an action we take at a step, and within the node we also |
| 72 | + indicate the combination we build so far. |
| 73 | + - From top to down, at each level we descend, we add one more element into the |
| 74 | + current combination. |
| 75 | + - Some nodes would sum up to the target value, they are the desired combination |
| 76 | + solutions. |
| 77 | + - Some nodes exceed the target value. Since all the candidates are positive |
| 78 | + value, there is no way we could bring the sum down to the target value, if we |
| 79 | + explore further. |
| 80 | + - At any instant, we can only be at one of the nodes. When we backtrack, we are |
| 81 | + moving from a node to its parent node. |
| 82 | +
|
| 83 | + An important detail on choosing the next number for the combination is that we |
| 84 | + select the candidates in order, where the total candidates are treated as a |
| 85 | + list. Once a candidate is added into the current combination, we will not look |
| 86 | + back to all the previous candidates in the next explorations. |
| 87 | +
|
| 88 | + To demonstrate the idea, let us zoom in a node to see how we can choose the next |
| 89 | + numbers. |
| 90 | + - When we are at the node of [4], the precedent candidates are [3], and the |
| 91 | + candidates followed are [4,5]. |
| 92 | + - We don't add the precedent numbers into the current node, since they would |
| 93 | + have been explored in the nodes in the left part of the subtree, i.e. the node |
| 94 | + of [3]. |
| 95 | + - Even through we have already the element 4 in the current combination, we are |
| 96 | + giving the element another chance in the next exploration, since the combination |
| 97 | + can contain duplicate numbers. |
| 98 | + - As a result, we would branch out in two directions, by adding the element 4 |
| 99 | + and 5 respectively into the current combination. |
| 100 | +
|
| 101 | + == Algorithm == |
| 102 | + As one can see, the above backtracking algorithm is unfolded as a DFS (Depth- |
| 103 | + First Search) tree traversal which is often implemented with recursion. |
| 104 | + Here we define a recursive function of backtrack(remain, comb, start) which |
| 105 | + populates the combinations, starting from the current combination comb, the |
| 106 | + remaining sum to fulfill remain and the current cursor start to the list of |
| 107 | + candidates. |
| 108 | + - For the first base case of the recursive function, if the remain==0, i.e. we |
| 109 | + fulfill the desired target sum, therefore we can add the current combination |
| 110 | + to the final list. |
| 111 | + - As another base case, if remain < 0, i.e. we exceed the target value, we will |
| 112 | + cease the exploration here. |
| 113 | + - Other than the above two base cases, we would then continue to explore the |
| 114 | + sublist of candidates as [start, ..., n]. For each of the candidate, we invoke |
| 115 | + the recursive function itself with updated parameters. |
| 116 | + - Specifically, we add the current candidate into the combination. |
| 117 | + - With the added candidate, we now have less sum to fulfill, i.e. |
| 118 | + remain - candidate. |
| 119 | + - For the next exploration, still we start from the current cursor start. |
| 120 | + - At the end of each exploration, we backtrack by popping out the candidate |
| 121 | + out of the combination. |
| 122 | +
|
| 123 | + == Complexity Analysis == |
| 124 | + Let N be the number of candidates, T be the target value, and M be the minimal |
| 125 | + value among the candidates. |
| 126 | +
|
| 127 | + Time Complexity: O(N ^ (T/M + 1)). |
| 128 | + - As we illustrated before, the execution of the backtracking is unfolded as |
| 129 | + a DFS traversal in a n-ary tree. The total number of steps during the |
| 130 | + backtracking would be the number of nodes in the tree. |
| 131 | + - At each node, it takes a constant time to process, except the leaf nodes |
| 132 | + which could take a linear time to make a copy of combination. So we can say |
| 133 | + that the time complexity is linear to the number of nodes of the execution |
| 134 | + tree. |
| 135 | + - Here we provide a loose upper bound on the number of nodes. |
| 136 | + - First of all, the fan-out of each node would be bounded to N, i.e. the |
| 137 | + total number of candidates. |
| 138 | + - The maximal depth of the tree, would be T/M, where we keep on adding |
| 139 | + the smallest element to the combination. |
| 140 | + - As we know, the maximal number of nodes in N-ary tree of T/M height |
| 141 | + would be N ^ (T/M + 1). |
| 142 | + - Note that, the actual number of nodes in the execution tree would be much |
| 143 | + smaller than the upper bound, since the fan-out of the nodes are decreasing |
| 144 | + level by level. |
| 145 | +
|
| 146 | + Space Complexity: Additional space is O(T/M). |
| 147 | + - We implement the algorithm in recursion, which consumes some additional |
| 148 | + memory in the function call stack. |
| 149 | + - The number of recursive calls can pile up to T/M, where we keep on adding |
| 150 | + the smallest element to the combination. As a result, the space overhead of |
| 151 | + the recursion is O(T/M). |
| 152 | + - In addition, we keep a combination of numbers during the execution, which |
| 153 | + requires at most O(T/M) space as well. |
| 154 | + - To sum up, the total space complexity of the algorithm would be O(T/M). |
| 155 | + - Note that, we did not take into the account the space used to hold the |
| 156 | + final results for the space complexity. |
| 157 | + """ |
| 158 | + results = [] |
| 159 | + |
| 160 | + def backtrack(remain: int, comb: List[int], start: int): |
| 161 | + if remain == 0: |
| 162 | + # make a deep copy of the current combination |
| 163 | + results.append(list(comb)) |
| 164 | + return |
| 165 | + elif remain < 0: |
| 166 | + return |
| 167 | + |
| 168 | + for i in range(start, len(candidates)): |
| 169 | + comb.append(candidates[i]) |
| 170 | + backtrack(remain=remain - candidates[i], comb=comb, start=i) |
| 171 | + comb.pop() |
| 172 | + |
| 173 | + backtrack(remain=target, comb=[], start=0) |
| 174 | + |
| 175 | + return results |
0 commit comments