How code might help you understand the Monty Hall problem

Sam Van Overmeire
4 min readOct 16, 2022

--

Illustration from Wikimedia

There’s a famous brain teaser that many of you are probably familiar with, called the ‘Monty Hall problem’. It goes like this (from Wikipedia):

Suppose you’re on a game show, and you’re given the choice of three doors: Behind one door is a car; behind the others, goats. You pick a door, say No1, and the host, who knows what’s behind the doors, opens another door, say No3, which has a goat. He then says to you, “Do you want to pick door No2?” Is it to your advantage to switch your choice?

At first glance, we would assume that switching or staying put are equally valid choices. After all, we know that with three doors and the prize randomly placed behind one of them, every door has ⅓ chance of containing the prize and a probability of ⅔ of containing a goat. Once a door with a goat has been opened, we assume that the probabilities now change to ½ (or 50%) for the two remaining door. Which makes it all the more surprising that switching is actually a way more sensible choice!

For me, the best explanation I’ve heard is that at the start of the puzzle, our chosen door has a ⅓ probability of containing the prize, whereas the other two doors combined have ⅔ (⅓ + ⅓). When we discover that there is a goat behind one of the latter doors, this means the entire probability of ⅔ can now be assigned to the remaining door. So switching would double our chance of winning! It can sound incredible the first time you hear it. In fact many well-educated readers of the puzzle refused to believe that the answer was correct. But it has stood the test of time and countless computer simulations.

The reason I’m writing about this particular brain teaser is because I decided to have some fun with my own simple simulation of the problem. The basic scenario is easy enough:

let number_of_door_where_car_is: u32 = rng.gen_range(1..4);
let guess: u32 = rng.gen_range(1..4);
// slightly shorter, cleaner and (seemingly faster)
// than a for loop
let doors_that_can_be_opened: Vec<u32> = (1..4)
.filter(|v| *v != number_of_door_where_car_is)
.filter(|v| *v != guess)
.collect();

let opened_door = doors_that_can_be_opened.choose(&mut rng).unwrap();
// determine whether staying put or switching wins

We randomly pick a number from 1 to 3 (the range is exclusive) for the door containing the prize. A second randomly selected number represents our guess. To find out which door will be opened, we loop over all of them and keep those that were not a guess or a winner — we only want to open a door that was not picked by the candidate & does not contain the car. This leaves us either with one or two choices, depending on whether the ‘picked door’ and the ‘prize door’ are the same. From this selection, we randomly pick a result: the unpicked door with the goat.

While writing this, it dawned on me that much of my code was totally unnecessary. To determine a ‘win’ we only need to know the guess and the correct answer. When calculating the chance of winning when you stick with your original choice, this is probably obvious: all we have to do is increment our stay-put win counter when our guess matches the winner. A little less intuitive is the solution for always switching: we simply check that the original guess does not match the winning door.

if guess == number_of_door_where_car_is {
wins_when_staying_put = wins_when_staying_put + 1;
}
if guess != number_of_door_where_car_is {
wins_when_switching = wins_when_switching + 1;
}

Why? Because if we always switch and our first guess is correct, we lose. In all other scenarios we win because the other two doors will contain a) a goat and b) the car. Since the door with the goat has already been opened, only the door with the prize car is left!

That is when, on a code level, the solution clicked for me. The = and != conditions contain all possible probabilities, leaving only ⅓ probability for the ‘equals’. This leaves the remaining ⅔ for the ‘not equals’ condition. And sure, one of the doors in our ‘always switch’ scenario will have a goat. Since there’s only one car to go around, that’s pretty much unavoidable...

Here’s the entire program (compiles and runs, but won’t win any beauty prizes):

fn main() {
let mut rng = rand::thread_rng();
let mut current_run = 0;
let number_of_rounds = 100_000;
let mut wins_when_staying_put = 0;
let mut wins_when_switching = 0;

while current_run < number_of_rounds {
let (number_of_door_where_car_is, guess) = random_guess_and_winning_door(&mut rng);
// 'else' as an alternative to two ifs used above
if guess == number_of_door_where_car_is {
wins_when_staying_put = wins_when_staying_put + 1;
} else {
wins_when_switching = wins_when_switching + 1;
}

current_run = current_run + 1;
}

println!("{}", wins_when_switching as f32 / number_of_rounds as f32);
println!("{}", wins_when_staying_put as f32 / number_of_rounds as f32);
}

fn random_guess_and_winning_door(rng: &mut ThreadRng) -> (u32, u32) {
let number_of_door_where_car_is: u32 = rng.gen_range(1..4);
let guess: u32 = rng.gen_range(1..4);
(number_of_door_where_car_is, guess)
}

And, unless I made a grave error somewhere, the results confirm what we thought: switching gives us almost a 67% chance of success, staying put is at a mere 33%.

--

--

Sam Van Overmeire

Medium keeps bugging me about filling in a Bio. Maybe this will make those popups go away.