A space-efficient probabilistic data structure that answers "is this element in the set?" โ with a small chance of being wrong in one direction only.
IntermediateYou've got a massive set of items โ maybe a billion URLs, or every username in a database. You want to quickly check: "is this item in the set?" A hash set (a data structure that stores items and supports O(1) membership checks by hashing each item to a bucket) gives you O(1) lookups, but storing a billion items takes enormous memory โ each item is a full string consuming bytes of RAM.
A Bloom filter trades perfect accuracy for massive space savings. It uses a bit array (a compact array where each position holds a single bit โ either 0 or 1) and multiple hash functions (functions that convert an input into a fixed-size numerical output, ideally with uniform distribution) to answer membership queries.
The catch: it can produce false positives โ saying "probably yes" when the answer is actually "no." But it never produces false negatives โ if it says "definitely no," the item is guaranteed absent. This one-directional error property is what makes it useful.
Think of it as a bouncer with a fuzzy guest list. If the bouncer says "you're not on the list," you're definitely not โ go home. But if they say "yeah, I think you're on the list," there's a small chance they're confusing you with someone else. In most systems, this is totally acceptable: a false positive just means you do a slightly more expensive check to confirm.
Burton Howard Bloom invented the Bloom filter in 1970, publishing it in a paper titled "Space/Time Trade-offs in Hash Coding with Allowable Errors." He was working on hyphenation algorithms for a text processing system and needed a memory-efficient way to check whether a word was in a dictionary. At the time, memory was absurdly expensive โ his solution traded a small error rate for an order-of-magnitude reduction in memory use. Over fifty years later, the same trade-off is just as relevant, except now we're dealing with billions of items instead of thousands.
Space. A hash set storing 1 billion URLs (averaging 50 bytes each) needs at least 50 GB of memory, plus overhead for hash table bookkeeping. A Bloom filter with a 1% false positive rate needs about 1.2 GB โ a 40ร reduction. If you can tolerate occasional false positives (and in most applications, you can), the space savings are huge.
A Bloom filter has two components:
m, initialized to all zeros. Think of it as m light switches, all starting in the "off" position.Run the item through all k hash functions. Each gives you an index into the bit array. Set those k bits to 1 (flip those switches "on"). That's it โ no actual item stored anywhere.
Example: With k=3 hash functions and m=10 bits. Inserting "hello" โ hash functions return positions {1, 4, 7}. Set bits 1, 4, 7 to 1. The bit array: [0, 1, 0, 0, 1, 0, 0, 1, 0, 0]
Run the query item through the same k hash functions. Check if ALL k bits are set to 1:
As you add more items, more bits get set to 1. Eventually, the k bit positions for a new query might all happen to be 1 โ not because this item was added, but because k different previously-added items each set one of those positions. The more items you add, the more bits are set, and the higher the false positive rate climbs.
After inserting n items into a Bloom filter with m bits and k hash functions, the probability of a false positive is approximately:
P(fp) โ (1 - e-kn/m)k
The optimal number of hash functions (minimizing false positives for given m and n) is:
koptimal = (m/n) ยท ln(2) โ 0.693 ยท (m/n)
Some practical numbers:
That's 10 bits per item for 1% error. Compare that to storing the item itself, which could be hundreds of bytes. The compression ratio is extraordinary.
Setting a bit back to 0 would break other items that also hash to that position. If items A and B both set bit 5, clearing bit 5 when deleting A would cause a false negative for B โ and false negatives violate the fundamental guarantee.
Add items and watch the hash functions light up bits. Check items to see if they produce false positives. The more items you add, the more bits get set, and the higher the false positive rate climbs.
| Operation | Time | Space | Notes |
|---|---|---|---|
| Add | O(k) | โ | k = number of hash functions (typically 3-15, treated as constant) |
| Check | O(k) | โ | May return false positive, never false negative |
| Delete | Not supported (use Counting Bloom Filter or Cuckoo Filter) | ||
| Space | โ | O(m) bits | m โ -nยทln(p) / (ln2)ยฒ where n = items, p = desired FP rate |
With k hash functions, each operation is O(k). Since k is typically between 3 and 15 (determined by the desired false positive rate), it's effectively O(1). The real win is space: a Bloom filter uses about 10 bits per element for a 1% false positive rate. A billion URLs at 1% FP uses ~1.2 GB, versus ~50+ GB for the actual URL strings in a hash set.
h(i) = h1 + i ร h2 for i = 0, 1, ..., k-1. This gives the same theoretical guarantees with far less computation. Most real implementations use this trick.
import math
import mmh3 # pip install mmh3 โ MurmurHash3, a fast non-cryptographic hash
class BloomFilter:
"""Space-efficient probabilistic set membership filter.
False positive rate is configurable. False negatives never occur.
Uses the double-hashing trick (Kirsch-Mitzenmacher 2004) to generate
k hash positions from just 2 base hashes.
"""
def __init__(self, expected_items, fp_rate=0.01):
"""
Args:
expected_items: How many items you plan to add (n)
fp_rate: Acceptable false positive probability (e.g., 0.01 = 1%)
"""
# Calculate optimal bit array size: m = -n * ln(p) / (ln2)ยฒ
self.m = int(-expected_items * math.log(fp_rate) / (math.log(2) ** 2))
# Calculate optimal number of hash functions: k = (m/n) * ln(2)
self.k = max(1, int((self.m / expected_items) * math.log(2)))
# Bit array stored as a bytearray (8 bits per byte)
self.bits = bytearray(self.m // 8 + 1)
self.count = 0 # Track how many items have been added
self.fp_rate = fp_rate
def _get_positions(self, item):
"""Generate k bit positions using the double hashing trick.
Two base hashes (with different seeds) generate all k positions:
position_i = (h1 + i * h2) % m
This is mathematically equivalent to k independent hash functions
but only requires 2 hash computations."""
s = str(item)
h1 = mmh3.hash(s, 0) % self.m # Base hash 1 (seed=0)
h2 = mmh3.hash(s, 42) % self.m # Base hash 2 (seed=42)
return [(h1 + i * h2) % self.m for i in range(self.k)]
def _set_bit(self, pos):
"""Set bit at position pos to 1."""
byte_idx, bit_idx = divmod(pos, 8)
self.bits[byte_idx] |= (1 << bit_idx)
def _get_bit(self, pos):
"""Check if bit at position pos is 1."""
byte_idx, bit_idx = divmod(pos, 8)
return bool(self.bits[byte_idx] & (1 << bit_idx))
def add(self, item):
"""Add an item to the filter. O(k) โ O(1).
After this, might_contain(item) is guaranteed to return True."""
for pos in self._get_positions(item):
self._set_bit(pos)
self.count += 1
def might_contain(self, item):
"""Check if item was possibly added.
Returns True โ item is PROBABLY in the set (could be false positive)
Returns False โ item is DEFINITELY NOT in the set (never wrong)"""
for pos in self._get_positions(item):
if not self._get_bit(pos):
return False # One bit is 0 โ definitively absent
return True # All bits are 1 โ probably present
def estimated_fp_rate(self):
"""Estimate the current false positive rate based on how full the filter is."""
# Count set bits
set_bits = sum(bin(byte).count('1') for byte in self.bits)
fill_ratio = set_bits / self.m
return fill_ratio ** self.k
def __len__(self):
return self.count
def __repr__(self):
return (f"BloomFilter(nโ{self.count}, m={self.m} bits, "
f"k={self.k} hashes, est_fp={self.estimated_fp_rate():.4%})")
# Usage
bf = BloomFilter(expected_items=10000, fp_rate=0.01)
print(bf) # BloomFilter(nโ0, m=95851 bits, k=7 hashes, est_fp=0.0000%)
# Add items
for url in ["https://google.com", "https://github.com", "https://python.org"]:
bf.add(url)
print(bf.might_contain("https://google.com")) # True (correct โ was added)
print(bf.might_contain("https://example.com")) # False (correct โ wasn't added)
print(bf.might_contain("https://evil-site.com")) # False (probably โ correct)
print(f"Using {bf.m} bits ({bf.m // 8 // 1024} KB) and {bf.k} hash functions")
class CountingBloomFilter:
"""Bloom filter variant that supports deletion.
Instead of single bits, each position is a 4-bit counter (0-15).
Adding increments the counters; deleting decrements them.
Uses 4ร more space than a standard Bloom filter.
Overflow (counter > 15) is possible but extremely unlikely with
properly sized filters. If it happens, the counter stays at 15
(saturates) to avoid wraparound bugs.
"""
def __init__(self, expected_items, fp_rate=0.01):
import math
self.m = int(-expected_items * math.log(fp_rate) / (math.log(2) ** 2))
self.k = max(1, int((self.m / expected_items) * math.log(2)))
# 4 bits per counter โ each byte holds 2 counters
self.counters = bytearray(self.m // 2 + 1)
self.count = 0
def _get_positions(self, item):
import mmh3
s = str(item)
h1 = mmh3.hash(s, 0) % self.m
h2 = mmh3.hash(s, 42) % self.m
return [(h1 + i * h2) % self.m for i in range(self.k)]
def _get_counter(self, pos):
byte_idx = pos // 2
if pos % 2 == 0:
return self.counters[byte_idx] & 0x0F # Lower nibble
else:
return (self.counters[byte_idx] >> 4) & 0x0F # Upper nibble
def _set_counter(self, pos, val):
val = min(val, 15) # Saturate at 15 to prevent overflow
byte_idx = pos // 2
if pos % 2 == 0:
self.counters[byte_idx] = (self.counters[byte_idx] & 0xF0) | val
else:
self.counters[byte_idx] = (self.counters[byte_idx] & 0x0F) | (val << 4)
def add(self, item):
for pos in self._get_positions(item):
self._set_counter(pos, self._get_counter(pos) + 1)
self.count += 1
def might_contain(self, item):
return all(self._get_counter(pos) > 0 for pos in self._get_positions(item))
def remove(self, item):
"""Remove an item. Only call if you're sure the item was added!
Removing an item that was never added will corrupt the filter."""
if not self.might_contain(item):
return False
for pos in self._get_positions(item):
current = self._get_counter(pos)
if current > 0 and current < 15: # Don't decrement saturated counters
self._set_counter(pos, current - 1)
self.count -= 1
return True
# Usage
cbf = CountingBloomFilter(1000, fp_rate=0.01)
cbf.add("hello")
cbf.add("world")
print(cbf.might_contain("hello")) # True
cbf.remove("hello")
print(cbf.might_contain("hello")) # False (successfully deleted!)
class BloomFilter {
constructor(expectedItems, fpRate = 0.01) {
// Calculate optimal size and hash count
this.m = Math.ceil(-expectedItems * Math.log(fpRate) / (Math.log(2) ** 2));
this.k = Math.max(1, Math.round((this.m / expectedItems) * Math.log(2)));
this.bits = new Uint8Array(Math.ceil(this.m / 8));
this.count = 0;
}
// FNV-1a hash variant with seed
_hash(str, seed) {
let h = 2166136261 ^ seed;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619); // 32-bit multiply
}
return Math.abs(h) % this.m;
}
// Generate k positions using double hashing
_positions(item) {
const s = String(item);
const h1 = this._hash(s, 0);
const h2 = this._hash(s, 42);
return Array.from({ length: this.k }, (_, i) => (h1 + i * h2) % this.m);
}
add(item) {
for (const pos of this._positions(item)) {
this.bits[pos >> 3] |= (1 << (pos & 7)); // Set bit
}
this.count++;
}
mightContain(item) {
return this._positions(item).every(pos =>
(this.bits[pos >> 3] & (1 << (pos & 7))) !== 0 // Check bit
);
}
get fillRatio() {
let set = 0;
for (const byte of this.bits) set += popcount(byte);
return set / this.m;
}
}
// Helper: count set bits in a byte
function popcount(n) {
let count = 0;
while (n) { count += n & 1; n >>= 1; }
return count;
}
A web crawler needs to track which URLs it has already visited to avoid crawling the same page twice. With billions of URLs on the internet, storing every visited URL in a hash set would consume hundreds of gigabytes. A Bloom filter reduces this to a couple of gigabytes, with the trade-off that occasionally (~1%) a new URL gets skipped because the filter thinks it was already crawled.
from collections import deque
class WebCrawler:
"""Simplified web crawler with Bloom filter deduplication.
Without Bloom filter: 1 billion URLs ร 50 bytes = ~50 GB.
With Bloom filter at 1% FP: 1 billion ร 10 bits = ~1.2 GB.
The false positive cost: ~1% of valid URLs get skipped.
"""
def __init__(self, seed_urls, max_pages=1000000):
self.frontier = deque(seed_urls)
self.seen = BloomFilter(expected_items=max_pages, fp_rate=0.01)
self.pages_crawled = 0
for url in seed_urls:
self.seen.add(url)
def crawl(self, max_iterations=100):
while self.frontier and self.pages_crawled < max_iterations:
url = self.frontier.popleft()
new_links = self._fetch_and_extract(url)
self.pages_crawled += 1
for link in new_links:
if not self.seen.might_contain(link):
self.seen.add(link)
self.frontier.append(link)
return {
"pages_crawled": self.pages_crawled,
"urls_tracked": self.seen.count,
"bloom_size_kb": self.seen.m // 8 // 1024,
}
def _fetch_and_extract(self, url):
import hashlib
return [f"https://example.com/{hashlib.md5(f'{url}-{i}'.encode()).hexdigest()[:8]}"
for i in range(3)]
Real crawlers at Google and Bing use distributed Bloom filters (partitioned across machines) alongside URL databases. The Bloom filter acts as the fast first check โ if it says "not seen," the URL is definitely new. If it says "maybe seen," a more expensive database check confirms.
LSM-tree databases like Cassandra, RocksDB, and LevelDB store data across multiple on-disk files called SSTables. When you query a key, the database doesn't know which file contains it โ it might need to check dozens of files. Disk reads are thousands of times slower than memory access, so checking every file is expensive.
Each SSTable has an associated Bloom filter. Before reading the file, check the filter: "could this key be in this file?" If the filter says "no," skip the entire disk read. In practice, this eliminates 99%+ of unnecessary disk I/O. The filters live in memory (a few MB per SSTable), while the data files can be gigabytes each.
class SSTable:
"""Simulated sorted string table with Bloom filter guard.
In real databases (Cassandra, RocksDB), each SSTable file
on disk has an in-memory Bloom filter. Before doing a disk seek,
the DB checks the filter. If it says 'no', the disk read is skipped.
"""
def __init__(self, data, fp_rate=0.01):
self.data = dict(data) # The actual key-value pairs (on disk)
self.bloom = BloomFilter(len(data), fp_rate)
for key in data:
self.bloom.add(key)
self.disk_reads = 0
def get(self, key):
# First: check Bloom filter (in-memory, microseconds)
if not self.bloom.might_contain(key):
return None # Definitely not here โ skip disk read!
# Bloom filter said "maybe" โ do the expensive disk read
self.disk_reads += 1
return self.data.get(key)
# Simulate querying across multiple SSTables
tables = [
SSTable([(f"user:{i}", f"data_{i}") for i in range(j*1000, (j+1)*1000)])
for j in range(10) # 10 SSTables, 1000 keys each
]
# Query a key โ only 1 table does a disk read instead of all 10
for table in tables:
result = table.get("user:5500")
if result:
print(f"Found: {result}")
break
total_reads = sum(t.disk_reads for t in tables)
print(f"Disk reads: {total_reads} out of {len(tables)} tables checked")
# Typically: 1-2 disk reads instead of 10
Google Chrome checks every URL you visit against a list of known malicious sites. There are millions of dangerous URLs, and Chrome can't send every URL you visit to Google's servers (privacy nightmare, plus latency). Instead, a Bloom filter containing all known dangerous URLs is downloaded to your browser (~30 MB). The check runs locally in microseconds. Only when the Bloom filter returns "maybe dangerous" does Chrome contact Google's server for confirmation.
class SafeBrowsingFilter:
"""Simplified version of Chrome's Safe Browsing check.
The Bloom filter runs entirely in the browser (no network).
Only positive results trigger a server round-trip.
With 2 million malicious URLs at 0.01% FP rate:
- Bloom filter size: ~4.8 MB (fits in browser memory)
- 99.99% of safe URLs are confirmed safe instantly
- Only 0.01% of safe URLs trigger an unnecessary server check
"""
def __init__(self, malicious_urls):
self.bloom = BloomFilter(len(malicious_urls), fp_rate=0.0001)
for url in malicious_urls:
self.bloom.add(url)
def check_url(self, url):
if not self.bloom.might_contain(url):
return "safe" # Guaranteed safe โ no network needed
# Bloom says "maybe malicious" โ verify with server
return self._verify_with_server(url)
def _verify_with_server(self, url):
# In real Chrome, this calls Google's Safe Browsing API
# to confirm whether the URL is actually in the blacklist.
# The Bloom filter eliminated 99.99%+ of these checks.
return "checking_server..."
Medium, Netflix, and YouTube all need to avoid recommending content you've already seen. Storing every user-content pair in a database is expensive at scale (millions of users ร thousands of items each). A per-user Bloom filter tracks "already shown" content with minimal memory โ a few KB per user instead of hundreds of KB.
class RecommendationFilter:
"""Per-user Bloom filter to avoid showing already-seen content.
Memory per user: ~2.4 KB (for 1000 articles at 1% FP)
vs. hash set: ~50 KB (1000 article IDs ร ~50 bytes)
With 10 million users:
- Bloom filters: ~24 GB total
- Hash sets: ~500 GB total
"""
def __init__(self, max_items_per_user=1000):
self.user_filters = {}
self.max_items = max_items_per_user
def mark_seen(self, user_id, article_id):
if user_id not in self.user_filters:
self.user_filters[user_id] = BloomFilter(self.max_items, fp_rate=0.01)
self.user_filters[user_id].add(article_id)
def filter_recommendations(self, user_id, candidates):
"""Remove already-seen articles from recommendation candidates."""
if user_id not in self.user_filters:
return candidates # New user โ show everything
bf = self.user_filters[user_id]
return [article for article in candidates
if not bf.might_contain(article)]
# Occasional false positive means we skip a new article.
# Better than showing something the user already read.
Bloom filters appear constantly in system design interviews. "How would you design a URL shortener that rejects duplicates?" "How would you avoid recommending content a user has already seen?" "How would you check if a username is taken without hitting the database every time?" The answer often involves a Bloom filter as the first layer of defense, with a real database as the authoritative fallback.
Before querying a cache or database for a key, check the Bloom filter. If it says "no," skip the lookup entirely โ the key doesn't exist. This prevents cache penetration โ an attack where adversaries flood your system with requests for keys that don't exist, bypassing the cache and hammering the database. The Bloom filter blocks these requests at the gate. This is one of the most practical Bloom filter use cases and comes up in almost every system design round that involves caching.
Processing a stream of events (log lines, URLs to crawl, network packets, ad impressions)? Use a Bloom filter to track what you've already processed. The occasional false positive (skipping a new item you think you've seen) is usually acceptable. This pattern shows up in stream processing systems like Apache Kafka and Apache Flink. The key insight: at billions of events per day, even a hash set is too expensive in memory. A Bloom filter gives you "good enough" deduplication at a fraction of the cost.
Bloom filter as the fast first check (microseconds, in-memory), followed by a definitive but slow check (milliseconds, disk or network). Chrome does this for Safe Browsing: local Bloom filter โ if positive, verify with Google's server. The Bloom filter eliminates 99%+ of lookups, reducing server load and user-perceived latency. This "cheap filter โ expensive confirm" pattern applies to any system where lookups are costly and most queries will be negative.
The magic numbers for interviews: for 1% FP rate โ ~10 bits per element, 7 hash functions. For 0.1% โ ~14 bits, 10 hashes. For 0.01% โ ~20 bits, 14 hashes. A Bloom filter holding 1 billion items at 1% FP uses ~1.2 GB. Know these numbers cold โ interviewers love when you can estimate on the spot.
Don't use Bloom filters when: (1) you need zero false positives (use a hash set), (2) you need to enumerate the stored items (Bloom filters can't iterate), (3) you need deletion (use Counting Bloom Filter or Cuckoo Filter), (4) you need exact counts (use Count-Min Sketch instead). Knowing when NOT to use a tool is as impressive as knowing when to use it.
In distributed systems, you can partition the Bloom filter across nodes (shard by hash of item) or replicate it (every node has a full copy). Partitioning saves memory per node but requires a network hop for every check. Replication uses more total memory but allows local checks. The right choice depends on your read/write ratio and latency requirements.
A common follow-up: "what if you need deletion?" Cuckoo Filters (2014, by Fan et al.) store fingerprints in a cuckoo hash table instead of setting bits. They support deletion, have better lookup performance (one or two memory accesses vs. k for Bloom), and use less space for FP rates below ~3%. The trade-off: insertion can fail if the table is too full (needs rehashing). If an interviewer asks about alternatives to Bloom filters, Cuckoo Filters are the answer.
Bloom filters don't have many direct LeetCode problems โ they're a system design tool, not a competitive programming structure. But these problems test the underlying concepts, and the system design scenarios are common interview topics:
| Problem / Scenario | Type | Key Idea |
|---|---|---|
| 705. Design HashSet | Easy | Understand hashing fundamentals โ Bloom filters extend this concept |
| 387. First Unique Character | Easy | Membership tracking โ Bloom filter works for the "seen before?" check |
| Design: Web Crawler Dedup | System Design | Track billions of visited URLs with limited memory |
| Design: Spam Email Filter | System Design | Check sender/content against known spam signatures |
| Design: Content Recommendation | System Design | Avoid showing already-viewed content to users |
| 219. Contains Duplicate II | Easy | Sliding window membership โ Bloom filter with window concept |
| 128. Longest Consecutive Sequence | Medium | Set membership checks โ Bloom filter for pre-filtering large datasets |
| Design: Rate Limiter | System Design | Track seen IPs/tokens with Bloom filter + sliding window |