11

I'm using the standard Fisher-Yates algorithm to randomly shuffle a deck of cards in an array. However, I'm unsure if this will actually produce a true distribution of all possible permutations of a real-world shuffled deck of cards.

V8's Math.random only has 128-bits of internal state. Since there are 52 cards in a deck, 52 factorial would require 226-bits of internal state to generate all possible permutations.

However, I'm unsure if this applies when using Fisher-Yates since you aren't actually generating each possible but just getting one position randomly out of 52.

function shuffle(array) {
  var m = array.length, t, i;

  while (m) {
    i = Math.floor(Math.random() * m--);
    t = array[m];
    array[m] = array[i];
    array[i] = t;
  }

  return array;
}
James Simpson
  • 13,488
  • 26
  • 83
  • 108
  • Correct. Each call to random generates the position of one card, not the state of the entire deck. – James Aug 02 '19 at 20:10
  • Doesn't it get biased over time if you don't have enough bits for the full state? – James Simpson Aug 02 '19 at 21:04
  • Yep. I was wrong and @Bergi explains well why. – James Aug 04 '19 at 09:58
  • I'm trying to figure out where the number 226 comes from. If we number all the permutations, this seems to be the same problem as choosing a number between 1 and 52!. And `ceil(log(52!)/log(2))` is only 220. What are the other 6 bits for? Are we adding `ceil(log(52)/log(2))` bits for some reason? – Wyck Aug 12 '19 at 04:02

3 Answers3

4

In general, if a pseudorandom number generator (PRNG) admits fewer than 52 factorial different seeds, then there are some permutations that particular PRNG can't choose when it shuffles a 52-item list, and Fisher-Yates can't change that. (The set of permutations a particular PRNG can choose can be different from the set of permutations another PRNG can choose, even if both PRNGs are initialized with the same seed. Nothing is said here about PRNGs that admit as many or more seeds than the number of permutations or whether the permutations the PRNG can choose have an equal probability of occurring, as opposed to an ideal process of generating perfectly independent uniform random integers.) See also this question.

Note that although the Math.random algorithm used by V8 admits any of about 2^128 seeds at the time of this writing, no particular random number algorithm is mandated by the ECMAScript specification of Math.random, which states only that that method uses an "implementation-dependent algorithm or strategy" to generate random numbers (see ECMAScript sec. 20.2.2.27).


A PRNG's period can be extended with the Bays-Durham shuffle, which effectively increases that PRNG's state length (see Severin Pappadeux's answer). However, if you merely initialize the Bays-Durham table entries with outputs of the PRNG (rather than use the seed to initialize those entries), it will still be the case that that particular PRNG (which includes the way in which it initializes those entries and selects those table entries based on the random numbers it generates) can't choose more permutations than the number of possible seeds to initialize its original state, because there would be only one way to initialize the Bays-Durham entries for a given seed — unless, of course, the PRNG actually shuffles an exorbitant amount of lists, so many that it generates more random numbers without cycling than it otherwise would without the Bays-Durham shuffle.

For example, if the PRNG is 128 bits long, there are only 2^128 possible seeds, so there are only 2^128 ways to initialize the Bays-Durham shuffle, one for each seed, unless a seed longer than 128 bits extends to the Bays-Durham table entries and not just the PRNG's original state. (This is not to imply that the set of permutations that PRNG can choose is always the same no matter how it selects table entries in the Bays-Durham shuffle.)

Peter O.
  • 32,158
  • 14
  • 82
  • 96
  • Well, state (and period) could be easily extended – Severin Pappadeux Aug 04 '19 at 02:16
  • @Severin Pappadeux : Edited. – Peter O. Aug 04 '19 at 02:31
  • This is really helpful, though I'm still not sure of something. Is it not possible to do this in JavaScript? – James Simpson Aug 06 '19 at 19:55
  • You will have to find a JavaScript implementation of a PRNG with a state length of 226 bits or more and [supply a seed to that PRNG](https://stackoverflow.com/questions/521295). It's outside the scope of this question to [recommend specific PRNGs](https://peteroupc.github.io/random.html) or describe how to generate a suitable seed for that PRNG. In general, though, most PRNG implementations in JavaScript are not appropriate for information security, unlike the methods in `window.crypto` or `crypto`, which include a cryptographic random number generator. – Peter O. Aug 06 '19 at 23:01
  • `because there would be only one way to initialize the Bays-Durham entries for a given seed.` This is, of course, not correct. B-D shuflle implementation could use different strategies for indices to deal with the table. Basically, if you have 128 entries table, if you pick 7bits for index from the start of the random value. Or you could pick 7bits from the end of the value, or you could pick from the middle, or you could revert it, etc. Each of those possibilities will generate different extended sequences. – Severin Pappadeux Aug 07 '19 at 01:39
  • @SeverinPappadeux: The strategies you mention merely affect how to select a Bays-Durham table entry _after the table is initialized_. Each of these strategies results in a separate PRNG (for example, a PRNG in which Bays-Durham table entries are selected from the first 7 bits of each random number, and the same PRNG in which those entries are selected from the last 7 bits, are two separate PRNGs). Thus this doesn't change the conclusion of my answer, which applies to a particular PRNG implemented in a particular way. – Peter O. Aug 07 '19 at 04:33
  • Strategies I mentioned affects how WHOLE SEQUENCE would be generated even if you initialized original V8 RNG with EXACTLY THE SAME 128bits seed. It means state of the B-D shuffled RNG is (partially) encoded in the algorithm /code itself, making B-D shuffled V8 RNG state a LOT BIGGER than 128bits you're implying – Severin Pappadeux Aug 07 '19 at 19:02
  • Of if you want to express B-D config as data, imagine 226bits mask with 7 ones and 219 zeros, and ones could be anywhere and used as positions to pick bits to form index for 128 entries B-D table. Code would be fixed, but each different permutation of above mentioned mask will produce different B-D shuffled sequence. – Severin Pappadeux Aug 07 '19 at 19:11
  • @SeverinPappadeux: Clarified my answer. – Peter O. Aug 08 '19 at 02:12
  • Well, just to demonstrate how it would work, I've implemented XorShift128+ as well as B-D shuffle with clear intent to show how seed to be extended as well. Please check my answer – Severin Pappadeux Aug 12 '19 at 03:36
2

You are right. With 128 bits of starting state, you can only generate at most 2128 different permutations. It doesn't matter how often you use this state (call Math.random()), the PRNG is deterministic after all.

Where the number of calls to Math.random() actually matter is when

  • each call would draw some more entropy (e.g. from hardware random) into the system, instead of relying on the internal state that is initialised only once
  • the entropy of a single call result is so low that you don't use the entire internal state over the run of the algorithm
Bergi
  • 630,263
  • 148
  • 957
  • 1,375
1

Well, you definitely need RNG with 226bits period for all permutation to be covered, @PeterO answer is correct in this regard. But you could extend period using Bays-Durham shuffle, paying by effectively extending state of RNG. There is an estimate of the period of the B-D shuffled RNG and it is

P = sqrt(Pi * N! / (2*O))

where Pi=3.1415..., N is B-D table size, O is period of the original generator. If you take log2 of the whole expression, and use Stirling formula for factorial, and assume P=2226 and O=2128, you could get estimate for N, size of the table in B-D algorithm. From back-of-the envelope calculation N=64 would be enough to get all your permutations.

UPDATE

Ok, here is an example implementation of RNG extended with B-D shuffle. First, I implemented in Javascript Xorshift128+, using BigInt, which is apparently default RNG in V8 engine as well. Compared with C++ one, they produced identical output for first couple of dozen calls. 128bits seed as two 64bits words. Windows 10 x64, NodeJS 12.7.

const WIDTH = 2n ** 64n;
const MASK  = WIDTH - 1n; // to keep things as 64bit values

class XorShift128Plus { // as described in https://v8.dev/blog/math-random
    _state0 = 0n;
    _state1 = 0n;

    constructor(seed0, seed1) { // 128bit seed as 2 64bit values 
        this._state0 = BigInt(seed0) & MASK;
        this._state1 = BigInt(seed1) & MASK;
        if (this._state0 <= 0n)
            throw new Error('seed 0 non-positive');
        if (this._state1 <= 0n)
            throw new Error('seed 1 non-positive');
    }

    next() {
        let s1 = this._state0;
        let s0 = this._state1;
        this._state0 = s0;
        s1  = ((s1 << 23n) ^ s1 ) & MASK;
        s1 ^= (s1 >> 17n);
        s1 ^= s0;
        s1 ^= (s0 >> 26n);
        this._state1 = s1;
        return (this._state0 + this._state1) & MASK; // modulo WIDTH
    }
}

Ok, then on top of XorShift128+ I've implemented B-D shuffle, with table of size 4. For your purpose you'll need table more than 84 entries, and power of two table is much easier to deal with, so let's say 128 entries table (7bit index) shall be good enough. Anyway, even with 4 entries table and 2bit index we need to know which bits to pick to form index. In original paper B-D discussed picking them from the back of rv as well as from front of rv etc. Here is where B-D shuffle needs another seed value - telling algorithm to pick say, bits from position 2 and 6.

class B_D_XSP {
    _xsprng;
    _seedBD = 0n;

    _pos0 = 0n;
    _pos1 = 0n;

    _t; // B-D table, 4 entries
    _Z = 0n;

    constructor(seed0, seed1, seed2) { // note third seed for the B-D shuffle
        this._xsprng = new XorShift128Plus(seed0, seed1);

        this._seedBD = BigInt(seed2) & MASK;
        if (this._seedBD <= 0n)
            throw new Error('B-D seed non-positive');

        this._pos0 = findPosition(this._seedBD);                         // first  non-zero bit position
        this._pos1 = findPosition(this._seedBD & (~(1n << this._pos0))); // second non-zero bit position

        // filling up table and B-D shuffler
        this._t = new Array(this._xsprng.next(), this._xsprng.next(), this._xsprng.next(), this._xsprng.next());
        this._Z = this._xsprng.next();
    }

    index(rv) { // bit at first position plus 2*bit at second position
        let idx = ((rv >> this._pos0) & 1n) + (((rv >> this._pos1) & 1n) << 1n);
        return idx;
    }

    next() {
        let retval = this._Z;
        let j = this.index(this._Z);
        this._Z = this._t[j];
        this._t[j] = this._xsprng.next();
        return retval;
    }
}

Use example is as follow.

let rng = new B_D_XSP(1, 2, 4+64); // bits at second and sixth position to make index

console.log(rng._pos0.toString(10));
console.log(rng._pos1.toString(10));

console.log(rng.next());
console.log(rng.next());
console.log(rng.next());

Obviously, third seed value of say 8+128 would produce different permutation from what is shown in the example, you could play with it.

Last step would be to make 226bit random value by calling several (3 of 4) times B-D shuffled rng and combine 64bit values (and potential carry over) to make 226 random bits and then convert them to the deck shuffle.

Severin Pappadeux
  • 18,636
  • 3
  • 38
  • 64
  • Can you explain your calculations in further detail? Or is there an error in your formula? With n=64 and O=2^128, the log2 of P is not even close to 256; it's more like 84.3. – Peter O. Aug 04 '19 at 04:00
  • @PeterO. well, main term in factorial was `N log_2(N)`, so from simple expression `N log_2(N)=log_2(O)+log_2(P)=226+128=354` you could estimate `N`. If you got it precise as 84, so be it. Point is, you could extend period by doing B-D shuffle and paying with having somewhat large table. – Severin Pappadeux Aug 04 '19 at 19:44