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
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:
| Strategy | Collisions go | Pros | Cons |
|---|---|---|---|
| separate chaining | into a small list per slot | simple; degrades gracefully | a pointer chase per lookup |
| open addressing (linear probing) | into the next free slot | one contiguous array, cache-friendly | clusters; 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.
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)
}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² = 9999800001maphash.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.
| Formula | Meaning |
|---|---|
| m = -n · ln(p) / (ln 2)² | bits needed for n items at false-positive rate p |
| k = (m / n) · ln 2 | the 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.
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 {
ifb.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)
}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.
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
}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#
| Question | Exact | Probabilistic | Typical saving |
|---|---|---|---|
| is x in the set? | map / set | Bloom filter | 10× or more |
| how often does x occur? | map of counts | count-min sketch | fixed memory for any number of keys |
| how many distinct items? | set | HyperLogLog | from gigabytes to kilobytes |
| random k items from a stream | store everything | reservoir sampling | from 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.
Q1.Why must a hash table resize as it fills up?
Q2.Deleting from an open-addressing table by simply emptying the slot is wrong because:
Q3.A Bloom filter answers 'is x in the set?' with:
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:
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.
- Design HashMap ↗easy
Show hint
Implement it twice: separate chaining with slices, then open addressing.
- Design HashSet ↗easy
Show hint
Same as the map without values.
Show hint
Reservoir sampling with k = 1.
- Shuffle an Array ↗medium
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)togodsa, 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.