Caveat: This is not provably the best algorithm. There are some duplicate checks that might be able to be removed. But it is a working solution without duplicate results.
Any absolute sum of non-zero integers with cost Q<M can be made to have cost M by adding (M-Q)/2 zeros to it. So, we can ignore anything with a 0 in it for now. We will generate all bags containing no zeros with cost <= M, and then add the appropriate number of 0s to make it cost M afterwards.
Additionally, we can simplify further by finding the set of bags that have a cost of M exactly. Then, to find all bags with <= M, we can just loop over all numbers up to M.
So, now our goal is to find a set of bags containing no zeros that have cost exactly M.
To make it easier, let's just consider how many ways we can select k numbers to make exactly M, and then loop over different k's. We can then assign some k to the positives, and some to the negatives, and ensure that they sum to the same value.
So, now we're looking for a way to find all partitions of n, but only of a particular length. Then there is a bag that satisfies the constraints for each possible pair of these partitions. I adapted the answer to the linked question, and put it together with the above reasoning to get:
import functools
@functools.lru_cache(maxsize=1024*1024)
def partitions_with_k(k, largest, *rest):
'''
Finds all partitions which sum to `largest`
with precisely k elements in it
'''
result = [[largest, *rest]]
if (rest and len(rest) == k-1 or k == 1):
return result
min = rest[0] if rest else 1
max = largest // 2
for n in range(min, max+1):
result.extend(partitions_with_k(k, largest-n, n, *rest))
result = [p for p in result if len(p) == k]
return result
def get_bags_exact(M, a=1, b=2):
'''
Finds all bags without zeros with total cost exactly equal to M
'''
# let k be the number of integers in the bag.
# The minimum cost of including a non-zero value is a*1+b,
# therefore we only need to check k up to M//(a+b) (the +/-1 solution, if it exists)
result = []
min_cost = a+b
for k in range(1, 1+M//min_cost):
for kn in range(1, k):
kp = k-kn
# Let P be the positive sum of integers in the partition
# M = a*P*2 + k*b
P = (M-k*b)/(a*2)
# Clearly we can only accept integer sums
if P % 1 == 0:
P = int(P)
pos = partitions_with_k(kp, P)
neg = partitions_with_k(kn, P)
neg = [[-e for e in part] for part in neg]
for partp in pos:
for partn in neg:
result.append(partp + partn)
return result
def get_bags(M, a=1, b=2):
''' Finds all bags with cost M or less '''
result = []
n_zeros = (M)//b
for i in range(n_zeros+1):
result.append([0]*i)
for Q in range(1, 1+M):
n_zeros = (M-Q)//b
exactlyQ = get_bags_exact(Q, a, b)
for i in range(n_zeros+1):
for bag in exactlyQ:
result.append(bag+[0]*i)
return result
A major benefit of using recursive functions in python is the ease with which you can cache results. So I will keep it as a recursive version, but you could make it iterative, if you wanted. I found that partitions_with_k wasn't really the bottleneck, anyway (see below).
I checked it manually up to M=14 and with a sanity check up to M=50:
bags = get_bags(50)
to_check = [tuple(sorted(b)) for b in bags]
assert len(to_check) == len(set(to_check))
Theoretically, this may be able to be improved inside partitions_with_k by not doing an explicit filter, and instead never ending up adding anything of the wrong length, but I think you'd have to know ahead of time how long all of the results would be. I'm not sure if it's possible. I can't see it. Perhaps you can.
Potentially, there's a good way to use the dual solutions: taking the negative of any bag is also a solution. Here, I don't explicitly use that at all.
Run-time complexity of get_bags without accounting for partitions_with_k is O(M^4). Before caching, partitions_with_k is O(M^2). So, roughly speaking, it's O(M^6). The sequence size grows reasonably quickly. It gets slow pretty quickly, and the memory cost is pretty big.
e.g. M=70 has ~9 million bags, took ~9.2s on my machine, and used up ~1.5GB of RAM while processing
I used cProfile to see where the time is actually spent. It's telling me that ~5.6s of those 9.2s, the interpreter was in get_bags, and only 0.14s is in partitions_with_k. I'm honestly not sure what's so slow in get_bags; adding all the solutions with 0s takes time, I guess?
$ python3 -m cProfile -s cumtime bags.py
9012501
12069213 function calls (12004973 primitive calls) in 9.246 seconds
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 9.262 9.262 {built-in method builtins.exec}
1 0.030 0.030 9.262 9.262 bags.py:1(<module>)
1 5.670 5.670 9.233 9.233 bags.py:45(get_bags)
70 2.949 0.042 3.222 0.046 bags.py:19(get_bags_exact)
11704256 0.444 0.000 0.444 0.000 {method 'append' of 'list' objects}
64614/374 0.108 0.000 0.140 0.000 bags.py:3(partitions_with_k)
2970 0.015 0.000 0.031 0.000 bags.py:39(<listcomp>)
52305 0.019 0.000 0.024 0.000 bags.py:16(<listcomp>)
180736 0.008 0.000 0.008 0.000 {built-in method builtins.len}
64240 0.004 0.000 0.004 0.000 {method 'extend' of 'list' objects}
Your timings will vary based on hardware and versions of software, but the proportions were consistent when I ran this using a python:3.6 docker image, compared to my native python 3.6 install.