5

Variations on this are pretty common questions, but all my google-fu has left me stumped. I would like to calculate the odds of a fair dice roll, but I want to do it efficiently. There are lots of examples out there of how to do this, but all the algorithms I've found are too computationally expensive (exponential time) to work for large numbers of dice with many sides.

The Simple Problem: Calculate the odds of a roll of n, on x y sided dice.

The Simple Solution: Create the n-ary Cartesian product of the roll, sum each product, count the number of times the sum is the target, do a little division and voila.

Example of a Simple Solution in Go: https://play.golang.org/p/KNUS4YBQC0g

The Simple Solution works perfectly. I extended it to allow for cases like dropping the highest/lowest n faces, and the results hold up to spot testing.

But consider {Count: 20,Sides: 20,DropHighest: 0,DropLowest:0, Target: 200}.

If I evaluated that with the previous solution, my "table" would have 104 some odd septillion cells and will max the CPU pretty easily.

Is there a more efficient way to calculate probability for large numbers of dice with many sides? If so, can it account for more complex selection of "success" conditions like dropping some dice?

I'm convinced it's possible due to the existence of this beautiful website: https://anydice.com/program/969

EDIT:

The solution that worked best for me was David Eisenstat's answer, which I ported to go: https://play.golang.org/p/cpD51opQf5h

  • 2
    [The dice roll sum problem](https://www.lucamoroni.it/the-dice-roll-sum-problem/) gives a good explanation of the problem with N 6-sided dice. You should be able to generalize it to n-sided dice, provided you understand that article. Also see http://mathworld.wolfram.com/Dice.html, which the first article references. – Jim Mischel Jun 05 '18 at 00:35
  • whew! that's going to require some detailed study... thanks for the pointer. – Aaron Small Jun 05 '18 at 01:16
  • Yeah, it's a mouthful. But considering the alternative . . . – Jim Mischel Jun 05 '18 at 02:26
  • 1
    @AaronSmall The distribution of the sum of independent dice is the convolution of the distribution of the number on each die. In this case, the polynomial multiplication gives the same result as convolution, but convolution is the more general concept (and not any more complicated). See: https://en.wikipedia.org/wiki/Convolution – Robert Dodier Jun 05 '18 at 17:07
  • 1
    @JimMischel I dunno about https://www.lucamoroni.it/the-dice-roll-sum-problem/ , it seems needlessly complex. Just to get the sum of dice doesn't require all the combinatorics. – Robert Dodier Jun 05 '18 at 17:12
  • 1
    @RobertDodier It's a thorough review of the subject, with emphasis on understanding the math behind it. Yes, it's needlessly complex if all you want is a formula. – Jim Mischel Jun 05 '18 at 19:53
  • 1
    @AaronSmall can you help me understand how to intend to use DropHighest and DropLowest. Do you mean that faces higher than DropHighest and lower than DropLowest are excluded? Or that, of all the dice, whatever they are showing, the DropHighest highest ones are excluded, and the DropLowest lowest ones are excluded? (If it's the latter, how are ties handled?) Thanks for any info. – Robert Dodier Jun 05 '18 at 20:04
  • @RobertDodier, Drops represent counts of dropped dice, so it ties don't matter. 6d6-L == 6d6-L1 == "roll 6 6 sided dice, discard the lowest, and sum the remaining" means that one die will be dropped from the summation and it will have a face equal to the lowest face rolled in the set. 6d6-H2 == "roll 6 6 sided dice, discard the highest 2, and sum the remaining" means that 2 dice will be dropped from the summation. They will have a sum of faces equal to the sum of the highest 2 dice rolled in the set. – Aaron Small Jun 05 '18 at 20:14
  • 1
    @AaronSmall OK, do I understand correctly then that if 3 dice are tied for highest, let's say with value 5, then two of the 5's are removed, and one 5 remains in the ones that are added up? – Robert Dodier Jun 05 '18 at 23:31
  • @RobertDodier, that is correct. – Aaron Small Jun 05 '18 at 23:58

2 Answers2

4

Here's some code that handles dropping low and high rolls. Sorry for switching to Python, but I needed easy bignums and a memoization library to keep my sanity. I think the complexity is something like O(count^3 sides^2 drop_highest).

The way this code works is to divide the space of possibilities for rolling count dice each with sides sides by how many dice are showing the maximum number (count_showing_max). There are binomial(count, count_showing_max) ways to achieve such a roll on uniquely labeled dice, hence the multiplier. Given count_showing_max, we can figure out how many maxed dice get dropped for being high and how many get dropped for being low (it happens) and add this sum to the outcomes for the remaining dice.

#!/usr/bin/env python3
import collections
import functools
import math


@functools.lru_cache(maxsize=None)
def binomial(n, k):
    return math.factorial(n) // (math.factorial(k) * math.factorial(n - k))


@functools.lru_cache(maxsize=None)
def outcomes(count, sides, drop_highest, drop_lowest):
    d = collections.Counter()
    if count == 0:
        d[0] = 1
    elif sides == 0:
        pass
    else:
        for count_showing_max in range(count + 1):  # 0..count
            d1 = outcomes(count - count_showing_max, sides - 1,
                          max(drop_highest - count_showing_max, 0),
                          drop_lowest)
            count_showing_max_not_dropped = max(
                min(count_showing_max - drop_highest,
                    count - drop_highest - drop_lowest), 0)
            sum_showing_max = count_showing_max_not_dropped * sides
            multiplier = binomial(count, count_showing_max)
            for k, v in d1.items():
                d[sum_showing_max + k] += multiplier * v
    return d


def main(*args):
    d = outcomes(*args)
    denominator = sum(d.values()) / 100
    for k, v in sorted(d.items()):
        print(k, v / denominator)


if __name__ == '__main__':
    main(5, 6, 2, 2)
David Eisenstat
  • 64,237
  • 7
  • 60
  • 120
  • This is awesome, thank you. I will dig in today and try to port to go. I guess I'll have to build my own dynamic programming libraries... – Aaron Small Jun 05 '18 at 19:46
  • Your implementation is a thing of beauty. Porting to Go really made me miss Python :P https://play.golang.org/p/cpD51opQf5h – Aaron Small Jun 05 '18 at 23:55
  • 1
    @david-eisenstat I have to say I'm pretty impressed by your solution. I tinkered with this problem off and on for a month and eventually came up with a somewhat different solution, which is great, but I see you were able to pull together a brief, general solution very quickly. My hat's off to you, keep up the good work. – Robert Dodier Jul 10 '18 at 04:10
  • Thanks a lot! I replaced the binomial function by the standard `math.comb(n, k)` function. I also had to increase the recursion limit with `sys.setrecursionlimit(10000)` for my data. – e741af0d41bc74bf854041f1fbdbf Dec 13 '22 at 13:00
1

You can compute the distribution of sums of x y-sided dice by multiplying out the following polynomial in variable Z:

(Z + Z^2 + ... + Z^x)^y / x^y.

For example, for two six-sided dice:

(Z + Z^2 + ... + Z^6)^2 / 6^2
  = (Z + Z^2 + ... + Z^6) * (Z + Z^2 + ... + Z^6) / 36
  = (Z^2 + 2Z^3 + 3Z^4 + 4Z^5 + 5Z^6 + 6Z^7 + 5Z^8 + 4Z^9 + 3Z^10 + 2Z^11 + Z^12) / 36,

so you can read off the probability of getting sum 6 as the coefficient of Z^6 (5/36).

For three "two-sided" dice:

(Z + Z^2)^3 / 2^3 = (Z + Z^2) * (Z + Z^2) * (Z + Z^2) / 8
                  = (Z^2 + 2Z^3 + Z^4) (Z + Z^2) / 8
                  = (Z^3 + 3Z^4 + 3Z^5 + Z^6) / 8,

so the probability of getting sum 4 is the coefficient of Z^4 (3/8).

You can use the school algorithm to get a polynomial algorithm for this problem. Lightly tested Go code:

package main

import "fmt"

func dieRolls(x, y int) map[int]float64 {
    d := map[int]float64{0: 1.0}
    for i := 0; i < x; i++ {
        d1 := make(map[int]float64)
        for j := 1; j <= y; j++ {
            for k, v := range d {
                d1[k+j] += v / float64(y)
            }
        }
        d = d1
    }
    return d
}

func main() {
    for k, v := range dieRolls(2, 6) {
        fmt.Printf("%d %g\n", k, v)
    }
    fmt.Printf("\n")
    for k, v := range dieRolls(3, 2) {
        fmt.Printf("%d %g\n", k, v)
    }
}

Run the code: https://play.golang.org/p/O9fsWy6RZKL

stevenferrer
  • 2,504
  • 1
  • 22
  • 33
David Eisenstat
  • 64,237
  • 7
  • 60
  • 120
  • This seems to be what most of what I asked for. I'm still a little puzzled on how it works, so I'm aggressively renaming variables to try to understand. I imagine that if I sum the probabilities between the target and the max I will get the probability of exceeding the target and hat the inverse holds true for getting less than the target. I'm still stumped on how I change the distribution to account for "Drop Highest" and "Drop Lowest" – Aaron Small Jun 05 '18 at 16:36
  • 1
    @AaronSmall We're figuring out the probability distribution for 1 y-sided die, then 2 y-sided dice, then 3, etc. We can imagine rolling x y-sided dice as rolling a y-sided die and a weirdo composite die that represents the sum of (x-1) y-sided dice. If the composite rolled a (say) 4, it doesn't matter if it was 1+3 or 2+2 or 3+1 -- these possibilities are all equivalent in the final accounting. – David Eisenstat Jun 05 '18 at 17:03
  • That seems perfectly true, except I want to support the case where the combinations do matter so that I can only select a subset of the dice rolled. A common RPG scenario would be "roll 4d6-L" or "Roll 4 6 sided dice, discard the lowest face, sum the remaining." Here is an example: https://anydice.com/program/1033e In that case, I'm not sure how to proceed. – Aaron Small Jun 05 '18 at 18:44
  • 1
    @AaronSmall The combinatorics get significantly more complicated. Let me think about how to write it up. – David Eisenstat Jun 05 '18 at 18:51