Looking at this thread inspired me to go back on work on Project Euler. I just solved problem 84 “Monopoly Odds”, which finishes the first 100 problems for me.
I was putting this one off, since it’s just a boring chore to code up all of the transition rules, and it’s conceptually easy and somewhat uninteresting to solve.
Code and Discussion
The problem is about finding the steady state distribution (or rather just identify the top 3 states) of a Markov chain. The vast bulk of the code is just coding up the rules to calculate the transition matrix. After doing that, the steady state distribution is trivial enough to find with a few lines of code to simulate a 1000 distributional transitions (it probably converges with far fewer than that). One could instead solve a system of equations, but just iterating is fast and precise enough to get the right answer, with less coding work.
import numpy as np
from scipy.linalg import circulant
sides = 4
denom = sides ** 2
prob_vector = np.zeros(40)
for i in range(1, sides + 1):
for j in range(1, sides + 1):
prob_vector[i + j] += 1
if i == j: # Double outcome
# Subtract odds of previous two rolls are doubles
prob_vector[i + j] -= (1 / denom)
prob_vector /= denom
base_transitions = circulant(prob_vector)
# Likelihood of winding up in jail from rolling three doubles
base_transitions[10, :] += (1 / (sides ** 3))
# Go directly to jail
base_transitions[10, :] += base_transitions[30, :]
base_transitions[30, :] = 0
print(np.sum(base_transitions, 0)) # Sanity check
# Community Chest (2/16 cards): (02, 17, 33)
## Advance to GO (00)
base_transitions[0, :] += (base_transitions[2, :] + base_transitions[17, :] + base_transitions[33, :]) / 16
## Go to JAIL (10)
base_transitions[10, :] += (base_transitions[2, :] + base_transitions[17, :] + base_transitions[33, :]) / 16
base_transitions[2, :] *= (14 / 16)
base_transitions[17, :] *= (14 / 16)
base_transitions[33, :] *= (14 / 16)
print(np.sum(base_transitions, 0)) # Sanity check
# Chance (10/16 cards): (07, 22, 36)
## Advance to GO (00)
base_transitions[0, :] += (base_transitions[7, :] + base_transitions[22, :] + base_transitions[36, :]) / 16
## Go to JAIL (10)
base_transitions[10, :] += (base_transitions[7, :] + base_transitions[22, :] + base_transitions[36, :]) / 16
## Go to C1 (11)
base_transitions[11, :] += (base_transitions[7, :] + base_transitions[22, :] + base_transitions[36, :]) / 16
## Go to E3 (24)
base_transitions[24, :] += (base_transitions[7, :] + base_transitions[22, :] + base_transitions[36, :]) / 16
## Go to H2 (39)
base_transitions[39, :] += (base_transitions[7, :] + base_transitions[22, :] + base_transitions[36, :]) / 16
## Go to R1 (05)
base_transitions[5, :] += (base_transitions[7, :] + base_transitions[22, :] + base_transitions[36, :]) / 16
## Go to next R (15, 25, 05)
## Go to next R (15, 25, 05)
base_transitions[15, :] += base_transitions[7, :] / 8
base_transitions[25, :] += base_transitions[22, :] / 8
base_transitions[5, :] += base_transitions[36, :] / 8
## Go to next U (12, 28, 12)
base_transitions[12, :] += base_transitions[7, :] / 16
base_transitions[28, :] += base_transitions[22, :] / 16
base_transitions[12, :] += base_transitions[36, :] / 16
## Go back 3 (04, 19, 33)
base_transitions[4, :] += base_transitions[7, :] / 16
base_transitions[19, :] += base_transitions[22, :] / 16
base_transitions[33, :] += base_transitions[36, :] / 16
# Staying in place
base_transitions[7, :] *= (6 / 16)
base_transitions[22, :] *= (6 / 16)
base_transitions[36, :] *= (6 / 16)
print(np.sum(base_transitions, 0)) # Sanity check
# Initialize with uniform likelihood over all but jail
initial = np.ones(40) / 39
initial[30] = 0
for i in range(1000): # More than enough to converge for finding top 3
initial = base_transitions @ initial
print(initial)
print(initial[10], initial[24], initial[0]) # Top 3 for sides = 6
print(np.argsort(-initial)[:3])
I made the simplifying assumption that going to jail for speeding (rolling doubles 3 times in a row) does not reset the double counter, so that rolling doubles 4 times in a row means two trips to jail (after both the third and fourth roll). It is straightforward to modify the code to account for this, but it does not change the top 3.
Edit: checking the PE forum, I see that I also neglected to account for the possibility of going from Chance 3 to Community Chest 3, but clearly that has a small effect.