The Big DK

Puzzle: 100 Prisoners and a Light Bulb (Part 2)

September 26, 2026 ~ 12 min read


Presents

Intro

This is a follow-up to my previous post on the 100100 Prisoners and a Lightbulb puzzle. Here, I’ll explain two additional solutions that drastically decrease the expected time until the prisoners are free. These solutions unfortunately have no closed-form representation that I could calculate, so we’ll have to rely entirely on simulation for runtime analysis.

Solutions

Solution #4: Multiple Counters
Solution #5: Multiple Counters with Staged Counter Selection

Solution 4: Multiple Counters

Procedure:
The prisoners first define the following parameters:

aa: The number of sub-counters
qq: The number of prisoners each sub-counter is responsible for counting
s1s_1: The length of the first stage
s2s_2: The length of the second stage
s1′s_1': The length of subsequent stage 1s
s2′s_2': The length of subsequent stage 2s
Note: qq can be derived from the value of aa: qq = 100a−1\frac{100}{a} - 1

They then pick aa prisoners to be “sub-counters,” one of which will also be the “main counter.” The sub-counters keep track of how many prisoners they have counted so far, denoted countsubcount_{sub} and initialized to 00. The main counter additionally keeps track of how many sub-counters they have counted, denoted countmaincount_{main} and also initialized to 00. Each prisoner additionally keeps track of two more variables, t1t_1 and t2t_2, which represent the number of times they must turn the lightbulb on in each phase. Regular prisoners initialize t1t_1 to 11, all other instances of t1t_1 and t2t_2 are initialized to 00:

Initial Variable Values per Prisoner Typet1t_1t2t_2countsubcount_{sub}countmaincount_{main}
Sub-counters / Main counter00000000
Regular prisoner1100N/AN/A

Once the setup is settled, Stages 11 and 22 are repeated in sequence until the head counter declares that all prisoners have visited the room. The lengths of stages 11 and 22 are denoted by s1s_1 and s2s_2 in the first iteration, and by s1′s_1' and s2′s_2' in all subsequent iterations. The prisoners act as follows during each stage:

  • Stage 11:
    • If any prisoner sees an OFF lightbulb and has a positive t1t_1, they turn the lightbulb ON and decrement t1t_1.
    • If a sub-counter sees an ON lightbulb and has a countsubcount_{sub} less than qq, they turn it OFF and increment countsubcount_{sub}. If countsubcount_{sub} now equals qq, they increment t2t_2.
    • If it is the last day of stage 11 and the current prisoner still sees an ON lightbulb after executing any relevant actions above, they turn the lightbulb OFF and increment t1t_1.
  • Stage 22:
    • If any prisoner sees an OFF lightbulb and has a positive t2t_2, they turn the lightbulb ON and decrement t2t_2.
    • If the main counter sees an ON lightbulb, they turn the lightbulb OFF and increment countmaincount_{main}. If countmaincount_{main} is now equal to aa, they declare that all prisoners have visited the room.
    • If it is the last day of stage 22 and the current prisoner still sees an ON lightbulb after executing any relevant actions above, they turn the lightbulb OFF and increment t2t_2.

Intuitively, this procedure improves on the one counter solution by allowing multiple sub-counters to count prisoners during stage 11. During stage 22, the main counter counts the number of sub-counters who have counted their “quota” of qq prisoners. For the main counter to be sure that each prisoner has visited the room at least once, each sub-counter needs to count to qq in stage 11, and the main counter needs to count to aa in stage 22. With well-chosen parameters, this ends up taking significantly less time than a single counter counting to 100100.

Mathematically calculating the expected runtime of this procedure is quite difficult, if not impossible, so we rely entirely on simulation for runtime analysis.

Simulating the Procedure:

import random
from types import SimpleNamespace
CONFIG = SimpleNamespace(
num_iterations=10000,
num_prisoners=100,
num_sub_counters=10,
stage_1_length=2252,
stage_2_length=1810,
secondary_stage_1_length=266,
secondary_stage_2_length=257,
)
class Prisoner:
def __init__(self, is_main_counter, is_sub_counter):
self.sub_count = 0
self.main_count = 0
self.t1 = 1 if not is_sub_counter else 0
self.t2 = 0
self.is_main_counter = is_main_counter
self.is_sub_counter = is_sub_counter
def setup_prisoners(config):
prisoners = []
sub_counters = random.sample(range(0, config.num_prisoners), config.num_sub_counters)
main_counter = sub_counters[0]
for i in range(0, config.num_prisoners):
is_sub_counter = False
is_main_counter = False
if i == main_counter:
is_main_counter = True
if i in sub_counters:
is_sub_counter = True
prisoners.append(Prisoner(is_main_counter, is_sub_counter))
return prisoners
def simulate_procedure(config):
bulb_on = False
prisoner_list = setup_prisoners(config)
num_days_taken = 0
sub_counter_quota = config.num_prisoners // config.num_sub_counters - 1
stage_1_length = config.stage_1_length
stage_2_length = config.stage_2_length
while True:
for i in range(0, stage_1_length):
num_days_taken += 1
chosen_prisoner = prisoner_list[random.randrange(0, config.num_prisoners)]
if not bulb_on and chosen_prisoner.t1 > 0:
bulb_on = True
chosen_prisoner.t1 -= 1
if (
bulb_on
and chosen_prisoner.is_sub_counter
and chosen_prisoner.sub_count < sub_counter_quota
):
bulb_on = False
chosen_prisoner.sub_count += 1
if chosen_prisoner.sub_count >= sub_counter_quota:
chosen_prisoner.t2 += 1
if i == stage_1_length - 1:
if bulb_on:
bulb_on = False
chosen_prisoner.t1 += 1
for i in range(0, stage_2_length):
num_days_taken += 1
chosen_prisoner = prisoner_list[random.randrange(0, config.num_prisoners)]
if not bulb_on and chosen_prisoner.t2 > 0:
bulb_on = True
chosen_prisoner.t2 -= 1
if bulb_on and chosen_prisoner.is_main_counter:
bulb_on = False
chosen_prisoner.main_count += 1
if chosen_prisoner.main_count == config.num_sub_counters:
return num_days_taken
if i == stage_2_length - 1:
if bulb_on:
bulb_on = False
chosen_prisoner.t2 += 1
stage_1_length = config.secondary_stage_1_length
stage_2_length = config.secondary_stage_2_length
def estimate_mean(config):
sim_results = []
for _ in range(0, config.num_iterations):
sim_results.append(simulate_procedure(config))
mean = sum(sim_results) / config.num_iterations
std_dev = (sum((x - mean) ** 2 for x in sim_results) / config.num_iterations) ** 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 / config.num_iterations**0.5:.1f}"
)
return mean
if __name__ == "__main__":
estimate_mean(CONFIG)

Mean: 5,616
Min: 2,760
Max: 16,763
Standard Error of Mean: 1.1

As shown above, with 1010 sub-counters and all stage lengths being set to 100100 days, the prisoners can expect to be free in roughly 5,6165{,}616 days, or 15.415.4 years. We can further improve on this by tweaking the parameters, i.e. the number of sub-counters and the length of each stage. Since we lack a closed-form representation and don’t have derivatives to perform gradient descent on, we’ll instead tune using Bayesian optimization in the Python implementation, and try out the MaxLIPO+TR algorithm in the C++ implementation. Both of these methods can quite easily be incorporated into our existing simulations using the fantastic scikit-learn and dlib libraries respectively:

from types import SimpleNamespace
from sim import estimate_mean
from skopt import gp_minimize
from skopt.space import Integer, Categorical
NUM_ITERATIONS = 750
search_space = [
Categorical([5, 10, 20], name="num_sub_counters"),
Integer(250, 2500, name="stage_1_length"),
Integer(250, 2500, name="stage_2_length"),
Integer(50, 500, name="secondary_stage_1_length"),
Integer(50, 500, name="secondary_stage_2_length"),
]
def build_config(params):
(
num_sub_counters,
stage_1_length,
stage_2_length,
secondary_stage_1_length,
secondary_stage_2_length
) = params
return SimpleNamespace(
num_iterations=NUM_ITERATIONS,
num_prisoners=100,
num_sub_counters=num_sub_counters,
stage_1_length=stage_1_length,
stage_2_length=stage_2_length,
secondary_stage_1_length=secondary_stage_1_length,
secondary_stage_2_length=secondary_stage_2_length,
)
def objective(params):
config = build_config(params)
return estimate_mean(config)
result = gp_minimize(
func=objective,
dimensions=search_space,
n_calls=10,
n_initial_points=10,
random_state=48,
)
print(
f"Number of sub-counters: {result.x[0]}\n"
f"Stage 1 length: {result.x[1]}\n"
f"Stage 2 length: {result.x[2]} \n"
f"Subsequent stage 1 length: {result.x[3]}\n"
f"Subsequent stage 2 length: {result.x[4]}\n"
)

Number of sub-counters: 10
Stage 1 length: 2201
Stage 2 length: 1827
Subsequent stage 1 length: 269
Subsequent stage 2 length: 354

Plugging these optimized parameters back into the simulation code yields:

Avg: 3,620
Min: 2,433
Max: 10,186
Standard Error of Mean: 0.7

Thus, with proper parameter tuning, this procedure cuts the expected time until the prisoners are free to 3,6203{,}620 days, or just under 1010 years. That’s almost 2.52.5x faster than the Staged Counter Selection solution discussed part one!

Solution 5: Multiple Counters with Staged Counter Selection

This solution is an adaptation of the Staged Counter Selection procedure discussed in part one, modified to work with multiple counters.

Procedure:
The prisoners define the same parameters as the Multiple Counters solution, with the addition of s0s_0, the length of sub-stages in a newly introduced stage 00. Each prisoner will also keep track of one additional variable, countsub−rolescount_{sub-roles}, which tracks how many times the sub-counter role has been assigned to them. Each sub-stage will produce one sub-counter, so it follows that there are aa sub-stages in stage 00. Stage 00 occurs only once before the first iteration of stage 11. During each sub-stage of stage 00, the prisoners act as follows:

  • Day 11:
    • If a prisoner enters the room and the lightbulb is ON, they add s0−1s_0 - 1 to t1t_1, then assume a sub-counter role. To assume a sub-counter role, they transfer their value of t1t_1 to countsubcount_{sub}. They then increment countsub−rolescount_{sub-roles}.
    • After potentially executing the above, the prisoner does one of the following:
      • If the prisoner is a sub-counter, they decrement countsubcount_{sub} and turn the light ON.
      • If the prisoner has a positive t1t_1, they decrement t1t_1 and turn the light ON.
      • If the prisoner is neither a sub-counter nor has a positive t1t_1, they turn the light OFF and assume the role of a sub-counter (only the increment of countsub−rolescount_{sub-roles} is needed here).
  • Days 22 through s0−1s_0 - 1:
    • If a prisoner enters the room and the lightbulb is ON, they do one of the following:
      • If they are a sub-counter, they decrement countsubcount_{sub}
      • If they have a positive t1t_1, they decrement t1t_1
      • If the prisoner is neither a sub-counter nor has a positive t1t_1, they add dd to t1t_1, where dd is the number of days that have passed in the current sub-stage. They then assume a sub-counter role (only the increment of countsub−rolescount_{sub-roles} is needed here)
  • Last Day of sub-stage:
    • If a prisoner enters the room, the lightbulb is ON, and they are not currently a sub-counter, they add s0−1s_0 - 1 to t1t_1, then assume a sub-counter role (transfer their value of t1t_1 to countsubcount_{sub}, then increment countsub−rolescount_{sub-roles})

Following stage 00, the remainder of the procedure is identical to the Multiple Counters solution, with three small changes:

  1. If the first prisoner who enters the room after stage 00 has completed sees an ON lightbulb, they turn it OFF, add s0−1s_0 - 1 to t1t_1, then assume a sub-counter role (transfer their value of t1t_1 to countsubcount_{sub}, then increment countsub−rolescount_{sub-roles}). They then follow the rest of the Multiple Counters procedure as normal.
  2. Sub-counters now count up to q⋅countsub−rolesq \cdot count_{sub-roles}
  3. Sub-counters now increment t2t_2 if countsub mod q=0count_{sub} \bmod q = 0.

Changes 22 and 33 are necessary because, in rare cases, a single prisoner can be assigned the role of sub-counter more than once.

The stage 00 procedure is the warped, demented, and pure-evil twin sibling of the initial stage in the Staged Counter Selection solution. Nevertheless, the intuition for it stays the same - each sub-stage results in a single sub-counter with (hopefully) some amount of prisoners pre-counted. The complexity of stage 00 arises from optimizations done in order to maximize the amount of pre-counted prisoners. Chief amongst these is giving sub-counters the ability to “loan” their counted prisoners to others. This happens whenever countsubcount_{sub} is decremented. Loaning prisoners is beneficial because it increases the time for a new sub-counter to be selected in each sub-stage, thus increasing the expected number of pre-counted prisoners. One interesting thing to note is that a sub-counter can loan a prisoner even when their countsub=0count_{sub} = 0 or negative. Since the total sum of counts possessed by the prisoners stays 100100, and each sub-counter must reach their quota before the main counter declares for victory, this is not a problem.



Simulating the Procedure:

import random
from types import SimpleNamespace
CONFIG = SimpleNamespace(
num_iterations=2000,
num_prisoners=100,
num_sub_counters=10,
stage_0_sub_length=4,
stage_1_length=2000,
stage_2_length=1500,
secondary_stage_1_length=300,
secondary_stage_2_length=300,
)
class Prisoner:
def __init__(self):
self.sub_count = 0
self.main_count = 0
self.t1 = 1
self.t2 = 0
self.is_main_counter = False
self.num_sub_counter_tokens = 0
def convert_to_sub_counter(self, sub_counter_quota, stage=1):
self.sub_count += self.t1
self.t1 = 0
self.num_sub_counter_tokens += 1
if self.sub_count >= sub_counter_quota:
self.t2 += 1
if stage == 0:
self.is_main_counter = True
def is_sub_counter(self):
return self.num_sub_counter_tokens > 0
def is_under_quota(self, sub_counter_quota):
return self.sub_count < self.num_sub_counter_tokens * sub_counter_quota
def setup_prisoners(config):
return [Prisoner() for _ in range(config.num_prisoners)]
def run_stage_zero(prisoner_list, sub_counter_quota, config):
bulb_on = False
for stage in range(config.num_sub_counters):
for day in range(config.stage_0_sub_length):
chosen_prisoner = prisoner_list[random.randrange(0, config.num_prisoners)]
if day == 0:
if bulb_on:
chosen_prisoner.t1 += config.stage_0_sub_length - 1
chosen_prisoner.convert_to_sub_counter(sub_counter_quota, stage)
if chosen_prisoner.is_sub_counter():
chosen_prisoner.sub_count -= 1
bulb_on = True
elif chosen_prisoner.t1 > 0:
chosen_prisoner.t1 -= 1
bulb_on = True
else:
bulb_on = False
chosen_prisoner.convert_to_sub_counter(sub_counter_quota, stage)
elif day == config.stage_0_sub_length - 1:
if bulb_on and not chosen_prisoner.is_sub_counter():
chosen_prisoner.t1 += config.stage_0_sub_length - 1
chosen_prisoner.convert_to_sub_counter(sub_counter_quota, stage)
bulb_on = False
else:
if bulb_on:
if chosen_prisoner.is_sub_counter():
chosen_prisoner.sub_count -= 1
elif chosen_prisoner.t1 > 0:
chosen_prisoner.t1 -= 1
else:
bulb_on = False
chosen_prisoner.t1 += day
chosen_prisoner.convert_to_sub_counter(sub_counter_quota, stage)
return bulb_on
def simulate_procedure(config):
sub_counter_quota = config.num_prisoners // config.num_sub_counters
prisoner_list = setup_prisoners(config)
bulb_on = run_stage_zero(prisoner_list, sub_counter_quota, config)
num_days_taken = config.stage_0_sub_length * config.num_sub_counters
next_prisoner_is_counter = bulb_on
stage_1_length = config.stage_1_length
stage_2_length = config.stage_2_length
while True:
for i in range(0, stage_1_length):
num_days_taken += 1
chosen_prisoner = prisoner_list[random.randrange(0, config.num_prisoners)]
if bulb_on and next_prisoner_is_counter:
chosen_prisoner.t1 += config.stage_0_sub_length - 1
chosen_prisoner.convert_to_sub_counter(sub_counter_quota)
next_prisoner_is_counter = False
bulb_on = False
if not bulb_on and chosen_prisoner.t1 > 0:
bulb_on = True
chosen_prisoner.t1 -= 1
if (bulb_on
and chosen_prisoner.is_sub_counter()
and chosen_prisoner.is_under_quota(sub_counter_quota)
):
bulb_on = False
chosen_prisoner.sub_count += 1
if chosen_prisoner.sub_count % sub_counter_quota == 0:
chosen_prisoner.t2 += 1
if i == stage_1_length - 1:
if bulb_on:
bulb_on = False
chosen_prisoner.t1 += 1
for i in range(0, stage_2_length):
num_days_taken += 1
chosen_prisoner = prisoner_list[
random.randrange(0, config.num_prisoners)
]
if not bulb_on and chosen_prisoner.t2 > 0:
bulb_on = True
chosen_prisoner.t2 -= 1
if bulb_on and chosen_prisoner.is_main_counter:
bulb_on = False
chosen_prisoner.main_count += 1
if chosen_prisoner.main_count == config.num_sub_counters:
return num_days_taken
if i == stage_2_length - 1:
if bulb_on:
bulb_on = False
chosen_prisoner.t2 += 1
stage_1_length = config.secondary_stage_1_length
stage_2_length = config.secondary_stage_2_length
def estimate_mean(config):
sim_results = []
for _ in range(config.num_iterations):
sim_results.append(simulate_procedure(config))
mean = sum(sim_results) / config.num_iterations
std_dev = (sum((x - mean) ** 2 for x in sim_results) / (config.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 / config.num_iterations**0.5:.1f}"
)
return mean
if __name__ == "__main__":
estimate_mean(CONFIG)

Similar to before, we first optimize over our parameters:

from types import SimpleNamespace
from sim import estimate_mean
from skopt import gp_minimize
from skopt.space import Integer, Categorical
NUM_ITERATIONS = 1000
search_space = [
Categorical([10], name="num_sub_counters"),
Integer(3, 8, name="stage_0_sub_length"),
Integer(250, 2500, name="stage_1_length"),
Integer(250, 2500, name="stage_2_length"),
Integer(50, 500, name="secondary_stage_1_length"),
Integer(50, 500, name="secondary_stage_2_length"),
]
def build_config(params):
(
num_sub_counters,
stage_0_sub_length,
stage_1_length,
stage_2_length,
secondary_stage_1_length,
secondary_stage_2_length,
) = params
return SimpleNamespace(
num_iterations=NUM_ITERATIONS,
num_prisoners=100,
num_sub_counters=num_sub_counters,
stage_0_sub_length=stage_0_sub_length,
stage_1_length=stage_1_length,
stage_2_length=stage_2_length,
secondary_stage_1_length=secondary_stage_1_length,
secondary_stage_2_length=secondary_stage_2_length,
)
def objective(params):
config = build_config(params)
return estimate_mean(config)
result = gp_minimize(
func=objective,
dimensions=search_space,
n_calls=100,
n_initial_points=5,
random_state=48,
)
print(
f"Number of sub-counters: {result.x[0]}\n"
f"Stage 0 sub-lengths: {result.x[1]}\n"
f"Stage 1 length: {result.x[2]}\n"
f"Stage 2 length: {result.x[3]} \n"
f"Subsequent stage 1 length: {result.x[4]}\n"
f"Subsequent stage 2 length: {result.x[5]}\n"
)

Number of sub-counters: 10
Stage 0 sub-lengths: 4
Stage 1 length: 1931
Stage 2 length: 1717
Subsequent stage 1 length: 380
Subsequent stage 2 length: 494

Plugging these optimized parameters back into the simulation code yields:

Avg: 3,458
Min: 2,178
Max: 10,326
Standard Error of Mean: 0.7

After optimizing their brains out, the prisoners manage to shave off an additional 162162 days off of their expected escape time, bringing the total down to 3,4583{,}458 days. One additional optimization that comes to mind is to vary the lengths and quotas assigned to each sub-stage of stage 00. The idea here is that earlier substages are more likely than later substages to include prisoners that haven’t been counted yet, so they should a little longer and have their corresponding quotas be slightly higher. I can’t imagine this would lower the prisoners’ expected escape time by more than ~2525 days though, so I’ll leave that exploration for another day (or to you, an inquisitive reader!)

Conclusion:

Whew, what a journey through the wonderful world of incremental optimization! All code snippets used to simulate / optimize each procedure can be found on my github. If you enjoy logic puzzles, also make sure to check out my interactive puzzle site TheRiddleMan.com!

Summary of Solutions:

Solution NameClosed-form Runtime ExpressionExpected Runtime
Lucky Chunks100101/100!100^{101}/100!1.07⋅10441.07 \cdot 10^{44}
One Counting Prisoner1+100H99+9,9001 + 100H_{99} + 9{,}90010,41910{,}419
Staged Counter Selection10,100+100(H99−1.01−101100∑j=299(100!(100j)(100−j)!))10{,}100 + 100\left(H_{99} - 1.01 - \frac{101}{100}\sum_{j=2}^{99}\left(\frac{100!}{(100^j)(100-j)!}\right) \right)9,3859{,}385
Multiple CountersN/A3,6203{,}620
Multiple Counters with Staged Counter SelectionN/A3,4583{,}458