Bloom Filters, LRU Cache & Trie: Advanced Java Algorithm Implementations for Production (2026)
A deep dive into production-grade implementations of Bloom filters (BitSet + MurmurHash), LRU/LFU caches (LinkedHashMap, doubly-linked list + HashMap, frequency maps), and Trie (prefix tree with autocomplete and Word Search II). Covers thread-safety, complexity analysis, real system examples, and when to choose each structure.
1. Why These Algorithms Matter in Production
Beyond standard HashMap and ArrayList, a handful of specialized data structures deliver outsized performance gains in production systems. Bloom filters, LRU/LFU caches, and Tries all solve specific problems where general-purpose structures fall short: memory efficiency at scale, O(1) eviction, or sub-linear prefix queries.
| Structure | Insert | Lookup | Delete | Space |
|---|---|---|---|---|
| Bloom Filter | O(k) | O(k) | ✗ N/A | O(m) bits |
| LRU Cache | O(1) | O(1) | O(1) | O(n) |
| LFU Cache | O(1) | O(1) | O(1) | O(n) |
| Trie | O(L) | O(L) | O(L) | O(ALPHABET * L * N) |
| Skip List | O(log n) | O(log n) | O(log n) | O(n log n) |
| HashMap | O(1) avg | O(1) avg | O(1) avg | O(n) |
Real production examples: Apache Cassandra uses Bloom filters per SSTable to avoid reading disk for non-existent keys — saving ~60% of disk I/Os. Redis uses an LRU eviction policy (allkeys-lru) so the most recently accessed data stays in memory. Google Chrome Safe Browsing uses Bloom filters to check if a URL is potentially malicious before an expensive server-side lookup. Elasticsearch uses a Trie-based structure (FST) for its term dictionary.
Memory comparison: A Bloom filter storing 1 million URLs at 1% FP rate needs just ~1.2 MB. A HashSet of the same 1 million URLs (each ~60 bytes average) would consume ~60 MB — a 50x memory advantage.
2. Bloom Filter — Theory
A Bloom filter uses a bit array of m bits (all initialized to 0) and k independent hash functions. To add an element, compute k hash positions and set those bits to 1. To query membership, check all k positions — if any bit is 0, the element is definitely NOT in the set. If all bits are 1, the element is probably in the set (false positive possible).
False positive rate formula: f ≈ (1 - e^(-kn/m))^k where n = number of inserted elements, m = bit array size, k = number of hash functions.
Optimal k: k = (m/n) × ln(2). Optimal m: m = -n × ln(p) / (ln(2))² where p is your target false positive rate.
| m/n ratio (bits per element) | Optimal k | False Positive Rate |
|---|---|---|
| 6.24 | 4 | 10% |
| 9.59 | 7 | 1% |
| 14.38 | 10 | 0.1% |
| 19.17 | 13 | 0.01% |
Bit array diagram — inserting "hello" with k=3 hash functions sets bits at positions 1, 4, 6:
Key properties: No false negatives (if the filter says NO, it's definitely not present). False positives exist (if YES, it might be present). Cannot delete elements (use Counting Bloom Filter for deletion support). Space-efficient: ~10 bits per element at 1% FP rate.
3. Bloom Filter — Java Implementation
The following implementation uses Java's BitSet for the bit array and a MurmurHash3-inspired double-hashing scheme to simulate k independent hash functions efficiently:
import java.util.BitSet;
import java.nio.charset.StandardCharsets;
public class BloomFilter<T> {
private final BitSet bitSet;
private final int bitSetSize;
private final int numHashFunctions;
private int elementCount;
public BloomFilter(int expectedElements, double falsePositiveRate) {
this.bitSetSize = optimalBitSetSize(expectedElements, falsePositiveRate);
this.numHashFunctions = optimalNumHashFunctions(expectedElements, bitSetSize);
this.bitSet = new BitSet(bitSetSize);
}
private static int optimalBitSetSize(int n, double p) {
return (int) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
}
private static int optimalNumHashFunctions(int n, int m) {
return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
}
// MurmurHash3-inspired double hashing: h_i(x) = h1(x) + i * h2(x)
private int[] getHashPositions(T element) {
byte[] bytes = element.toString().getBytes(StandardCharsets.UTF_8);
int h1 = murmurHash(bytes, 0);
int h2 = murmurHash(bytes, h1);
int[] positions = new int[numHashFunctions];
for (int i = 0; i < numHashFunctions; i++) {
positions[i] = Math.abs((h1 + i * h2) % bitSetSize);
}
return positions;
}
private int murmurHash(byte[] data, int seed) {
int h = seed;
for (byte b : data) {
h ^= b;
h *= 0x5bd1e995;
h ^= h >>> 15;
}
return h;
}
public void add(T element) {
for (int pos : getHashPositions(element)) {
bitSet.set(pos);
}
elementCount++;
}
public boolean mightContain(T element) {
for (int pos : getHashPositions(element)) {
if (!bitSet.get(pos)) return false;
}
return true; // might be false positive
}
public double expectedFalsePositiveRate() {
double exp = -((double) numHashFunctions * elementCount) / bitSetSize;
return Math.pow(1 - Math.exp(exp), numHashFunctions);
}
public int getBitSetSize() { return bitSetSize; }
public int getNumHashFunctions() { return numHashFunctions; }
public int getElementCount() { return elementCount; }
}
For 1 million expected elements at 1% FP: bitSetSize ≈ 9,585,059 bits (~1.2 MB), numHashFunctions = 7. Compare to a HashSet of 1M strings which would use ~60 MB.
Production tip: For production use, prefer Guava's com.google.common.hash.BloomFilter which uses a well-tested Funnel interface and 128-bit MurmurHash3. The custom implementation above is ideal for understanding internals and interview discussions.
4. Bloom Filter — Use Cases
| Use Case | System | Why Bloom Filter |
|---|---|---|
| SSTable key existence | Cassandra, HBase | Avoid disk read for non-existent keys; saves ~60% I/O |
| Safe Browsing | Google Chrome | Local filter check before expensive server-side lookup |
| URL deduplication | Web crawlers (Googlebot) | Check if URL already crawled without storing full URL set |
| Cache pre-check | Redis/Memcached layers | Avoid DB query for keys guaranteed not in cache |
| Spam email filtering | Email servers | Quick blocklist check before full spam analysis |
URL deduplication example — web crawler:
BloomFilter<String> crawledUrls = new BloomFilter<>(10_000_000, 0.01);
public void crawl(String url) {
if (crawledUrls.mightContain(url)) {
// ~1% chance of false positive — skip to avoid re-crawl
return;
}
crawledUrls.add(url);
// proceed with fetching and indexing
fetchAndIndex(url);
}
At 10 million URLs with 1% FP rate: memory usage ~12 MB vs ~600 MB for a HashSet. False positives mean occasional skips of valid URLs — acceptable for a crawler. If that 1% is unacceptable, drop to 0.1% FP rate, costing ~18 MB instead.
5. LRU Cache — Theory
An LRU (Least Recently Used) cache achieves O(1) get and put operations by combining two data structures: a HashMap for O(1) key lookup and a doubly-linked list to maintain access order. The most recently accessed node lives at the head; the least recently used node sits at the tail and is evicted when capacity is exceeded.
Why LinkedHashMap works: Java's LinkedHashMap maintains a doubly-linked list of entries in insertion or access order. When constructed with accessOrder=true, every get() and put() moves the accessed entry to the tail. Overriding removeEldestEntry() to return true when size exceeds capacity gives you a functional LRU cache in ~5 lines of code.
6. LRU Cache — Java Implementation
// Solution 1: LinkedHashMap (simplest)
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75f, true); // accessOrder=true for LRU
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}
// Solution 2: Thread-safe LRU with explicit doubly-linked list
public class ConcurrentLRUCache<K, V> {
private final int capacity;
private final Map<K, Node<K, V>> map;
private final Node<K, V> head, tail; // sentinel nodes
public ConcurrentLRUCache(int capacity) {
this.capacity = capacity;
this.map = new ConcurrentHashMap<>();
this.head = new Node<>(null, null);
this.tail = new Node<>(null, null);
head.next = tail;
tail.prev = head;
}
public synchronized V get(K key) {
Node<K, V> node = map.get(key);
if (node == null) return null;
moveToFront(node);
return node.value;
}
public synchronized void put(K key, V value) {
if (map.containsKey(key)) {
Node<K, V> node = map.get(key);
node.value = value;
moveToFront(node);
} else {
if (map.size() >= capacity) {
Node<K, V> lru = tail.prev;
remove(lru);
map.remove(lru.key);
}
Node<K, V> newNode = new Node<>(key, value);
addToFront(newNode);
map.put(key, newNode);
}
}
private void moveToFront(Node<K, V> node) {
remove(node);
addToFront(node);
}
private void addToFront(Node<K, V> node) {
node.next = head.next;
node.prev = head;
head.next.prev = node;
head.next = node;
}
private void remove(Node<K, V> node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
static class Node<K, V> {
K key; V value;
Node<K, V> prev, next;
Node(K key, V value) { this.key = key; this.value = value; }
}
}
Production recommendation: For single-node in-process caching, use Caffeine (Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(5, MINUTES).build()). Caffeine uses a Window TinyLFU admission policy that outperforms pure LRU by ~10–30% hit rate and handles concurrency without global locks. For distributed caching across microservices, use Spring Cache with Redis.
7. LFU Cache — Theory and Implementation
LFU (Least Frequently Used) evicts the entry with the lowest access count. When counts tie, the least recently accessed among the minimum-frequency entries is evicted. The O(1) implementation uses three maps: keyToVal, keyToFreq, and freqToKeys (a LinkedHashSet preserves insertion order for tie-breaking).
public class LFUCache<K, V> {
private final int capacity;
private int minFreq;
private final Map<K, V> keyToVal;
private final Map<K, Integer> keyToFreq;
private final Map<Integer, LinkedHashSet<K>> freqToKeys;
public LFUCache(int capacity) {
this.capacity = capacity;
this.minFreq = 0;
this.keyToVal = new HashMap<>();
this.keyToFreq = new HashMap<>();
this.freqToKeys = new HashMap<>();
}
public V get(K key) {
if (!keyToVal.containsKey(key)) return null;
increaseFreq(key);
return keyToVal.get(key);
}
public void put(K key, V value) {
if (capacity <= 0) return;
if (keyToVal.containsKey(key)) {
keyToVal.put(key, value);
increaseFreq(key);
return;
}
if (keyToVal.size() >= capacity) {
removeMinFreqKey();
}
keyToVal.put(key, value);
keyToFreq.put(key, 1);
freqToKeys.computeIfAbsent(1, k -> new LinkedHashSet<>()).add(key);
minFreq = 1;
}
private void increaseFreq(K key) {
int freq = keyToFreq.get(key);
keyToFreq.put(key, freq + 1);
freqToKeys.get(freq).remove(key);
if (freqToKeys.get(freq).isEmpty()) {
freqToKeys.remove(freq);
if (minFreq == freq) minFreq++;
}
freqToKeys.computeIfAbsent(freq + 1, k -> new LinkedHashSet<>()).add(key);
}
private void removeMinFreqKey() {
LinkedHashSet<K> keys = freqToKeys.get(minFreq);
K evictKey = keys.iterator().next(); // oldest with minFreq
keys.remove(evictKey);
if (keys.isEmpty()) freqToKeys.remove(minFreq);
keyToVal.remove(evictKey);
keyToFreq.remove(evictKey);
}
}
LRU vs LFU tradeoffs: LRU suffers from "cache pollution" — a one-time bulk scan can evict frequently used items. LFU solves this but is slower to adapt to workload shifts (an entry that was popular in the past but no longer accessed stays warm for too long). Caffeine's W-TinyLFU elegantly combines both: a small LRU window admits new items, while the main LFU-protected area holds popular items.
8. Trie Data Structure — Theory
A Trie (prefix tree) stores strings by decomposing them into characters. Each node represents a character; paths from root to marked nodes spell out complete words. All words sharing a prefix share the same path from the root — making prefix queries O(prefix length) regardless of the total number of stored words.
TrieNode: Each node holds a Map<Character, TrieNode> children (or TrieNode[26] for lowercase ASCII), a boolean isEndOfWord, and optionally the full word string for easy retrieval during DFS.
root
|
c
|
a
/ \
t* r
/ \
d* e*
Space complexity: O(ALPHABET_SIZE × key_length × N) in the worst case. For lowercase English with 26-char alphabet, average word length 7, storing 10K words: ~1.82M node references. In practice, prefix sharing reduces actual usage significantly.
Variants: Compressed Trie (Patricia Trie) merges single-child nodes to save space. Ternary Search Trie is more memory-efficient for sparse character sets. Aho-Corasick automaton extends Trie with failure links for multi-pattern substring search.
9. Trie — Java Implementation
This implementation supports insert, search, prefix check, autocomplete (DFS from prefix node), and the classic Word Search II problem (find all dictionary words in a 2D character board):
public class Trie {
private final TrieNode root;
public Trie() {
root = new TrieNode();
}
public void insert(String word) {
TrieNode node = root;
for (char c : word.toCharArray()) {
node.children.putIfAbsent(c, new TrieNode());
node = node.children.get(c);
}
node.isEndOfWord = true;
node.word = word;
}
public boolean search(String word) {
TrieNode node = findNode(word);
return node != null && node.isEndOfWord;
}
public boolean startsWith(String prefix) {
return findNode(prefix) != null;
}
// Autocomplete: return all words with given prefix
public List<String> autocomplete(String prefix) {
List<String> results = new ArrayList<>();
TrieNode prefixNode = findNode(prefix);
if (prefixNode == null) return results;
dfs(prefixNode, results);
return results;
}
private void dfs(TrieNode node, List<String> results) {
if (node.isEndOfWord) results.add(node.word);
for (TrieNode child : node.children.values()) {
dfs(child, results);
}
}
private TrieNode findNode(String prefix) {
TrieNode node = root;
for (char c : prefix.toCharArray()) {
node = node.children.get(c);
if (node == null) return null;
}
return node;
}
// Word Search II — find all words from dictionary in a 2D board
public List<String> findWords(char[][] board, String[] words) {
for (String w : words) insert(w);
Set<String> found = new HashSet<>();
for (int i = 0; i < board.length; i++)
for (int j = 0; j < board[0].length; j++)
dfsBoard(board, i, j, root, found);
return new ArrayList<>(found);
}
private void dfsBoard(char[][] board, int i, int j, TrieNode node, Set<String> found) {
if (i < 0 || i >= board.length || j < 0 || j >= board[0].length) return;
char c = board[i][j];
if (c == '#' || !node.children.containsKey(c)) return;
TrieNode next = node.children.get(c);
if (next.isEndOfWord) found.add(next.word);
board[i][j] = '#'; // mark visited
dfsBoard(board, i+1, j, next, found);
dfsBoard(board, i-1, j, next, found);
dfsBoard(board, i, j+1, next, found);
dfsBoard(board, i, j-1, next, found);
board[i][j] = c; // restore
}
static class TrieNode {
Map<Character, TrieNode> children = new HashMap<>();
boolean isEndOfWord;
String word; // store word at terminal node for easy retrieval
}
}
Autocomplete in practice: For a typeahead service with a dictionary of 100K words, searching with prefix "pro" traverses only the "p→r→o" path and DFS collects all completions. Total time: O(prefix_len + number_of_matches). Compare to a linear scan of all 100K strings: O(n × prefix_len).
Production note: For large-scale autocomplete (e.g., Google Search with billions of queries), use a radix tree (Patricia Trie) to compress single-child chains, or Apache Commons Collections4's PatriciaTrie which implements SortedMap with prefixMap(prefix) support.
10. Skip List and ConcurrentSkipListMap
A skip list is a probabilistic data structure with multiple levels of linked lists. Lower levels contain all elements; upper levels contain a random subset used for fast skipping. Average O(log n) for search, insert, and delete — same as a balanced BST but simpler to implement and naturally lock-free for concurrent access.
ConcurrentSkipListMap is Java's thread-safe sorted map backed by a skip list. Unlike Collections.synchronizedSortedMap(new TreeMap<>()), it allows concurrent reads and writes without serializing all access.
// ConcurrentSkipListMap: thread-safe sorted map, O(log n) ops
ConcurrentSkipListMap<Integer, String> leaderboard =
new ConcurrentSkipListMap<>(Comparator.reverseOrder());
leaderboard.put(100, "Alice");
leaderboard.put(250, "Bob");
leaderboard.put(175, "Charlie");
// Top 3 players: O(log n) per operation
leaderboard.entrySet().stream().limit(3).forEach(e ->
System.out.println(e.getValue() + " -> " + e.getKey()));
// Range query: scores between 100 and 200
NavigableMap<Integer, String> range = leaderboard.subMap(200, true, 100, true);
// ConcurrentSkipListSet for sorted deduplication
ConcurrentSkipListSet<String> sortedUserIds = new ConcurrentSkipListSet<>();
sortedUserIds.add("user:abc");
sortedUserIds.add("user:xyz");
String lowest = sortedUserIds.first();
String highest = sortedUserIds.last();
Use ConcurrentSkipListMap when: you need a thread-safe sorted map, range queries under concurrent load, leaderboards or time-series data sorted by score/timestamp, or a concurrent priority queue with O(log n) operations. The trade-off vs ConcurrentHashMap is higher constant factor due to skip list pointer overhead (~3-4x memory) and O(log n) vs O(1) ops.
11. Comparing: When to Use Which Structure
| Structure | Time Complexity | Space | Thread-safe? | Best Use Case | Java Implementation |
|---|---|---|---|---|---|
| Bloom Filter | O(k) insert/lookup | O(m) bits — very small | ✗ (wrap BitSet) | Probabilistic membership, URL dedup, cache pre-check | BitSet + MurmurHash; Guava BloomFilter |
| LRU Cache | O(1) get/put/evict | O(n) | With sync wrapper | Temporal locality — recently accessed items are accessed again | LinkedHashMap(n, 0.75f, true); Caffeine |
| LFU Cache | O(1) get/put/evict | O(n) | With sync wrapper | Frequency locality — popular items always accessed (catalog, CDN) | 3-map design (keyToVal, keyToFreq, freqToKeys) |
| Trie | O(L) all ops (L=word len) | O(ALPHA × L × N) | ✗ (needs sync) | Prefix search, autocomplete, IP routing, spell check | TrieNode[26] or HashMap<Char, TrieNode>; Apache PatriciaTrie |
| Skip List | O(log n) avg | O(n log n) | ✓ ConcurrentSkipListMap | Sorted concurrent map, leaderboards, range queries under concurrency | ConcurrentSkipListMap / ConcurrentSkipListSet |
| HashMap | O(1) avg | O(n) | ConcurrentHashMap | General key-value lookup, no ordering or eviction needed | HashMap; ConcurrentHashMap |
| TreeMap | O(log n) | O(n) | ✗ (use CSLM) | Sorted map in single-threaded context, floor/ceiling operations | TreeMap (Red-Black tree) |
Decision flowchart: Need membership check at scale? → Bloom Filter. Need bounded in-memory cache with recency eviction? → LRU (Caffeine). Need popularity-based eviction? → LFU or W-TinyLFU. Need prefix queries or autocomplete? → Trie. Need concurrent sorted map with range queries? → ConcurrentSkipListMap.
12. Production Checklist and Interview Tips
Production Checklist:
- ☑ Tune Bloom filter bit array size and hash count using the optimal formulas for your target FP rate.
- ☑ Prefer Guava's
BloomFilterin production; implement custom only for interview/learning. - ☑ For in-process LRU caching, use Caffeine — not raw LinkedHashMap — for concurrency and statistics.
- ☑ Set a maximum cache size on all Caffeine caches to prevent unbounded heap growth.
- ☑ Monitor cache hit ratio with Micrometer + Caffeine's
recordStats(); alert if hit rate drops below 70%. - ☑ For distributed caching across services, use Spring Cache with Redis — not in-process caches.
- ☑ In Trie implementations, consider memory limits: 10M words × 26-pointer nodes can exceed heap.
- ☑ Use
ConcurrentSkipListMapinstead ofsynchronizedSortedMap(new TreeMap<>())for concurrent sorted access. - ☑ Never use Counting Bloom Filter for security-sensitive membership (timing attacks via false positive patterns).
- ☑ For LFU cache in distributed systems, consider Redis's
allkeys-lfumaxmemory-policy instead of a custom implementation.
Interview Tips:
| Question Pattern | Key Answer Points |
|---|---|
| Design an LRU cache | HashMap + doubly-linked list, sentinel head/tail, O(1) all ops, thread-safe with synchronized or Caffeine |
| Design a rate limiter | Sliding window counter using ConcurrentSkipListMap<Long(timestamp), Integer> for O(log n) time-range eviction |
| Implement autocomplete | Trie insert + DFS from prefix node; optimize with top-k heap at each node for ranked results |
| Reduce DB load for existence checks | Bloom filter as gate — if mightContain() returns false, skip DB; if true, verify with DB to handle false positives |
| Word Search II (LeetCode 212) | Build Trie from word list, DFS the board; prune branches when no Trie child matches current char |
Common interview mistakes to avoid: Forgetting sentinel head/tail nodes in LRU (causes null pointer edge cases). Using array[26] in Trie but forgetting Unicode inputs. Claiming Bloom filter can return false negatives (it cannot). Claiming TreeMap is thread-safe. Not mentioning that LFU can hurt performance after a workload shift since stale-frequency items stay warm.
Leave a Comment
Related Posts
Last updated: April 11, 2026