Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Bits & Machine Numbers  ›  Lesson

Bloom Filters

Bloom Filters 30 minA row of bits and a few hashes that can say "definitely not" with certainty and "maybe" with a known error
You're building a piece ofQuarry — the record encoder
This piece — bloom_lookup(): Answers "definitely not stored" without touching disk.
Scenario A lookup for a missing record used to read every segment on disk. Now each segment carries a Bloom gate of about 10 bits per key. Most segments answer "definitely not" from memory, and only a false positive, about 1 time in 120, costs a wasted read.
Your task
Build bloom_lookup(stored, queries, m, k). Insert every stored key into an m-bit Bloom filter using positions(key, m, k) from the starter. Return one boolean per query: True for maybe (all k bits set), False for definitely not. Example: bloom_lookup(["rec-11", "rec-42", "rec-97"], ["rec-42", "rec-6", "rec-67"], 16, 3) → [True, False, True].

Bloom Filters

In short: a Bloom filter is m bits plus k hash functions. Inserting a key sets its k positions; querying checks them, and if any is still 0 the key was definitely never inserted, while all-1s means "maybe". Insert and query both cost O(k) time, and the whole filter costs O(m) space, about 10 bits per key for a 1% false-positive rate — far less than storing the keys themselves. It can never produce a false negative, because nothing ever clears a bit, but it can be fooled into a false positive once enough other keys have filled in the same positions.

An 8-bit row where inserting "cat" and "dog" sets five of the bits green, and querying "fox" stops at bit 5, which is still 0

Quarry writes records into segment files on disk. Looking a record up means asking each segment "do you have it?", and each question costs a disk read. That's fine when the answer is yes. It's pure waste when the answer is no, and most answers are no: a key lives in one segment, not in the twenty others you'd have to rule out.

What Quarry wants is a tiny in-memory summary per segment that can say "definitely not here" without touching the disk. It's allowed to sometimes say "maybe" wrongly, since that only costs a wasted read, but it must never say "not here" about a key that is there, because that would lose data. A Bloom filter is exactly that. It's a row of bits plus a few hash functions, and it costs about 10 bits per key for a 1% false-positive rate, however long the keys are.

How it works

A Bloom filter is an array of m bits, all 0 at the start, and k hash functions that each map a key to a position in 0 … m − 1.

  • Insert a key: compute its k positions and set those bits.
  • Query a key: compute the same k positions. If any of those bits is 0, the key was never inserted. That's a certain "no". If all are 1, the answer is "maybe". The bits might have been set by other keys.

Step through a 16-bit filter with k = 3. Three records go in, then two lookups happen, one of which is fooled:

watch it move · array

Inserts only ever set bits. A query stops at the first 0 it finds, and it can be fooled only when other keys have set every one of its bits.

Insert rec-11. Its three hashes land on 4, 3 and 2, so those bits are set. 16 cells.

1 / 6

Two facts fall out of this picture:

  1. No false negatives. An inserted key's bits were set, and nothing ever clears a bit, so its query always finds all ones.
  2. False positives grow as the filter fills. The more bits are 1, the likelier a random key's k probes all land on ones.
What does this print?
bits = 0
for p in (3, 9, 3):
    bits |= 1 << p
print(bin(bits).count("1"), bits >> 9 & 1)

Hashing you can put on disk

Python's built-in hash() of a string is salted per process. Run the same program twice and hash("rec-11") is different, which is a deliberate defence against attacks on dicts. A filter saved to disk next to its segment has to give the same positions tomorrow, so Quarry uses a fixed hash. Here it's a 32-bit FNV-1a, a simple hash made of XOR, multiply and mask, followed by one XOR-shift that folds the well-mixed high bits into the weak low ones:

step through it
1h = 2166136261
2for byte in "id".encode():
3 h ^= byte
4 h = (h * 16777619) & 0xFFFFFFFF
5h ^= h >> 16
6print(h, h % 16)

The mask & 0xFFFFFFFF is the fixed-width rule from lesson 1. It makes Python's unbounded multiply behave like a 32-bit register.

You don't need k independent hash functions. Two hashes h1 and h2 are enough, because position i can be (h1 + i·h2) % m. That's double hashing, and it's proven to give essentially the same false-positive rate as k independent hashes. Forcing h2 odd stops it from getting stuck on a few positions when m is a power of two.

python

Storing the bits

For a small m, a Python int is a perfectly good bitset: bits |= 1 << p sets a bit and bits >> p & 1 tests one. A real filter has millions of bits and lives in a bytearray. Bit p is bit p & 7 of byte p >> 3, which is the same shift-and-mask arithmetic as lesson 2, one level down:

python

A query has to check all k bits. This one gets it the wrong way round:

broken — fix it

It prints [True, True, False]. rec-6 was never inserted, but one of its bits happens to be set, and one set bit is enough for this version to say maybe.

Build one yourself. Every insert and every query below is drawn. Change M and K, or add more keys, and watch the filter fill up and start saying maybe to strangers:

your code, drawn · array

Your filter. Inserts set k bits, and a query stops at its first 0 or says maybe when every probe finds a 1.

How wrong is "maybe"?

After inserting n keys into m bits with k hashes, a given bit is still 0 with probability about e^(−kn/m). A false positive needs all k probes to hit ones, so

p ≈ (1 − e^(−kn/m))^k

Only the ratio m/n, bits per key, matters. More hashes set more bits, which fills the filter faster but also means a query has more chances to hit a 0. So there's a best k. Quarry gives each segment's filter 10 bits per key:

hashes k false-positive rate at 10 bits per key
2 3.29%
4 1.18%
6 0.84%
8 0.85%
10 1.02%

The lowest rate sits between 6 and 8. The best k is (m/n)·ln 2 ≈ 0.69 × 10 = 6.9, the point where about half the bits end up set. A filter that's half ones carries the most information per bit.

What does this print?
import math
print(round((1 - math.exp(-7 * 1 / 10)) ** 7, 4))

Interview corner — sizing a filter

A dedupe service must remember 50 million seen ids and may wrongly report "seen" at most 1% of the time. How much memory does it need, and how many hash functions? Invert the formula. Bits per key is −ln p / (ln 2)², and k = (m/n) · ln 2:

python

That's 57 MB, where a Python set of 50 million ints would take several gigabytes. The filter never stores the ids themselves, only a smear of bits. That's also why it can't list its contents, can't count exactly, and can't forget a key. The explain exercise takes that last point further.

Quick practice

What does this print?
bits = 0
for p in (2, 5, 9):
    bits |= 1 << p
print(all((bits >> p) & 1 for p in (2, 5, 9)), all((bits >> p) & 1 for p in (2, 5, 6)))
broken — fix it

It prints 1 — all four probes land on the same bit. Fix positions so a key always gets k different positions.


🎯 Your turn

Write bloom_lookup(stored, queries, m, k). Build an m-bit filter from the keys in stored using the lesson's positions(key, m, k) (it's in the starter). Return a list of booleans, one per query: True for "maybe" and False for "definitely not".

  • bloom_lookup(["rec-11", "rec-42", "rec-97"], ["rec-42", "rec-6", "rec-67"], 16, 3) → [True, False, True], where the last one is the false positive from the picture

Hint — set bits with bits |= 1 << p for every position of every stored key. A query is True only if bits >> p & 1 for all its positions.

Then press ▶ Run, try the Live App chips, and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upA hash that survives a restart

Write hash32(text): 32-bit FNV-1a followed by a fold. Start with h = 2166136261. For each byte of text.encode(), do h ^= byte and then h = (h * 16777619) masked to 32 bits. Finally return h ^ (h >> 16). Unlike the built-in hash(), this gives the same answer in every run.

hashingbitmask
Drillk positions from two hashes

Using hash32 (given in the starter), write bloom_positions(key, m, k) returning the key's k bit positions by double hashing: h1 = hash32(key), h2 = hash32(key + "!") | 1, and position i is (h1 + i * h2) % m for i in 0 … k − 1.

bloom-filterhashing
BuildWhich bits does a segment set?

Using positions (given), write filter_bits(keys, m, k) that inserts every key into an m-bit filter and returns the sorted list of bit positions that are set. Store the filter as an int bitset while you build it.

bloom-filterbitset
BossCatch the filter lying

Using positions (given), write false_positives(stored, probes, m, k). Build the filter from stored, then return the probes (in their original order) that were not stored but that the filter answers 'maybe' for. These are the lookups that would cost Quarry a wasted disk read.

bloom-filterfalse-positive
CapstoneHow many keys, and how many shared?

Two segments' filters use the same m and k and arrive as sorted lists of set bit positions. A filter with X ones out of m bits holds about n ≈ −(m/k) · ln(1 − X/m) keys. Write overlap_estimate(a_bits, b_bits, m, k) returning [n_a, n_b, n_union, n_shared], each rounded to the nearest int. n_union comes from the OR of the two filters (which is exactly the filter of the union), and n_shared = n_a + n_b − n_union, computed before rounding. If any of the three filters has every bit set, return None.

probabilistic-structuresbitset
ExplainWhy a Bloom filter can't forget

Quarry deletes records. A teammate suggests that deleting a record should also clear its k bits from the segment's Bloom filter, so lookups for it stop costing a disk read. Explain why this breaks the filter's one guarantee. Then describe what Quarry should do about deleted keys instead.

bloom_lookup.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
bloom_lookup(stored, queries, m, k) → listMaybe (True) or definitely not (False) for each query.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.