Back to the Project Euler puzzles

By: Blog by Bogumił Kamiński

Re-posted from: https://bkamins.github.io/julialang/2023/10/20/pe.html

Introduction

After several technical posts today I decided to switch back to puzzle-solving mode.
My readers probably know that I like and promote the Project Euler project.

This time I picked a relatively easy puzzle 205 as it nicely shows several
functionalities of Julia that are worth learning.

The post was written under Julia 1.9.2 and StatsBase.jl v0.34.2.

The problem

The problem 205 is stated as follows:

Peter has nine four-sided dice, each with faces numbered from 1 to 4.
Colin has six six-sided dice, each with faces numbered from 1 to 6.
Peter and Colin roll their dice and compare totals: the highest total wins.
The result is a draw if the totals are equal.
What is the probability that Peter beats Colin?

Simulation approach

To get some intuition for our problem let us first try simulating the distribution of the results.
We will draw one million times from Peter’s and Colin’s dice:

julia> using Random

julia> using StatsBase

julia> Random.seed!(1234);

julia> sim_p = [sum(rand(1:4) for _ in 1:9) for _ in 1:10^6]
1000000-element Vector{Int64}:
 24
 23
 20
 26
 23
  ⋮
 20
 27
 22
 24
 23

julia> sim_c = [sum(rand(1:6) for _ in 1:6) for _ in 1:10^6]
1000000-element Vector{Int64}:
 21
 23
 23
 24
 24
  ⋮
 16
 30
 15
 20
 25

Now let us compare the results:

julia> describe(sim_p)
Summary Stats:
Length:         1000000
Missing Count:  0
Mean:           22.498804
Std. Deviation: 3.355036
Minimum:        9.000000
1st Quartile:   20.000000
Median:         22.000000
3rd Quartile:   25.000000
Maximum:        36.000000
Type:           Int64

julia> describe(sim_c)
Summary Stats:
Length:         1000000
Missing Count:  0
Mean:           20.996381
Std. Deviation: 4.186906
Minimum:        6.000000
1st Quartile:   18.000000
Median:         21.000000
3rd Quartile:   24.000000
Maximum:        36.000000
Type:           Int64

julia> mean(sim_p .> sim_c)
0.5728

We see that Peter’s chances of winning are around 57%.
We also see that both the mean and the median of Peter’s dice are better than Colin’s.

However, the simulation results are only approximate. Let us thus compute the exact result.

Exact approach

To compute the exact probability of Peter’s win first calculate the distribution of
Peter’s and Colin’s dice. With StatsBase.jl it is easy to do using the countmap function:

julia> ex_p = countmap(map(sum, Iterators.product((1:4 for _ in 1:9)...)))
Dict{Int64, Int64} with 28 entries:
  16 => 4950
  20 => 23607
  35 => 9
  12 => 165
  24 => 27876
  28 => 8451
  30 => 2598
  17 => 8451
  23 => 30276
  19 => 18351
  22 => 30276
  32 => 486
  11 => 45
  36 => 1
  9  => 1
  31 => 1206
  ⋮  => ⋮

julia> ex_c = countmap(map(sum, Iterators.product((1:6 for _ in 1:6)...)))
Dict{Int64, Int64} with 31 entries:
  16 => 2247
  20 => 4221
  35 => 6
  12 => 456
  24 => 3431
  28 => 1161
  8  => 21
  17 => 2856
  30 => 456
  23 => 3906
  19 => 3906
  22 => 4221
  32 => 126
  6  => 1
  11 => 252
  36 => 1
  ⋮  => ⋮

As a result we get the number of times out of the total possible outcomes that a given sum on a dice occurs.
Let us check that the total counts of the outcomes are correct. We can do it as we know that on Peter’s dice
we can get 4^9 outcomes and on Colin’s dice 6^6:

julia> sum(values(ex_p)), 4^9
(262144, 262144)

julia> sum(values(ex_c)), 6^6
(46656, 46656)

Indeed the results match.

Using distributions stored in ex_p and ex_c variables we can count the number of times Peter wins with Collin
using the Cartesian product of the distributions (the tosses of the dice are independent) and, in consequence compute the
exact probability that Peter wins:

julia> p_win = sum(pk > ck ? pv*cv : 0 for
                   (pk, pv) in pairs(ex_p),
                   (ck, cv) in pairs(ex_c))
7009890480

julia> total = 4^9 * 6^6
12230590464

julia> p_win / total
0.5731440767829801

julia> p_win // total
48679795//84934656

Note that in Julia we can nicely compute both approximate solution (using Float64) and exact solution using rational numbers.

One important aspect that we should have checked when solving this puzzle is if we do not have an integer overflow issue when
computing the result. On 64-bit machine the overflow happens for the value:

julia> typemax(Int)
9223372036854775807

which is much larger than 12230590464. But how can we be sure that we do not get an overflow when computing 4^9 * 6^6?
The easiest check is to take the logarithms of both expressions:

julia> log(typemax(Int))
43.66827237527655

julia> 9 * log(4) + 6 * log(6)
23.227206065447348

Indeed we see that we have a wide safety margin.

Note, however, that on 32-bit machine Julia would use Int32 type to represent integers by default, and hen we have:

julia> typemax(Int32)
2147483647

julia> log(typemax(Int32))
21.487562596892644

And in this case we would have an integer overflow issue, so some care is needed.

Conclusions

I hope you enjoyed the puzzle, the solution, and the code examples I have presented!