I was glancing over one of my old college books; Optimisation from Brinkhuis, page 448. It had a fun problem that got me thinking. This document contains a solution to said problem that can be solved with maths, but is more fun to solve with python.

Party at a Convex Castle

There's a long summer party happening at a convex shaped castle. The main attraction of the party is a wine cellar that contains many bottles of wine, $n$ in fact. Every night the attendees of the party can drink a bottle of wine, causing a party vibe. The attendees wonder, should all the wine be drunk on a single night or should the wine be spread out?

Let's assume that the partyvibe can be quantified. The total party vibe is a product of the number of bottles of wine drunk per evening.

Example

Suppose that we have 20 bottles of wine. Then these allocations would have the following values:

$$ \begin{equation} \label{eq1} \begin{split} \text{score}({10, 10}) & = 10 \times 10 = 100 \\ \text{score}({4, 6, 10}) & = 10 \times 5 \times 5 = 240 \\ \text{score}({10, 5, 5}) & = 10 \times 5 \times 5 = 250 \end{split} \end{equation} $$

The problem

What is the optimal allocation? How many bottles should be opened on every evening? Assume that we only have $n$ bottles to start with.

Python Solution

This problem, turns out, can be solved with a bit of recursion via dynamic programming. The main thing to recognize is the recursive relationship. In maths; I have a function $s(n,b)$ that I want to optimise where $n$ is the number of bottles that are currently in the cellar and $b$ is the number of bottles that is being taken for this night. On considering the problem, maths look like this:

$$ \arg \max_b s(n, b) = b \times \arg \max_{b'} s(n-b, b')$$

Writing this in python turns out to be relatively simple, but it took me 10 minutes on paper to get it right.

from functools import reduce

def argmax_slow(bottles):
    if bottles == 0:
        return [1]
    if bottles <= 3:
        return [bottles]
    else:
        max_score, max_args = 0, []
        for b in range(1, bottles + 1):
            b_args = argmax(bottles - b)
            b_value = reduce(lambda x,y: x * y, [b] + b_args)
            if b_value > max_score: 
                max_score, max_args = b_value, [b] + b_args
        return max_args

Since the function is recursive, one would be wise to add a cache in order to memoize intermediate results.

from functools import lru_cache

@lru_cache(maxsize=512)
def argmax(bottles):
    if bottles == 0:
        return [1]
    if bottles <= 3:
        return [bottles]
    else:
        max_score, max_args = 0, []
        for b in range(1, bottles + 1):
            b_args = argmax(bottles - b)
            b_value = reduce(lambda x,y: x * y, [b] + b_args)
            if b_value > max_score: 
                max_score, max_args = b_value, [b] + b_args
        return max_args

The timing from jupyter lab shows the speedup is definately measureable.

> %time _ = argmax(512)
CPU times: user 4 µs, sys: 1 µs, total: 5 µs
Wall time: 6.2 µs
> %time _ = argmax_slow(512)
CPU times: user 14.8 ms, sys: 1.07 ms, total: 15.9 ms
Wall time: 15 ms

Math Solution

The interesting thing about this problem really shows itself when you see a solution.

> argmax_slow(20)
[2, 3, 3, 3, 3, 3, 3]

Maybe we didn't need any programming here. Let's consider some parts of the problem:

You never want to open just one bottle. Instead of opening lots of bottles in one evening, it seems a better tactic to open bottles on many evenings. Why so many threes though?

It might not seem straightforward, but it's because $2^3 < 3^2$. Spending two nights with three bottles is "more better" in this example than spending three nights with two.

As a final conclusion, I would mainly like to point out that associating alcohol to "more fun" is a path to ruin, albeit a fun optimisation problem.