Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°34

Bloom Filter

Medium Data StructuresProbabilisticHashing

A tiny bit-array that answers "have I seen this?" using a fraction of the memory of a real set — at the cost of occasional false positives, but never a false negative. It guards databases, CDNs and crawlers from pointless lookups. Build one with double hashing. Provided hashes keep Python and TypeScript in sync. Hidden tests.

The problem

Build a Bloom filter of m bits using k hash functions, insert every item in items, then answer each query in queries: return True if the item is possibly present (all k of its bits are set) or False if it is definitely absent. Derive the k bit positions by double hashing: bit i is (hash32(x) + i·hb(x)) % m for i in 0…k-1. Both hashes are provided. Implement bloom(items, queries, m, k).

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement bloom(items, queries, m, k): make an m-bit array, set the k double-hashed bits of every item, then for each query return whether all of its k bits are set.

CPython · WebAssembly
Examples and constraints 3 examples
EXAMPLE 1
Input bloom(['cat','dog','bird'], ['cat','dog','bird','zebra','fish'], 64, 3)
Output [True, True, True, False, False]
inserted items are always found; these absent ones happen to hit an unset bit
EXAMPLE 2
Input bloom(['apple','banana','cherry','date','elder'], ['apple','fish','zebra'], 16, 2)
Output [True, True, False]
'fish' was never inserted, but its bits 8 and 14 were set by other words: a false positive
EXAMPLE 3
Input bloom([], ['a','b'], 64, 3)
Output [False, False]
edge case: an empty filter has no bits set, so nothing is possibly present
CONSTRAINTS
  • Use the provided hash32 and hb — the tests depend on the exact bit positions.
  • No false negatives: every inserted item must return True. Bloom filters can only err the other way.
  • A false positive is possible (an absent item whose k bits all happen to be set) — that is allowed by design.
  • An empty filter (no items) must return False for everything.
Hints 4 ideas
HINTS — 4 IDEAS
  1. A bit array is just a list of 0/1 of length m. Setting a bit twice is fine (idempotent).
  2. The i-th bit position for item x is (hash32(x) + i * hb(x)) % m — this is double hashing, giving k independent-looking positions from two hashes.
  3. Insert: for every item, set all k of its bits to 1.
  4. Query: the item is "possibly present" only if every one of its k bits is already 1; a single 0 proves it was never inserted.
Approach, complexity & discussion — open after you solve

Start from brute force

The obvious approach is an exact set: add every item to a hash set and answer each query with q in seen. Membership is exact, and each insert or query costs O(L) expected time to hash a string of length L. The cost is space: O(total size of all items), because the set keeps every string plus per-entry overhead. A billion 50-byte URLs need at least 50 GB for the strings alone, while a Bloom filter at a 1% false-positive rate needs about 9.6 bits per item, roughly 1.2 GB, however long the items are. It is also wrong for this spec, which defines the answer by the bits: in the third example 'fish' must return True, and a set says False.

The approach

The insight is that an item's bit positions depend only on the item, so insertion and lookup touch exactly the same k bits. Insert sets them; lookup checks them. That gives the one guarantee a Bloom filter makes: an inserted item always finds all of its bits set, so there are no false negatives. An absent item returns True only when other items happened to set all k of its positions, which is a false positive and is allowed.

The reference solution makes bits = [0] * m, then for every x in items and every i in range(k) sets bits[(hash32(x) + i * hb(x)) % m] = 1. For each query it appends all(bits[(hash32(q) + i * hb(q)) % m] for i in range(k)), which stops at the first 0 it reads. Double hashing is what makes k hash functions cheap: two base hashes generate all k positions, and Kirsch and Mitzenmacher showed that this loses nothing in the asymptotic false-positive rate compared with k independent hashes.

Walk through an example

Trace the first example, bloom(['cat','dog','bird'], ['cat','dog','bird','zebra','fish'], 64, 3), where each row lists the three positions (hash32(x) + i * hb(x)) % 64 for i = 0, 1, 2.

stepoperationpositionsbits thereresult
1insert 'cat'21, 55, 25set to 13 bits set
2insert 'dog'3, 45, 23set to 16 bits set
3insert 'bird'56, 44, 32set to 19 of 64 bits set
4query 'cat'21, 55, 251, 1, 1True
5query 'dog'3, 45, 231, 1, 1True
6query 'bird'56, 44, 321, 1, 1True
7query 'zebra'29, 23, 170, 1, 0False (stops at bit 29)
8query 'fish'8, 30, 520, 0, 0False (stops at bit 8)

Complexity

Optimal: O(k) bit operations per insert and per query, plus O(L) to hash a length-L string once (the reference recomputes both hashes for every i, which makes it O(k·L)); space is m bits, independent of item size. Brute force (a hash set): O(L) expected per operation, but space grows with every stored string.

Edge cases to test

  • Empty filter: bloom([], ['a', 'b'], 64, 3) → [False, False]. Every bit is 0, so the first bit checked already proves absence.
  • A false positive: bloom(['apple', 'banana', 'cherry', 'date', 'elder'], ['fish'], 16, 2) → [True]. 'fish' maps to bits 8 and 14, both set by other words; returning False means the code is not reading the bits.
  • A second hash that shares a factor with m: with m = 64, hb('olive') % 64 is 32, so its positions cycle 52, 20, 52 and k = 3 sets only two distinct bits. Still no false negatives, just a weaker filter; real implementations use a prime m or force the second hash odd.
  • m = 1: every position is bit 0, so after any insert every query returns True: bloom(['a'], ['a', 'b'], 1, 3) → [True, True].
  • k = 0: all() over zero bits is True, so every query is “possibly present”, even on an empty filter. Valid input needs k ≥ 1.
  • Duplicate inserts: setting a bit that is already 1 changes nothing, so inserting 'cat' twice gives the same filter as inserting it once.

Common mistakes

  • Sizing m too small or picking the wrong k for the target rate, so false positives spike.
  • Reading “maybe present” as “present” — it must be verified; only “absent” is certain.
  • Trying to delete an element — a standard Bloom filter cannot; use a counting variant.
  • Deriving positions from Python’s built-in hash(): string hashing is randomized per process, so a filter saved and reloaded by another process returns false negatives. Use a fixed hash, as the provided hash32 is.

Follow-ups an interviewer asks next

How do you choose m and k?
For n items and a target false-positive rate p, the optimal size is m = −n·ln p / (ln 2)² and the best hash count is k = (m / n)·ln 2. At p = 1% that is about 9.6 bits per item and k ≈ 7. For any chosen values, the rate is approximately (1 − e^(−kn/m))^k.
How would you support deletion?
A counting Bloom filter replaces each bit with a small counter, often 4 bits: insert increments the k counters, delete decrements them, and a query checks that all k are nonzero. It costs several times the memory, and deleting an item that was never inserted corrupts other items. A cuckoo filter supports deletion more compactly by storing short fingerprints instead of bits.
What if the set grows past the n you sized it for?
The false-positive rate climbs as the bits fill up, and nothing signals it. A scalable Bloom filter adds a new, larger layer with a tighter error target each time the current one reaches capacity, and a query checks every layer. The simpler option is to rebuild the filter at a larger m from the source of truth.
How do two machines combine their filters?
Two filters built with the same m, k and hash functions merge with a bitwise OR, and the result is exactly the filter of the union of their items. AND gives only an approximation of the intersection. Distributed databases use this to ship a compact filter of one side’s join keys so the other side can skip rows that cannot match.

Where this shows up

Bloom filters guard expensive lookups with tiny memory: an LSM-tree checks one before hitting disk, a crawler dedups URLs, a cache skips a certain miss. Trading a small, tunable false-positive rate for “definitely not here, skip the slow path” is the deal that makes them ubiquitous.

Reference solution

Reference solution — Python
def hash32(s):                      # provided (xmur3 32-bit, matches TypeScript)
    h = (1779033703 ^ len(s)) & 0xffffffff
    for ch in s:
        h = ((h ^ ord(ch)) * 3432918353) & 0xffffffff
        h = ((h << 13) | (h >> 19)) & 0xffffffff
    h = ((h ^ (h >> 16)) * 2246822507) & 0xffffffff
    h = ((h ^ (h >> 13)) * 3266489909) & 0xffffffff
    return (h ^ (h >> 16)) & 0xffffffff

def hb(s):                          # provided second hash (for double hashing)
    return hash32("salt:" + s)


def bloom(items, queries, m, k):
    bits = [0] * m
    for x in items:                 # insert: set all k bits of every item
        for i in range(k):
            bits[(hash32(x) + i * hb(x)) % m] = 1
    out = []
    for q in queries:               # possibly-present iff every bit is set
        present = all(bits[(hash32(q) + i * hb(q)) % m] for i in range(k))
        out.append(present)
    return out


print(bloom(['cat', 'dog', 'bird'], ['cat', 'dog', 'bird', 'zebra', 'fish'], 64, 3))
# -> [True, True, True, False, False]
Reference solution — TypeScript
function hash32(s: string): number {   // provided (xmur3 32-bit, matches Python)
  let h = (1779033703 ^ s.length) >>> 0;
  for (let i = 0; i < s.length; i++) {
    h = Math.imul(h ^ s.charCodeAt(i), 3432918353);
    h = (h << 13) | (h >>> 19);
  }
  h = Math.imul(h ^ (h >>> 16), 2246822507);
  h = Math.imul(h ^ (h >>> 13), 3266489909);
  h ^= h >>> 16;
  return h >>> 0;
}
function hb(s: string): number { return hash32("salt:" + s); }  // second hash

function bloom(items: string[], queries: string[], m: number, k: number): boolean[] {
  const bits = new Array(m).fill(0);
  for (const x of items)                 // insert: set all k bits
    for (let i = 0; i < k; i++) bits[(hash32(x) + i * hb(x)) % m] = 1;
  return queries.map((q) => {
    for (let i = 0; i < k; i++) if (!bits[(hash32(q) + i * hb(q)) % m]) return false;
    return true;                          // every bit set → possibly present
  });
}

console.log(bloom(['cat', 'dog', 'bird'], ['cat', 'dog', 'bird', 'zebra', 'fish'], 64, 3));
// -> [true, true, true, false, false]

Questions about Bloom Filter

Can a Bloom filter give a false negative?

Not if it is used as designed. Inserting an item sets its k bits, bits are never cleared, and a lookup recomputes the same k positions, so an inserted item always finds every one of its bits set. False negatives only appear when something breaks that chain: deleting by clearing bits, using a hash that changes between processes such as Python’s salted built-in hash for strings, or querying with different m, k or hash functions than the filter was built with. Within this challenge every inserted item must return True, and the hidden tests check it.

How do you calculate the false positive rate of a Bloom filter?

With n items inserted into m bits using k hash functions, the chance that a given bit is still 0 is about e to the power of minus kn over m. A false positive needs all k bits of an absent item to be 1, so the rate is approximately one minus that, raised to the power k. The best k for a given m and n is m over n times ln 2, and at that k a 1 percent false-positive rate takes about 9.6 bits per item. The formula assumes independent, uniform hashes, which double hashing approximates well.

What is double hashing in a Bloom filter?

A Bloom filter needs k bit positions per item, and computing k independent hash functions is slow. Double hashing computes two hashes, h1 and h2, and derives the rest as h1 plus i times h2, modulo m, for i from 0 to k minus 1. In this challenge h1 is hash32 and h2 is hb, which is hash32 of the item with a salt prepended. Kirsch and Mitzenmacher proved this costs nothing in the asymptotic false-positive rate. One caution: if h2 shares a factor with m, the positions repeat, so implementations pick a prime m or force h2 to be odd.

Why can you not delete an item from a standard Bloom filter?

Because bits are shared. Each bit may have been set by several different items, and the filter keeps no record of which ones. Clearing an item’s k bits to delete it would also clear bits that other inserted items depend on, and those items would then fail their lookups, which is a false negative, the one error a Bloom filter promises never to make. If you need deletion, use a counting Bloom filter, which keeps a small counter per position, or a cuckoo filter, which stores short fingerprints that can be removed individually.

Finished this one? 0 / 76 Challenges done
NextStreaming Median Next on the “Structures you build” ladder · rung 4 of 7

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges