Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°47

Reservoir Sampling

Medium SamplingStreamingProbability

Pick k items uniformly at random from a stream of unknown length — in one pass, using O(k) memory, never storing the whole thing. It’s how you sample from a firehose of logs or an endless feed. Here the random draws are supplied, so the result is deterministic and testable. Solve it in Python or TypeScript, with hidden tests.

The problem

Implement reservoir(stream, k, randoms). Keep a "reservoir" of the first k items. For each later item at index i (i ≥ k), you are given a random integer randoms[i-k] uniformly in [0, i]; if it is less than k, replace reservoir slot randoms[i-k] with stream[i]. Return the final reservoir. (Supplying the randoms makes the normally-random algorithm deterministic so it can be tested — the logic is exactly real reservoir sampling.)

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement reservoir(stream, k, randoms): fill the reservoir with the first k items, then for each subsequent index i use the provided draw to decide whether (and where) to replace. Return the reservoir.

CPython · WebAssembly
Examples and constraints 3 examples
EXAMPLE 1
Input stream = [1,2,3,4,5], k = 2, randoms = [0, 3, 1]
Output [3, 5]
i=2 draws 0<2 → slot0=3; i=3 draws 3 (not below 2, skip); i=4 draws 1<2 → slot1=5
EXAMPLE 2
Input stream = [0,1,2,3,4], k = 2, randoms = [0, 0, 0]
Output [4, 1]
slot 0 is overwritten three times; slot 1 keeps its first item
EXAMPLE 3
Input stream = [1,2], k = 5, randoms = []
Output [1, 2]
edge case: a stream shorter than k is returned whole, with no draws used
CONSTRAINTS
  • The reservoir starts as the first k items of the stream.
  • For item i ≥ k, use randoms[i-k] (a draw in [0, i]): if it is < k, overwrite that reservoir slot; otherwise keep the reservoir unchanged.
  • One pass, O(k) memory — you never store more than k items regardless of stream length. The trick gives every item an equal k/N chance of ending up in the reservoir.
Hints 4 ideas
HINTS — 4 IDEAS
  1. Copy the first k items into the reservoir. If the stream has ≤ k items, that is the answer.
  2. For each index i from k onward, read j = randoms[i-k] (the draw in [0, i]).
  3. If j < k, the new item wins slot j: reservoir[j] = stream[i]. Otherwise it is discarded.
  4. That j < k probability is exactly k/(i+1), which is what keeps every item equally likely to survive.
Approach, complexity & discussion — open after you solve

Start from brute force

The obvious way to sample k of n items is to buffer the whole stream, then pick k at random at the end, for example with random.sample(items, k). That is exactly uniform and takes O(n) time, but it needs O(n) space and the end of the stream before it can answer. A firehose of logs does not fit in memory and may never end, so there is no moment at which to sample. The other instinct, keeping each item with probability k/n, needs n up front and returns a sample whose size varies from run to run instead of exactly k.

The approach

Decide each item’s fate the moment it arrives. The reference reservoir starts with res = stream[:k], which also covers a stream shorter than k. For every later index i it reads the supplied draw j = randoms[i - k], uniform over [0, i], and if j < k it writes res[j] = stream[i]. That admits item i with probability k/(i + 1), and since j is uniform over the k slots whenever it is below k, the evicted item is a uniformly chosen resident.

The invariant, by induction: after index i, each of the first i + 1 items is in the reservoir with probability k/(i + 1). It holds at i = k - 1, where everything is kept. At the next index a resident is evicted only if the new item is admitted and lands on its slot, probability (k/(i + 2)) · (1/k), so it stays with probability (i + 1)/(i + 2), and k/(i + 1) · (i + 1)/(i + 2) = k/(i + 2), the new item’s own chance. The TypeScript version runs the same loop over stream.slice(0, k).

Walk through an example

On the first example, stream = [1, 2, 3, 4, 5], k = 2, randoms = [0, 3, 1], two of the three later items get in.

stepistream[i]draw jadmit chanceresultreservoir
1start———copy stream[:2][1, 2]
2230, from [0, 2]2/30 < 2: replace slot 0[3, 2]
3343, from [0, 3]2/43 is not below 2: skip[3, 2]
4451, from [0, 4]2/51 < 2: replace slot 1[3, 5]
5end———return[3, 5]

Complexity

Time O(n): copying the first k items is O(k), and every later item costs one comparison and at most one write. Space O(k) for the reservoir, whatever the stream length; the algorithm needs only the current item, although this problem passes the stream as a list. Buffering and then sampling is also O(n) time but takes O(n) space and must wait for the stream to end.

Edge cases to test

  • reservoir([1, 2], 5, []) → [1, 2]. A stream shorter than k is returned whole; stream[:k] handles it and no draw is read.
  • reservoir([9, 8, 7], 3, []) → [9, 8, 7]. Exactly k items, so the loop never runs.
  • reservoir([0, 1, 2, 3, 4], 2, [0, 0, 0]) → [4, 1]. The same slot can be overwritten again and again; slot 1 keeps its original item.
  • reservoir([1, 2, 3], 2, [2]) → [1, 2], while the draw 1 gives [1, 3]. The test is strict: with j <= k, Python raises IndexError on res[2] and JavaScript silently grows the array to three items.
  • reservoir(list(range(10)), 3, [3, 4, 5, 6, 7, 8, 9]) → [0, 1, 2]. Every draw is at least k, so nothing is replaced.
  • reservoir([1, 2, 3], 0, [0, 1, 2]) → []. With k = 0 no draw is below k, so the reservoir stays empty.

Common mistakes

  • Requiring the total count first — that defeats the entire point of the algorithm.
  • Using the wrong replacement probability — for 0-based index i it must be exactly k/(i + 1), which is what a draw from [0, i] landing below k gives.
  • Biasing toward early or late items by mishandling the boundary at i = k.

Follow-ups an interviewer asks next

Items have weights. Sample with probability proportional to weight.
Use the Efraimidis–Spirakis scheme: give each item the key u ** (1 / w), where u is uniform in (0, 1) and w is the item’s weight, and keep the k items with the largest keys in a min-heap of size k. Each item costs O(log k), so the pass is O(n log k) with O(k) memory.
The stream is enormous. Can you avoid one random draw per item?
Yes. Algorithm L draws how many items to skip before the next replacement, from a geometric-style distribution, so it only touches the items that actually enter the reservoir. The expected number of random draws falls to O(k(1 + log(n/k))) instead of O(n), and the sample stays exactly uniform.
Each shard of a cluster sees part of the stream. Build one global sample.
Tag every item with an independent uniform random key and keep, on each shard, the k items with the smallest keys. The k smallest keys of the union are a uniform sample of everything, so merging is just taking the k smallest across the shard results. Plain per-shard reservoirs can also be merged, but only by weighting each shard by how many items it saw.

Where this shows up

Reservoir sampling draws a uniform random sample from a stream you cannot hold in memory or whose size you do not know — log sampling, A/B bucketing, picking a random line from a huge file, or sampling high-volume telemetry. It is the canonical “fair sample in one pass” technique.

Reference solution

Reference solution — Python
def reservoir(stream, k, randoms):
    res = stream[:k]                        # start with the first k items
    for i in range(k, len(stream)):
        j = randoms[i - k]                  # provided draw in [0, i]
        if j < k:                           # with prob k/(i+1): keep the new item
            res[j] = stream[i]
    return res


print(reservoir([1, 2, 3, 4, 5], 2, [0, 5, 1]))   # -> [3, 5]
Reference solution — TypeScript
function reservoir(stream: number[], k: number, randoms: number[]): number[] {
  const res = stream.slice(0, k);           // first k items
  for (let i = k; i < stream.length; i++) {
    const j = randoms[i - k];               // provided draw in [0, i]
    if (j < k) res[j] = stream[i];          // with prob k/(i+1): keep new item
  }
  return res;
}

console.log(reservoir([1, 2, 3, 4, 5], 2, [0, 5, 1]));  // -> [3, 5]

Questions about Reservoir Sampling

How does reservoir sampling work?

Reservoir sampling picks k items uniformly at random from a stream in one pass, without knowing its length. Keep the first k items in a reservoir. When item number i arrives, counting from 0, draw a random integer j from 0 to i. If j is less than k, the new item replaces reservoir slot j; otherwise it is dropped. Early items face many later chances to be replaced, while late items have a smaller chance of getting in but fewer threats afterward, and the two effects balance exactly. Memory stays at k items no matter how long the stream runs.

Why does every item have the same chance of ending up in the reservoir?

Because admission and eviction cancel. An item at position i, counting from 0, with i at least k, gets in with probability k divided by i + 1. Each later item m then evicts it with probability 1 divided by m + 1, so it survives that step with probability m divided by m + 1. Multiplying those factors up to the end of an n-item stream telescopes to i + 1 divided by n, which leaves k divided by n. The first k items start inside and reach k divided by n the same way. Enumerating all 60 draw sequences for n = 5 and k = 2 gives every item exactly 2 in 5.

When should you use reservoir sampling instead of random.sample?

Use random.sample when the whole population is already in memory as a sequence and its size is known, since it is simpler and does the same job. Use reservoir sampling when the data arrives as a stream, is too large to hold, or has no known end: log lines, events from a message queue, or the lines of a file too big to load. It needs one pass and memory for only k items, and at every moment the reservoir is a valid uniform sample of everything seen so far, which also suits live dashboards.

What is the time and space complexity of reservoir sampling?

The basic algorithm, often called Algorithm R, takes O(n) time for a stream of n items, because every item after the first k costs one random draw, one comparison and at most one write. Space is O(k) for the reservoir, independent of n, which is the reason to use it. Algorithm L improves the time by drawing how many items to skip before the next replacement, so the expected number of random draws drops to O(k(1 + log(n/k))). Weighted variants that keep a heap of size k cost O(n log k).

Finished this one? 0 / 76 Challenges done
NextHyperLogLog Cardinality Estimator Next on the “Structures you build” ladder · rung 6 of 7

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges