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')
    # check if we've already computed this answer
    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')
    # check if we've already computed this answer
    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]