Problem 389: Platonic Dice
View on Project EulerProject Euler Problem 389 Solution
EulerSolve provides an optimized solution for Project Euler Problem 389, Platonic Dice, with C++, Python, Java, and a step-by-step mathematical explanation.
Problem Summary The experiment is a chain of random sums built from the five Platonic dice. First roll a 4-sided die and call the result \(T\). Then roll \(T\) fair 6-sided dice and sum them to obtain \(C\). Next roll \(C\) fair 8-sided dice to obtain \(O\), then \(O\) fair 12-sided dice to obtain \(D\), and finally \(D\) fair 20-sided dice to obtain \(I\). The task is to compute \(\operatorname{Var}(I)\). Mathematical Approach The local solution files do not enumerate huge distributions. Instead, they propagate only the mean and the variance from one layer to the next. That works because every stage has the same structure: a random number of independent fair dice are rolled and summed. Step 1: Moments of One Fair Die For a fair \(s\)-sided die \(X_s\) with values \(1,2,\dots,s\), the code uses $$\mathbb{E}[X_s]=\frac{s+1}{2},\qquad \operatorname{Var}(X_s)=\frac{s^2-1}{12}.$$ These are exactly the values returned by uniform_die_stats in C++, Python, and Java. For the first die, $$T \sim \text{Unif}\{1,2,3,4\},\qquad \mathbb{E}[T]=\frac{5}{2},\qquad \operatorname{Var}(T)=\frac{5}{4}.$$ Step 2: Random-Sum Identity Suppose \(N\) is a nonnegative integer-valued random variable, and conditional on \(N\) we roll \(N\) independent copies \(X_1,\dots,X_N\) of the same fair die, each with mean \(\mu\) and variance \(\sigma^2\)....
Detailed mathematical approach
Problem Summary
The experiment is a chain of random sums built from the five Platonic dice. First roll a 4-sided die and call the result \(T\). Then roll \(T\) fair 6-sided dice and sum them to obtain \(C\). Next roll \(C\) fair 8-sided dice to obtain \(O\), then \(O\) fair 12-sided dice to obtain \(D\), and finally \(D\) fair 20-sided dice to obtain \(I\). The task is to compute \(\operatorname{Var}(I)\).
Mathematical Approach
The local solution files do not enumerate huge distributions. Instead, they propagate only the mean and the variance from one layer to the next. That works because every stage has the same structure: a random number of independent fair dice are rolled and summed.
Step 1: Moments of One Fair Die
For a fair \(s\)-sided die \(X_s\) with values \(1,2,\dots,s\), the code uses
$$\mathbb{E}[X_s]=\frac{s+1}{2},\qquad \operatorname{Var}(X_s)=\frac{s^2-1}{12}.$$
These are exactly the values returned by uniform_die_stats in C++, Python, and Java. For the first die,
$$T \sim \text{Unif}\{1,2,3,4\},\qquad \mathbb{E}[T]=\frac{5}{2},\qquad \operatorname{Var}(T)=\frac{5}{4}.$$
Step 2: Random-Sum Identity
Suppose \(N\) is a nonnegative integer-valued random variable, and conditional on \(N\) we roll \(N\) independent copies \(X_1,\dots,X_N\) of the same fair die, each with mean \(\mu\) and variance \(\sigma^2\). Let
$$S=\sum_{j=1}^{N} X_j.$$
Conditioning on \(N\) gives
$$\mathbb{E}[S\mid N]=N\mu,\qquad \operatorname{Var}(S\mid N)=N\sigma^2.$$
Applying the laws of total expectation and total variance,
$$\mathbb{E}[S]=\mathbb{E}\!\left[\mathbb{E}[S\mid N]\right]=\mu\,\mathbb{E}[N],$$
$$\operatorname{Var}(S)=\mathbb{E}\!\left[\operatorname{Var}(S\mid N)\right]+\operatorname{Var}\!\left(\mathbb{E}[S\mid N]\right)=\sigma^2\mathbb{E}[N]+\mu^2\operatorname{Var}(N).$$
This is the whole algorithm. The function transition_sum_of_random_dice in C++ and transition_sum in Python/Java implement exactly these two formulas.
Step 3: A Recurrence for Each Layer
If the current stage has mean \(m\) and variance \(v\), and the next die has \(s\) sides, define
$$a_s=\frac{s+1}{2},\qquad b_s=\frac{s^2-1}{12}.$$
Then the next stage has
$$m' = a_s\,m,\qquad v' = b_s\,m + a_s^2 v.$$
The solution starts from \((m,v)=(5/2,5/4)\) for \(T\), then applies this update for \(s=6,8,12,20\).
Step 4: Compute \(C\), \(O\), \(D\), and \(I\)
For the 6-sided layer, \(a_6=7/2\) and \(b_6=35/12\). Therefore
$$\mathbb{E}[C]=\frac{7}{2}\cdot\frac{5}{2}=\frac{35}{4},$$
$$\operatorname{Var}(C)=\frac{35}{12}\cdot\frac{5}{2}+\left(\frac{7}{2}\right)^2\cdot\frac{5}{4}=\frac{1085}{48}.$$
For the 8-sided layer, \(a_8=9/2\) and \(b_8=21/4\):
$$\mathbb{E}[O]=\frac{9}{2}\cdot\frac{35}{4}=\frac{315}{8},$$
$$\operatorname{Var}(O)=\frac{21}{4}\cdot\frac{35}{4}+\left(\frac{9}{2}\right)^2\cdot\frac{1085}{48}=\frac{32235}{64}.$$
For the 12-sided layer, \(a_{12}=13/2\) and \(b_{12}=143/12\):
$$\mathbb{E}[D]=\frac{13}{2}\cdot\frac{315}{8}=\frac{4095}{16},$$
$$\operatorname{Var}(D)=\frac{143}{12}\cdot\frac{315}{8}+\left(\frac{13}{2}\right)^2\cdot\frac{32235}{64}=\frac{5567835}{256}.$$
For the 20-sided layer, \(a_{20}=21/2\) and \(b_{20}=133/4\):
$$\mathbb{E}[I]=\frac{21}{2}\cdot\frac{4095}{16}=\frac{85995}{32},$$
$$\operatorname{Var}(I)=\frac{133}{4}\cdot\frac{4095}{16}+\left(\frac{21}{2}\right)^2\cdot\frac{5567835}{256}=\frac{2464129395}{1024}.$$
So the requested variance is
$$\boxed{\operatorname{Var}(I)=\frac{2464129395}{1024}\approx 2406376.3623.}$$
Why the Checkpoint in the C++ Code Is Useful
The C++ version includes an explicit validation step. It computes the statistics of \(C\) in two independent ways:
First, it uses the moment formula above. Second, brute_one_layer_uniform_count(4, 6) constructs the full probability distribution when the number of dice is uniform on \(\{1,2,3,4\}\), by repeated convolution of one-die distributions. The program compares both mean and variance with a tight relative tolerance. This confirms that the recurrence is not merely plausible; it matches the exact distribution for the first nontrivial layer.
How the Code Works
The three implementations share the same mathematical core. C++ stores the pair \((\text{mean}, \text{variance})\) in a Stats struct using long double, then chains four calls to transition_sum_of_random_dice. Python and Java do the same update in a loop or sequence of assignments. All three versions finally format the variance to four decimal places, which yields 2406376.3623.
Complexity Analysis
The actual solver performs a fixed number of arithmetic operations, so its time complexity is \(O(1)\) and its memory usage is \(O(1)\). The optional C++ checkpoint also remains \(O(1)\) for this problem because it is hard-coded for the single small case \((4,6)\); it is a verification device, not part of the scalable algorithm.
Footnotes and References
- Problem page: https://projecteuler.net/problem=389
- Law of total expectation: Wikipedia — Law of total expectation
- Law of total variance: Wikipedia — Law of total variance
- Discrete uniform distribution: Wikipedia — Discrete uniform distribution
Problem 389 source code
C++
#include <cmath>
#include <iomanip>
#include <iostream>
#include <string>
#include <vector>
namespace {
struct Options {
bool run_checkpoints = true;
};
bool parse_arguments(int argc, char** argv, Options& options) {
for (int i = 1; i < argc; ++i) {
const std::string arg(argv[i]);
if (arg == "--skip-checkpoints") {
options.run_checkpoints = false;
continue;
}
std::cerr << "Unknown argument: " << arg << '\n';
return false;
}
return true;
}
struct Stats {
long double mean{0.0L};
long double variance{0.0L};
};
Stats uniform_die_stats(const int sides) {
const long double s = static_cast<long double>(sides);
return Stats{(s + 1.0L) / 2.0L, (s * s - 1.0L) / 12.0L};
}
Stats transition_sum_of_random_dice(const Stats& count_stats, const int sides) {
const Stats per_die = uniform_die_stats(sides);
const long double mean =
per_die.mean * count_stats.mean;
const long double variance =
per_die.variance * count_stats.mean +
per_die.mean * per_die.mean * count_stats.variance;
return Stats{mean, variance};
}
Stats brute_one_layer_uniform_count(const int max_count, const int sides) {
// N is uniform on {1..max_count}; then S is sum of N fair `sides`-sided dice.
const long double p_count = 1.0L / static_cast<long double>(max_count);
const int max_sum = max_count * sides;
std::vector<long double> total_dist(static_cast<std::size_t>(max_sum + 1), 0.0L);
for (int n = 1; n <= max_count; ++n) {
std::vector<long double> dist(1, 1.0L); // sum=0 before rolling.
for (int roll = 0; roll < n; ++roll) {
std::vector<long double> next(static_cast<std::size_t>(dist.size() + sides), 0.0L);
for (std::size_t sum = 0; sum < dist.size(); ++sum) {
const long double base = dist[sum] / static_cast<long double>(sides);
for (int face = 1; face <= sides; ++face) {
next[sum + static_cast<std::size_t>(face)] += base;
}
}
dist.swap(next);
}
for (std::size_t sum = 0; sum < dist.size(); ++sum) {
total_dist[sum] += p_count * dist[sum];
}
}
long double mean = 0.0L;
for (std::size_t sum = 0; sum < total_dist.size(); ++sum) {
mean += total_dist[sum] * static_cast<long double>(sum);
}
long double variance = 0.0L;
for (std::size_t sum = 0; sum < total_dist.size(); ++sum) {
const long double diff = static_cast<long double>(sum) - mean;
variance += total_dist[sum] * diff * diff;
}
return Stats{mean, variance};
}
bool nearly_equal(const long double a, const long double b, const long double rel_tol = 1e-14L) {
const long double scale = std::max(std::fabsl(a), std::fabsl(b));
if (scale == 0.0L) {
return true;
}
return std::fabsl(a - b) <= rel_tol * scale;
}
bool run_checkpoints() {
const Stats t = uniform_die_stats(4);
if (!nearly_equal(t.mean, 2.5L) || !nearly_equal(t.variance, 1.25L)) {
std::cerr << "Checkpoint failed for T\n";
return false;
}
const Stats formula_c = transition_sum_of_random_dice(t, 6);
const Stats brute_c = brute_one_layer_uniform_count(4, 6);
if (!nearly_equal(formula_c.mean, brute_c.mean) ||
!nearly_equal(formula_c.variance, brute_c.variance)) {
std::cerr << "Checkpoint failed for C (formula vs brute distribution)\n";
return false;
}
return true;
}
long double solve_variance() {
const Stats t = uniform_die_stats(4);
const Stats c = transition_sum_of_random_dice(t, 6);
const Stats o = transition_sum_of_random_dice(c, 8);
const Stats d = transition_sum_of_random_dice(o, 12);
const Stats i = transition_sum_of_random_dice(d, 20);
return i.variance;
}
} // namespace
int main(int argc, char** argv) {
Options options;
if (!parse_arguments(argc, argv, options)) {
return 1;
}
if (options.run_checkpoints && !run_checkpoints()) {
return 2;
}
std::cout << std::fixed << std::setprecision(4) << static_cast<double>(solve_variance()) << '\n';
return 0;
}
Python
def uniform_die_stats(sides):
s = float(sides)
return ((s + 1.0) / 2.0, (s * s - 1.0) / 12.0)
def transition_sum(count_mean, count_var, sides):
per_mean, per_var = uniform_die_stats(sides)
mean = per_mean * count_mean
var = per_var * count_mean + per_mean * per_mean * count_var
return (mean, var)
def solve():
mean, var = uniform_die_stats(4)
for sides in [6, 8, 12, 20]:
mean, var = transition_sum(mean, var, sides)
return "{:.4f}".format(var)
if __name__ == '__main__':
print(solve())
Java
public class Euler389 {
static class Stats {
double mean;
double variance;
Stats(double m, double v) {
mean = m;
variance = v;
}
}
private static Stats uniformDieStats(int sides) {
double s = sides;
return new Stats((s + 1.0) / 2.0, (s * s - 1.0) / 12.0);
}
private static Stats transitionSum(Stats countStats, int sides) {
Stats perDie = uniformDieStats(sides);
double mean = perDie.mean * countStats.mean;
double var = perDie.variance * countStats.mean + perDie.mean * perDie.mean * countStats.variance;
return new Stats(mean, var);
}
public static String solve() {
Stats s = uniformDieStats(4);
s = transitionSum(s, 6);
s = transitionSum(s, 8);
s = transitionSum(s, 12);
s = transitionSum(s, 20);
return String.format("%.4f", s.variance).replace(',', '.');
}
public static void main(String[] args) {
System.out.println(solve());
}
}