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.

Bloom Filters LRU Cache Trie Java Algorithms 2026
TL;DR: Use Bloom filters for O(1) probabilistic membership tests (URL dedup, cache pre-check). Use LRU cache via LinkedHashMap for simple eviction; use Caffeine for production-grade caching. Use LFU when frequency matters more than recency. Use Trie for prefix-search and autocomplete. Use ConcurrentSkipListMap for thread-safe sorted access.

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.

StructureInsertLookupDeleteSpace
Bloom FilterO(k)O(k)✗ N/AO(m) bits
LRU CacheO(1)O(1)O(1)O(n)
LFU CacheO(1)O(1)O(1)O(n)
TrieO(L)O(L)O(L)O(ALPHABET * L * N)
Skip ListO(log n)O(log n)O(log n)O(n log n)
HashMapO(1) avgO(1) avgO(1) avgO(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 kFalse Positive Rate
6.24410%
9.5971%
14.38100.1%
19.17130.01%

Bit array diagram — inserting "hello" with k=3 hash functions sets bits at positions 1, 4, 6:

Index: [0] [1] [2] [3] [4] [5] [6] [7]
Before: [ 0] [ 0] [ 0] [ 0] [ 0] [ 0] [ 0] [ 0]
After: [ 0] [1] [ 0] [ 0] [1] [ 0] [1] [ 0] ← "hello" sets bits 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 CaseSystemWhy Bloom Filter
SSTable key existenceCassandra, HBaseAvoid disk read for non-existent keys; saves ~60% I/O
Safe BrowsingGoogle ChromeLocal filter check before expensive server-side lookup
URL deduplicationWeb crawlers (Googlebot)Check if URL already crawled without storing full URL set
Cache pre-checkRedis/Memcached layersAvoid DB query for keys guaranteed not in cache
Spam email filteringEmail serversQuick 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.

Doubly-linked list access order (head = MRU, tail = LRU):
[sentinel HEAD] <-> [key=C, val=3] <-> [key=A, val=1] <-> [key=B, val=2] <-> [sentinel TAIL]
HashMap: A->nodeA | B->nodeB | C->nodeC (O(1) pointer lookup)
get(A): move nodeA to head → HEAD <-> A <-> C <-> B <-> TAIL
put(D): evict B (LRU at tail), insert D at head

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.

Trie storing: "cat", "car", "card", "care"
          root
           |
           c
           |
           a
          / \
        t*   r
            / \
          d*   e*
* = isEndOfWord = true

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

StructureTime ComplexitySpaceThread-safe?Best Use CaseJava Implementation
Bloom FilterO(k) insert/lookupO(m) bits — very small✗ (wrap BitSet)Probabilistic membership, URL dedup, cache pre-checkBitSet + MurmurHash; Guava BloomFilter
LRU CacheO(1) get/put/evictO(n)With sync wrapperTemporal locality — recently accessed items are accessed againLinkedHashMap(n, 0.75f, true); Caffeine
LFU CacheO(1) get/put/evictO(n)With sync wrapperFrequency locality — popular items always accessed (catalog, CDN)3-map design (keyToVal, keyToFreq, freqToKeys)
TrieO(L) all ops (L=word len)O(ALPHA × L × N)✗ (needs sync)Prefix search, autocomplete, IP routing, spell checkTrieNode[26] or HashMap<Char, TrieNode>; Apache PatriciaTrie
Skip ListO(log n) avgO(n log n)✓ ConcurrentSkipListMapSorted concurrent map, leaderboards, range queries under concurrencyConcurrentSkipListMap / ConcurrentSkipListSet
HashMapO(1) avgO(n)ConcurrentHashMapGeneral key-value lookup, no ordering or eviction neededHashMap; ConcurrentHashMap
TreeMapO(log n)O(n)✗ (use CSLM)Sorted map in single-threaded context, floor/ceiling operationsTreeMap (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 BloomFilter in 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 ConcurrentSkipListMap instead of synchronizedSortedMap(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-lfu maxmemory-policy instead of a custom implementation.

Interview Tips:

Question PatternKey Answer Points
Design an LRU cacheHashMap + doubly-linked list, sentinel head/tail, O(1) all ops, thread-safe with synchronized or Caffeine
Design a rate limiterSliding window counter using ConcurrentSkipListMap<Long(timestamp), Integer> for O(log n) time-range eviction
Implement autocompleteTrie insert + DFS from prefix node; optimize with top-k heap at each node for ranked results
Reduce DB load for existence checksBloom 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.

#BloomFilter #LRUCache #LFUCache #Trie #JavaAlgorithms #DataStructures

Leave a Comment

Related Posts

Microservices

Redis Caching in Spring Boot Production Guide

Core Java

Hibernate JPA N+1 Problem and Second-Level Cache

Core Java

Java Data Structures Deep Dive

Microservices

Distributed Tracing with OpenTelemetry and Spring Boot

← Back to Blog

Last updated: April 11, 2026