Go Guide

DP, math & design · Lesson 43 of 62 · about 60 min

Hash tables & probabilistic structures

Build a hash map from scratch with open addressing and resizing, then trade exactness for huge savings with Bloom filters, count-min sketches, reservoir sampling and correct shuffling.

On this page · 9 sections
  1. 01Hash tables from the inside
  2. 02Bloom filters: "definitely not" or "probably yes"
  3. 03Count-min sketch: approximate counting
  4. 04Sampling and shuffling correctly
  5. 05HyperLogLog, briefly
  6. 06Exact vs probabilistic
  7. 07Summary
  8. 08Check your understanding
  9. 09Practice in Go

Hash tables from the inside#

A hash table stores entries in an array of slots. A hash function maps each key to a slot index. Two keys landing on the same slot is a collision, and there are two classic ways to handle it:

StrategyCollisions goProsCons
separate chaininginto a small list per slotsimple; degrades gracefullya pointer chase per lookup
open addressing (linear probing)into the next free slotone contiguous array, cache-friendlyclusters; deletions need care

Go's built-in map is a Swiss table (since Go 1.24): open addressing where slots come in groups of 8, each group carrying 8 one-byte "control" fingerprints of the hashes, so a single word comparison checks a whole group at once.

Load factor and resizing

The load factor α is entries / slots. As α approaches 1, probe sequences explode: with linear probing, an unsuccessful lookup expects about ½(1 + 1/(1-α)²) probes, which is 2.5 at α = 0.5, 8.5 at α = 0.75, and 50.5 at α = 0.9. So tables grow (usually doubling) when α passes a threshold, and rehash every entry into the bigger array. Like append, doubling keeps inserts O(1) amortized.

Step through linear probing, then try inserting many keys with the same remainder to watch a cluster form:

Deletion: tombstones

If you delete by emptying a slot, any key that was placed after it during probing becomes unreachable, because lookups stop at the first empty slot. Mark deleted slots with a tombstone instead: lookups skip over it, and inserts may reuse it.

hashmap.go
Go
package main
 
import (
	"fmt"
	"hash/maphash"
)
 
type state uint8
 
const (
	empty state = iota
	used
	deleted // tombstone
)
 
type slot[K comparable, V any] struct {
	key   K
	val   V
	state state
}
 
// Map is an open-addressing hash map with linear probing and tombstones.
type Map[K comparable, V any] struct {
	slots      []slot[K, V]
	count      int // live entries
	tombstones int
	seed       maphash.Seed
}
 
func New[K comparable, V any]() *Map[K, V] {
	return &Map[K, V]{slots: make([]slot[K, V], 8), seed: maphash.MakeSeed()}
}
 
func (m *Map[K, V]) index(k K) int {
	return int(maphash.Comparable(m.seed, k) % uint64(len(m.slots)))
}
 
// find returns the slot holding k, or (-1, first reusable slot).
func (m *Map[K, V]) find(k K) (int, int) {
	reuse := -1
	for i, n := m.index(k), 0; n < len(m.slots); i, n = (i+1)%len(m.slots), n+1 {
		s := &m.slots[i]
		switch s.state {
		case empty:
			if reuse < 0 {
				reuse = i
			}
			return -1, reuse
		case deleted:
			if reuse < 0 {
				reuse = i // remember the first tombstone, but keep probing
			}
		case used:
			if s.key == k {
				return i, -1
			}
		}
	}
	return -1, reuse
}
 
func (m *Map[K, V]) grow() {
	old := m.slots
	m.slots = make([]slot[K, V], 2*len(old))
	m.count, m.tombstones = 0, 0
	for _, s := range old {
		if s.state == used {
			m.Set(s.key, s.val) // rehash into the bigger table; tombstones are dropped
		}
	}
}
 
func (m *Map[K, V]) Set(k K, v V) {
	if 4*(m.count+m.tombstones+1) > 3*len(m.slots) { // keep the load factor under 0.75
		m.grow()
	}
	i, reuse := m.find(k)
	if i >= 0 {
		m.slots[i].val = v
		return
	}
	if m.slots[reuse].state == deleted {
		m.tombstones--
	}
	m.slots[reuse] = slot[K, V]{key: k, val: v, state: used}
	m.count++
}
 
func (m *Map[K, V]) Get(k K) (V, bool) {
	if i, _ := m.find(k); i >= 0 {
		return m.slots[i].val, true
	}
	var zero V
	return zero, false
}
 
func (m *Map[K, V]) Delete(k K) {
	if i, _ := m.find(k); i >= 0 {
		var zero slot[K, V]
		m.slots[i] = zero
		m.slots[i].state = deleted
		m.count--
		m.tombstones++
	}
}
 
func (m *Map[K, V]) Len() int { return m.count }
 
func main() {
	m := New[string, int]()
	words := []string{"go", "rust", "zig", "c", "java", "python", "ruby", "elixir", "haskell", "ocaml"}
	for i, w := range words {
		m.Set(w, i)
	}
	fmt.Println("len", m.Len(), "slots", len(m.slots))
	v, ok := m.Get("haskell")
	fmt.Println("haskell:", v, ok)
	m.Delete("go")
	m.Delete("zig")
	_, ok = m.Get("go")
	fmt.Println("after deletes, go present:", ok, " len", m.Len())
 
	// Everything else must still be reachable past the tombstones.
	all := true
	for i, w := range words {
		if w == "go" || w == "zig" {
			continue
		}
		got, ok := m.Get(w)
		all = all && ok && got == i
	}
	fmt.Println("all other keys still found:", all)
 
	big := New[int, int]()
	for i := range 100_000 {
		big.Set(i, i*i)
	}
	x, _ := big.Get(99_999)
	fmt.Println("100000 keys, slots", len(big.slots), " 99999² =", x)
}
Output
len 10 slots 16
haskell: 8 true
after deletes, go present: false  len 8
all other keys still found: true
100000 keys, slots 262144  99999² = 9999800001

maphash.Comparable (Go 1.24) hashes any comparable value with a per-map random seed. Random seeds are also why Go's map iteration order varies between runs: it stops programs from depending on an order, and it stops attackers from crafting keys that all collide.

Bloom filters: "definitely not" or "probably yes"#

A Bloom filter is an array of m bits plus k hash functions. To add x, set the bits at positions h1(x), …, hk(x). To query x, check those bits: if any is 0, x was definitely never added; if all are 1, x was probably added (another combination of elements may have set them: a false positive). There are no false negatives.

Why it is worth it: storing 1 million URLs exactly takes tens of megabytes; a Bloom filter with a 1% false-positive rate needs about 1.2 MB (9.6 bits per item), regardless of how long the URLs are.

FormulaMeaning
m = -n · ln(p) / (ln 2)²bits needed for n items at false-positive rate p
k = (m / n) · ln 2the best number of hash functions

Real uses: databases (Cassandra, RocksDB, PostgreSQL's bloom index) check the filter before reading a file from disk; browsers checked URLs against malware lists; CDNs avoid caching one-hit wonders.

Instead of k independent hash functions, double hashing computes two hashes h1 and h2 and uses h1 + i·h2 for i = 0..k-1. It performs just as well in practice.

bloom.go
Go
package main
 
import (
	"fmt"
	"hash/fnv"
	"math"
)
 
type Bloom struct {
	bits []uint64
	m, k uint64
}
 
func NewBloom(n int, p float64) *Bloom {
	m := uint64(math.Ceil(-float64(n) * math.Log(p) / (math.Ln2 * math.Ln2)))
	k := uint64(math.Round(float64(m) / float64(n) * math.Ln2))
	return &Bloom{bits: make([]uint64, (m+63)/64), m: m, k: max(k, 1)}
}
 
func (b *Bloom) hashes(s string) (uint64, uint64) {
	h := fnv.New64a()
	h.Write([]byte(s))
	h1 := h.Sum64()
	h2 := h1>>33 | h1<<31 // a second, different-looking hash from the same bits
	return h1, h2 | 1     // odd, so the k positions differ
}
 
func (b *Bloom) Add(s string) {
	h1, h2 := b.hashes(s)
	for i := range b.k {
		pos := (h1 + i*h2) % b.m
		b.bits[pos/64] |= 1 << (pos % 64)
	}
}
 
func (b *Bloom) MayContain(s string) bool {
	h1, h2 := b.hashes(s)
	for i := range b.k {
		pos := (h1 + i*h2) % b.m
		if b.bits[pos/64]&(1<<(pos%64)) == 0 {
			return false // definitely never added
		}
	}
	return true // probably added
}
 
func main() {
	const n = 10_000
	b := NewBloom(n, 0.01)
	fmt.Printf("%d items at 1%%: %d bits (%.1f KB), %d hashes\n", n, b.m, float64(b.m)/8/1024, b.k)
	for i := range n {
		b.Add(fmt.Sprintf("user-%d", i))
	}
	misses := 0
	for i := range n {
		if !b.MayContain(fmt.Sprintf("user-%d", i)) {
			misses++
		}
	}
	fmt.Println("false negatives:", misses)
	fp := 0
	for i := range 100_000 {
		if b.MayContain(fmt.Sprintf("visitor-%d", i)) {
			fp++
		}
	}
	fmt.Printf("false positives: %d of 100000 (%.2f%%)\n", fp, float64(fp)/1000)
}
Output
10000 items at 1%: 95851 bits (11.7 KB), 7 hashes
false negatives: 0
false positives: 937 of 100000 (0.94%)

Count-min sketch: approximate counting#

A Bloom filter answers "seen it?"; a count-min sketch answers "how many times?". It keeps d rows of w counters. Adding x increments one counter per row (chosen by that row's hash). The estimate for x is the minimum of its d counters: collisions can only inflate a counter, never shrink it, so the minimum is the least-inflated one. It finds heavy hitters (top searched terms, top source IPs in a DDoS) in a fixed amount of memory.

Sampling and shuffling correctly#

Reservoir sampling

"Pick k items uniformly at random from a stream whose length you do not know" (log lines, a huge file, a linked list). Keep the first k items. For item number i (counting from 1) after that, replace a random reservoir slot with probability k / i. At every moment, each item seen so far is in the reservoir with probability exactly k / i.

Fisher-Yates shuffle

To shuffle a slice uniformly, walk backward and swap element i with a random index in [0, i]. Each of the n! orders is equally likely. The tempting version, swapping with a random index from the whole slice, produces nⁿ equally likely paths, and since nⁿ is not divisible by n! some orders come up more often than others. rand.Shuffle implements the correct version; the program below measures the bias of the wrong one.

sampling.go
Go
package main
 
import (
	"fmt"
	"math/rand/v2"
	"slices"
)
 
func reservoir(stream []int, k int, r *rand.Rand) []int {
	res := slices.Clone(stream[:k])
	for i := k; i < len(stream); i++ {
		if j := r.IntN(i + 1); j < k { // probability k/(i+1), with i 0-based
			res[j] = stream[i]
		}
	}
	return res
}
 
func fisherYates(a []int, r *rand.Rand) {
	for i := len(a) - 1; i > 0; i-- {
		j := r.IntN(i + 1) // 0..i
		a[i], a[j] = a[j], a[i]
	}
}
 
func naiveShuffle(a []int, r *rand.Rand) {
	for i := range a {
		j := r.IntN(len(a)) // WRONG: any index
		a[i], a[j] = a[j], a[i]
	}
}
 
func main() {
	r := rand.New(rand.NewPCG(7, 7))
 
	stream := make([]int, 100)
	for i := range stream {
		stream[i] = i
	}
	hits := make([]int, 100)
	for range 20_000 {
		for _, x := range reservoir(stream, 5, r) {
			hits[x]++
		}
	}
	fmt.Println("reservoir: each item picked about 1000 times:", hits[0], hits[50], hits[99])
 
	count := func(shuffle func([]int, *rand.Rand)) map[string]int {
		c := map[string]int{}
		for range 60_000 {
			a := []int{1, 2, 3}
			shuffle(a, r)
			c[fmt.Sprint(a)]++
		}
		return c
	}
	fmt.Println("fisher-yates:", count(fisherYates))  // all about 10000
	fmt.Println("naive:       ", count(naiveShuffle)) // some orders clearly favored
}
Output
reservoir: each item picked about 1000 times: 1042 1016 1024
fisher-yates: map[[1 2 3]:10050 [1 3 2]:9979 [2 1 3]:9899 [2 3 1]:10074 [3 1 2]:10007 [3 2 1]:9991]
naive:        map[[1 2 3]:8984 [1 3 2]:10898 [2 1 3]:11183 [2 3 1]:11122 [3 1 2]:8967 [3 2 1]:8846]

With 3 elements the naive shuffle has 3³ = 27 equally likely paths spread over 6 orders, so some orders get 5 paths and others 4: a bias of about 25% that random testing easily shows.

HyperLogLog, briefly#

"How many distinct visitors today?" Exactly, that needs a set of every ID. HyperLogLog estimates it with about 1.5 KB for a 2% error, no matter whether there are a thousand or a billion IDs. Intuition: in random hashes, seeing a hash that starts with 20 zero bits is a one-in-a-million event, so it suggests you have seen around a million distinct values. HyperLogLog averages this signal across many buckets to make it accurate. Redis exposes it as PFADD and PFCOUNT.

Exact vs probabilistic#

QuestionExactProbabilisticTypical saving
is x in the set?map / setBloom filter10× or more
how often does x occur?map of countscount-min sketchfixed memory for any number of keys
how many distinct items?setHyperLogLogfrom gigabytes to kilobytes
random k items from a streamstore everythingreservoir samplingfrom O(n) to O(k)

Summary#

  • A hash table maps keys to slots; collisions are chained or probed; resizing keeps the load factor, and the cost, low.
  • Open addressing needs tombstones (or shifting) for deletes; Go's map is a Swiss table with random seeds.
  • Bloom filters: no false negatives, tunable false positives, about 10 bits per item at 1%.
  • Count-min sketches count approximately; HyperLogLog counts distinct items in kilobytes.
  • Reservoir sampling and Fisher-Yates are simple, but only the exact formulas are unbiased.

Check your understanding

Answer before scrolling back up. If you miss one, reread the matching section, then try again.

  1. Q1.Why must a hash table resize as it fills up?

  2. Q2.Deleting from an open-addressing table by simply emptying the slot is wrong because:

  3. Q3.A Bloom filter answers 'is x in the set?' with:

  4. Q4.Reservoir sampling keeps a uniform random sample of k items from a stream of unknown length by replacing a random reservoir slot with item i (1-based) with probability:

  5. Q5.In the Fisher-Yates shuffle, element i is swapped with a random index from:

Practice in Go

Write a table test first, then the solution. On LeetCode, choose Go as the language. Open a hint only after 15 minutes stuck.

  • Show hint

    Implement it twice: separate chaining with slices, then open addressing.

  • Show hint

    Same as the map without values.

  • Show hint

    Reservoir sampling with k = 1.

  • Show hint

    Fisher-Yates on a copy; Reset returns the original.

  • Show hint

    Prefix sums of the weights, then binary search a random number.

  • Bloom filter packagebuild

    Add bloom.New(expectedItems, falsePositiveRate) to godsa, computing the bit count and hash count from the formulas in this lesson. Measure the real false-positive rate in a test.

Finished this lesson?

Mark it done to track your progress. It is saved in this browser only.