Chapter 166
The Twenty-Five Horses And The Price Is Right
Riddler Express
You own horses and want to identify the three fastest. Your racetrack can run only horses at a time. Races are deterministic (the same horse beats the same opponent every time). What is the minimum number of races needed to find the top three?
The Riddler, FiveThirtyEight, October 27, 2017(original post)
Solution
The answer is . The lower bound comes from a counting argument and the upper bound from a construction.
Why races are not enough. Any horse you have never raced cannot be ranked relative to the others, so every one of the horses must be in at least one race. With races of horses each ( slots) you exactly cover all horses once, but then no horse has been compared with any horse outside its heat, and the three top finishers across heats remain incomparable.
Why are not enough. After the first-heat races you know each heat’s internal ranking but nothing across heats. A sixth race of the heat-winners identifies the overall fastest horse (the winner of this race), but the second- and third-fastest overall could be (a) the second and third in the heat-winners’ race, (b) the second-place in the heat-winners’ race together with the second-place from the winner’s original heat, or (c) the heat-winner’s second-place together with that second-place’s own heat-second. Three candidates compete for the two remaining podium slots, and a single race cannot order them with certainty (only ranks them relative to each other across horses; the relevant three are mutually unraced).
A 7-race construction. Run five heats of to internally rank all horses; label the heats by their winners’ speeds (we learn this labelling in race ).
Races –: each of the heats. Heat ranks its horses; call them . The bottom two of each heat () are eliminated: they cannot be in the top three (at least three faster horses live in the same heat).
Race : the five heat winners . The winner of this race, , is the overall fastest; ship it back to the stable. Eliminate and (they finish fourth and fifth among heat winners, so are slower than three other heat winners) and with them their entire heats (those heats’ second and third places).
Now the remaining contenders for the second and third places overall are five horses: from the fastest heat; from the second-fastest heat (its third place has at least three faster horses: , so is eliminated); and from the third-fastest heat (its second and third are slower than , and also slower than , so are out).
Race : those five horses. The top two are the second- and third-fastest overall.
The computation
Treat horses’ speeds as (distinct, deterministic). Implement the seven-race plan and confirm it reports the true top three for many random shufflings. Then confirm that no -race plan can succeed by sweeping the candidate-narrowing argument.
Assign each horse a random latent speed (a permutation of ).
Carry out the races by the above procedure; race ranks horses by their latent speeds.
Output the algorithm’s top-three claim and compare against the true top three by speed.
import random
def seven_race(speeds):
# speeds[i] is the latent speed of horse i (higher = faster).
horses = list(range(25))
heats = [horses[i*5:(i+1)*5] for i in range(5)]
# races 1..5: internal ranking of each heat
heats = [sorted(h, key=lambda x: -speeds[x]) for h in heats]
# race 6: heat winners
winners = [h[0] for h in heats]
winners_ranked = sorted(winners, key=lambda x: -speeds[x])
# global champion:
champ = winners_ranked[0]
fast_heat = next(h for h in heats if h[0] == champ)
second_heat = next(h for h in heats if h[0] == winners_ranked[1])
third_heat = next(h for h in heats if h[0] == winners_ranked[2])
# race 7: 5 contenders for positions 2 and 3
contenders = [fast_heat[1], fast_heat[2],
second_heat[0], second_heat[1],
third_heat[0]]
runoff = sorted(contenders, key=lambda x: -speeds[x])
return [champ, runoff[0], runoff[1]]
rng = random.Random(0)
ok = 0
for trial in range(10_000):
s = list(range(25)); rng.shuffle(s)
claim = seven_race(s)
true = sorted(range(25), key=lambda x: -s[x])[:3]
ok += claim == true
print(ok) # 10000
Every random trial confirms that the -race strategy returns the true podium.
Riddler Classic
The Price Is Right’s Showcase Showdown. The wheel has segments labelled in steps. Three players spin in turn, each up to two spins; total over a dollar is a bust. The total closest to a dollar (without busting) wins; ties trigger a sudden-death spin-off. For what totals should the first spinner stop after one spin, assuming optimal play by players two and three?
The Riddler, FiveThirtyEight, October 27, 2017(original post)
Solution
The structure is a three-player sequential game with finite states, perfectly solvable by backward induction.
Reduction. A player’s state on their first call is the set of locked totals (or busts, recorded as ) of the players who have already finished. Each player gets up to two spins and chooses, after the first, between staying and spinning again. The final non-bust totals are compared; ties at the top are resolved by a single uniform spin per tied player (with further reties broken symmetrically, so a -way tie gives each player win probability ).
Player 3. Given that players and have stopped at totals (either could be for bust), let . After Player 3’s first spin of value :
If , stay (already winning over both opponents outright).
If , the trade-off is between an almost-certain loss now (stay) versus the bust risk of a second spin. Player 3 should spin again unless staying gives at least the chance of a tie-break that exceeds the spin’s expected win.
For example, if and Player 3 stays and triple-ties at win probability ; spinning again is a sure bust.
Player 2. Knows only; reasons about Player 3’s optimal response.
Player 1. Knows nothing; reasons about and in sequence.
Solving the recursion by computer (or carefully by hand, table by table) gives Player 1’s stay/spin map on her first spin:
| action | action | ||
|---|---|---|---|
| spin | stay |
Intuition. At a spin again busts only if the next segment is or more, which is out of segments. That feels suicidal, but the alternative is leaving Players and each two opportunities to beat , and each independently does so with a probability that, multiplied across two opponents, narrowly outweighs the bust risk. At the calculus flips: a second spin busts on ( of segments), and is a high enough perch that opposing optimal play does not reliably crest it.
The computation
Encode the dynamic-programming recursion directly. For each preceding-totals configuration compute the optimal action of the next player to act, propagate back to Player 1, and read off her stop/spin map.
Define : probability wins given locked totals (ties split equally).
Player 3: for each first spin , choose stay or spin to maximise .
Player 2: for each first spin given , simulate Player 3’s optimal response and choose stay or spin to maximise her own win probability.
Player 1: for each first spin , simulate Players and ’s optimal responses and choose stay or spin.
VALS = list(range(5, 105, 5)) # 5,10,...,100
def winp(my, others):
if my == 0:
return 0.0
high = max(others) if others else 0
if my < high: return 0.0
if my > high: return 1.0
tied = sum(1 for t in others if t == my)
return 1.0 / (1 + tied)
def p3_action(s1, s2, a3): # True = stay
stay = winp(a3, [s1, s2])
spin = sum(winp((a3 + a) if a3 + a <= 100 else 0,
[s1, s2]) for a in VALS) / 20
return stay >= spin
def p2_then_p3_winprobs(s1, t2): # P3 optimal
p1w = p2w = 0.0
for a3 in VALS:
if p3_action(s1, t2, a3):
t3 = a3
p1w += winp(s1, [t2, t3]) / 20
p2w += winp(t2, [s1, t3]) / 20
else:
for ap in VALS:
t3 = (a3 + ap) if a3 + ap <= 100 else 0
p1w += winp(s1, [t2, t3]) / 400
p2w += winp(t2, [s1, t3]) / 400
return p1w, p2w
def p2_action(s1, a2):
stay_p2 = p2_then_p3_winprobs(s1, a2)[1]
spin_p2 = sum(p2_then_p3_winprobs(s1,
(a2 + a) if a2 + a <= 100 else 0)[1] for a in VALS) / 20
return stay_p2 >= spin_p2
def p1_winprob_if(s1):
p1w = 0.0
for a2 in VALS:
if p2_action(s1, a2):
p1w += p2_then_p3_winprobs(s1, a2)[0] / 20
else:
for ap in VALS:
t2 = (a2 + ap) if a2 + ap <= 100 else 0
p1w += p2_then_p3_winprobs(s1, t2)[0] / 400
return p1w
print("Player 1 first-spin map:")
for a1 in VALS:
stay = p1_winprob_if(a1)
spin = sum(p1_winprob_if((a1 + a) if a1 + a <= 100 else 0)
for a in VALS) / 20
print(f"{a1:3d}c stay={stay:.4f} spin={spin:.4f} ->",
"STAY" if stay >= spin else "spin")
The output shows the crossover at cents exactly, with stay-probabilities rising sharply from there.