When the algorithm flips a coin

One pass, k slots, and two randomness budgets

Reservoir sampling takes a uniform sample of k items from a stream of unknown length in one pass and k slots. The textbook version and a second version draw from exactly the same distribution, and at 65,536 items one of them spends 1,356,399 random bits and the other spends 9,380.

Take a uniform random sample of a thousand items from a stream that cannot be counted, rewound or held. It arrives one item at a time; there may be a million of them or a hundred; when it stops the sample must hold exactly a thousand, each of which was equally likely to be any item that went past.

Written that way it sounds like it needs the length. Reservoir sampling does it in one pass and kk slots, and the algorithm is three lines. What makes it worth an essay here is not the trick but two things measurement adds to it: an off-by-one that produces a perfectly ordinary-looking sample and is invisible to any single run, and a second version with identical output whose randomness cost differs from the first by a factor of 145.

Algorithm R: how often each of 32 positions is sampled, 40,000 runsA uniform sampler chooses every position of the stream with probability k/n = 0.125, whatever order the items arrive in. The shaded band is four standard deviations of the estimate at 40,000 runs, which is what a correct sampler looks like at this trial count. The largest departure measured here is 3.3%. Each run costs 166 random bits, which is the other axis this sampler is measured on.k/n = 0.1250.0810.1250.169position in the streamshare of runs in which it was sampledAlgorithm R, n = 32, k = 4, 40,000 runsworst departure 3.3% · noise 1.4%
Fig. 1 Forty thousand runs of Algorithm R over a stream of 32 items, sampling 4. The line is how often each position of the stream ended up in the sample. It should be flat at k/n = 0.125 and it is: the largest departure is 3.34%, against a four-standard-deviation noise band of 5.66% at this trial count. The shaded band is that noise, drawn so the flatness can be read as a result rather than taken on trust.

Algorithm R

Keep the first kk items as the reservoir. Then for each later item at index ii, draw a uniform integer jj in [0,i][0, i]; if j<kj < k, overwrite slot jj with the new item, and otherwise discard it. That is the whole algorithm: one draw and one conditional store per item, with no state beyond the kk slots and the running index.

The correctness argument is one induction. Suppose after processing ii items each of them is in the reservoir with probability k/ik/i. Item ii (zero-indexed, so the (i+1)(i{+}1)-th) enters with probability k/(i+1)k/(i+1), directly. An older item survives if either the new item was rejected — probability 1k/(i+1)1 - k/(i+1) — or it was accepted but displaced a different slot, which happens with probability ki+1k1k\frac{k}{i+1}\cdot\frac{k-1}{k}. Add those and multiply by the k/ik/i it already had:

ki(1ki+1+k1i+1)=kiii+1=ki+1\frac{k}{i}\left(1 - \frac{k}{i+1} + \frac{k-1}{i+1}\right) = \frac{k}{i}\cdot\frac{i}{i+1} = \frac{k}{i+1}

which is the same probability the new item got. The invariant holds at every step, so it holds when the stream stops, whenever that is — and the algorithm never needed to know when.

That last clause is the whole point. The uniformity is maintained continuously, so the sample is valid at every prefix. Stop the stream anywhere and the reservoir holds a uniform sample of what has gone past.

What the off-by-one does

Write uniform integer in [0, i) instead of [0, i] — a mistake that is one character in most languages — and the algorithm still runs, still fills kk slots, and still returns a sample that looks exactly like a sample.

The bug is that the arriving item can now displace an older one but can never be rejected outright: when jj is drawn below ii rather than below i+1i+1, the acceptance probability is k/ik/i rather than k/(i+1)k/(i+1), so every item is slightly over-favoured relative to the ones before it, and the effect compounds down the stream.

Algorithm R with the off-by-one: how often each of 32 positions is sampled, 40,000 runsA uniform sampler chooses every position of the stream with probability k/n = 0.125, whatever order the items arrive in. The shaded band is four standard deviations of the estimate at 40,000 runs, which is what a correct sampler looks like at this trial count. The largest departure measured here is 23.5%. This version draws below i instead of below i+1: the arriving item can displace an older one but can never be rejected, so the early positions are systematically thinned.k/n = 0.1250.0810.1250.169position in the streamshare of runs in which it was sampledAlgorithm R with the off-by-one, n = 32, k = 4, 40,000 runsworst departure 23.5% · noise 1.4%
Fig. 2 The same measurement on the version with below(i) instead of below(i+1). The line is no longer flat: position 0 is sampled 9.71% of the time against the 12.5% it is owed, and the last positions sit above the line. The largest departure is 23.54% against the same 5.66% noise band — comfortably outside it, and comfortably invisible in any single run.

The first position is chosen 9.71% of the time where it is owed 12.5%: it is under-represented by 22%. The last position is chosen 12.97%. Over a whole stream the early items are systematically thinned.

Three things about this failure are worth naming, because it is the archetype for a whole class.

No single run reveals it. A biased sample of four items out of thirty-two is four items out of thirty-two. There is nothing to look at.

No correctness test catches it. The output has the right size, the right type, contains only items from the stream, and contains no duplicates. Every property an assertion would naturally check is satisfied.

Only a frequency measurement over many runs catches it, and only if the number of runs is chosen so that the estimator’s noise is below the bias. At 40,000 runs the noise floor is 1.41% and the bias is 23.5%, which is a comfortable margin. At 400 runs the noise floor is 14% and the two are indistinguishable, so the test would pass on the broken sampler and prove nothing about the correct one.

That last point is the reason the site’s gate brackets its tolerance rather than picking a round number: the threshold has to sit above the measured noise of a correct sampler (3.34%) and below the measured departure of the broken one (23.54%), and 8% is in that gap by a wide margin on both sides. A threshold chosen without measuring both ends is a threshold that has never been shown to be able to fail.

Algorithm L, and what it costs differently

Algorithm R draws a random number for every item in the stream. For a stream of sixty-five thousand items and a sample of sixteen, that is 65,520 draws to produce a sixteen-element answer.

Almost all of them are rejections. When ii is large the acceptance probability k/(i+1)k/(i+1) is tiny, so the algorithm’s inner loop is overwhelmingly “draw a number, discard it”. Algorithm L, due to Li, replaces that with drawing how far to skip before the next acceptance — the gap is geometrically distributed, so it can be sampled directly with one uniform and a logarithm.

The output distribution is unchanged. That is the claim, and it is the one worth checking rather than assuming, because a sampler that skips is a sampler with a second opportunity to be subtly wrong.

Algorithm L: how often each of 32 positions is sampled, 40,000 runsA uniform sampler chooses every position of the stream with probability k/n = 0.125, whatever order the items arrive in. The shaded band is four standard deviations of the estimate at 40,000 runs, which is what a correct sampler looks like at this trial count. The largest departure measured here is 1.7%. Each run costs 585 random bits, which is the other axis this sampler is measured on.k/n = 0.1250.0810.1250.169position in the streamshare of runs in which it was sampledAlgorithm L, n = 32, k = 4, 40,000 runsworst departure 1.7% · noise 1.4%
Fig. 3 Algorithm L on the same stream, over the same forty thousand runs. Flat, at 1.74% worst departure against the 5.66% noise band — indistinguishable from Algorithm R’s picture, which is the correctness claim measured rather than argued. The two algorithms have different inner loops, different numbers of draws, and the same distribution over outputs.

And then the difference, which needs the bit counter to see at all:

stream length R, bits L, bits ratio R, draws L, draws
1,024 12,793 4,212 3.0× 1,008 185
4,096 62,339 6,184 10.1× 4,080 272
16,384 293,687 8,020 36.6× 16,368 353
65,536 1,356,399 9,380 144.6× 65,520 413

Algorithm R’s consumption is Θ(nlogn)\Theta(n\log n) — one draw per item, each costing about log2i\log_2 i bits — and fits that class with a spread of 1.14 across three orders of magnitude. Algorithm L’s is Θ(klog(n/k))\Theta(k\log(n/k)), which for fixed kk is Θ(logn)\Theta(\log n), and fits at 1.52.

At the top of the table the ratio is 145. Two algorithms, identical output distribution, identical space, and one spends 1.36 million random bits where the other spends nine thousand.

Random bits consumed against n, k = 16Coin flips charged at the point they are spent, on logarithmic axes, with the class each consumer declares fitted by the same ratio test the comparison counts get. Rejections are charged: a draw below a range that is not a power of two costs whatever the rejection loop actually spent, not the ⌈log₂ k⌉ it would cost if the arithmetic were free. At n = 16,384 the consumers drawn here span 7,068 to 293,682 bits.10³10⁴10³10⁴10⁵nrandom bitsreservoir, Algorithm R — n log nreservoir, Algorithm L — log nskip list, one build — nn from 256 to 16,384bits charged including rejections
Fig. 4 The two reservoirs on logarithmic axes, with a skip list for scale. Algorithm R’s line has the slope of n log n; Algorithm L’s is nearly flat, because for a fixed sample size the number of skips grows only logarithmically. The gap widens without limit, which is the property that makes the second algorithm worth having and which no comparison counter, cache model or space counter on this site can see.

Where Algorithm L stops winning

The table above sweeps the stream length at a fixed sample size, and the ratio grows without limit. Sweeping the other way is more interesting, because it shows where the second algorithm is not worth having.

Algorithm L’s cost is Θ(klog(n/k))\Theta(k\log(n/k)) draws, each costing 32 bits for a uniform plus a few for the slot choice. Algorithm R’s is nn draws of about log2n\log_2 n bits. So L wins when klog(n/k)nk\log(n/k) \ll n, which is when the sample is small relative to the stream, and loses when kk approaches nn.

At n=32n = 32 and k=4k = 4 — the size the frequency figures use — Algorithm R spends 166 bits per run and Algorithm L spends 585. L is three and a half times worse at that size, because it pays a full 32-bit uniform per skip and there are barely any items to skip over. The crossover for these parameters is around n=400n = 400.

That is worth stating plainly because it is the ordinary shape of an optimisation and it is easy to lose: Algorithm L is an improvement for large streams and a pessimisation for small ones, the boundary is computable, and neither algorithm is simply better. A library that always uses L is wrong for short streams in exactly the way a library that always uses R is wrong for long ones — and since the whole premise is that the stream length is unknown, choosing between them is genuinely awkward. The practical answer is to start with R and switch to L once the index passes a threshold, which costs nothing because the two maintain the same invariant on the same reservoir.

Both columns, from two closed forms

The table has four rows and both of its algorithms are recoverable from it in closed form, which is worth doing because the forms say where the crossover between them lives and the table does not.

Divide bits by draws. Algorithm R spends 12.7, 15.3, 17.9 and 20.7 bits a draw across the four rows — that is log2n\log_2 n (10, 12, 14, 16) with about a 29% rejection overhead on top, which is the convention the section below makes explicit. Algorithm L spends 22.8, 22.7, 22.7 and 22.7. R’s cost per draw grows with the stream and L’s does not, so R is paying twice over: more draws, each wider.

The draw counts fit as cleanly. Algorithm R takes nkn - k of them, exactly. Algorithm L’s expected number of replacements is kln(n/k)k\ln(n/k), and at three draws per skip — one for the exponential, one for the gap, one for the slot — that predicts 200, 266, 333 and 399 against the measured 185, 272, 353 and 413. Within eight per cent at every row, with no constant fitted beyond the three.

So the two bills are

R1.29nlog2n,L68kln(n/k)\text{R} \approx 1.29\,n\log_2 n, \qquad \text{L} \approx 68\,k\ln(n/k)

and the second contains kk where the first does not. That is the fact the crossover turns on, and it is the one the sweep at fixed kk cannot show.

Raising the sample size raises L’s cost and leaves R’s alone. Algorithm R draws once per item whatever kk is; Algorithm L draws once per replacement, and there are more replacements when there are more slots to replace. So the stream length at which L becomes worth using is not a property of the stream — it moves with the sample, later for a large one and earlier for a small one.

That has a practical edge, because the switch a library would implement is a threshold on the index. The section above proposes exactly that — start with R, switch to L once the index passes a threshold — and the arithmetic says the threshold cannot be a constant. A sampler taking sixteen items and one taking sixteen thousand cross at very different points, and a library hard-coding one number is running the wrong algorithm for every sample size but one. The threshold has to be computed from kk, which is the one parameter the caller does supply.

It also explains why the small-stream case runs the way it does. At n=32n = 32 and k=4k = 4 the ratio n/kn/k is eight, so ln(n/k)\ln(n/k) is barely two and L’s per-skip cost of about seventy bits is being paid against a stream where R spends twenty bits a draw over twenty-eight draws. L is paying a fixed toll per replacement in a regime where replacements are nearly every item.

Which is where a crossing moved to’s standing point in a third field: a crossover quoted as a number is a crossover with its other parameters held fixed and unstated, and the useful form is the condition rather than the value. Counting the coin flips is what makes either side of this one measurable at all.

Both samplers are checked again at twice the stream and twice the reservoir, because a uniformity measurement at one shape is a measurement of one shape.

Algorithm R: how often each of 64 positions is sampled, 40,000 runsA uniform sampler chooses every position of the stream with probability k/n = 0.125, whatever order the items arrive in. The shaded band is four standard deviations of the estimate at 40,000 runs, which is what a correct sampler looks like at this trial count. The largest departure measured here is 3.8%. Each run costs 414 random bits, which is the other axis this sampler is measured on.k/n = 0.1250.0810.1250.169position in the streamshare of runs in which it was sampledAlgorithm R, n = 64, k = 8, 40,000 runsworst departure 3.8% · noise 1.4%
Fig. 5 Algorithm R over sixty-four items sampling eight, forty thousand runs. Every position is chosen with probability k/n=0.125k/n = 0.125 and the largest departure measured is 3.8%, inside the four-standard-deviation band the trial count allows. Each run costs 414 random bits.
Algorithm L: how often each of 64 positions is sampled, 40,000 runsA uniform sampler chooses every position of the stream with probability k/n = 0.125, whatever order the items arrive in. The shaded band is four standard deviations of the estimate at 40,000 runs, which is what a correct sampler looks like at this trial count. The largest departure measured here is 3.5%. Each run costs 1,150 random bits, which is the other axis this sampler is measured on.k/n = 0.1250.0810.1250.169position in the streamshare of runs in which it was sampledAlgorithm L, n = 64, k = 8, 40,000 runsworst departure 3.5% · noise 1.4%
Fig. 6 Algorithm L on the same stream and the same shape: flat, worst departure 3.5%, and 1,150 random bits a run against R’s 414. The two are equally uniform and they are not equally cheap, which is the trade the next plate is about — and it runs the opposite way from the one everybody quotes.

Why the bits matter, when they do

It would be easy to dismiss this as bookkeeping. Random bits are cheap; a pseudo-random generator produces them at gigabytes per second; a million of them is nothing.

That is true in the common case and it is worth being specific about the cases where it is not.

When the randomness is real. Entropy from an operating system’s pool, or from a hardware source, is rate-limited in a way a PRNG is not. An algorithm needing 1.36 million bits per pass over a stream is not going to get them from getrandom().

When the randomness must be secret. As the previous essay argues, a randomised algorithm’s guarantee holds only while its coins are unknown. Coins that come from a cryptographic generator cost more than a xorshift, and the count then translates directly into cycles.

When the algorithm is distributed. Shared randomness across machines has to be transmitted or derived from a common seed, and the volume is a network cost.

When the same stream is sampled many times. Drawing a hundred independent samples from one stream multiplies the cost by a hundred, and the difference between nine thousand bits and 1.36 million becomes the difference between a megabyte of entropy and a hundred and seventy.

And when the draws themselves are the expensive part. Algorithm R’s 65,520 draws are not free even from a fast generator: that is 65,520 iterations of a loop that mostly does nothing. Algorithm L’s 413 draws include a logarithm each, which is more expensive per draw and 158 times fewer of them. The bit count is the honest way to compare the two, and it comes out of the same counter for both.

The general position this site takes is the same one it takes about comparisons and cache misses: a resource that is not counted is a resource that cannot be traded. Nobody chooses Algorithm L over Algorithm R for its bit consumption if nothing reports the bit consumption.

Peak auxiliary space, random input, n = 4,096The largest number of slots live at once — scratch buffers plus stack frames — for each sort. The axis is logarithmic because the range is: 5 of these hold one slot at their peak, 3 hold a stack of about log₂ n frames, and the rest hold a second copy of the array. All of them are routinely described with the same two words.peak slots held at once, logarithmic18645124096Insertion sort11Selection sort11Bubble sort11Heapsort11Shellsort11Quicksort, median of three22log nQuicksort, random pivot28log nQuicksort, first-element29log nMerge sort with a cutoff4,106nMerge sort4,110nn = 4,096, random inputone slot = one array element or one stack frame
Fig. 7 The resource reservoir sampling is remarkable for not spending. Peak auxiliary space for the site’s sorts at n = 4,096, on a logarithmic axis because the range is: five hold one slot, three hold a stack of about log₂ n frames, and the rest hold a second copy of the array. A reservoir sampler holds k slots at every stream length, which on this axis would be a flat line under all of them.

The rejections are charged, and that is a choice

One detail of how the bits are counted is a cost model rather than a fact, and it is stated here because every figure that reports a bit count prints it.

Drawing a uniform integer below kk when kk is not a power of two is done by rejection: take log2k\lceil \log_2 k\rceil bits, and if the value is out of range, start again. The discarded attempts are charged. Two hundred draws below 3 cost 2.53 bits each rather than the 1.58 bits of information a draw below 3 actually carries, because the procedure spends more than the information it extracts.

Charging only the successful attempt would report the information-theoretic lower bound on the draw rather than what the algorithm spent, and those are different quantities with different uses. The lower bound answers “how much entropy does this algorithm fundamentally need”; the charged count answers “how much will it pull from the generator”. This site reports the second, because it is the one that a rate-limited entropy source or a cryptographic generator actually delivers.

The difference is small — about 60% overhead on the worst-case draw and much less on average — and it is the sort of thing that would silently change a reported constant if the convention drifted. So the convention is asserted: assertCoinsAreCounted requires two hundred draws below 3 to cost more than 400 bits, which fails if rejections ever stop being charged.

What the algorithm costs in the resources that were already counted

For completeness, since two of the three axes are unremarkable and it is worth saying so.

Space is kk slots plus a constant, for both algorithms, at every stream length. That is the headline property and it does not vary: a sample of a thousand from a stream of a billion holds a thousand items. There is no version of this problem where the space grows.

Time is one pass, Θ(n)\Theta(n) for Algorithm R — every item is examined even when no random number would have been needed. Algorithm L is Θ(klog(n/k))\Theta(k\log(n/k)) in operations only if the stream can be skipped over without touching the skipped items, which is true for an array or a seekable file and false for a socket. Over a socket both algorithms are Θ(n)\Theta(n) in reads and only the randomness differs, which is the case the table above is measured in.

Randomness is where they diverge, by the factor of 145 above.

Three resources, two algorithms, one difference. That is exactly the shape the space phase found between two merge sorts with identical comparison counts and different allocation totals, and it is the argument for having the third counter at all.

The parallel is worth making explicit because it is the same discovery twice. Two merge sorts, one allocating a single buffer and one allocating per merge, have byte-identical comparison, read, write and swap counts and identical peak space, and their allocation totals differ by a factor of log2n\log_2 n. Nothing in the time counters sees it and nothing in the peak sees it; only a counter for the quantity in question does. Here, two reservoir samplers have identical output distributions, identical space and identical passes, and their randomness differs by a factor of 145. A resource with no counter is a resource where two very different algorithms look like the same algorithm.

What is being sampled, and what is not

A closing precision, because “uniform sample” is doing specific work.

What reservoir sampling gives is a uniform sample without replacement of size kk: every kk-subset of the stream is equally likely. That implies each item is present with probability k/nk/n, which is the quantity the figures measure, and the figures measure the implication rather than the full statement — checking that all (nk)\binom{n}{k} subsets are equally likely would need vastly more runs and is not what has been done here. The essay claims what was measured.

What it does not give is a weighted sample, and weighting is the most common thing people actually want — sample proportional to size, to importance, to recency. The generalisation exists (the A-Res and A-ExpJ algorithms, which keep a priority queue keyed by u1/wu^{1/w} for a uniform uu and weight ww) and it is a different algorithm with a different proof, not a parameter of this one. Reaching for reservoir sampling when the requirement is weighted is a substitution that produces a plausible sample of the wrong distribution, which is the same failure mode as the off-by-one and just as quiet.

What this makes readable

Essays that name this one as a prerequisite.

Named alongside this one

Essays reaching for the same objects. Nobody chose these; they are what the concept index makes visible.

What links here

The 8 essays that link to this one and share the most of its objects, of 10 that link here.

The objects this essay names

Each one links to every other essay that touches it.

DistributionEntropyOff-by-oneRandom bitsRandomised algorithmReservoir samplingSamplingStreaming algorithmThresholdUniform sampling