
The Puzzle
One hundred prisoners have been newly ushered into prison. The warden tells them that starting tomorrow, each of them will be placed in an isolated cell, unable to communicate with each other. Each day, the warden will choose a random prisoner and bring them to a room containing only a single light bulb. The prisoner will be able to observe the current state of the light bulb, as well as toggle it if they wish. The warden will never touch the lightbulb, and the lightbulb is initially off on day . The prisoner also has the option of announcing that all prisoners have visited the room at some point in time. If the announcement is true, then all the prisoners are set free. However, if it is false, then all the prisoners are executed. The warden leaves, and the prisoners huddle together to discuss their fate. Can they agree on a procedure that will guarantee their freedom in as short a time as possible?
Note: Everyone I’ve talked to who was familiar with this puzzle had only ever heard of solution #2. However, there exist much better solutions, which will be shown below and in part of the post!
Solutions
Solutions are presented in order of increasing complexity.
Solution #1: Lucky Chunks
Solution #2: One Counting Prisoner
Solution #3: Staged Counter Selection
Solution 1: Lucky Chunks
Procedure:
The prisoners prearrange to track their time in prison in day chunks. During their stay, they act as follows:
- If it is a prisoner’s first time visiting the room in a given day chunk, do nothing.
- If it is a prisoner’s second time visiting the room in a given day chunk, turn the lightbulb ON.
- On the last day of each day chunk, if the light bulb is OFF, declare that every prisoner has visited the room. Otherwise, turn the lightbulb OFF.
This procedure allows the prisoners to detect whether each prisoner was brought to the room exactly once during a predetermined day chunk. If so, the light will remain off on the last day of the chunk. If any prisoner was selected more than once in the chunk, they turn the bulb on, signalling to the prisoner entering on the th day that there has been at least repeat. That prisoner then turns the bulb off, and a new chunk of days is tested.
Expected Runtime:
The expected number of days until the prisoners are free, denoted as , can be calculated as follows:
Thus, the prisoners should expect to be free in roughly days. This is unfortunately many orders of magnitude higher the current age of the universe. Even if our prisoners were immortal, I doubt any of them would have the patience to commit to this strategy. Let’s find something better for them!
Solution 2: One Counting Prisoner
Procedure:
- The first prisoner to visit the room is designated “the counter.” They keep an internal count in their head, initialized to .
- If a non-counter sees an OFF lightbulb and they have never switched the lightbulb ON before, they switch it ON.
- If the counter sees an ON lightbulb, they increment their internal count and switch the lightbulb OFF.
- Once the counter’s internal count reaches , they declare that all prisoners have visited the room.
Under this procedure, every prisoner except for the counter switches the lightbulb on exactly once. The counter’s internal count tracks precisely + the number of times the lightbulb has been switched on, which forms a strict lower bound for the number of unique prisoners that have visited the room. Thus, when the count reaches , the counter may declare with full confidence that all prisoners have visited the room at least once.
Expected Runtime:
To determine the expected number of days until the prisoners are free, first lets define a couple useful random variables and probabilities:
X - Number of days until the prisoners are free
Yi - Number of days for the counter to count from i to i + 1
Zi - Number of days for a prisoner who has not yet turned on the light to be brought in at count i
PZi - Daily probability that a prisoner who has not yet turned on the light is brought in at count i
Wi - Number of days for the counter to be brought in at count i
PWi - Daily probability that the counter is brought in at count i
Notice that for the counter to count from to , two things must happen: a prisoner who hasn’t yet turned on the light must be selected, and then the counter must be selected. Thus, . Let’s mark this down, and derive the expected values for and :
With the above, we can now calculate :
Simulating the Procedure:
import random
NUM_ITERATIONS = 10000NUM_PRISONERS = 100
def num_days_for_new_prisoner(counted_prisoners): num_days = 1 while True: chosen_prisoner = random.randrange(NUM_PRISONERS) if chosen_prisoner not in counted_prisoners: counted_prisoners.add(chosen_prisoner) return num_days num_days += 1
def num_days_for_counter(counting_prisoner): num_days = 1 while True: chosen_prisoner = random.randrange(NUM_PRISONERS) if chosen_prisoner == counting_prisoner: return num_days num_days += 1
sim_results = []
for i in range(0, NUM_ITERATIONS): counting_prisoner = random.randrange(NUM_PRISONERS) counted_prisoners = {counting_prisoner} num_days_for_escape, counting_prisoner_count = 1, 1 while counting_prisoner_count < NUM_PRISONERS: num_days_for_escape += num_days_for_new_prisoner(counted_prisoners) num_days_for_escape += num_days_for_counter(counting_prisoner) counting_prisoner_count += 1 sim_results.append(num_days_for_escape)
mean = sum(sim_results) / NUM_ITERATIONSstd_dev = (sum((x - mean) ** 2 for x in sim_results) / (NUM_ITERATIONS - 1))**0.5
print( f"Mean: {mean:.0f}\n" f"Min: {min(sim_results)}\n" f"Max: {max(sim_results)}\n" f"Standard Error of Mean: {std_dev / NUM_ITERATIONS**0.5:.1f}")#include <algorithm>#include <chrono>#include <cmath>#include <iomanip>#include <iostream>#include <random>#include <vector>#include <numeric>
constexpr int NUM_ITERATIONS = 1000000;constexpr int NUM_PRISONERS = 100;
std::mt19937 rng(std::random_device{}());std::uniform_int_distribution<int> dist(0, NUM_PRISONERS - 1);
int num_days_for_new_prisoner(std::vector<bool>& counted_prisoners) { int num_days = 1; while (true) { int chosen_prisoner = dist(rng); if (!counted_prisoners[chosen_prisoner]) { counted_prisoners[chosen_prisoner] = true; return num_days; } num_days++; }}
int num_days_for_counter(int counting_prisoner) { int num_days = 1; while (true) { int chosen_prisoner = dist(rng); if (chosen_prisoner == counting_prisoner) { return num_days; } num_days++; }}
int main() { auto start_time = std::chrono::steady_clock::now(); std::vector<int> sim_results; sim_results.reserve(NUM_ITERATIONS); for (int i = 0; i < NUM_ITERATIONS; i++) { int counting_prisoner = dist(rng); std::vector<bool> counted_prisoners(NUM_PRISONERS, false); counted_prisoners[counting_prisoner] = true; int num_days_for_escape = 1; int counting_prisoner_count = 1; while (counting_prisoner_count < NUM_PRISONERS) { num_days_for_escape += num_days_for_new_prisoner(counted_prisoners); num_days_for_escape += num_days_for_counter(counting_prisoner); counting_prisoner_count++; } sim_results.push_back(num_days_for_escape); }
double mean = std::accumulate(sim_results.begin(), sim_results.end(), 0.0) / NUM_ITERATIONS; double variance = std::accumulate( sim_results.begin(), sim_results.end(), 0.0, [mean](double acc, int x) {return acc + (x - mean) * (x - mean);} ); double std_dev = std::sqrt(variance / (NUM_ITERATIONS - 1)); double standard_error = std_dev / std::sqrt(NUM_ITERATIONS); auto [min, max] = std::minmax_element(sim_results.begin(), sim_results.end());
std::cout << std::fixed << std::setprecision(0); std::cout << "Mean: " << mean << '\n'; std::cout << "Min: " << *min << '\n'; std::cout << "Max: " << *max << '\n'; std::cout << std::fixed << std::setprecision(1); std::cout << "Standard Error of Mean: " << standard_error << '\n'; const auto elapsed = std::chrono::duration<double>( std::chrono::steady_clock::now() - start_time ); std::cout << std::setprecision(3) << "Runtime: " << elapsed.count() << " seconds\n"; return 0;}Mean: 10,417
Min: 6,269
Max: 15,996
Standard Error of Mean: 1.0
The simulations back up our calculations! Using this procedure the prisoners should expect to be free in around days, or just over years. This is much more reasonable than solution #, but we can still do better!
Solution 3: Staged Counter Selection
Procedure:
The prisoners prearrange to split their time in prison into two stages: stage will last for days, and stage will last until the prisoners are free. They then act as follows:
- Stage :
- If a prisoner enters the room for the first time and the lightbulb is OFF, they consider themselves “counted.” They are forbidden from turning the lightbulb ON in the second stage.
- If a prisoner enters the room for the second time and the lightbulb is OFF, they turn it ON, designate themselves as “the counter,” and set their internal count to the number of the current day - .
- If a prisoner enters the room for the first time on day and the lightbulb is OFF, they immediately declare that all prisoners have visited the room.
- Stage :
- The prisoners act identically to the One Counter solution, with the exception that the “counted” prisoners from above are never allowed to turn the lightbulb ON.
To show intuitively why this procedure works, let’s define as the first day a prisoner enters the room for the second time during stage . The prisoner entering on day will know that they’re the first repeat visitor because the lightbulb will still be off. They therefore also know that the number of unique visitors so far is exactly . If this prisoner is assigned the role of counter, and the already-counted prisoners are not allowed to touch the lightbulb in the nd stage, then the counter only needs to count other prisoners during stage to have full confidence that all prisoners have visited the room.
Expected Runtime:
We can calculate the expected runtime of this procedure by taking the weighted average of over all possible values of :
Notice that in order for the first repeat visit to happen on day , the previous visiting prisoners must have all been distinct. Additionally, the th prisoner must be non-distinct, i.e. drawn from the previous prisoners. These two facts allow us to calculate :
By the law of total probability, the sum of over all possible values of must equal . We can use this fact to derive the following, which will come in handy during the final calculation of :
The calculation of is similar to the calculation of in the one counter solution. There are, however, two important distinctions: first, a constant is added to the expectation to account for the length of the first stage, and second, the summation over begins at since prisoners have already been accounted for during the first stage.
With all of the above, we can finally begin to calculate :
All that work and the form is still messy 😢. Let’s define / derive a couple more terms that will allow us to simplify further:
Starting back where we left off:
Simulating the Procedure:
import random
NUM_ITERATIONS = 1000NUM_PRISONERS = 100
PHASE_ONE_LENGTH = 100
sim_results = []
def num_days_for_new_prisoner(counted_prisoners): num_days = 1 while True: chosen_prisoner = random.randrange(NUM_PRISONERS) if chosen_prisoner not in counted_prisoners: counted_prisoners.add(chosen_prisoner) return num_days num_days += 1
def num_days_for_counter(counting_prisoner): num_days = 1 while True: chosen_prisoner = random.randrange(NUM_PRISONERS) if chosen_prisoner == counting_prisoner: return num_days num_days += 1
def execute_phase_one(): counted_prisoners = set() for _ in range(0, PHASE_ONE_LENGTH): chosen_prisoner = random.randrange(NUM_PRISONERS) if chosen_prisoner in counted_prisoners: return counted_prisoners, chosen_prisoner counted_prisoners.add(chosen_prisoner) return counted_prisoners, chosen_prisoner
for i in range(0, NUM_ITERATIONS): counted_prisoners, counting_prisoner = execute_phase_one() num_days_for_escape = PHASE_ONE_LENGTH counting_prisoner_count = len(counted_prisoners) while counting_prisoner_count < NUM_PRISONERS: num_days_for_escape += num_days_for_new_prisoner(counted_prisoners) num_days_for_escape += num_days_for_counter(counting_prisoner) counting_prisoner_count += 1 sim_results.append(num_days_for_escape)
mean = sum(sim_results) / NUM_ITERATIONSstd_dev = (sum((x - mean) ** 2 for x in sim_results) / (NUM_ITERATIONS - 1))**0.5
print( f"Mean: {mean:.0f}\n" f"Min: {min(sim_results)}\n" f"Max: {max(sim_results)}\n" f"Standard Error of Mean: {std_dev / NUM_ITERATIONS**0.5:.1f}")#include <algorithm>#include <cmath>#include <chrono>#include <iomanip>#include <iostream>#include <random>#include <vector>#include <numeric>
constexpr int NUM_ITERATIONS = 1000000;constexpr int NUM_PRISONERS = 100;constexpr int PHASE_ONE_LENGTH = 100;
std::mt19937 rng(std::random_device{}());std::uniform_int_distribution<int> dist(0, NUM_PRISONERS - 1);
int num_days_for_new_prisoner(std::vector<bool>& counted_prisoners) { int num_days = 1; while (true) { int chosen_prisoner = dist(rng); if (!counted_prisoners[chosen_prisoner]) { counted_prisoners[chosen_prisoner] = true; return num_days; } num_days++; }}
int num_days_for_counter(int counting_prisoner) { int num_days = 1; while (true) { int chosen_prisoner = dist(rng); if (chosen_prisoner == counting_prisoner) { return num_days; } num_days++; }}
std::pair<std::vector<bool>, int> execute_phase_one() { std::vector<bool> counted_prisoners(NUM_PRISONERS, false); int chosen_prisoner = 0; for (int i = 0; i < PHASE_ONE_LENGTH; i++) { chosen_prisoner = dist(rng); if (counted_prisoners[chosen_prisoner]) { return {counted_prisoners, chosen_prisoner}; } counted_prisoners[chosen_prisoner] = true; } return {counted_prisoners, chosen_prisoner};}
int main() { auto start_time = std::chrono::steady_clock::now(); std::vector<int> sim_results; sim_results.reserve(NUM_ITERATIONS); for (int i = 0; i < NUM_ITERATIONS; i++) { auto [counted_prisoners, counting_prisoner] = execute_phase_one(); int num_days_for_escape = PHASE_ONE_LENGTH; int counting_prisoner_count = 0; for (int prisoner = 0; prisoner <= NUM_PRISONERS - 1; prisoner++) { if (counted_prisoners[prisoner]) { counting_prisoner_count++; } } while (counting_prisoner_count < NUM_PRISONERS) { num_days_for_escape += num_days_for_new_prisoner(counted_prisoners); num_days_for_escape += num_days_for_counter(counting_prisoner); counting_prisoner_count++; } sim_results.push_back(num_days_for_escape); }
double mean = std::accumulate(sim_results.begin(), sim_results.end(), 0.0) / NUM_ITERATIONS; double variance = std::accumulate( sim_results.begin(), sim_results.end(), 0.0, [mean](double acc, int x) {return acc + (x - mean) * (x - mean);} ); double std_dev = std::sqrt(variance / (NUM_ITERATIONS - 1)); double standard_error = std_dev / std::sqrt(NUM_ITERATIONS);
auto [min, max] = std::minmax_element( sim_results.begin(), sim_results.end() ); std::cout << std::fixed << std::setprecision(0); std::cout << "Mean: " << mean << '\n'; std::cout << "Min: " << *min << '\n'; std::cout << "Max: " << *max << '\n'; std::cout << std::setprecision(1); std::cout << "Standard Error of Mean: " << standard_error << '\n'; const auto elapsed = std::chrono::duration<double>( std::chrono::steady_clock::now() - start_time ); std::cout << std::setprecision(3) << "Runtime: " << elapsed.count() << " seconds\n";
return 0;}Mean: 9,384
Min: 3,848
Max: 15,803
Standard Error of Mean: 1.1
The simulation once again matches up with our calculations 😊. Using this procedure,
the prisoners should expect to be free in days, or roughly years.
We were able to shave almost three years off of the standard one counter solution!
Conclusion:
The prisoners might be a little more wrinkly and grey by the time they’re out, but at least they’ll taste freedom again in their lifetimes. However, what if I told you that these are actually extremely kind prisoners who walk grandmas across the street and save puppies in their free time? Well then, they must obviously be freed expeditiously! Check out part two of this post to see how we can optimize these procedures and free the prisoners even faster!
