Cairn
data structures · probabilistic approximate counting of a stream · O(1) increment, O(log log n) bits

back to Probabilistic

Morris Counter

Every other Probabilistic entry on this site that summarizes a stream in fixed memory — HyperLogLog, Count-Min Sketch, Count Sketch, Bloom Filter, Cuckoo Filter, XOR Filter — needs to hash whatever item just arrived, because the question being answered is always about which items: has this one been seen, how many times, how many distinct ones. The Morris Counter, published by Robert Morris in 1978 ("Counting Large Numbers of Events in Small Registers," decades before any of those), answers a question with no item identity in it at all: roughly how many events have happened, total, in a stream too long to tally exactly with the bits on hand? It doesn't take an item as input, doesn't hash anything, and doesn't care whether two events were "the same" — it counts ticks, not things.

The trick: keep a single small counter c, starting at 0. On every event, increment c by 1 — but only with probability 1/2^c, decided by one fresh random draw. Early on that probability is 1 (guaranteed) and drops fast: 1, then 1/2, then 1/4, then 1/8, and so on. The counter itself no longer holds the true count — it holds roughly log2(n) after n events — and the actual estimate is read back out as 2^c - 1. Trading an exact tally for an estimate is what buys the tiny footprint: a counter that needs to track up to a billion events exactly needs 30 bits; one that only needs to track up to log2 of a billion — about 30 — needs just 5 bits to hold c itself, worked out exactly in Complexity below.

Try it

A fixed 16-event stream feeding one Morris Counter, using a seeded random generator so the trace is identical every time you replay it. Click Next to feed one event at a time and watch the draw: the counter climbs to 1 then 2 on the first two events (both guaranteed, since p=1 then p=1/2), stalls at 2 for three events, jumps to 3, stalls for five, jumps to 4, then stalls for the rest of the stream — ending at c=4, an estimate of 2^4 - 1 = 15 against a true count of 16.

event stream (16 events, seed fixed for a reproducible trace)
counter (c, the only state kept)
Click Next to feed the first event.

Core operations

Why the estimator is unbiased

Let Y_n = 2^{c_n} after n events. The claim: E[Y_n] = n + 1 for every n, which makes E[2^{c_n} - 1] = n exactly — the estimator has no systematic bias in either direction. Base case n = 0: c_0 = 0, Y_0 = 1 = 0 + 1. Inductive step: assume E[Y_n] = n + 1. Event n+1 arrives; with probability 1/Y_n (since 1/2^{c_n} = 1/Y_n) the counter increments and Y doubles, otherwise Y is unchanged. So, conditioned on the current value of Y_n:

E[Y_{n+1} | Y_n] = (1 - 1/Y_n)·Y_n + (1/Y_n)·2Y_n
                 = Y_n - 1 + 2
                 = Y_n + 1

Taking expectation over Y_n itself: E[Y_{n+1}] = E[Y_n] + 1 = (n+1) + 1 = (n+1) + 1, matching the claim at n+1. The increment probability was deliberately built to be exactly 1/Y_n — the algebra above is why that specific choice, and not some other decreasing schedule, is the one that keeps E[Y_n] climbing by precisely 1 per event. The off-by-one pitfall below shows, both algebraically and measured live, what happens the moment that probability is shifted by even one factor of 2.

Reference implementation

class MorrisCounter {
  #c = 0;

  increment() {
    const p = 1 / Math.pow(2, this.#c);
    if (Math.random() < p) {
      this.#c++;
      return { incremented: true, c: this.#c };
    }
    return { incremented: false, c: this.#c };
  }

  estimate() {
    return Math.pow(2, this.#c) - 1;
  }
}

Verified three ways, all in Node before any page prose was written. (1) The exact shipped demo trace — all sixteen increment calls with the page's fixed seed reproduced standalone, step by step: increments at events 1, 2, 6, and 12, stalling in between, ending c=4, estimate 15 — matching the page's prose exactly. (2) A 20,000-trial sweep at n=100 using real Math.random(), not the demo's fixed seed: mean estimate 99.94 against a true count of 100, and a measured standard deviation of 70.91 against the closed-form prediction √(n(n+1)/2) = 71.06 — both the unbiasedness and the variance formula land where the induction and a known second-moment derivation say they should, not just "roughly in the neighborhood." (3) Averaging 16 independent counters over 5,000 trials at the same n=100: mean 99.6, standard deviation 17.65 against the predicted 71.06/√16 = 17.77 — confirming the variance really does shrink like 1/√k for k independent counters, the standard real-world fix for a single counter's wide spread. See /tmp/morris/sim.js and /tmp/morris/stats.js, scratch, not committed.

Pitfalls

Unbiased does not mean tight. A single Morris counter's estimate really is correct on average, but any one run can land far from the true count — the induction above proves the mean, not the spread. Run the sweep below yourself: at n=100, a lone counter's estimate has a standard deviation over 70, more than two-thirds of the true count itself. Real deployments never rely on one counter alone — they keep several independent counters and average the estimates, which is the second row below, shrinking the spread by 1/√k for k counters at the cost of k times the state (still tiny — k times a handful of bits, nowhere near an exact tally).

Shifting the increment probability by one factor of 2 provably halves the estimate — not approximately, exactly, in expectation. A tempting one-character slip is computing the probability as 1/2^(c+1) instead of 1/2^c — "the counter is about to become c+1, so shouldn't the odds be based on that?" Redo the induction above with this probability instead: conditioned on Y_n, the counter now increments with probability 1/(2Y_n), so E[Y_{n+1} | Y_n] = Y_n·(1 - 1/(2Y_n)) + 2Y_n·(1/(2Y_n)) = Y_n + 1/2 — a constant 1/2 per event instead of 1, regardless of the current value of Y_n. That telescopes to E[Y_n] = 1 + n/2, so the estimate Y_n - 1 averages out to exactly n/2: a systematic, provable, exact halving, not noise. Run the sweep below and watch the third row land at roughly half the true count every time — the bug doesn't throw, doesn't look obviously wrong on any single run (it's still a plausible-looking number, still growing with the stream), and is only exposed by checking the mean against a known value across many runs, the same category of pitfall as Reservoir Sampling's own off-by-one.

mean estimate and standard deviation across independent trials, vs. the true count (100)
VariantMean estimateStd dev
Not run yet — click above.

The counter itself is not the estimate. Reading c directly and treating it as "roughly how many events" is a different, much cruder error than any bug above — at n=100, correct c typically lands around 6 or 7, nowhere near 100. estimate()'s 2^c - 1 conversion is not optional decoration; the whole point of paying for randomness is to make that conversion an unbiased estimator of n, and skipping it throws that away for a number with no defined meaning at all.

Where the Morris Counter shows up

Complexity

Time: O(1) per event — one random draw, one comparison, at most one increment, regardless of how many events have already been seen. Space: to count up to n events, an exact counter needs O(log n) bits; a Morris Counter's c only needs to climb to around log2(n), which itself only takes O(log log n) bits to represent. Concretely: counting exactly up to one billion events needs 30 bits; the Morris Counter's c only needs to reach about 30, which fits in 5 bits — a 6× reduction here, and the gap between O(log n) and O(log log n) only widens as n grows further.

This site's guide, Choosing a Probabilistic Structure, places this entry among the other fixed-memory stream summarizers — including the one respect in which it's simpler than all of them: it's the only one that needs no hash function at all, because it never has to tell one item apart from another.