2 Cups 100 Floors

There is a brainteaser that goes like this. There is a building with 100 floors and you have two identical cups made from some unknown type of glass. You want to determine the highest floor that you can drop the glass from without it shattering. Call it floor k. The glasses can be dropped as many times as you want from floors below k without breaking, but as soon as you go above k, they break.

So one way to do this is by dropping the cup from each floor from 1 to 100, sequentially, until it breaks. But this is obviously pretty inefficient. What is the most efficient way of determining k (in the sense of requiring the fewest number of test drops)?

(I heard a version with eggs instead of cups, and my friend immediately answered that k should be 1 because it’s an egg. “What the hell kind of eggs are you using??”)

There is a fairly simple solution that people commonly come up with. And, it’s pretty good, but not optimal. This post is about how to get the really actually truly optimal solution. Here is a pause in case you haven’t heard this before and want to figure it out for yourself. Otherwise, read on.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

First, since you don’t know what k is, “fewest” means minimizing the average number of steps your algorithm takes. You could define some other measure of optimality, but this is what we’ll go with. It’s important to note that the answer depends on the distribution k takes on, but it seems pretty reasonable to assume a uniform distribution.

The simple algorithm goes like this: first, try the 10th, 20th, … etc. floors until the first glass breaks. Once the first glass breaks, you’ve narrowed k to a range of 10 floors. E.g., it didn’t break at 30 but it broke at 40, so k must be between 31 and 39, so you use the second glass to try 31, 32, … etc.

It’s clear that, once the first glass breaks, you must try the second glass one floor at a time, starting from the highest known floor not to break a glass. So the strategy is entirely determined by what sequence of floors to try for the first glass, before it breaks. The simple solution can be represented by the array [10, 20, …, 90].

You can calculate (e.g., by writing code, or using maths) that the average running time for this algorithm is 10.81 steps. But, the average running time for the following algorithm, which is optimal, is 10.31:

[13, 25, 36, 46, 55, 63, 71, 78, 84, 89, 93, 96, 98, 99, 100]

It turns out that the simple algorithm is actually pretty good. The optimal is only a 5% improvement or so. But why isn’t the simple algorithm optimal? Because it doesn’t self adjust to additional information.

What is the optimal solution if you only had 50 floors? There’s nothing special about the number 10, so there is no reason to think that [10, 20, 30, 40] should be optimal for 50. If we think the heuristic is a square root, then we should be going up by about 7 floors each time, something like [7, 14, 21, …].

Note

(Square root is actually a pretty reasonable heuristic — if you make the interval too large, you have to a lot of drops with the second glass, and if you make the interval too small, you have to do a lot of drops with the first glass. Square root balances the expected amount of work you need to do with the first glass with the second.) (And, in fact, the average running time for [7, 14, 21, …] is 7.86, better than [10, 20, 30, 40]’s 8.22.)

So, back to the 100 floors case, this means that, if for your first glass, you went 10, 20, … all the way up to 50, and the glass hasn’t broken yet, then switching to going up in intervals of 7 instead of 10 would yield a lower average running time. Because if you’re at 50 and the first glass hasn’t broken yet, then you have reduced to the 50 floors case. The naive strategy of always going up by 10 floors doesn’t adjust to the information you get from the glass not breaking.

This reduction idea hints at how to find an optimal solution. What if you already knew optimal solutions for the 1 floor, 2 floor, … 99 floors cases? Then to find an optimal solution for the 100 floors case, all you need to do is figure out what is the first floor p that you should test drop at. If the glass breaks at p, you are forced to use the second glass one floor at the time. If the glass does not break at p, then you follow the optimal solution for 100-p floors from your book of solutions.

(My first attempt at explicitly writing down the optimal solution was to brute force all possible strategies — that is, all sequences [a1, a2, … am] with \(0 < a_1 < a_2 < \ldots < a_m <= 100\). My code ran for 600k iterations before I decided to estimate the number of such sequences and found it was about 2^100 = 10^30. Whoops.)

Let \(f(n)\) denote the optimal average running time of the n floors case. Let’s say that we decide to pick the first floor as p. If \(k = p\), then we need to try every floor below p, so the cost is p. If \(k < p\), then the cost is 1+k: 1 for trying the first glass at floor p, which breaks, and k tries with the second glass until it breaks. If \(k > p\), then we reduce to the \(n-p\) floors case, so the cost is \(f(n-p) + 1\). To be optimal, we need to pick the p that minimizes the expected value of the cost, so:

\begin{equation*} f(n) = \min_{p \in [1, n]} E[1+k | k < p] + \frac pn + (1- \frac pn)(f(n-p) + 1) \end{equation*}
\begin{equation*} f(n) = \min_{p \in [1, n]} \frac{p-1}{n} + \frac{(p-1)p}{2n} + \frac pn + (1- \frac pn)(f(n-p) + 1) \end{equation*}
\begin{equation*} f(n) = \min_{p \in [1, n]} \frac{p^2 + 3p - 2}{2n} + (1- \frac pn)(f(n-p) + 1) \end{equation*}

I am not entirely sure how to “solve” for \(f\)–maybe there is some slick trick, or maybe it just can’t be done. But it’s straightforward enough to write some code to compute it:

def poly(p):
  return p*p + 3*p -2

n = 10000
f = range(0, n+1)
g = range(0, n+1)

for i in range(2, n+1):
  min_f = -1
  min_p = -1
  for p in range (1, i+1):
    tmp = poly(p)/2
    if p < i:
      tmp = poly(p)/2 + (i-p)*(f[i-p] + 1)
    if min_f < 0 or tmp < min_f:
      min_f = tmp
      min_p = p
  f[i] = (min_f+0.)/i
  g[i] = min_p

Saving the g vector is important if you want to actually know what the optimal algorithm looks like. You can read it off with something like

k = 100
s = [g[k]]
k = k - s[0]
while k > 0:
  s.append(s[-1] + g[k])
  k = k - g[k]

Bonus:

Here is a graph of how \(g\) behaves. As with \(f\), I don’t know how to write it down, but it should track \(1/\sqrt{n}\) fairly closely.