Skip to content

Commit 5426d69

Browse files
committed
solve Combination Sum
1 parent 5f0c9ff commit 5426d69

File tree

2 files changed

+214
-0
lines changed

2 files changed

+214
-0
lines changed

problems/combination_sum.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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

tests/test_combination_sum.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import unittest
2+
3+
from combination_sum import Solution, OfficialSolution
4+
5+
6+
class TestCombinationSum(unittest.TestCase):
7+
def test_example_1(self):
8+
assert Solution().combinationSum(candidates=[2, 3, 6, 7], target=7) == [
9+
[2, 2, 3],
10+
[7],
11+
]
12+
assert OfficialSolution().combinationSum(candidates=[2, 3, 6, 7], target=7) == [
13+
[2, 2, 3],
14+
[7],
15+
]
16+
17+
def test_example_2(self):
18+
assert Solution().combinationSum(candidates=[2, 3, 5], target=8) == [
19+
[2, 2, 2, 2],
20+
[2, 3, 3],
21+
[3, 5],
22+
]
23+
assert OfficialSolution().combinationSum(candidates=[2, 3, 5], target=8) == [
24+
[2, 2, 2, 2],
25+
[2, 3, 3],
26+
[3, 5],
27+
]
28+
29+
def test_example_3(self):
30+
assert Solution().combinationSum(candidates=[2], target=1) == []
31+
assert OfficialSolution().combinationSum(candidates=[2], target=1) == []
32+
33+
def test_example_4(self):
34+
assert Solution().combinationSum(candidates=[1], target=1) == [[1]]
35+
assert OfficialSolution().combinationSum(candidates=[1], target=1) == [[1]]
36+
37+
def test_example_5(self):
38+
assert Solution().combinationSum(candidates=[1], target=2) == [[1, 1]]
39+
assert OfficialSolution().combinationSum(candidates=[1], target=2) == [[1, 1]]

0 commit comments

Comments
 (0)