# More memoization: getting the optimal solution and not just its value¶

### Figuring out which items to actually put in the box to get the most value (from yesterday)¶

As asked yesterday: we saw how to figure out how to optimize a function using recursion/memoization, but what if we want to remember the decisions we made to obtain the optimal value?

As discussed yesterday, suppose you have a box that can hold $W$ kilos. You are given a list $L$ of items $[[w_0,v_0],\ldots, [w_{n-1},v_{n-1}]]$ where the $i^{th}$ item weighs $w_i$ kilos and is worth $v_i$ birr. Yesterday we asked: is the maximum amount of value you can fit into the box?

Today we will ask the question: what if you want to know specifically which items to pack in the box to have the most value without breaking the box?

In [2]:
def mostValueMemo(W, L, mem, choices):
"""returns the maximum amount of value we can pack in <= W kilos, using the items listed in L,
where L is a tuple/list containing pairs (w,v) of weight/value for every item."""

# base case
if len(L)==0:
# if L is empty then we have no items to pack
return 0

# check memory
elif mem[W][len(L)] != -1:
return mem[W][len(L)]

# recursive case

# max value we can get if we don't pack the first item
A = mostValueMemo(W, L[1:], mem, choices)

# if we can't fit the first item then we have no choice to make
if W<L[0][0]:
mem[W][len(L)] = A
choices[W][len(L)] = 'D' # 'D' means "Don't pack" L[0]
return mem[W][len(L)]

# otherwise we will check both options and see what's the maximum value we can pack
B = L[0][1] + mostValueMemo(W-L[0][0], L[1:], mem, choices)
if A > B:
mem[W][len(L)] = A
choices[W][len(L)] = 'D'
else:
mem[W][len(L)] = B
choices[W][len(L)] = 'P' # 'P' means "Pack" the first item
return mem[W][len(L)]

def initMem(n, m, initValue):
# returns a 2-dimensional list of lists mem, where mem has n lists each of size m
mem = []
for i in range(n):
mem += [[-1]*m]
return mem

def mostValueFast(W, L, choices):
mem = initMem(W+1, len(L)+1, -1)
return mostValueMemo(W, L, mem, choices)

def whichItems(W, L):
choices = initMem(W+1, len(L)+1, -1)
mostValueFast(W, L, choices)
items = []
while len(L) > 0:
c = choices[W][len(L)]
if c == 'P':
items += [L[0]]
W -= L[0][0]
L = L[1:]
return items

In [20]:
whichItems(4,((1,6),(2,5),(3,6),(2,10)))
Out[20]:
[(1, 6), (2, 10)]
In [3]:
whichItems(4, [[3,9], [2,5], [2,6]])
Out[3]:
[[2, 5], [2, 6]]

### Another example: making change¶

We saw an example problem yesterday of calculating the number of ways to make change for n cents using a list of coins L. While this is more of a toy problem, a more real problem comes from the fact that many people do not like to carry lots of coins around. Thus when a shopkeeper makes change for $n$ cents, as a service to the customer it mayu be desired to make change for n cents using as few coins as possible (in fact, in Japan sometimes change is even rounded up to minimize the number of coins returned, with "サービス", or "service", written on the receipt!). For example, in the Ethiopian coin system one could make change for $n=8$ cents by giving eight 1-cent pieces, for a total of 8 coins. A better way to make change though is to give a five 1-cent pieces and one 5-cent piece, for a total of only 6 coins. We would like to write a function that tells the shopkeeper which coins to give to the customer to make change for $n$ cents so as to minimize the number of coins.

First, let us write a recursive function fewestCoins which just tells us what the minimum number of coins to use is. We will use the convention that if it is impossible to make change for $n$ cents, then the number of coins required is "infinity". Python has a built-in infinity value, float('infinity'), which we will use to denote this (it behaves as one would expect, e.g. infinity plus or minus any other finite value is infinity, etc.).

In [4]:
def fewestCoins(n, L):
# base case
if len(L) == 0:
if n == 0:
return 0
else:
return float('infinity')
# recursive case
else:
# we can either not use coin L[0] at all, option A, or use it at least once, option B
A = fewestCoins(n, L[1:])
if L[0] > n:
# coin L[0] is too big, so we can't use it
return A
B = 1 + fewestCoins(n - L[0], L)
return min(A, B)
In [5]:
print(fewestCoins(14, [1,5,10,25,50,100]))
# should print 5

print(fewestCoins(3, [2,5]))
# should print inf (short for "infinity")

print(fewestCoins(8, [1,4,5]))
# should print 2 since it is better to use two 4-cent pieces than one 5-cent pieces and three 1-cent pieces
5
inf
2

Now let's throw some memoization on this $\ldots$

In [6]:
def fewestCoinsMemo(n, L, mem, seen):
# base case
if len(L) == 0:
if n == 0:
return 0
else:
return float('infinity')
if seen[n][len(L)]:
return mem[n][len(L)]
# recursive case
else:
seen[n][len(L)] = True
# we can either not use coin L[0] at all, option A, or use it at least once, option B
A = fewestCoinsMemo(n, L[1:], mem, seen)
if L[0] > n:
# coin L[0] is too big, so we can't use it
mem[n][len(L)] = A
return A
B = 1 + fewestCoinsMemo(n - L[0], L, mem, seen)
mem[n][len(L)] = min(A, B)
return min(A, B)

def fewestCoinsFast(n, L):
# mem[i][j] stores the minimum number of coins needed to make change for i cents
# when using the coins L[len(L)-j:]
mem = []
seen = []
for i in range(n+1):
mem += [[-1]*(len(L)+1)]
seen += [[False]*(len(L)+1)]
return fewestCoinsMemo(n, L, mem, seen)
In [8]:
print(fewestCoinsFast(14, [1,5,10,25,50,100]))
# should print 5

print(fewestCoinsFast(3, [2,5]))
# should print inf (short for "infinity")

print(fewestCoinsFast(8, [1,4,5]))
# should print 2 since it is better to use two 4-cent pieces than one 5-cent pieces and three 1-cent pieces
5
inf
2

Now back to the point of today's lecture: how can we trace the mem table to know which coins to actually give back to the customer? We will return the list of coins if possible to make change, and otherwise we will return float('infinity').

In [14]:
def fewestCoinsMemo(n, L, mem, seen, choices):
# base case
if len(L) == 0:
if n == 0:
return 0
else:
return float('infinity')
if seen[n][len(L)]:
return mem[n][len(L)]
# recursive case
else:
seen[n][len(L)] = True
choices[n][len(L)] = False
# we can either not use coin L[0] at all, option A, or use it at least once, option B
A = fewestCoinsMemo(n, L[1:], mem, seen, choices)
if L[0] > n:
# coin L[0] is too big, so we can't use it
mem[n][len(L)] = A
return A
B = 1 + fewestCoinsMemo(n - L[0], L, mem, seen, choices)
mem[n][len(L)] = min(A, B)
if B<A:
choices[n][len(L)] = True
return min(A, B)

def fewestCoinsFast(n, L, choices):
# mem[i][j] stores the minimum number of coins needed to make change for i cents
# when using the coins L[len(L)-j:]
mem = []
seen = []
for i in range(n+1):
mem += [[-1]*(len(L)+1)]
seen += [[False]*(len(L)+1)]
return fewestCoinsMemo(n, L, mem, seen, choices)

def whichCoins(n, L):
# choices[i][j] is True if we should use coin L[len(L)-j] at least once to make change for i cents,
# else choices[i][j] is False
choices = []
for i in range(n+1):
choices += [[False]*(len(L)+1)]
best = fewestCoinsFast(n, L, choices)
if best == float('infinity'):
return best
coins = []
while len(L) > 0:
c = choices[n][len(L)]
if c:
coins += [L[0]]
n -= L[0]
else:
L = L[1:]
return coins

In [15]:
print(whichCoins(14, [1,5,10,25,50,100]))
# should print 5

print(whichCoins(3, [2,5]))
# should print inf (short for "infinity")

print(whichCoins(8, [1,4,5]))
# should print 2 since it is better to use two 4-cent pieces than one 5-cent pieces and three 1-cent pieces
[1, 1, 1, 1, 10]
inf
[4, 4]