← Data structures & algorithms
Approximate Estimate how often, with the error made explicit

Count-min sketch

Spot what is trending in a fixed amount of memory.

A trending list, a “most viewed this hour” rail, and a cache deciding what to keep all ask the same question: how often has this come up? At scale, a counter for every key ever seen gets expensive. Caffeine, one of the most widely used caches in Java, answers with what its documentation calls “a 4-bit CountMinSketch,” and Redis offers the same structure for counting sales. Let’s count a neighborhood forum’s hashtags in a grid of counters that never grows, and see exactly how wrong it is allowed to be.

TypeScriptGoOne trending panel, two implementations.

01 / The idea

Count how often, without keeping a count for everything.

A forum wants a trending panel: which hashtags are people using most this hour? The exact answer is a hash map from tag to count. It is also a map that grows with every tag anyone invents, every typo, every hour, on every server that sees posts.

A count-min sketch keeps a fixed grid instead: depth rows of width counters. Counting a tag hashes it to one column in each row and raises those counters. Estimating reads the same counters and takes the smallest. Tags that collide share counters and push them up; nothing pushes them down. So an estimate can be too high, and is never too low.

Graham Cormode and S. Muthukrishnan invented it in 2003 as a frequency table for streams: far smaller than a table of every item, at the cost of sometimes counting too high. Caffeine’s FrequencySketch keeps one with “periodic aging” for its TinyLFU admission policy, and Redis documents counting “the sales volume (on a certain day) for a product” with one sketch per day. Its Bloom filter cousin answers whether; this answers how often.

02 / Name the rule

Raise one counter per row, then trust the smallest.

Every counter a tag uses holds its own count plus whatever other tags landed there. The smallest of them has the least company, so it is the best estimate, and it is still at least the true count. That is the guarantee: never too low.

How high it can be is set by the grid. With N counted in all, a width of w keeps an estimate within e·N/w of the truth, and each extra row makes it less likely that every counter a tag uses is crowded: the bound holds with probability at least 1 − e to the minus depth. Width sets the size of the error; depth sets how often it is exceeded.

A conservative update squeezes the error further. Read the estimate first, then raise only the counters that are below the new estimate. Counters already high because of other tags stay where they are.

Why not one wide row?One busy neighbor ruins it

With one row, a tag’s estimate is its only counter. If a popular tag shares it, the estimate is wrong by that tag’s whole count, and nothing can tell. With several rows, each hashed differently, a quiet tag would have to share a busy counter in every row to be badly wrong, and the smallest counter usually escapes.

The rows need hashes that disagree. This lesson derives one per row from two hashes of the tag, a common trick, and forces the second to be odd so that, at the power-of-two widths used here, rows do not repeat each other’s columns.

03 / Follow one operation

Ten posts, 24 counters.

The animation uses a deliberately tiny sketch, three rows of eight, so you can see every counter. An hour of forum posts arrives: #roadworks, #lostcat, #bakery, and a few quieter tags, and the panel tracks three trending candidates.

Before you watch, predict whether a tag used once could ever beat one used twice, and what has to happen for it to. The animation replays what the TypeScript example recorded. Try it lets you post and estimate your own.

Count-min sketch

Raise a counter per row. Trust the smallest.

Count-min sketch · a tag raises one counter in each row; its estimate is the smallest

Hour 1, plain updates

Counters, row by row
Row01234567
R000000000
R100000000
R200000000

Trending nothing yet

Hour 1 Counted 0 Raised 0 Read 0

01/ 07
Twenty-four empty counters

Three rows of eight counters.

The sketch has 24 counters, 96 bytes, however many different hashtags the forum uses. Each row hashes a tag to its own column.

Reduced motion: choose a scene to see its completed state.

Read this scene

The sketch has 24 counters, 96 bytes, however many different hashtags the forum uses. Each row hashes a tag to its own column.

The sketch has 24 counters, 96 bytes, however many different hashtags the forum uses. Each row hashes a tag to its own column.

Counters raised so far: 0. Counters read so far: 0.

Watch restarts when you return. Step through keeps your selected step. Try it starts from an empty sketch each time you open it.

04 / Read the shape

One sketch, one trending panel.

Basic form is CountMinSketch: add and estimate, plain or conservative, recording every counter read or raised. In the wild wraps it in HashtagTrends, which reads hashtags out of posts, counts them in this hour’s sketch, keeps a few trending candidates, rotates to a fresh sketch each hour, and reports its noise level.

A count-min sketch: depth rows of width 32-bit counters, one hash per row derived from two. Add raises one counter per row, plainly or conservatively; an estimate reads them and takes the smallest. Every counter read or raised can be recorded.

TypeScriptReading
trends.ts
export type Step = {
	/** read: look at a counter. bump: raise it. keep: leave a counter already high enough. */
	kind: 'read' | 'bump' | 'keep';
	row: number;
	column: number;
	before: number;
	after: number;
};

const MAX_COUNTER = 0xffffffff;
const encoder = new TextEncoder();

// FNV-1a over the UTF-8 bytes, then a final mix, so Go computes the same 32-bit hash.
function hash(text: string): number {
	let h = 0x811c9dc5;
	for (const byte of encoder.encode(text)) {
		h ^= byte;
		h = Math.imul(h, 0x01000193) >>> 0;
	}
	return mix(h);
}

function mix(value: number): number {
	let h = value >>> 0;
	h ^= h >>> 16;
	h = Math.imul(h, 0x85ebca6b) >>> 0;
	h ^= h >>> 13;
	h = Math.imul(h, 0xc2b2ae35) >>> 0;
	h ^= h >>> 16;
	return h >>> 0;
}

// A count-min sketch estimates how often each item appeared using a fixed grid of counters:
// `depth` rows, each `width` columns wide. Adding an item hashes it to one column in every row
// and raises those counters. Its estimate is the smallest of them. Other items can share a
// counter and push it up, but nothing ever pushes one down, so an estimate is never too low.
export class CountMinSketch {
	readonly width: number;
	readonly depth: number;
	/** Raise only the counters that are below the new estimate, which overcounts less. */
	readonly conservative: boolean;
	#counters: Uint32Array;
	#total = 0;

	constructor({
		width,
		depth,
		conservative = false
	}: {
		width: number;
		depth: number;
		conservative?: boolean;
	}) {
		if (!Number.isInteger(width) || width < 1 || width > 1_000_000)
			throw new RangeError('width must be a whole number from 1 to 1000000');
		if (!Number.isInteger(depth) || depth < 1 || depth > 16)
			throw new RangeError('depth must be a whole number from 1 to 16');
		this.width = width;
		this.depth = depth;
		this.conservative = conservative;
		this.#counters = new Uint32Array(width * depth);
	}

	/** The column an item uses in each row: two hashes combined, one per row. */
	columns(item: string): number[] {
		const first = hash(item);
		const step = (mix(first ^ 0x9e3779b9) | 1) >>> 0;
		return Array.from(
			{ length: this.depth },
			(_, row) => ((first + Math.imul(row, step)) >>> 0) % this.width
		);
	}

	// Returns the item's estimate after adding. Counters stop at 4,294,967,295.
	add(item: string, count = 1, steps?: Step[]): number {
		if (!Number.isInteger(count) || count < 1 || count > 1_000_000)
			throw new RangeError('count must be a whole number from 1 to 1000000');
		const columns = this.columns(item);
		let estimate = MAX_COUNTER;
		if (this.conservative) {
			// Every counter for this item is at least its true count, so raising the ones below
			// the new estimate is enough.
			estimate = Math.min(MAX_COUNTER, this.#read(columns, steps) + count);
			columns.forEach((column, row) => {
				const before = this.#counters[row * this.width + column];
				const after = Math.max(before, estimate);
				this.#counters[row * this.width + column] = after;
				steps?.push({ kind: after === before ? 'keep' : 'bump', row, column, before, after });
			});
		} else {
			columns.forEach((column, row) => {
				const before = this.#counters[row * this.width + column];
				const after = Math.min(MAX_COUNTER, before + count);
				this.#counters[row * this.width + column] = after;
				steps?.push({ kind: 'bump', row, column, before, after });
				estimate = Math.min(estimate, after);
			});
		}
		this.#total += count;
		return estimate;
	}

	/** The smallest counter the item hashes to: never below its true count. */
	estimate(item: string, steps?: Step[]): number {
		return this.#read(this.columns(item), steps);
	}

	/** Every counter, row by row, for drawing. */
	rows(): number[][] {
		return Array.from({ length: this.depth }, (_, row) =>
			Array.from(this.#counters.subarray(row * this.width, (row + 1) * this.width))
		);
	}

	/** The sum of every count added. */
	get total(): number {
		return this.#total;
	}

	#read(columns: number[], steps?: Step[]): number {
		let smallest = MAX_COUNTER;
		columns.forEach((column, row) => {
			const value = this.#counters[row * this.width + column];
			steps?.push({ kind: 'read', row, column, before: value, after: value });
			smallest = Math.min(smallest, value);
		});
		return smallest;
	}
}
GoAlongside
trends.go
// Step is one counter touched. Kind is "read" (look at a counter), "bump" (raise it), or
// "keep" (leave a counter already high enough). Before and After are its values.
type Step struct {
	Kind   string `json:"kind"`
	Row    int    `json:"row"`
	Column int    `json:"column"`
	Before uint32 `json:"before"`
	After  uint32 `json:"after"`
	Tag    string `json:"tag,omitempty"`
}

const maxCounter = ^uint32(0)

// hash is FNV-1a over the UTF-8 bytes, then a final mix, so TypeScript computes the same
// 32-bit hash.
func hash(text string) uint32 {
	h := uint32(0x811c9dc5)
	for i := 0; i < len(text); i++ {
		h ^= uint32(text[i])
		h *= 0x01000193
	}
	return mix(h)
}

func mix(h uint32) uint32 {
	h ^= h >> 16
	h *= 0x85ebca6b
	h ^= h >> 13
	h *= 0xc2b2ae35
	h ^= h >> 16
	return h
}

func record(steps *[]Step, step Step) {
	if steps != nil {
		*steps = append(*steps, step)
	}
}

// CountMinSketch estimates how often each item appeared using a fixed grid of counters:
// depth rows, each width columns wide. Adding an item hashes it to one column in every row
// and raises those counters. Its estimate is the smallest of them. Other items can share a
// counter and push it up, but nothing ever pushes one down, so an estimate is never too low.
type CountMinSketch struct {
	width, depth int
	// conservative raises only the counters below the new estimate, which overcounts less.
	conservative bool
	counters     []uint32
	total        uint64
}

func NewCountMinSketch(width, depth int, conservative bool) (*CountMinSketch, error) {
	if width < 1 || width > 1_000_000 {
		return nil, errors.New("width must be a whole number from 1 to 1000000")
	}
	if depth < 1 || depth > 16 {
		return nil, errors.New("depth must be a whole number from 1 to 16")
	}
	return &CountMinSketch{width: width, depth: depth, conservative: conservative, counters: make([]uint32, width*depth)}, nil
}

// Columns returns the column an item uses in each row: two hashes combined, one per row.
func (s *CountMinSketch) Columns(item string) []int {
	first := hash(item)
	step := mix(first^0x9e3779b9) | 1
	columns := make([]int, s.depth)
	for row := range columns {
		columns[row] = int((first + uint32(row)*step) % uint32(s.width))
	}
	return columns
}

// Add returns the item's estimate after adding. Counters stop at 4,294,967,295.
func (s *CountMinSketch) Add(item string, count int, steps *[]Step) (uint32, error) {
	if count < 1 || count > 1_000_000 {
		return 0, errors.New("count must be a whole number from 1 to 1000000")
	}
	columns := s.Columns(item)
	estimate := maxCounter
	if s.conservative {
		// Every counter for this item is at least its true count, so raising the ones below
		// the new estimate is enough.
		estimate = addCapped(s.read(columns, steps), count)
		for row, column := range columns {
			before := s.counters[row*s.width+column]
			after := max(before, estimate)
			s.counters[row*s.width+column] = after
			kind := "bump"
			if after == before {
				kind = "keep"
			}
			record(steps, Step{Kind: kind, Row: row, Column: column, Before: before, After: after})
		}
	} else {
		for row, column := range columns {
			before := s.counters[row*s.width+column]
			after := addCapped(before, count)
			s.counters[row*s.width+column] = after
			record(steps, Step{Kind: "bump", Row: row, Column: column, Before: before, After: after})
			estimate = min(estimate, after)
		}
	}
	s.total += uint64(count)
	return estimate, nil
}

// Estimate returns the smallest counter the item hashes to: never below its true count.
func (s *CountMinSketch) Estimate(item string, steps *[]Step) uint32 {
	return s.read(s.Columns(item), steps)
}

// Rows returns every counter, row by row, for drawing.
func (s *CountMinSketch) Rows() [][]uint32 {
	rows := make([][]uint32, s.depth)
	for row := range rows {
		rows[row] = slices.Clone(s.counters[row*s.width : (row+1)*s.width])
	}
	return rows
}

// Total is the sum of every count added.
func (s *CountMinSketch) Total() uint64 { return s.total }

func (s *CountMinSketch) read(columns []int, steps *[]Step) uint32 {
	smallest := maxCounter
	for row, column := range columns {
		value := s.counters[row*s.width+column]
		record(steps, Step{Kind: "read", Row: row, Column: column, Before: value, After: value})
		smallest = min(smallest, value)
	}
	return smallest
}

func addCapped(value uint32, count int) uint32 {
	if uint64(value)+uint64(count) > uint64(maxCounter) {
		return maxCounter
	}
	return value + uint32(count)
}
Reading the TypeScriptA Uint32Array and Math.imul

The counters are one Uint32Array, row after row, so a counter is row * width + column. Math.imul multiplies as 32-bit integers and >>> 0 keeps results unsigned, which is what makes the hash match Go’s bit for bit.

Hashtags are read by code point, and a character counts as part of a tag when it matches \p{L}, \p{Nd}, or an underscore, the same Unicode letters and digits Go’s unicode package reports.

Reading the Gouint32 arithmetic and a capped add

uint32 multiplication wraps on its own, so the hash needs no masking. Counters stop at the largest uint32 rather than wrapping to zero: addCapped checks the sum in 64 bits first.

Top sorts with cmp.Or, estimate first and tag second, and the candidates are a map[string]bool, cleared with clear when the hour turns.

What would I normally use in application code?Often an exact map, sometimes a library

Neither TypeScript nor Go ships a count-min sketch. If the different keys fit in memory, and for one forum’s hour they easily do, count exactly with a Map or a map[string]int.

Reach for a sketch when keys are unbounded or counts must be kept per shard, per hour, or per page across a fleet: Redis has CMS.INCRBY and CMS.QUERY, Java has Caffeine’s sketch inside its cache, and the stream libraries of most data platforms include one.

05 / Try a decision

Size the sketch before you create it.

A sketch cannot grow later without starting over, so its width and depth are a decision made up front. Work out which one meets the forum’s goal before the feedback tells you.

The forum sees about 200,000 hashtags an hour. You want each tag’s estimate at most about 1,000 too high, for 99% of tags. Which sketch do you create?

06 / Follow the cost

16 KB for 200,000 uses.

Here is every operation at a glance, with width w, depth d, N counted, and k candidates. The rest of this section measures a busy hour.

Hashtag sketch: time and extra space
OperationTimeExtra spaceWhat it assumes
Count a hashtagO(d)O(1)d is the depth: hash once, raise one counter in each of the d rows.
Estimate a hashtagO(d)O(1)Read the same d counters and take the smallest. Never below the true count.
Count conservativelyO(d)O(1)Read first, then raise only the counters below the new estimate.
Keep k trending candidatesO(k·d) when a new tag challengesO(k)Find the weakest candidate’s estimate; a binary heap would make this O(log k) for large k.
The sketch itself—O(w·d)Fixed however many tags appear. The error is at most e·N/w with probability at least 1 − e^−d.
Exact hash map insteadO(1) averageO(n)Exact counts, with one entry for each of the n different tags ever seen.

In the animation, 24 counters held seven tags, and #garden, used once, was estimated at 3 and entered the trending list. To measure a busier hour, the lesson’s TypeScript sketch counted 200,000 uses of 20,000 possible tags whose popularity falls off steeply, as tags do, drawn with a seeded generator that the lesson’s tests replay: 14,279 different tags actually appeared, the top one 29,273 times and the tenth 2,340 times. Each sketch had four rows.

With 256 columns, 4,096 bytes, estimates ran 228 too high on average and at worst 3,120, and nine of the true top ten came out on top. Conservative updates cut the average to 133 and the worst error among the top ten from 382 to 24. With 1,024 columns, 16,384 bytes, the average error was 35 and the true top ten all came out on top, their estimates up to 60 high. Two of the 14,279 tags came out past the bound of 531, which the guarantee allows: it holds for each tag with probability 1 − e⁻⁴, about 98%. Conservatively, the average fell to 19 and the top ten estimates were exact. With 4,096 columns, 65,536 bytes, none was more than 61 high, and 12% of estimates were exact, 31% conservatively. These are counts, not timings.

Be honest about the alternative: 14,279 tags fit in a small hash map, and for this forum that map is the better choice. The sketch earns its place when the keys have no ceiling, such as every search typed into a site or every IP address that reaches a server, or when thousands of small sketches, one per page or per hour, must stay the same small size.

07 / Give it a real job

Trending, hour by hour.

HashtagTrends writes the policy down. A hashtag is # then up to 50 letters, digits, or underscores, not straight after one; capitals A to Z fold to lowercase, so #café and #CAFÉ stay different, which a real forum would fix with a proper Unicode case fold. A post counts each tag once, and a post with more than 30 tags is rejected. Each hour gets a fresh sketch, and last hour’s is kept for comparison.

Low counts deserve suspicion. Redis’s documentation says results below the error threshold “should be ignored and often even approximated to zero,” and noise reports that threshold, e × counted ÷ width. The candidate list can also be fooled, as #garden showed: a panel that matters should confirm its winners, for example by counting just the current candidates exactly.

08 / Make the call

Ask whether “about how often” is enough.

Reach for a count-min sketch when you need frequencies from a stream whose keys you cannot bound, the answer may run a little high, and the memory must stay fixed. Frequent items are estimated well; rare ones drown in collisions, so ask about the frequent ones.

Look elsewhere when the question changes. For exact counts over keys that fit, a hash map is simpler. To know only whether a key has appeared, a Bloom filter uses less. To know how many different keys appeared, HyperLogLog answers in a few kilobytes. And to keep the top k exactly as counts change, a binary heap of candidates is the other half of most trending lists.

09 / Take the idea with you

Explain it without saying “count-min sketch.”

“I keep a few rows of counters. Each row sends a hashtag to one counter, chosen by a different hash, and counting the tag adds one to each of those. To ask how often it appeared, I read its counters and trust the smallest, because other tags can only have added to them. More columns make the answers closer; more rows make a bad answer rarer.” That describes the mechanism. The name is what you call it in a review.

Before moving on, explain three things without the name: why an estimate is never too low, how #garden got into the trending list, and why the width, not the depth, set the size of the error. Then look for a map in your code that counts things forever, and decide whether its keys have a ceiling.

Connections to follow nextRelated lessons
  • Bloom filter hashes to bits instead of counters, for whether rather than how often.
  • Hash map counts exactly, one entry per key.
  • Binary heap keeps the top candidates in order as their counts change.
  • LRU cache evicts by recency; Caffeine adds a sketch to judge frequency too.

Take the sketch into your editor. Count a million words from a book in widths of 256, 1,024, and 4,096, then list the ten most frequent from each and compare with exact counts.

Back to data structures & algorithms →