Using @Henry's suggestion of recursion over the dice, below is an R function that will return the full PMF of the sum resulting from rolling a set of fair dice with number of faces of each die given by the vector d
.
The function manages combinatorial explosion by summing the probability by each possible cumulative die roll at each iteration. So the length of the PMF after iteration $i$ is:
$$1-i+\sum_{j=1}^id_j$$
I also provide a function employing the fast Fourier transform.
Recursive Algorithm
To illustrate the recursive algorithm, consider rolling three 4-sided dice. Initialize the result by starting with the first die roll.
d <- rep(4, 3) # 3d4
v <- 1:d[1] # initialize the total value of the rolls
p <- rep(1/d[1], d[1]) # the probability of each value
Calculate all possible results of adding the first roll to the second roll and their probabilities.
(mv <- outer(v, 1:d[2], "+"))
#> [,1] [,2] [,3] [,4]
#> [1,] 2 3 4 5
#> [2,] 3 4 5 6
#> [3,] 4 5 6 7
#> [4,] 5 6 7 8
(mp <- outer(p, rep(1/d[2], d[2])))
#> [,1] [,2] [,3] [,4]
#> [1,] 0.0625 0.0625 0.0625 0.0625
#> [2,] 0.0625 0.0625 0.0625 0.0625
#> [3,] 0.0625 0.0625 0.0625 0.0625
#> [4,] 0.0625 0.0625 0.0625 0.0625
Aggregate probabilities by value to get the PMF of the first two rolls.
(p <- sapply(split(mp, mv), sum))
#> 2 3 4 5 6 7 8
#> 0.0625 0.1250 0.1875 0.2500 0.1875 0.1250 0.0625
(v <- as.integer(names(p)))
#> [1] 2 3 4 5 6 7 8
Repeat the process by adding the result of the third roll to the first two.
(mv <- outer(v, 1:d[3], "+"))
#> [,1] [,2] [,3] [,4]
#> [1,] 3 4 5 6
#> [2,] 4 5 6 7
#> [3,] 5 6 7 8
#> [4,] 6 7 8 9
#> [5,] 7 8 9 10
#> [6,] 8 9 10 11
#> [7,] 9 10 11 12
(mp <- outer(p, rep(1/d[3], d[3])))
#> [,1] [,2] [,3] [,4]
#> 2 0.015625 0.015625 0.015625 0.015625
#> 3 0.031250 0.031250 0.031250 0.031250
#> 4 0.046875 0.046875 0.046875 0.046875
#> 5 0.062500 0.062500 0.062500 0.062500
#> 6 0.046875 0.046875 0.046875 0.046875
#> 7 0.031250 0.031250 0.031250 0.031250
#> 8 0.015625 0.015625 0.015625 0.015625
(p <- sapply(split(mp, mv), sum))
#> 3 4 5 6 7 8 9 10
#> 0.015625 0.046875 0.093750 0.156250 0.187500 0.187500 0.156250 0.093750
#> 11 12
#> 0.046875 0.015625
(v <- as.integer(names(p)))
#> [1] 3 4 5 6 7 8 9 10 11 12
Notice that the probability matrix is constant across each row, so we can use cumsum
instead of outer
to speed up the algorithm. We can also wait until the end to generate the vector v
.
droll <- function(d) {
d <- sort(d, TRUE)
p <- rep(1/d[1], d[1])
if (length(d) > 1L) {
for (x in d[-1]) {
cs <- cumsum(p/x)
k <- length(p) - x
p <- c(
cs[1:x],
cs[seq.int(x + 1, length.out = k)] - cs[seq_len(k)],
cs[length(p)] - cs[(k + 1):(length(p) - 1)]
)
}
}
list(v = length(d):sum(d), p = p)
}
Testing with four 8-sided and two 10-sided dice:
# 4d8 + 2d10
with(
droll(rep.int(c(8, 10), c(4, 2))),
barplot(p, names.arg = v, main = "4d8 + 2d10")
)
A larger test:
# 24d6 + 16d8 + 17d10 + 9d12 + 11d20
with(
droll(rep.int(c(6, 8, 10, 12, 20), c(24, 16, 17, 9, 11))),
barplot(p, names.arg = v, main = "24d6 + 16d8 + 17d10 + 9d12 + 11d20")
)
Fast Fourier Transform
If many of the dice share the same number of sides, the fast Fourier transform is more performant still, requiring a single transform for each denomination of dice and one inverse transform.
droll.fft <- function(d) {
n <- table(d)
d <- as.integer(names(n))
maxroll <- sum(n*d)
r <- maxroll - sum(n) + 1
N <- as.integer(2^ceiling(log2(r)))
p <- fft(rep.int(c(1/d[1], 0), c(d[1], N - d[1])))^n[1]
if (length(d) > 1L) {
for (i in 2:length(d)) {
p <- p*fft(rep.int(c(1/d[i], 0), c(d[i], N - d[i])))^n[i]
}
}
list(v = sum(n):maxroll, p = Re(fft(p, TRUE)[1:r])/N)
}
microbenchmark::microbenchmark(
droll = droll(rep.int(c(6, 8, 10, 12, 20), c(24, 16, 17, 9, 11))),
droll.fft = droll.fft(rep.int(c(6, 8, 10, 12, 20), c(24, 16, 17, 9, 11))),
check = "equal"
)
#> Unit: microseconds
#> expr min lq mean median uq max neval
#> droll 658.6 853.90 1201.909 949.20 1039.35 9536.1 100
#> droll.fft 306.6 332.45 356.929 346.85 372.40 576.2 100