I’m going to describe my favorite interview question and then ad-lib a bit on it. The question has a somewhat mathematical bent but connects to several practical problems I had in my career. I used it many dozens of times when interviewing for xAI and other places, to the extent that it’s slightly worn out (apparently it was asked at Google going back to at least 2005 and unsurprisingly is on actual leetcode). However, as per the last blog post, I don’t think I am giving “too much away” by describing it here, and I may well use this question again. If candidates read this, all the better – we can dive in deep then. And if they have not, despite knowing I’d interview them, that’s an ever so weak signal too.

I first heard this question when Greg Chanan interviewed me for FAIR in 2018. I probably did okay-but-not-great. I did end up getting an offer and I did join Facebook (renamed to Meta while I was there) in early 2019.

Without further ado, here’s the question in its first variant.

Random sampling question, version 1

Given an array L of length N and a number k with 0 <= k <= N, sample k elements from L, uniformly and without replacement.

I typically would suggest some kind of source of randomness, e.g. the Python standard library functions random.random and random.randrange, with the following behavior:1

random.random()
Return the next random floating-point number in the range 0.0 <= X < 1.0
 
random.randrange(stop)
Return a randomly selected element from range(stop).

Some candidates want an explanation of the terms: “uniform” means every entry of the array has the same chance of being sampled; “without replacement” means elements are discarded after being sampled and cannot be sampled again: in the “urn model”, the balls taken out of the urn are not being put back into the urn, so they are not being replaced.

This opens the door to another question: What is a “same element” for the purpose of this question? It’s overall more interesting if there’s no object equality check a la __eq__ in Python here but instead all elements of the array can be assumed to be unique. This also implies their index is the only thing that matters, and what the elements of the array are is otherwise not important.

Monte Carlo solution (aka rejection sampling)

A first attempt to solve this could look like2

from random import randrange

def sample(L, k):
    result = set()
    while len(result) < k:
        result.add(L[randrange(len(L))])
    return result

This is decent if k/N is a small number but catastrophically slow when k is close to N. However, in the case k/N >= 0.5 one can choose to sample rejected instead of selected items and arrive at a decent solution overall. This way, the algorithm makes progress in each iteration with probability \(\ge 0.5\), which means the chance of not making progress after \(m\) steps decays exponentially in \(m\). Note though that the worst case time complexity is still unbounded (“\(O(\infty)\)”).

That’s good enough for most practical applications – but not good enough for a coding interview. So let’s say we are looking for a solution with deterministic complexity.

Naive deletion

One solution that maps well to “don’t replace balls in the urn” is simply

def sample(L, k):
    result = []
    for _ in range(k):
        j = randrange(len(L))
        result.append(L[j])
        del L[j]
    return result

In place of del L[j], some people even go for L = L[: j] + L[j + 1 :]. If they have more Python knowledge they might propose

def sample(L, k):
    result = []
    for _ in range(k):
        result.append(L.pop(randrange(len(L))))
    return result

All of these are correct solutions, but they suffer from a similar problem. In order to delete an element of an array while keeping \(O(1)\) lookups in place, deletion requires shifting all later elements which is \(O(N)\).

In memory, an array is a buffer that starts at start_ptr and ends at start_ptr + buffer_len:

[ a b c d e f g h i j k l m ]
  ^                         ^
  start_ptr                 |
       start_ptr + buffer_len

Looking up the element at index i means resolving a pointer to start_ptr + i. This makes lookups \(O(1)\) and cheap even within that class.

Deleting an element – say, g – creates a hole:

[ a b c d e f _ h i j k l m ]

The only way to keep simple \(O(1)\) lookups is to shift later elements into the hole:

[ a b c d e f h _ i j k l m ]
                  <

[ a b c d e f h i _ j k l m ]
                    <

[ a b c d e f h i j _ k l m ]
                      <

etc.

This makes deletion shift \(N/2\) elements on average and \(O(N)\).

Note that a Python list is also just such a buffer in memory with a physical capacity and a logical length. When it needs to grow beyond its capacity a new buffer is created and all elements (which are pointers of type PyObject*) are copied. Candidates often think they don’t know that that is the case but they typically can infer this once the “contract” of the list is pointed out, which is that random access is \(O(1)\). There is simply no alternative to an array under this contract! See listobject.c in the CPython source for the details.

Keeping track of gaps?

Some people, myself included, find it natural to try to track already selected elements. In step i, one could attempt to sample from randrange(N - i) and then add the number of gaps already chosen left of i. While natural, this is complicated and yields at best some kind of tree structure and an \(O(k \log k)\) solution, while \(O(k)\) should be and is possible.

Some folks propose all kinds of dictionary/hash map approaches, most of which fail (with an exception, not realistically findable in an interview, see below!). The fundamental problem is that arrays allow \(O(1)\) lookup of the \(i\)th element but require \(O(N)\) deletions, while hash maps might allow for \(O(1)\) deletions but looking up the \(i\)th element costs \(O(N)\). Hash maps generally are somewhat overused due to their convenience. Some candidates have a really hard time giving them up for this problem.

Swapping to the end

Still, modifying the array is promising, and candidates often find a better solution when they are explicitly told that’s an okay thing to do. For instance, one could do

from random import randrange

def sample(L, k):
    N = len(L)
    result = []
    for i in range(k):
        j = randrange(N - i)
        # Take element j, then move in element from the end.
        result.append(L[j])
        L[j] = L[N - i - 1]
    return result

Note that L[j] = L[N - i - 1] can be a no-op if the sampled index j was the end.

This is an example of a “swap with the end” trick that’s useful more generally. With swapping instead of moving one can also avoid the result array:

from random import randrange

def sample(L, k):
    N = len(L)
    for i in range(k):
        j = randrange(N - i)
        L[j], L[N - i - 1] = L[N - i - 1], L[j]
    return L[N - k :]

This is pretty short and \(O(k)\) in time with essentially no extra space, assuming the slice at the end doesn’t create a copy – true if L is an object like a NumPy array but not true for plain Python lists. If k is nonzero, the slice at the end can be written as L[-k :] but that fails for k == 0.

Via partial shuffle

Sometimes candidates find that “shuffle the list and take the first k items” is a more intuitive solution:

def sample(L, k):
    shuffle(L)
    return L[:k]

The question then is how to shuffle. A moment’s reflection shows that “go through all slots and swap with a random one” is promising:

def shuffle(L):
    for i in range(len(L)):
        j = i + randrange(len(L) - i)
        L[i], L[j] = L[j], L[i]

The logic of this algorithm (known as Fisher-Yates in the literature) is to keep a range of not-yet-chosen elements that is initially the full array and then shrinks from the left by one element each iteration. (Since the last random “choice” is choosing from the one-element range(1), the loop could actually use range(len(L) - 1).)

This has the nice property that each to-be-filled slot is chosen once and then the algorithm sticks to its choice.

It can be turned into a “partial shuffle” where one stops after k slots:

def shuffle(L, k):
    for i in range(k):
        j = i + randrange(len(L) - i)
        L[i], L[j] = L[j], L[i]

With this, shuffle(L, k) and then returning L[: k] is equivalent to the swapping-to-the-end solution, but easier to find for some people.

A fun side quest

Here’s a side question: What if we don’t stick with our choices, but instead allow the full array to be selected every time? This way we potentially swap already chosen elements:

def alt_shuffle(L):
    for i in range(len(L)):
        j = randrange(len(L))
        L[i], L[j] = L[j], L[i]

As an algorithm, this feels worse – for instance, the original shuffle allows us to yield results as they come. But is it any worse otherwise? Is it correct? Are both shuffle and alt_shuffle correct, or only one of them? Or, less plausibly, neither?

Click here to find out about shuffle vs. alt_shuffle.

It turns out, alt_shuffle is wrong in that it doesn’t result in a uniform shuffle.

This is true in general for \(N\ge 3\) but one easy way to see this is to assume that \(k = N = p\) for a prime number \(p > 2\). The number of different random inputs from randrange for shuffle is exactly \(N(N-1)\cdots 1 = N!\). This is also the number of random permutations of \(N\) items; the algorithm is selecting one such permutation, step by step.

For alt_shuffle, the number of different random inputs the algorithm uses is \(N^N\). That is a vastly larger number than \(N!\), but that by itself doesn’t mean alt_shuffle is incorrect, assuming the \(N^N\) random choices can be mapped to \(N!\) choices in a way that divides the \(N^N\) choices evenly. However, the expression \(N!\) has prime factors for every prime up to \(N\), while \(N^N\) only has \(p\) as a prime factor. \(N^N\) isn’t divisible by \(N!\) (that is, \(N^N/N!\) is not an integer). This means alt_shuffle selects some of the \(N!\) permutations with higher probability than others and isn’t uniform.

Floyd’s algorithm

What is optimal? In a sense, the partial shuffle/swapping-based solution is optimal – it’s \(O(k)\) with minimal space overhead. Its only imperfection is the fact that the input array L is modified. Of course this can be avoided by making a copy, but that means we are no longer \(O(k)\).

Can we avoid modifying the input? No simple change to the swapping-based solution will achieve that, because the algorithm uses the buffer as a scratchpad to keep track of which of the \(N!\) permutations has been selected.

An \(O(k)\) space and time solution to this exercise could dispense with the array altogether, since it really just selects \(k\) indices. One could simply ask:

Generate k distinct uniformly random integers in range(N) in \(O(k)\) space and time.

I had asked many candidates the sampling question before I found this version, innocently stated as Exercise 5-2 of the AWK programming book. Their proposed solution, translated to Python, reads like this:

from random import randrange

def sample(N, k):
    result = set()
    for i in range(N - k, N):  # k steps.
        r = randrange(i + 1)
        if r in result:
            r = i
        result.add(r)
    return result

This is known as Floyd’s algorithm, due to R.W. Floyd, 1978 Turing Award winner and close collaborator of Donald Knuth at Stanford.

It wasn’t immediately obvious to me why this algorithm is correct. The fact that the math of “if seen already, choose the end” works is somewhat magical. For a discussion of Floyd’s algorithm, check out “A Sample of Brilliance” in Jon Bentley’s Programming Pearls.

Very few people will be able to derive something like that in the scope of a coding interview. Only a handful of people even know this algorithm in the first place; nobody I ever interviewed did.

Version 2: \(O(N)\) solutions

One of the neat properties of this problem is its many variations. Having solved the original version, what if we look to sample from a stream instead? A stream is an object that we can ask for its next value, which will either yield another value or tell us the stream has ended. In Python, that maps to the concept of an iterator.

For this exercise to make sense, we need some guarantees: The stream needs to (1) have at least k elements, and (2) eventually end.

The point of asking about streams is to force the solution to be \(O(N)\), which gives a different landscape of trade-offs. Of course, we could simply collect all \(N\) items in an array and apply the solutions we already saw, but perhaps we can do this in \(O(k)\) space?

Streams of known length

Selection sampling

Let’s say we also get information about the eventual length of the stream. In that case, sampling \(k\) out of the \(N\) items we will see, naively, is3

from random import random

def sample(it, N, k):
    for e in it:
        if random() < k/N:
            yield e

The issue with this algorithm is that it could sample both fewer and more than \(k\) elements. It’s correct “on average” but each run has some variation.

Sampling too much is easily fixable – we can just stop after \(k\) samples. But it’s unclear if that maintains uniformity, and anyway what can we do about sampling too little? What we should do is to increase the probability as we go along, for instance in a situation where we have sampled \(k - 1\) elements and the stream only contains a single final element, that one has to be sampled with probability \(1\).

To achieve that, note that regardless of whether we chose to sample or not to sample an element in a given round, we modified the problem by reducing the size of the remaining stream by one, and if we sampled an element we reduced the number of yet-to-be-sampled elements by one as well. In a situation where only a single element is left to be sampled and we need another sample, we have a \(k=1\), \(N=1\) subproblem. This suggests the approach

from random import random

def sample(it, N, k):
    for e in it:
        if random() < k/N:
            yield e
            k -= 1
        N -= 1

Optionally, we could also short-circuit via something like if not k: return. A moment’s reflection shows that this algorithm always produces \(k\) elements and is correct.

This algorithm is Knuth’s Algorithm S, “selection sampling” (from The Art of Computer Programming, Vol. 2, §3.4.2). It is short, sweet, and “online” – we can yield instead of waiting for the stream to finish. It is the right approach for many applications, including handling classic magnetic tapes which are linear by nature.

Streams of unknown length

What should we do if we are not being given the length of the stream in advance? What can we do?

One obvious thing we cannot do anymore is to have an online solution that yields its results. Since the stream could go on for many more elements or stop right now and each element needs to have the same chance of making it, any solution will have to have the form

def sample(it, k):
    results = []

    for e in it:
        # do things and add to results
        ...

    return results

Heaps

One first idea might be to sort random numbers. We could for instance maintain a min-heap, along the following lines:

import heapq
from random import random

def sample(it, k):
    h = [(random(), next(it)) for _ in range(k)]
    heapq.heapify(h)  # Tuples compare by first value first.

    for e in it:
        # Push e on heap, then pop smallest.
        heapq.heappushpop(h, (random(), e))

    return [e for _, e in h]

Somewhat surprisingly, heapify is implementable in \(O(k)\). The use of heappushpop here makes sense since heap operations are typically “modify the heap as requested, then recreate the heap property”. Since here each push is followed by a pop, heappushpop allows us to skip one heap recreation. It runs in \(O(\log k)\) for a heap of size \(k\).

Overall this algorithm is \(O(k) + O(N \log k)\) in time.

There is an issue with this approach though: It relies on each random() number being truly different from each other one. Since Python’s floats don’t have infinite precision, collisions are possible and due to the birthday paradox are actually more likely than intuitively expected.4 If our streamed elements are not comparable, this will make the above algorithm crash at runtime – and if they are, uniformity will be violated. The only real solution to this I can see is to modify the comparison to have random tie breaks which breaks the deterministic runtime behavior and is not pleasant to implement.

Quickselect

While it doesn’t apply in the streaming case, it’s good to remember that quickselect is the standard algorithm for finding the \(k\)th smallest element in an unsorted list, or the \(k\) smallest elements. However since it requires modifying the full array, it isn’t the best approach for the problem at hand. (It also suffers from the same floating point collision issue.) It’s still very good to know quickselect, which is why I mention it here.

Reservoir sampling

The real beauty of the streaming version of this question comes out in this solution. Let’s go back to the template we know we will require:

def sample(it, k):
    results = []

    for e in it:
        # do things and add to results
        ...

    return results

We can view this question in several ways. One of them is to think of it as some sort of adversarial game: Let’s say you are the algorithm and the interviewer is the stream. The algorithm can do whatever it wants with its inputs, but the stream can decide to either give another element or end itself and ask you to “show your work” in the least convenient moment possible.

Thinking along these lines creates one realization: We have to be correct for all kinds of values of \(N\) and \(k\), including \(N = k\). In that case there isn’t much we can do, we need to return the first \(k\) inputs. So let’s code that:

def sample(it, k):
    results = [next(it) for _ in range(k)]

    for e in it:
        # do things and add to results
        ...

    return results

This now is at least correct for the \(N = k\) case. Can we make it correct for \(N = k + 1\) as well? (You see where this is going.) For \(N = k + 1\), what’s the chance of any given element to be in the results array? It will be \(\frac{k}{k+1}\). Essentially, if we see another element after the guaranteed first \(k\), what choice do we have? We can choose it or reject it with a certain probability, and if we choose it, we can evict a previously chosen element with some probability. Since the stream could end right after that additional element, its probability of being chosen has to be correct, namely k / N which is k / stream_len_so_far in this case.

This is the beauty of this algorithm: We have to assume a solution can be found, and under that assumption we only have that choice:

from random import random

def sample(it, k):
    results = [next(it) for _ in range(k)]

    for stream_len, e in enumerate(it, k + 1):  # Start with k + 1
        if random() < k / stream_len:
            # Choose `e`.
            ...

    return results

This is almost the full algorithm. The remaining question is what to evict? Given the uniform sample we want to produce, it stands to reason that we want to select elements for eviction from the pool results – let’s actually call it a reservoir – with uniform probability:

from random import random, randrange

def sample(it, k):
    reservoir = [next(it) for _ in range(k)]

    for stream_len, e in enumerate(it, k + 1):
        if random() < k / stream_len:
            reservoir[randrange(k)] = e

    return reservoir

A slight optimization requires only a single random choice here:

from random import randrange

def sample(it, k):
    reservoir = [next(it) for _ in range(k)]

    for stream_len, e in enumerate(it, k + 1):
        if (i := randrange(stream_len)) < k:
            reservoir[i] = e

    return reservoir

That is a neat algorithm. But is it correct? We arrived at it by assuming something like this is possible; in particular, we chose the probabilities such that the final element’s chance of ending in the chosen set is correct.

Given enough time, good candidates arrive at a short inductive proof of the correctness of this algorithm. For a given \(k\), the probability for any element to end up in the final set is obviously correct if \(N = k\). Assuming the algorithm is correct for a given stream length \(N\), moving to a stream length \(N + 1\) requires one more iteration. We already know the final new element is chosen with the right probability, but what happens to the other elements? Their previous chance to end up in reservoir was \(k / N\). Their chance to still be in the reservoir after one more iteration is that, times (1) the chance of the final element to be discarded, plus (2) the chance of the final element to be chosen, but another element to be evicted. This telescopes nicely since

\[\frac{k}{N} \cdot \Big(1 - \frac{k}{N+1} + \frac{k}{N+1} \cdot \frac{k-1}{k}\Big) = \frac{k}{N} \cdot \Big(\frac{N + 1 - k}{N + 1} + \frac{k - 1}{N + 1}\Big) = \frac{k}{N} \cdot \frac{N}{N+1} = \frac{k}{N+1}.\]

So the probabilities of the previous elements are updated in the correct way too.

This algorithm is known as reservoir sampling. It’s a classic from the literature; Knuth calls it Algorithm R in TAOCP Volume 2.

Reservoir sampling with skipping ahead

Fancier versions of reservoir sampling exist. One interesting variant is if we assume we can cheaply skip ahead in the iterator. If the length of the stream N is large compared to the number of selected elements and there is processing work for each element that pays off to be avoided where possible, this can be a powerful idea.

Let’s invent an iterator method where say skip(it, j) skips and discards j elements from the iterator (making skip(it, 0) a no-op). The skipping version of reservoir sampling can then be written as

from math import exp, floor, log, log1p
from random import random, randrange

def sample(it, k, done=object()):
    reservoir = [next(it) for _ in range(k)]
    w = 1.0
    while True:
        w *= exp(log(random()) / k)
        skip(it, floor(log(random()) / log1p(-w)))
        if (e := next(it, done)) is done:
            break
        reservoir[randrange(k)] = e

    return reservoir

Here w is the probability that the next element of the stream will be accepted, and log1p(-w) computes \(\log(1 - w)\) without losing precision when w is small.5

This version of reservoir sampling was published in 1994 by Kim-Hung Li, see Reservoir-sampling algorithms of time complexity \(O(n(1 + \log(N/n)))\).

There are other interesting variants of reservoir sampling, e.g., sampling from non-uniform distributions. But let’s end with something a bit farther afield that deserves to be better known.

Online shuffling

Let’s say we have a large dataset (e.g., a table in SQL) which comes in the form

  # |   value
-------------
  1 |  entry1
  . | .
  . | .
  . | .
  N |  entryN

We now want to do “online shuffling”, i.e., go through the dataset in a random order, visiting each sample exactly once. We also want to be preemptible – our algorithm can be stopped and later restarted from a checkpoint. Perhaps we would even like this to be seekable: Quickly asking for the entry that comes at position i of the shuffle.

What we are asking for is equivalent to selecting one of the \(N!\) many permutations of \(N\) items, then doing lookups of the form

for i in range(N):
    sample = samples[p(i)]
    ...

This is similar to the sampling question in that doing this requires a scratch buffer of size \(O(N)\) to keep track of what has been sampled so far. For instance, this could be done like:

import numpy as np
from random import randrange

def permit(N):
    L = np.arange(N)
    for i in range(N):
        j = i + randrange(N - i)
        L[i], L[j] = L[j], L[i]
        yield L[i]

However, this is a heavy amount of state for our checkpoint: For a dataset of 1B entries we would use 8 GiB just for the np.arange(N). It’s also not quickly seekable in that the full array needs to be assembled before we can do lookups.

Block ciphers

If we want to deal with far less state (say, a few kilobytes), the problem cannot be solved exactly. But it can be solved approximately, and one can prove the approximation is good.

One trick is to use an idea from cryptography: A block cipher is a symmetric encryption algorithm which takes a secret key k and buffers of a fixed length and maps them to encrypted buffers of the same length:

plain text buffer: B
encrypted buffer:  encrypt(B, k)
decrypted buffer:  B == decrypt(encrypt(B, k), k)

Both the unencrypted input B and the encrypted buffer encrypt(B, k) have the same number of bits, say \(n\). In particular, this means that for a given key k the functions

\[\begin{align} \mathtt{encrypt}(\dotid, \mathtt{k}) & \from \set{0, 1}^{n} \to \set{0, 1}^{n}, \\ \mathtt{decrypt}(\dotid, \mathtt{k}) & \from \set{0, 1}^{n} \to \set{0, 1}^{n} \end{align}\]

are bijections (one-to-one) and each is the inverse of the other. Here, \(\set{0, 1}^{n}\) is the set of all buffers of 0s and 1s of length \(n\), which is a set of \(2^n\) elements.

Pseudorandom permutations

How are we going to use this block cipher? We will use \(n = 64\) and interpret a given index i of type uint64 as a block of 64 bits and use a block cipher for that block size. We then encrypt that index to find where it maps to:

import numpy as np

def perm(i):
    B = np.array(i, dtype=np.uint64)
    k = KEY  # Global in this example.
    return encrypt(B, k).item()

def permit(N):
    for i in range(N):
        yield perm(i)

Since \(\mathtt{encrypt}(\dotid, \mathtt{k})\) is one-to-one, each possible bit pattern is produced exactly once. perm is an actual permutation of the set of uint64s (a set of \(2^{64}\) elements).

This works as described if the (implausibly large) table has size exactly \(N = 2^n = 2^{64}\). Realistically, it’s smaller and we need some additional ideas.

Let’s say we have a block cipher for any power of two and a table of any size. We can use as block size \(n\) the smallest number such that \(2^n \ge\) the table size. Worst case, this additional padding at the end is roughly half the range of the permutation. When we apply perm and hit the padded region, we will simply iterate perm until we get out of it – since the permutation is one-to-one, that will eventually happen, and since the padded part is at most half, it happens with probability at least 0.5 every iteration. This trick is known as cycle walking.

Is 0% enough?

Note that the permutation is parameterized only by the key k. If k has m bits, that means we can only produce \(2^m\) different permutations, which is vastly fewer than the full number of permutations of the set of uint64s, which is \((2^{64})!\), a number that has about \(3.47 \times 10^{20}\) digits. By contrast, the number of atoms in the observable universe has about 80 digits! Combinatorics creates large numbers very fast.6 The actual fraction of permutations reachable by this scheme is very close to \(0\%\).

So we will only ever get an absurdly tiny fraction of all possible permutations this way. How far away from truly random permutations are we with these pseudorandom permutations?

The answer depends on the quality of the encrypt function. In many scenarios, cryptographers could prove that one cannot actually tell the difference! To learn more, check out Feistel networks7 and a result known as the Luby-Rackoff theorem.8

Cheap parameterized permutations

On the other end of the spectrum, computationally cheap parameterized permutations come from mixers (or finalizers) of hash functions like MurmurHash3. A very influential 2011 blog post by David Stafford proposed using

uint64_t mix13(uint64_t x)
{
    x ^= x >> 30;
    x *= 0xBF58476D1CE4E5B9UL;
    x ^= x >> 27;
    x *= 0x94D049BB133111EBUL;
    x ^= x >> 31;
    return x;
}

MurmurHash3 and this blog post were the starting point of all kinds of results around fast permutations. Stafford’s suggestion made it into JDK 8 in 2013 and was also cited as canonical in Fast Splittable Pseudorandom Number Generators by Steele, Lea and Flood in 2014. Since 2018, Pelle Evensen has had several blog posts around improvements. These fast permutations satisfy certain statistical properties but not others (e.g., mix13 above sends an all zero buffer to zero; this can be fixed with extra xors). They are certainly not “cryptographically strong”, but their statistical properties can be surprisingly good and they are extremely cheap.

Much more can be said about random sampling and shuffling. But this blog post is long enough and making it longer will not illustrate the point any better.

  1. The Python standard library also has a random.randrange(start, stop[, step]) version, but we will use the one-argument version of randrange here. 

  2. Yes, we’ll be using L as a Python variable name, regardless of what the Google Python Style Guide says. One of the advantages of leaving Google is to be free

  3. Note that random() < k/N with a strict < (and not <=) is the correct comparison; this matches the property where the return value of random() can be 0.0 but can not be 1.0

  4. On a typical contemporary computer, a Python float is a double aka float64. Python’s random() returns one of \(N = 2^{53}\) equally spaced doubles in \([0, 1)\). The birthday threshold is \(\sqrt{2N\ln 2}\) which is around 111M. But for the heap solution we have to worry about collisions at the selection boundary, which are very unlikely for realistic stream lengths. 

  5. Funnily enough, the code isn’t quite bug-free as written. Since random() can be 0.0, math.log(random()) can raise a ValueError with probability \(2^{-53}\). A full implementation should probably special-case both w == 0.0 and w == 1.0

  6. How do I know the digits of \((2^{64})!\)? It turns out the Log-gamma function \(\ln\circ\,\Gamma\) is a well understood object with a fast converging series representation. Since \(\Gamma(n + 1) = n!\), we can literally compute math.lgamma(2**64 + 1) and convert \(\ln\) to \(\log_{10}\). 

  7. The tl;dr on Feistel networks is: Split the input into its left and right part, then compute right, left = left ^ hash(right, key), right for a number of rounds. Decryption is doing the same kind of operation in reverse. 

  8. Note though that the specific mathematical guarantee from Luby-Rackoff does not map well to a large table that we shuffle completely since the bound is \(q^2 / 2^{n/2}\) after \(q\) queries. For \(n=64\) this is exhausted after \(q\approx 2^{16} = 65536\) queries. For better mathematical guarantees, unbalanced Feistel ciphers can help, as can modern schemes like swap-or-not, which survives for \(q\) close to \(2^n\). Alternatively, one can use ciphers like Speck, which have no formal guarantee but are deemed secure because no successful attack is publicly known (but people tried). Note though that for practical table sizes one would need something unofficial like Speck40 to get the right block size, which is likely just as good but has had fewer eyes on it.