Data Structures/Data-Stream Algorithms

Lesson 4.112,640 words

Data-Stream Algorithms

Most of this course assumes data sits in fast memory, addressable at will. External sorting relaxed that to a re-readable disk.

╌╌╌╌

Almost every algorithm in this course so far has assumed the same machine: the input sits in memory, and any element is as cheap to touch as any other. That is the RAM model, and it underwrites our analyses of hash tables, balanced trees, and the rest. External sorting was our first crack in that assumption: the data was too big for RAM, so we counted block transfers to a disk we could re-read at will. This lesson drops even that assumption. In the streaming model, the input flows past once, item by item, faster and larger than we can store. We may keep only a tiny working set, and once an item slides by we may never see it again. The question is what we can still compute.

Three models, three currencies

It helps to put the models side by side. Each makes a different assumption about where the data lives and how often we may touch it, and each counts a different cost.

Three cost models. The RAM model keeps all items in memory with full random access. The external-memory model spills to a re-readable disk and counts block transfers. The streaming model sees each item once and keeps only state, trading exact answers for approximate ones.

With so little memory we cannot, in general, answer exactly. The obstruction is information-theoretic. An algorithm with bits of memory has at most distinguishable states, so if two different prefixes of the stream drive it into the same state, its future behavior on any common suffix is identical. For a question like were all items distinct? there are more prefixes demanding different future behavior than a sublinear memory has states: two different -element prefixes must be distinguished, because an adversary can extend each with an element that appears in one but not the other. Counting those prefixes forces bits for an exact answer, and a similar argument yields the same bound for the exact median.

So streaming algorithms return approximate or probabilistic answers, accepting a small, tunable error in exchange for space that is exponentially smaller than the stream. This is the same trade a Bloom filter makes for set membership, and, as there, the tools are hashing and randomized analysis.1

The rest of the lesson is five techniques, each a small variation on that trade-off: sample the stream, count it, fingerprint its frequencies, find its heavy hitters, and estimate how many distinct things it contained.

Reservoir sampling: a uniform sample of unknown length

Problem. A stream of items flows past, with unknown until the end. Keep a uniform random sample of of them, using only space, so that at every moment the stored items are a uniform random -subset of everything seen so far.

The naive approach, store everything and sample at the end, needs space and a known . Reservoir sampling removes both costs with one idea: keep a reservoir of the first items, then for each later item decide on the spot whether it displaces one of them.

Idea. When the -th item arrives (), admit it into the reservoir with probability ; if admitted, it evicts a uniformly random current occupant. In a uniform size- sample of items, each item appears with probability exactly , and that is the probability we give it.

Algorithm 1:Reservoir(stream,k)\textsc{Reservoir}(\text{stream}, k) — uniform kk-sample in O(k)O(k) space
  1. 1
    R[1k]R[1 \dots k] \gets first kk items of the stream
    fill the reservoir
  2. 2
    iki \gets k
  3. 3
    for each remaining item xx do
  4. 4
    ii+1i \gets i + 1
    xx is the ii-th item overall
  5. 5
    jj \gets uniform random integer in [1,i][1, i]
  6. 6
    if jkj \le k then
    happens with probability k/ik / i
  7. 7
    R[j]xR[j] \gets x
    evict occupant jj, admit xx
  8. 8
    return RR

One subtlety in the pseudocode: a single random draw decides both questions at once. The event has probability exactly , which settles admission, and conditioned on admission is uniform over , which settles who gets evicted. One random number per item, work per item, space total.

The -th arrival is admitted with probability . When admitted it evicts one of the current occupants, chosen uniformly. The green slot is the new item; the faded slot is the one it replaced.

A worked trace. Run the algorithm with on the stream . The first two items fill the reservoir, so after it holds . Then the coin flips begin.

  • Item (). Draw ; admission needs , which happens with probability . Suppose : is admitted and evicts the occupant of slot . Reservoir: .
  • Item (). Admission probability . Suppose : the draw exceeds , so is rejected and discarded forever. Reservoir unchanged: .
  • Item (). Admission probability . Suppose : is admitted and evicts slot . Reservoir: .
Reservoir sampling with on the stream . Items fill the reservoir; each later arrival is admitted with probability (, then , then ) and, when admitted, evicts a uniformly chosen occupant. This run admits and , rejects , and ends at .

The run above is one sample path; the theorem below says that averaging over all coin flips, every one of the pairs is equally likely, and each individual item ends up retained with probability exactly . This local rule produces a globally uniform sample at every prefix, not just at the end: stop the stream after any , and the reservoir is a uniform -subset of the first items.

Check the theorem against the trace: item arrived at , so it should be retained with probability , admission times two survivals. Item , present from the start, must survive the arrivals at , each of which evicts it with probability : as well. Every path through the coin flips is different; the retention probability is not.

Edge cases and variants. If the stream ends with items, the reservoir simply holds all of them, which is the only correct answer. The special case is worth memorizing on its own: keep one item, and replace it with the -th arrival with probability . That is the entire solution to Linked List Random Node, a uniform pick from a list of unknown length in one pass and space. For very long streams where the random-number generator is the bottleneck, the admission probability shrinks, so most draws are rejections; one can instead draw, in time, the number of items to skip before the next admission, and fast-forward. And when items carry weights and the sample should favor heavy items, a weighted variant assigns each item the key for a uniform and keeps the largest keys in a small heap; unweighted reservoir sampling is the special case where all weights are . (The LeetCode problem Random Pick with Weight is the offline cousin: with all weights in memory, prefix sums and a binary search do the job.)

Reservoir sampling underlies A/B test logging, random log extraction, and fair sampling anywhere the choice is from a sequence whose length is learned only at the end.

reservoir_sampling.pypython
import random
from collections.abc import Iterable
from typing import Generic, Optional, TypeVar

Item = TypeVar("Item")

class ReservoirSampler(Generic[Item]):
  """
    A streaming uniform sampler keeping at most `capacity` items.\n
    Feed items one at a time with `offer`; `sample` returns the current\n
    reservoir. An optional `seed` makes the sampling reproducible.\n
  """

  def __init__(self, capacity: int, seed: Optional[int] = None) -> None:
    if capacity < 0:
      raise ValueError("capacity must be non-negative")
    self.capacity: int = capacity
    self.seen: int = 0
    self._reservoir: list[Item] = []
    self._random: random.Random = random.Random(seed)

  def offer(self, item: Item) -> None:
    """
      Present one stream item to the sampler.\n
      While the reservoir is unfilled the item is admitted outright;\n
      afterward it is admitted with probability capacity / seen, evicting\n
      a uniformly random occupant.\n
    """
    # while the reservoir has room, admit the item outright.
    self.seen += 1
    if len(self._reservoir) < self.capacity:
      self._reservoir.append(item)
      return

    # the seen-th item earns a slot with probability capacity / seen.
    position: int = self._random.randint(1, self.seen)
    if position <= self.capacity:
      self._reservoir[position - 1] = item

  def sample(self) -> list[Item]:
    """
      A copy of the current reservoir contents.\n
    """
    return list(self._reservoir)

  def __len__(self) -> int:
    return len(self._reservoir)

def reservoir_sample(
  stream: Iterable[Item],
  capacity: int,
  seed: Optional[int] = None,
) -> list[Item]:
  """
    Draw a uniform random `capacity`-subset of `stream` in one pass.\n
    Convenience wrapper over ReservoirSampler for an entire iterable.\n
  """
  sampler: ReservoirSampler[Item] = ReservoirSampler(capacity, seed)
  for item in stream:
    sampler.offer(item)
  return sampler.sample()

Morris counting: a count in bits

Problem. Count events up to , but spend far fewer than the bits an exact counter needs.

Idea. Don't store the count ; store an exponent that approximates . On each increment, advance only probabilistically, with probability , so that grows by one roughly every time the true count doubles. The estimate read out is .

Algorithm 2:Morris\textsc{Morris} — approximate counting in O(loglogN)O(\log\log N) bits
  1. 1
    X0X \gets 0
  2. 2
    procedure Increment()\textsc{Increment}()
  3. 3
    with probability 2X2^{-X} do
    one biased coin flip
  4. 4
    XX+1X \gets X + 1
  5. 5
    procedure Query()\textsc{Query}()
  6. 6
    return 2X12^{X} - 1
    unbiased estimate of the count

Because only needs to reach about , storing takes bits, an exponential saving over the counter it approximates. Counting to a billion takes , which fits in bits.

A worked trace. One possible run of ten increments:

incrementcoin after
1up
2up
3stay
4stay
5up
6–10stay

After ten increments the estimate reads against a true count of : off by , which is typical, since a single Morris counter has constant relative error. The estimate is coarse but never drifts systematically, and that is the precise content of the next claim.

Variance, and how averaging fixes it. The same conditioning computes the second moment. With ,

so , and summing from gives . Therefore

The standard deviation is about , comparable to the count itself, so one counter is only good to a constant factor. Chebyshev's inequality makes the repair quantitative: averaging independent counters divides the variance by , so

which drops below once . The total space is bits, still exponentially below an exact counter for fixed accuracy.2 Alternatively, replacing base by makes advance more often and shrinks the per-counter variance at the cost of a slightly larger register.

Morris counting anticipates the sketches that follow: a single small random variable standing in for a quantity too large to store exactly, made accurate by averaging independent copies.3

morris_counter.pypython
import random
from typing import Optional

class MorrisCounter:
  """
    A single probabilistic counter storing only an integer exponent.\n
    `increment` advances the exponent with probability 2^-exponent;\n
    `estimate` reads out 2^exponent - 1.\n
  """

  def __init__(self, seed: Optional[int] = None) -> None:
    self.exponent: int = 0
    self._random: random.Random = random.Random(seed)

  def increment(self) -> None:
    """
      Register one event, raising the exponent with probability\n
      2^-exponent so it advances about once per doubling of the count.\n
    """
    if self._random.random() < 2.0 ** (-self.exponent):
      self.exponent += 1

  def estimate(self) -> float:
    """
      The unbiased count estimate 2^exponent - 1.\n
    """
    return 2.0**self.exponent - 1.0

class AveragedMorrisCounter:
  """
    A bank of independent Morris counters whose estimates are averaged.\n
    Averaging `trials` copies shrinks the variance by a factor `trials`,\n
    trading a little more space for a tighter relative error.\n
  """

  def __init__(self, trials: int = 16, seed: Optional[int] = None) -> None:
    if trials < 1:
      raise ValueError("trials must be at least 1")
    base: random.Random = random.Random(seed)
    self._counters: list[MorrisCounter] = [
      MorrisCounter(base.randrange(2**31)) for _ in range(trials)
    ]

  def increment(self) -> None:
    """
      Register one event in every underlying counter.\n
    """
    for counter in self._counters:
      counter.increment()

  def estimate(self) -> float:
    """
      The mean of the per-counter estimates.\n
    """
    total: float = sum(counter.estimate() for counter in self._counters)
    return total / len(self._counters)

Where sampling and counting run

Reservoir sampling and Morris counting are old ideas, 1985 and 1978 respectively, that never went away, because the problems they solve, sampling a stream whose length you learn only at the end and counting without room for a counter, keep recurring at scale.

Reservoir sampling (Vitter, 1985, who named it and gave the skip-ahead optimization) is the standard way to draw a fair sample from a log you cannot buffer: distributed systems use it to keep a bounded, uniform sample of requests for tracing and A/B analysis, and Apache Kafka and Spark ship variants. The weighted generalization, keying each item by and keeping the top keys, is the A-Res algorithm of Efraimidis and Spirakis (2006), used when items should be sampled with unequal probability, such as sampling clicks in proportion to dwell time.

Morris counting (Morris, 1978, analyzed rigorously by Flajolet in 1985) foreshadowed a whole family of probabilistic counters. Its modern descendants are the approximate counters inside monitoring systems that must track billions of events per second in a fixed register budget, where the constant relative error is a fair price for shrinking a -bit counter to bits. The averaging trick that repairs its variance, run independent copies and average, is the same move that turns the noisy sketches of the next lesson into trustworthy estimators.4

Continuing on to sketches

Reservoir sampling and Morris counting each summarized the stream as a whole, a representative subset, a single approximate total. The harder and more common questions are about the stream's contents: how often did a particular item appear, which items are frequent, how many distinct items were there? Answering those in sublinear space calls for sketches, small hashed counter arrays with the same exactness-for-space trade-off pushed one level further. This continues in Streaming Sketches.

Takeaways

  • The streaming model processes in one pass, keeping only state; items are seen once and discarded. An information-theoretic argument forces exact answers to cost space, so the model trades exactness for approximate, probabilistic answers.
  • The three cost models line up by what they assume about the data: RAM (fits, freely re-readable, spends time, exact), external memory (re-readable but too big, spends block transfers, exact), streaming (seen once, spends space, approximate).
  • Reservoir sampling keeps a uniform -sample of an unknown-length stream in space by admitting item with probability ; the telescoping product makes every item equally likely to be retained, at every prefix. The case is a uniform pick from a list of unknown length in space.
  • Morris counting approximates a count up to in bits with the unbiased estimator ; its variance is cut by averaging independent copies, the same technique the sketches of the next lesson use.
  • These two summarize the stream as a whole; estimating its per-item frequencies needs the sketches that follow.

Footnotes

  1. Skiena, §3.7 and the randomized-sampling discussion: hashing as the engine of streaming sketches, and uniform sampling without replacement from a sequence.
  2. CLRS, App. C — Counting and Probability: the expectation, Markov-inequality, and union-bound arguments behind the reservoir and Morris-counter guarantees.
  3. Erickson, Ch. — Randomized Algorithms: analysis of estimators by their expectation and variance, and the averaging trick that drives accuracy with independent copies.
  4. Vitter, Random sampling with a reservoir (1985); Efraimidis & Spirakis, Weighted random sampling with a reservoir (A-Res, 2006); Morris, Counting large numbers of events in small registers (1978), analyzed by Flajolet, Approximate counting: a detailed analysis (1985).
Practice

╌╌ END ╌╌