3

I'm building an app to be used for a workout with less than 10 different weights. For example: the workout might call for weights of {30,40,45,50,55,65,70,80}.

Now it would be nice for the user to not have to determine how many 45 lb weights, 35 lb weights, 25 lb weights, etc. to grab and for the app to display a table with the number of weights of each size needed.

My question is, given we have an unlimited number of 5lb, 10lb, 25lb, 35lb and 45lb weights, what is the optimal number of each to be able to sum to each of the weights in the array? Optimal is the fewest total weights first and then lightest total second.

For example, suppose I want to optimize {25, 35, 45}, then if my answer array is {num_5lbs, num_10lbs, num_25lbs, num_35lbs, num_45lbs} we could do {0,0,1,1,1} but then the total is 25+35+45=105lbs. We could also do {0,2,1,0,0}, what I believe is optimal, since it is 3 weights but the total is only 45 lbs.

Another example, suppose I want to optimize {30,40,50}, then we could have {1,2,1,0,0} and {1,1,1,1,0}. Both use 4 weights, but the former is a total of 5+20+25=50 lbs, while the latter is a total of 5+10+25+35=75 lbs.

2 Answers2

1

You caught the competitive programmer in me.

Using dynamic programming (memoization) I was able to get the runtime down to a reasonable level.

First, we define the kind of weights we have, and the targets we want to hit.

weights = [5, 10, 25, 35, 45]
targets = [30, 40, 45, 50, 55, 65, 70, 80]

Next up we have the main DP function. walk takes an ordered list of used weights (initially empty) and a pos telling us what weights we have already considered (initially zero). This lowers the number of calls to walk from O(n!) to O(2^n). walk is also memoized, further lowering execution time from O(2^n) to O(n).

There are a few base cases, some of which are dynamically modified for performance:

  • pos >= len(weights) If pos is greater than the length of weights, we have checked all weights, and we are done recursing.
  • len(used) > max(targets) / min(weights) This is a weak bound on the number of weights to use. If there's a way to just use the smallest kind of weight and still pass the biggest target, we know we have checked enough numbers, and that this branch is useless. Moving on.
  • len(used) > bwnum where bwnum is the number of weights used in the best answer so far. Since that's our primary criterion, we can stop recursing when we have picked more than bwnum weights. This is a big optimization, assuming we quickly find any valid answer.

As for the two cases a and b, we can either pick another weight of the kind at pos, or we can move pos forward. The best one (shortest, then smallest sum) is memoized and returned. Since there are two cases, we have a branching factor of 2.

mem = {}
bwnum = len(weights)+1

def walk(used, pos):
    k = (used, pos)
    global bwnum, weights, targets

    if pos >= len(weights) or len(used) > bwnum or len(used) > max(targets) / min(weights):
        return used if valid(used) else (1e9,)*(bwnum+1)

    if k not in mem:
        a = walk(used + (weights[pos],), pos)
        b = walk(used, pos + 1)

        mem[k] = a if len(a) < len(b) or (len(a) == len(b) and sum(a) < sum(b)) else b
        if valid(mem[k]):
            bwnum = min(bwnum, len(mem[k]))

    return mem[k]

Then we need a validator function, to check if a given list of weights is enough to reach all targets. This can be optimized further, but it's pretty fast. I'm spending 80% of my execution time in this function.

from itertools import combinations

vmem = {}


def valid(used):
    if used not in vmem:
        tmap = {}
        for t in targets:
            tmap[t] = 0

        for le in range(1, len(used) + 1):
            for c in combinations(used, le):
                if sum(c) in tmap:
                    del tmap[sum(c)]

        vmem[used] = len(tmap) == 0

    return vmem[used]

Finally, call walk with empty arguments, and print the result.

r = walk((), 0)
print r, len(r), sum(r)

Which outputs:

(5, 5, 10, 25, 35) 5 80

Oh, by the way, your examples were correct. Thanks.

Filip Haglund
  • 13,919
  • 13
  • 64
  • 113
  • Your code looks correct to me, but AFAICT the memoisation of `walk()` has no effect, because if the weights are distinct then `walk()` will never be called with the same argument pair twice. (I believe; I'm happy to be proven wrong!) – j_random_hacker Apr 29 '16 at 11:49
  • Also, in the absence of further arguments explaining why this could not happen, `walk()` could be called once for each possible distinct `used` vector. Suppose that all weights and all targets are multiples of the smallest weight, and let r = RoundDown(minTarget / maxWeight); then for every multiset of weights having exactly r (not necessarily distinct) weights, there is a distinct corresponding `used` vector that is part of some valid solution (because we could just add enough copies of the smallest weight to it to reach every target). ... – j_random_hacker Apr 29 '16 at 12:53
  • ... There are (n+r-1 CHOOSE r-1) such multisets, meaning it's possible to construct inputs with at least this many distinct `used` vectors (this is superexponential in n.) – j_random_hacker Apr 29 '16 at 12:53
  • I think you're right about the memoization of `walk` :) It used to help, but then I changed it, and leaved the memoization in :) As for the second comment: that was the optimization I was talking about. I didn't implement it, since it was "fast enough" with memoization, and I wrote the answer at like 3AM. Do you mean `walk` is superexponential in `len(weights)`? Or `valid`? – Filip Haglund Apr 29 '16 at 15:23
  • Thanks so much for taking the time to write this up! I'm going to study this a bit and try to rewrite it in Java. – David Ackerman Apr 29 '16 at 17:07
  • I meant `walk()` is superexponential in `len(weights)`. (This may be necessary for correctness, mind you...) (Also I think `valid()` can possibly be sped up too -- e.g. as well as testing whether the exact `used` passed in has been seen before, you could test whether the vector consisting of all but the last element of `used` has been seen before; if it has and the answer was "yes", then it must be for this `used` too. Alternatively you could solve `len(targets)` coin-change problems, but whether that's faster would depend on the maximum target value.) – j_random_hacker Apr 29 '16 at 17:23
  • I thought of that too, but that optimization is already taken care of in `walk`. If you are trying to use a bigger set of weights, it's going to break by the `len(used) > bwnum` check. However, I think a bottom-up DP could make `valid` faster. – Filip Haglund Apr 29 '16 at 19:41
1

You can solve this as an integer linear programming problem.

Introduce integer variables n5, n10, n25, n35, n45 which count the number of each weight in a possible solution.

The optimization target is:

minimize (n5+n10+n25+n35+n45) * 1000 + 5*n5 + 10*n10 + 25*n25 + 35*n35 + n45

Here, 1000 is any integer larger than the maximum total weight that occurs in the session, and this function is designed to maximize the total number of weights first, and the total weight second.

Next, suppose w[1] to w[k] are the target weights you want. Add (non-negative) integer variables a5[i], a10[i], a25[i], a35[i], and a45[i] (where i ranges over 1 to k), and add these linear constraints:

a5[i]*5 + a10[i]*10 + a25[i]*25 + a35[i]*35 + a45[i]*45 = w[i]
a5[i] <= n5
a10[i] <= n10
a25[i] <= n25
a35[i] <= n35
a45[i] <= n45

These constraints guarantee that w[i] can be constructed from the limited number of weights you have in the solution.

I don't know it makes sense to include in your app an ILP solver rather than brute-force the solution or even just use heuristics to produce a locally optimal but not necessarily globally optimal solution.

Paul Hankin
  • 54,811
  • 11
  • 92
  • 118