Skip to content

Streaming and online algorithms

intermediateCommon atAnthropicGoogleMetaStripeRead firstHeapsHash mapsSliding windowPractise in the Sandbox →

A streaming (or online) algorithm reads each item once, in arrival order, and keeps a small summary instead of the data. The invariant: after every item, the summary answers the question for everything seen so far. Exact answers like mean and variance fit in O(1) memory; others (median, distinct count, frequencies) need O(n) to be exact, so at scale you trade a little accuracy for fixed memory.

Think of a turnstile clicker at a stadium gate. It doesn’t remember faces. It keeps one number, and that number is always the right answer to “how many people came in?”.

A streaming algorithm is a smarter clicker. You decide what question you need answered, then ask: what is the least I must remember so I can update the answer when one more item arrives?

  • Count, sum, min, max: one number each. Trivial.
  • Mean and variance: three numbers (count, mean, a running “spread” total). This is Welford’s method.
  • Median: no small summary exists. You must keep the values, but you can keep them organised so each update is O(log n).
  • “How often did X appear?” / “How many distinct?” over billions of items: exact needs memory per distinct item. Sketches (count-min, HyperLogLog) answer approximately in a few kilobytes.

Two words you will hear. Online means you must answer after each item without seeing the future (like a live dashboard). One pass means you may only read the input once, perhaps because it is a 2 TB log file or a network socket.

Type a stream, then step through. The left panel shows Welford’s three numbers: they never grow. The right panel keeps the exact median with two halves (a max-heap of the smaller half and a min-heap of the larger half). Watch the halves stay within one element of each other, and notice that they hold every value.

One pass: running mean, variance and median
step through
·
·
·
·
·
·
·
·
·
·
dimmed = already consumed; dashed = not arrived yet
Welford: O(1) memory
n0
mean0
M20
variance0
stddev0
Two halves: median = –
lower half · max-heap, biggest on top
(empty)
upper half · min-heap, smallest on top
(empty)
arriving nowheap topjust changed
Values arrive one at a time and are never revisited. Welford keeps 3 numbers; the median keeps two halves.
1 / 16

When exact is too expensive, you sketch. A count-min sketch keeps a small grid of counters. Each item bumps one counter in every row (picked by a different hash per row). Collisions only ever add, so each counter is an over-estimate, and the smallest of an item’s counters is the best guess.

Count-min sketch: approximate counts in a fixed grid
sandbox
0
7
0
0
0
1
0
0
0
1
1
4
0
0
0
2
0
4
0
3
1
0
0
0
Each word bumps one counter per row. Highlighted: the 3 counters for cat; green is the minimum, the estimate.
wordtrue countestimateover by
cat440
dog220
bird110
fish110
items8
distinct4
counters24
total over-count0
Click a row to see its counters. Shrink the width or add other words: collisions push estimates up, never down. Add rows and the minimum filters most collisions out.
the word's counter in a rowsmallest one = estimate

A HyperLogLog estimates how many distinct items it saw. Drag n from 10 to 200,000: the memory stays at m registers, while the error stays near ±1.04/√m.

HyperLogLog: count distinct items with a few hundred bytes
drag the sliders
registers m
register 0register 63 · bar = longest leading-zero run + 1
true n1,000
estimate1,125
error+12.5%
typical error±13.0%
memory64 × 6 bits = 48 B
exact set would need~32,000 B
Flip a fair coin until heads: a run of k tails before the first head turns up about once in 2k tries. So the longest run you have seen (11 zeros, amber) hints that you saw about 211 things. One register is noisy; HyperLogLog splits items over m registers and takes a harmonic mean, so the error shrinks like 1.04/√m.
The problem says… Why streaming fits
“the input does not fit in memory” / “a 500 GB log file” One pass with a summary is the only way to avoid loading it.
“after each event, report…” / “live dashboard” An online algorithm updates the answer per item instead of recomputing.
“running average / standard deviation” Welford: O(1) memory, numerically stable.
“median so far” with frequent queries Two heaps: O(log n) per insert, O(1) per query.
“approximately how many distinct users / how often” over huge volume Sketches give bounded error in fixed memory.
“pick k random lines from a file of unknown length” Reservoir sampling, one pass, O(k) memory.

The textbook variance formula, mean of squares minus square of mean, is one pass but numerically unstable: with values near 10⁹, both terms are about 10¹⁸ and their difference (the variance you want) disappears in floating-point rounding. Welford updates the mean and a running sum of squared deviations directly.

  1. Count the new value: n += 1.
  2. Measure how far it is from the old mean: delta = x - mean.
  3. Move the mean a fraction of the way: mean += delta / n.
  4. Grow the spread by old distance times new distance: M2 += delta * (x - mean). Variance is M2 / (n - 1) (sample) or M2 / n (population).
RunningStats.py
import math
class RunningStats:
"""Mean and variance of a stream in one pass and O(1) memory (Welford, 1962)."""
def __init__(self) -> None:
self.n = 0
self.mean = 0.0
self.m2 = 0.0 # sum of squared distances from the current mean
def push(self, x: float) -> None:
self.n += 1 # 1. count the new value
delta = x - self.mean # 2. distance from the OLD mean
self.mean += delta / self.n # 3. nudge the mean toward x
self.m2 += delta * (x - self.mean) # 4. old distance x new distance: no big sums to cancel
def variance(self) -> float:
"""Sample variance (divide by n - 1). Needs at least two values."""
return self.m2 / (self.n - 1) if self.n > 1 else 0.0
def stddev(self) -> float:
return math.sqrt(self.variance())
if __name__ == "__main__":
s = RunningStats()
for x in [2, 4, 4, 4, 5, 5, 7, 9]:
s.push(x)
assert s.n == 8 and s.mean == 5.0
assert abs(s.variance() - 32 / 7) < 1e-12 # population var is 4, sample var 32/7
# Large offset: the naive sum(x^2)/n - mean^2 formula loses every digit here.
big = RunningStats()
for x in [1e9 + 4, 1e9 + 7, 1e9 + 13, 1e9 + 16]:
big.push(x)
assert abs(big.variance() - 30.0) < 1e-6
print("ok")

Any exact one-pass median needs memory proportional to n in the worst case (Munro and Paterson proved this lower bound in 1980): until the stream ends, any value you threw away might turn out to be the middle one. The standard exact structure is the two-heaps idea you stepped through above:

  • The lower half lives in a max-heap, so its biggest value is on top.
  • The upper half lives in a min-heap, so its smallest value is on top.
  • Invariant: every value in the lower half ≤ every value in the upper half, and the sizes differ by at most one. Then the median is one top, or the average of the two tops.

Each new value goes into one heap and may cause one value to move across: O(log n) per item, O(1) to read the median. For bounded memory at scale, systems use approximate quantile sketches instead (Greenwald-Khanna, t-digest, KLL), which answer “the 99th percentile” within a stated rank error.

  1. Allocate depth rows of width counters, all zero, and pick one hash function per row.
  2. To add an item, hash it once per row and increment that row’s counter.
  3. To estimate an item, read its counter in every row and return the minimum. Collisions only inflate counters, so the error is one-sided: never too low.
CountMinSketch.py
class CountMinSketch:
"""Approximate counts in fixed memory: depth rows x width counters.
Never under-counts; over-counts by at most ~ e/width * total with high probability."""
def __init__(self, width: int, depth: int) -> None:
self.width, self.depth = width, depth
self.rows = [[0] * width for _ in range(depth)]
def _col(self, item: str, row: int) -> int:
# FNV-1a, with a different starting value per row, so rows collide differently.
h = (2166136261 ^ (row * 0x9E3779B9)) & 0xFFFFFFFF
for b in item.encode():
h = ((h ^ b) * 16777619) & 0xFFFFFFFF
return h % self.width
def add(self, item: str, count: int = 1) -> None:
for r in range(self.depth): # 1. one counter per row goes up
self.rows[r][self._col(item, r)] += count
def estimate(self, item: str) -> int:
# 2. every row over-counts (collisions only add), so the smallest is the best guess
return min(self.rows[r][self._col(item, r)] for r in range(self.depth))
if __name__ == "__main__":
words = "the cat sat on the mat the end the cat".split()
cms = CountMinSketch(width=16, depth=3)
for w in words:
cms.add(w)
true = {w: words.count(w) for w in words}
for w, c in true.items():
assert cms.estimate(w) >= c # one-sided error: never too low
assert all(sum(row) == len(words) for row in cms.rows) # every row saw every item
assert cms.estimate("the") == 4
print("ok")

Sizing comes from Cormode and Muthukrishnan: with width = ⌈e / ε⌉ and depth = ⌈ln(1/δ)⌉, every estimate is at most true + ε·N (N = total count) with probability at least 1 − δ. For ε = 0.1% and δ = 1%, that is 2,719 × 5 counters: about 54 KB of 32-bit counters, whatever the number of distinct items.

Hash each item to random-looking bits. In random bits, a run of k leading zeros appears about once every 2^k values, so the longest run you have seen estimates log₂ of the distinct count. Duplicates hash identically, so they never change the answer: that is why it counts distinct items. One such estimate is very noisy, so HyperLogLog uses the first few bits to pick one of m registers, keeps the longest run per register, and combines them with a harmonic mean plus a correction factor. Standard error is about 1.04 / √m. Redis uses m = 16,384 registers in 12 KB for 0.81% error. Registers merge by taking the maximum, so sketches from many servers combine exactly.

To keep a uniform random sample of k items from a stream of unknown length: keep the first k; for item number i > k, with probability k / i replace a uniformly random slot. Each item ends up in the sample with probability exactly k / n (Vitter’s Algorithm R, from Knuth).

Question Exact, one pass Memory Approximate alternative
count, sum, min, max yes O(1)
mean, variance (Welford) yes O(1)
median / percentile two heaps, O(log n) per item O(n) GK / t-digest / KLL: O(1/ε · polylog)
frequency of an item hash map of counts O(distinct) count-min: O(1/ε · log 1/δ), over-counts only
distinct count hash set O(distinct) HyperLogLog: m registers, ±1.04/√m
top-k heavy hitters full count map + heap O(distinct) Misra-Gries / Space-Saving: O(k)
uniform sample of k reservoir O(k)

The pattern: anything you can write as a fold with a small accumulator (state = update(state, x)) streams in O(1). Anything that depends on the rank or identity of many items needs memory for those items, unless you accept a bounded error.

  • Windowed streams. “Over the last 5 minutes” instead of “ever”. Old items must expire: keep a queue of buckets or timestamps and subtract what falls out (see Sliding window and TTL and expiry). Welford can be reversed to remove a value, though removal is less numerically stable than adding.
  • Exponentially weighted averages. avg = α·x + (1 − α)·avg forgets old data smoothly in O(1), with no window to store. Common in load balancers and latency trackers.
  • Merging sorted streams. k sorted inputs into one sorted output with a heap of k heads: O(log k) per item.
  • Out-of-order and late data. Real streams arrive late. Systems keep a “grace period” or watermark and decide what to do with items older than it.
  • Heavy hitters. Misra-Gries keeps k − 1 counters and guarantees any item with frequency > n/k survives.
  • Naive variance (E[x²] − E[x]²) on large or similar values returns garbage, even negative numbers. Use Welford.
  • Integer division and overflow. A running sum of int32 values overflows quickly in C#; use long. In TypeScript every number is a double, exact only up to 2^53.
  • Pretending the median is O(1) memory. It is not. Say so, then offer an approximation if memory is bounded.
  • Under-sized sketches. A count-min sketch narrower than the number of heavy items reports large errors. Size it from ε and δ, and remember the error scales with total volume N.
  • Weak hash functions correlate rows and break the guarantees. Use independent seeds.
  • Reading the whole file anyway. f.readlines(), File.ReadAllLines and JSON.parse on the whole input load everything. Iterate lines instead (see Memory efficiency).
  • Ask: does it fit in memory? Is it one pass or can I read twice? Do I need exact answers? Is there a time window? Do items arrive in order?
  • Name the summary and its invariant out loud: “After each item, mean is the mean of everything so far and M2 is the sum of squared deviations from it.”
  • Separate exact from approximate explicitly and quote the bound: “count-min never under-counts; it over-counts by at most ε·N with probability 1 − δ.”
  • Expect follow-ups: make it windowed, run it on 100 machines and merge, what if memory is capped at 1 MB, what if data arrives late.
Check yourself
pick one
You compute variance in one pass as sum(x²)/n − mean². The values are all near 1,000,000,000 and differ by a few units. What happens?
Check yourself
pick one
A count-min sketch reports 57 for item X. What can you say about X's true count?
Check yourself
pick one
Why does HyperLogLog count distinct items rather than total items?

These are classic textbook exercises, not gym questions:

  • Merge two Welford summaries computed on different halves of an array; check against a two-pass variance.
  • Reservoir-sample k lines from a file of unknown length; test uniformity by sampling a 10-line file 100,000 times.
  • Misra-Gries: find every element that appears more than n/3 times in one pass with two counters.
  • Exponential moving average of request latency; compare its reaction to a spike against a 10-item simple moving average.
  • Estimate distinct words in a large text with a 64-register HyperLogLog; compare to a set.