A Pure Python Solution (i.e. without itertools.product
)
The main difficulty here is executing a variable number of for-loops
inside a function. The way we can get around this easily is using recursion which involves a function calling itself.
If we use recursion, then inside any instance of the function, only one for-loop
is actually iterated through. So to apply this to the problem at hand, we want our function to take two parameters: the target for what number we are trying to sum to, and n
- the number of positive integers we have available to use.
Each function will then return (given a target
and n
numbers), all the combinations that will make that target - in the form of a two-dimensional list
.
The only special case that we must consider is the "leaf nodes" of our recursive tree (the cases where we have a certain target, but n == 1
, so we only have one number to make the target with). This is easy to handle, we just need to remember that we should always return all combinations that make the target so in this case, there is only one "combination" which is the target
.
Then (if n > 1
) the rest is self explanatory, we are simply looping through every number less than target
and adding to a list
of combinations (cs
) with the results of calling the function again.
However, before we concatenate these combos onto our list
, we need to use a comprehension
to add i
(the next number) to the start of every combination.
And that's it! Hopefully you can see how the above translates into the following code:
def combos(target, n):
if n == 1:
return [[target]]
cs = []
for i in range(0, target+1):
cs += [[i]+c for c in combos(target-i, n-1)]
return cs
and a test (with target
as 10
and n
as 3
to make it clearer) shows it works:
>>> combos(10, 3)
[[0, 0, 10], [0, 1, 9], [0, 2, 8], [0, 3, 7], [0, 4, 6], [0, 5, 5], [0, 6, 4], [0, 7, 3], [0, 8, 2], [0, 9, 1], [0, 10, 0], [1, 0, 9], [1, 1, 8], [1, 2, 7], [1, 3, 6], [1, 4, 5], [1, 5, 4], [1, 6, 3], [1, 7, 2], [1, 8, 1], [1, 9, 0], [2, 0, 8], [2, 1, 7], [2, 2, 6], [2, 3, 5], [2, 4, 4], [2, 5, 3], [2, 6, 2], [2, 7, 1], [2, 8, 0], [3, 0, 7], [3, 1, 6], [3, 2, 5], [3, 3, 4], [3, 4, 3], [3, 5, 2], [3, 6, 1], [3, 7, 0], [4, 0, 6], [4, 1, 5], [4, 2, 4], [4, 3, 3], [4, 4, 2], [4, 5, 1], [4, 6, 0], [5, 0, 5], [5, 1, 4], [5, 2, 3], [5, 3, 2], [5, 4, 1], [5, 5, 0], [6, 0, 4], [6, 1, 3], [6, 2, 2], [6, 3, 1], [6, 4, 0], [7, 0, 3], [7, 1, 2], [7, 2, 1], [7, 3, 0], [8, 0, 2], [8, 1, 1], [8, 2, 0], [9, 0, 1], [9, 1, 0], [10, 0, 0]]
Improving performance
If we consider the case where we are trying to make 10
with 4
numbers. At one point, the function will be called with a target of 6
after say 1
and 3
. The algorithm will as we have already explained and return the combinations using 2
numbers to make 6
. However, if we now consider another case further down the line when the function is asked to give the combinations that make 6
(same as before) having been called with say 2
and 2
. Notice how even though we will get the right answer (through our recursion and the for-loop
), we will return the same combinations as before - when we were called with 1
and 3
. Furthermore, this scenario will happen extremely often: the function will be called from different situations but be asked to give the same combinations that have already been previously calculated at a different time.
This gives way to a great optimisation technique called memoization which essentially just means storing the results of our function as a key: value
pair in a dictionary (mem
) before returning.
We then just check at the start of every function call if we have ever been called before with the same parameters (by seeing if the key is in the dictionary) and if it is, then we can just return the result we got last time.
This speeds up the algorithm dramatically.
mem = {}
def combos(target, n):
k = (target, n)
if k in mem:
return mem[k]
if n == 1:
return [[target]]
cs = []
for i in range(0, target+1):
cs += [[i]+c for c in combos(target-i, n-1)]
mem[k] = cs
return cs