Problem 205: Dice Game
View on Project EulerProject Euler Problem 205 Solution
EulerSolve provides an optimized solution for Project Euler Problem 205, Dice Game, with C++, Python, Java, and a step-by-step mathematical explanation.
Problem Summary Pete rolls nine 4-sided dice, so his total lies between \(9\) and \(36\). Colin rolls six 6-sided dice, so his total lies between \(6\) and \(36\). The task is to compute the exact probability that Pete's total is strictly larger than Colin's total. A naive enumeration would reason about all \(4^9 \cdot 6^6\) joint outcomes separately. The key simplification is that the full roll sequence does not matter once its sum is known, so the problem can be compressed into two exact sum distributions and one final comparison of those distributions. Mathematical Approach The natural state space is not the list of individual face results but the running sum after a given number of dice. That is exactly what the implementations count. Exact State for One Player For \(n\) dice with \(s\) sides, let \(D_i(t)\) denote the number of ordered outcomes of the first \(i\) dice whose sum is \(t\). The base state is $$D_0(0)=1,\qquad D_0(t)=0 \text{ for } t\neq 0.$$ After \(i\) dice, the only possible sums satisfy \(i \le t \le is\). Another invariant is $$\sum_t D_i(t)=s^i,$$ because \(D_i\) counts all ordered outcomes of \(i\) independent \(s\)-sided dice....
Detailed mathematical approach
Problem Summary
Peter rolls nine four-sided dice with faces numbered \(1\) to \(4\), and Colin rolls six six-sided dice with faces numbered \(1\) to \(6\). The higher total wins, and equal totals are a draw. We must find the probability that Peter's total is strictly greater than Colin's, rounded to seven decimal places in the form 0.abcdefg. [1] As usual, all faces are equally likely and the dice independent.
The whole solution in one sentence: for each player we count, die by die, in how many ways each total can be rolled, then add the products of the two counts over all pairs of totals in which Peter's is larger, and divide by the number \(4^9\cdot6^6\) of equally likely outcomes.
Mathematical Approach
A roll of \(n\) dice with \(s\) sides is the ordered list \((f_1,\dots,f_n)\) of the faces shown.
1. The smallest game
Let Peter roll one four-sided die and Colin one six-sided die. The \(4\cdot6=24\) pairs of faces are equally likely, so the probability of an event is the number of pairs in which it happens divided by \(24\), the classical definition of probability. [2] Peter wins with the \(6\) pairs \((2,1)\), \((3,1)\), \((3,2)\), \((4,1)\), \((4,2)\), \((4,3)\), the \(4\) pairs \((1,1),\dots,(4,4)\) are draws, and Colin wins with the other \(14\), so Peter wins with probability \(6/24=1/4\). The C++ program checks this value before it solves the real game.
2. Only the totals matter
In the real game an outcome is the list of all fifteen faces, and by the rule of product [3] there are
$$\Omega=4^9\cdot6^6=262144\cdot46656=12\,230\,590\,464$$
equally likely outcomes. Only the two totals decide the game, and Peter has just \(28\) possible totals, \(9\) to \(36\), and Colin \(31\), \(6\) to \(36\). Write \(N_{n,s}(t)\) for the number of rolls of \(n\) dice with \(s\) sides whose total is \(t\), and put \(A(p)=N_{9,4}(p)\) and \(B(c)=N_{6,6}(c)\). Any of Peter's \(A(p)\) rolls with total \(p\) can be combined with any of Colin's \(B(c)\) rolls with total \(c\), so by the rule of product \(A(p)\,B(c)\) outcomes have these two totals. Peter therefore wins in
$$W=\sum_{p\gt c}A(p)\,B(c)$$
outcomes, with probability \(W/\Omega\).
3. Adding one die at a time
For two four-sided dice the total \(5\) comes from the rolls \((1,4)\), \((2,3)\), \((3,2)\), \((4,1)\), so \(N_{2,4}(5)=4\), and the totals \(2,\dots,8\) have the counts \(1,2,3,4,3,2,1\). A roll of three dice with total \(7\) is a roll of two dice with total \(7-f\) followed by a last face \(f\), so \(N_{3,4}(7)=3+4+3+2=12\), the sum of the counts of the totals \(6\), \(5\), \(4\) and \(3\).
Claim. \(N_{0,s}(0)=1\), \(N_{0,s}(t)=0\) for \(t\ne0\), and for every \(n\ge0\) and every integer \(t\)
$$N_{n+1,s}(t)=\sum_{f=1}^{s}N_{n,s}(t-f).$$
Proof. The only roll of no dice is the empty list, whose total is \(0\). Cutting off the last face maps the rolls of \(n+1\) dice with total \(t\) and last face \(f\) one to one onto the rolls of \(n\) dice with total \(t-f\), and appending \(f\) reverses it. So there are \(N_{n,s}(t-f)\) such rolls, and adding over \(f=1,\dots,s\) proves the claim. \(\square\)
The recurrence is a convolution: the distribution of a sum of independent variables is the convolution of their distributions. [4] Computing the tables for \(1,2,3,\dots\) dice, each from the one before, is dynamic programming. [5] By induction, \(N_{n,s}(t)\ne0\) exactly for \(n\le t\le ns\), and the counts add up to \(s^n\). Replacing every face \(f\) by \(s+1-f\) maps rolls one to one and the total \(t\) to \(n(s+1)-t\), so every table is symmetric:
$$N_{n,s}(t)=N_{n,s}\bigl(n(s+1)-t\bigr).$$
For three four-sided dice the totals \(3,\dots,12\) have the counts \(1,3,6,10,12,12,10,6,3,1\), and for two six-sided dice the totals \(2,\dots,12\) have the counts \(1,2,3,4,5,6,5,4,3,2,1\).
4. A closed formula for one count
Multiplying out \((x+x^2+\dots+x^s)^n\) gives one term \(x^{f_1+\dots+f_n}\) per roll; for two two-sided dice, \((x+x^2)^2=x^2+2x^3+x^4\), where the \(2\) counts the rolls \((1,2)\) and \((2,1)\). So \(N_{n,s}(t)\) is the coefficient of \(x^t\), and expanding the polynomial as \(x^n(1-x^s)^n(1-x)^{-n}\) with the binomial theorem and the binomial series gives, for \(n\ge1\) and \(t\ge0\), [6]
$$\begin{aligned}N_{n,s}(t)&=\sum_{k=0}^{K}(-1)^k\binom{n}{k}\binom{t-sk-1}{n-1},\\K&=\lfloor (t-n)/s\rfloor.\end{aligned}$$
For Colin's total \(21\) the terms are \(\binom{20}{5}=15504\), \(6\binom{14}{5}=12012\) and \(15\binom{8}{5}=840\), so \(N_{6,6}(21)=15504-12012\)\({}+840=4332\). The programs do not use this formula, but it checks their tables.
5. The two tables of the real game
Nine steps of the recurrence give Peter's table: totals \(9\) to \(36\), counts adding up to \(4^9=262144\), symmetric about \(22.5\), rising from \(A(9)=1\) and \(A(10)=9\) to the largest counts \(A(22)=A(23)=30276\). Six steps give Colin's table: totals \(6\) to \(36\), counts adding up to \(6^6=46656\), symmetric about \(21\), rising from \(B(6)=1\) and \(B(7)=6\) to the largest count \(B(21)=4332\).
6. From two tables to one count
Grouping the terms of \(W\) by Peter's total, the inner sum is the number \(L(p)\) of Colin's rolls with a total below \(p\):
$$L(p)=\sum_{c\lt p}B(c),\qquad W=\sum_{p=9}^{36}A(p)\,L(p).$$
The draws, \(A(p)\,B(p)\) outcomes for each \(p\), are left out because the inequality is strict. The programs do not form \(L(p)\): they add \(A(p)\,B(c)\) for every pair with \(p\gt c\), which gives the same sum.
7. Worked example: three dice against two
Three four-sided dice against two six-sided dice is the real game with a third of the dice, and again both players can reach the same maximum, \(12\). With \(A=N_{3,4}\) and \(B=N_{2,6}\) from Section 3, the numbers \(L(p)\) for \(p=3,\dots,12\) are \(1,3,6,10,15,21,26,30,33,35\), and
$$\begin{aligned}W&=1\cdot1+3\cdot3+6\cdot6+10\cdot10\\&\quad+12\cdot15+12\cdot21+10\cdot26\\&\quad+6\cdot30+3\cdot33+1\cdot35=1152\end{aligned}$$
of the \(4^3\cdot6^2=2304\) outcomes, so Peter wins with probability exactly \(1/2\). The draws add up to \(2+9+24+50+72\)\({}+60+40+18+6+1\)\({}=282\), so Colin wins in the other \(870\) outcomes. A listing of all \(2304\) outcomes confirms the three counts.
8. Why the example gives exactly one half
Let \(X\) and \(Y\) be the two totals and \(Z=X-Y\). Turning each face \(f\) of a four-sided die into \(5-f\) and of a six-sided die into \(7-f\) maps outcomes one to one. In the example it sends \(X\) to \(15-X\) and \(Y\) to \(14-Y\), hence \(Z\) to \(1-Z\), so it matches Peter's wins, \(Z\ge1\), with the outcomes where \(1-Z\ge1\), that is \(Z\le0\); as \(Z\) is an integer, these are all the others. In the real game it sends \(Z\) to \((45-X)-(42-Y)=3-Z\) and matches the outcomes with \(Z\ge2\) with those with \(Z\le1\). Each kind is again half of all outcomes, and adding the case \(Z=1\) gives
$$\Pr(Z\ge1)=\tfrac12+\Pr(Z=1).$$
Since \(\Pr(Z=1)\) still needs both tables, this identity checks the computation rather than replacing it.
9. Exact counts and seven correct decimals
All counts are integers, and every partial sum formed in the computation is at most \(\Omega\lt2^{53}\). A double has a 53-bit significand, so it holds every integer up to \(2^{53}\) exactly, and in IEEE 754 arithmetic a division is correctly rounded. [7] The sums are therefore exact, and the computed quotient differs from \(W/\Omega\) by less than \(10^{-15}\). This matters only if \(W/\Omega\) lies very close to a midpoint between consecutive multiples of \(10^{-7}\), which integers alone can rule out. Write
$$10^7\,W=Q\,\Omega+R,\qquad 0\le R\lt\Omega.$$
The nearest midpoint is \((Q+\tfrac12)\cdot10^{-7}\), at distance \(|2R-\Omega|/(2\cdot10^7\,\Omega)\) from \(W/\Omega\). For the real game the test \(5\,|2R-\Omega|\gt\Omega\) holds, so this distance exceeds \(10^{-8}\), and the seven printed decimals are those of the exact probability, correctly rounded.
How the Code Works
The C++ program [8] keeps the parameters, by default \(9\), \(4\), \(6\) and \(6\), and a checkpoint switch in the struct Options. parse_arguments accepts --skip-checkpoints and the options --pete-dice=, --pete-sides=, --colin-dice=, --colin-sides=, whose digits parse_int_after_prefix reads into an int without an overflow check; on anything else, or on fewer than one die or two sides, the program stops with exit code \(1\). Unless switched off, run_checkpoints requires win_probability(1, 4, 1, 6) to lie within \(10^{-15}\) of \(0.25\), the game of Section 1; otherwise the exit code is \(2\).
distribution(dice, sides) implements Section 3 with a std::vector<long double> named dp of dice * sides + 1 entries, starting from dp[0] = 1. For each die it fills a fresh vector next, adding every nonzero count dp[sum] to next[sum + face] for face from \(1\) to sides; then next replaces dp. Before die \(i+1\) the nonzero counts lie at the totals \(i\) to \(is\), so no index leaves the vector. win_probability builds both tables, adds pete[p] * colin[c] for every pair of totals with nonzero counts to total, and also to wins when p > c, and returns wins / total, which is \(W/\Omega\) because total ends at \(\Omega\). main prints it as a double with std::fixed and std::setprecision(7).
The Python and Java programs fix the parameters at \((9,4)\) and \((6,6)\), have no options or checkpoint, and test s + f against the array length before each addition, a test that never fails. C++ counts in long double, at least as precise as double, [7] Java in double and Python in exact integers, so for this game every count and sum is exact in all three, and the only rounding is the final division and, in C++, the conversion to double. Python prints with f"{wins/total:.7f}" and Java with System.out.printf("%.7f%n", ...), which formats in the default locale; [9] under a locale with a decimal comma, such as German, Java therefore prints a comma instead of the point.
Complexity and Verification
For \(n\) dice with \(s\) sides, distribution makes \(n\) passes over \(ns+1\) totals, and pass \(i=0,\dots,n-1\) makes \(s\) additions for each of its \(i(s-1)+1\) nonzero counts: \(s\bigl(n+(s-1)n(n-1)/2\bigr)\) additions, \(468\) for Peter and \(486\) for Colin. The final double loop multiplies the \(28\cdot31=868\) pairs of nonzero counts, so a few thousand operations replace the \(1.2\cdot10^{10}\) outcomes of Section 2. In general, with \(m\) dice of \(r\) sides for Colin and each arithmetic operation counted as one step, the program takes \(O(n^2s^2+m^2r^2+nmsr)\) steps and \(O(ns+mr)\) memory.
The C++ checkpoint is the game of Section 1. The following checks were made for this article and are not part of the published solutions. Enumerating all \(262144\) rolls of Peter and \(46656\) of Colin reproduces the tables of Section 5, and the formula of Section 4 agrees with them. Listing all outcomes of five small games, among them those of Sections 1 and 7, confirms their wins, draws and losses. For all \(625\) games with one to five dice of two to six sides per player, the C++ program printed the exact probability rounded to seven decimals, or one of the two nearest values in the \(14\) games where it lies exactly halfway; modified copies of the Python and Java programs give \(0.5000000\) for Section 7. For the real game, exact rational arithmetic confirms the identity of Section 8 and the test of Section 9, and all three programs print the same seven decimals, \(W/\Omega\) correctly rounded.
Footnotes and References
The references below supply the background facts used above; the recurrence, the examples, the symmetry identity and the operation counts are derived here.
- Project Euler 205 — Dice Game. The official statement gives Peter nine four-sided and Colin six six-sided dice, lets the higher total win, with equal totals a draw, and asks for the probability that Peter wins, to seven decimal places.
- Wikipedia — Classical definition of probability. Laplace defined the probability of an event as the ratio of the number of cases favourable to it to the number of all cases, when these are equally possible.
- Wikipedia — Rule of product. If there are a ways of doing one thing and b ways of doing another, there are a · b ways of doing both.
- Wikipedia — Convolution of probability distributions. The distribution of a sum of independent random variables is the convolution of their distributions; for integer-valued X and Y, P(X + Y = z) = Σₖ P(X = k) P(Y = z − k).
- Wikipedia — Dynamic programming. Dynamic programming breaks a problem into simpler subproblems, solves each once and builds larger solutions from smaller ones.
- MathWorld — Dice. The number of ways to roll p points with n s-sided dice is the coefficient of xᵖ in (x + x² + ⋯ + xˢ)ⁿ; expanding it gives the alternating sum of Section 4, after Uspensky (1937).
- Wikipedia — Double-precision floating-point format. The binary64 format has a 53-bit significand and represents every integer from −2⁵³ to 2⁵³ exactly. IEEE 754 operations are correctly rounded unless specified otherwise, and the C++ working draft, [basic.fundamental], makes long double at least as precise as double.
- C++ — Euler205.cpp. The linked immutable revision contains
parse_arguments,distribution,win_probabilityandrun_checkpoints. It is the implementation record for this article. - Java — java.io.PrintStream. printf behaves like format, which always uses the default FORMAT locale; java.util.Formatter then substitutes a locale-specific decimal separator.
Problem 205 source code
C++
#include <cmath>
#include <iomanip>
#include <iostream>
#include <string>
#include <vector>
namespace {
struct Options {
int pete_dice = 9;
int pete_sides = 4;
int colin_dice = 6;
int colin_sides = 6;
bool run_checkpoints = true;
};
bool parse_int_after_prefix(const std::string& arg, const std::string& prefix, int& value) {
if (arg.rfind(prefix, 0U) != 0U) {
return false;
}
const std::string tail = arg.substr(prefix.size());
if (tail.empty()) {
return false;
}
int parsed = 0;
for (char c : tail) {
if (c < '0' || c > '9') {
return false;
}
parsed = parsed * 10 + static_cast<int>(c - '0');
}
value = parsed;
return 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;
}
if (parse_int_after_prefix(arg, "--pete-dice=", options.pete_dice) ||
parse_int_after_prefix(arg, "--pete-sides=", options.pete_sides) ||
parse_int_after_prefix(arg, "--colin-dice=", options.colin_dice) ||
parse_int_after_prefix(arg, "--colin-sides=", options.colin_sides)) {
continue;
}
std::cerr << "Unknown argument: " << arg << '\n';
return false;
}
return options.pete_dice >= 1 && options.colin_dice >= 1 &&
options.pete_sides >= 2 && options.colin_sides >= 2;
}
std::vector<long double> distribution(const int dice, const int sides) {
std::vector<long double> dp(static_cast<std::size_t>(dice * sides + 1), 0.0L);
dp[0] = 1.0L;
for (int i = 0; i < dice; ++i) {
std::vector<long double> next(static_cast<std::size_t>(dice * sides + 1), 0.0L);
for (std::size_t sum = 0; sum < dp.size(); ++sum) {
const long double ways = dp[sum];
if (ways == 0.0L) {
continue;
}
for (int face = 1; face <= sides; ++face) {
next[sum + static_cast<std::size_t>(face)] += ways;
}
}
dp = std::move(next);
}
return dp;
}
long double win_probability(const int pete_dice,
const int pete_sides,
const int colin_dice,
const int colin_sides) {
const std::vector<long double> pete = distribution(pete_dice, pete_sides);
const std::vector<long double> colin = distribution(colin_dice, colin_sides);
long double wins = 0.0L;
long double total = 0.0L;
for (std::size_t p = 0; p < pete.size(); ++p) {
if (pete[p] == 0.0L) {
continue;
}
for (std::size_t c = 0; c < colin.size(); ++c) {
if (colin[c] == 0.0L) {
continue;
}
const long double ways = pete[p] * colin[c];
total += ways;
if (p > c) {
wins += ways;
}
}
}
return wins / total;
}
bool run_checkpoints() {
if (std::abs(win_probability(1, 4, 1, 6) - 0.25L) > 1e-15L) {
std::cerr << "Checkpoint failed for 1d4 vs 1d6" << '\n';
return false;
}
return true;
}
} // 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(7)
<< static_cast<double>(win_probability(options.pete_dice,
options.pete_sides,
options.colin_dice,
options.colin_sides))
<< '\n';
return 0;
}
Python
# Problem 205: Dice Game
# Pete has 9 four-sided dice, Colin has 6 six-sided dice.
# What is the probability that Pete beats Colin? (7 decimal places)
def solve():
def distribution(dice, sides):
dp = [0] * (dice * sides + 1)
dp[0] = 1
for _ in range(dice):
nxt = [0] * (dice * sides + 1)
for s in range(len(dp)):
if dp[s] == 0: continue
for f in range(1, sides+1):
if s + f < len(nxt):
nxt[s+f] += dp[s]
dp = nxt
return dp
pete = distribution(9, 4)
colin = distribution(6, 6)
wins = 0
total = 0
for p in range(len(pete)):
if pete[p] == 0: continue
for c in range(len(colin)):
if colin[c] == 0: continue
ways = pete[p] * colin[c]
total += ways
if p > c:
wins += ways
print(f"{wins/total:.7f}")
solve()
Java
public class Euler205 {
public static void main(String[] args) {
double[] pete = distribution(9, 4);
double[] colin = distribution(6, 6);
double wins = 0, total = 0;
for (int p = 0; p < pete.length; p++) {
if (pete[p] == 0)
continue;
for (int c = 0; c < colin.length; c++) {
if (colin[c] == 0)
continue;
double ways = pete[p] * colin[c];
total += ways;
if (p > c)
wins += ways;
}
}
System.out.printf("%.7f%n", wins / total);
}
static double[] distribution(int dice, int sides) {
double[] dp = new double[dice * sides + 1];
dp[0] = 1;
for (int i = 0; i < dice; i++) {
double[] nxt = new double[dp.length];
for (int s = 0; s < dp.length; s++) {
if (dp[s] == 0)
continue;
for (int f = 1; f <= sides; f++)
if (s + f < nxt.length)
nxt[s + f] += dp[s];
}
dp = nxt;
}
return dp;
}
}