Chapter 14: Hash Tables, Maps and Sets
14.09Beginner / Intermediate4 topics17 min4 interactive animations

Cache applications

Hash tables in caches: an LRU cache with LinkedHashMap, the same cache built from a hash map and a doubly linked list, the LFU cache idea, and memoization tables.

Learning goals

  • Build an LRU cache with LinkedHashMap and from scratch
  • Explain how an LFU cache tracks frequencies and breaks ties
  • Use a hash map as a memoization table

Prerequisites

Java mapsDoubly linked lists
14.09.1

LRU cache with LinkedHashMap

A cache keeps recent results so repeated requests are fast, but it has a limited size and must decide what to throw away. The **least recently used (LRU)** policy evicts the entry that has gone unused the longest, betting that recent use predicts future use.

LinkedHashMap in access-order mode is an LRU cache almost for free: every get or put moves the entry to the end of its internal list, and removeEldestEntry evicts the head when the cache is over capacity. All operations are O(1). The “Capacity 3” preset of the animation below shows the same policy step by step; with Java's class, after putting 1, 2, 3, reading 1 and putting 4 the keys are [3, 1, 4].

Java
1class LruCache<K, V> extends LinkedHashMap<K, V> {
2 private final int capacity;
3 LruCache(int capacity) { super(16, 0.75f, true); this.capacity = capacity; } // true = access order
4 @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > capacity; }
5}

LRU cache: hash map + doubly linked list

Interactive · edit the input

Your input

Try

most recent → least recent · 0 nodes

null
Accessed nowHitEvicted

map: key → node

empty
Setup

A cache of capacity 3. The list keeps keys from most to least recently used; the hash map finds a key's node in O(1).

Step 1 / 18
Java
1class LRUCache {
2 private final int capacity;
3 private final Map<Integer, DNode> map = new HashMap<>(); // key -> node: O(1) lookup
4 private final DNode head = new DNode(), tail = new DNode(); // head.next is the most recently used
5 boolean access(int key) {
6 DNode node = map.get(key);
7 if (node != null) { unlink(node); addFirst(node); return true; } // hit: move to the front
8 node = new DNode(key); map.put(key, node); addFirst(node); // miss: insert at the front
9 if (map.size() > capacity) { DNode lru = tail.prev; unlink(lru); map.remove(lru.key); } // evict the oldest
10 return false;
11 }
12}

Live state

capacity

3

No condition evaluated on this step
Execution trace (dry run table)Show 18 steps
StepLineStateWhat happens
1L4capacity=3A cache of capacity 3. The list keeps keys from most to least recently used; the hash map finds a key's node in O(1).
2L8access=1 hits=0 misses=1Access 1: miss. Insert a new node at the front and record it in the map.
3L8access=2 hits=0 misses=2Access 2: miss. Insert a new node at the front and record it in the map.
4L8access=3 hits=0 misses=3Access 3: miss. Insert a new node at the front and record it in the map.
5L7access=1 hits=1 misses=3Access 1: hit. Unlink its node and re-insert it at the front (O(1) because the node knows both neighbours).
6L8access=4 hits=1 misses=4Access 4: miss. Insert a new node at the front and record it in the map.
7L9evict=2The cache holds 4 > 3 keys: evict the least recently used, 2, from the tail and from the map.
8L9size=32 is gone.
9L8access=2 hits=1 misses=5Access 2: miss. Insert a new node at the front and record it in the map.
10L9evict=3The cache holds 4 > 3 keys: evict the least recently used, 3, from the tail and from the map.
11L9size=33 is gone.
12L8access=5 hits=1 misses=6Access 5: miss. Insert a new node at the front and record it in the map.
13L9evict=1The cache holds 4 > 3 keys: evict the least recently used, 1, from the tail and from the map.
14L9size=31 is gone.
15L8access=1 hits=1 misses=7Access 1: miss. Insert a new node at the front and record it in the map.
16L9evict=4The cache holds 4 > 3 keys: evict the least recently used, 4, from the tail and from the map.
17L9size=34 is gone.
18L12hits=1 misses=7 return=[1, 5, 2]1 hit and 7 misses out of 8 accesses. Final order, most recent first: [1, 5, 2]. Every operation is O(1).
14.09.2

LRU with hash map + doubly list

LinkedHashMap hides the mechanism. Built by hand, an LRU cache has two parts: a hash map from key to node for O(1) lookup, and a doubly linked list ordering the nodes from most to least recently used. A hit unlinks the node and re-links it at the head; an insertion at capacity removes the tail node and its map entry. The doubly linked list is what makes unlinking an arbitrary node O(1), because it knows its own predecessor.

Use dummy head and tail nodes so no operation has to special-case an empty list. This is a favourite interview problem, and the animation draws the list and the map in step with each access.

Java
1class LRU {
2 private static class Node { int key, val; Node prev, next; }
3 private final Map<Integer, Node> map = new HashMap<>();
4 private final Node head = new Node(), tail = new Node(); // dummy sentinels
5 private final int capacity;
6 LRU(int capacity) { this.capacity = capacity; head.next = tail; tail.prev = head; }
7 private void unlink(Node n) { n.prev.next = n.next; n.next.prev = n.prev; }
8 private void addFirst(Node n) { n.next = head.next; n.prev = head; head.next.prev = n; head.next = n; }
9 int get(int key) {
10 Node n = map.get(key);
11 if (n == null) return -1;
12 unlink(n); addFirst(n); // most recently used goes to the front
13 return n.val;
14 }
15 void put(int key, int val) {
16 Node n = map.get(key);
17 if (n != null) { n.val = val; unlink(n); addFirst(n); return; }
18 if (map.size() == capacity) { Node lru = tail.prev; unlink(lru); map.remove(lru.key); } // evict the oldest
19 n = new Node(); n.key = key; n.val = val;
20 map.put(key, n); addFirst(n);
21 }
22}

LRU cache: hash map + doubly linked list

Interactive · edit the input

Your input

Try

most recent → least recent · 0 nodes

null
Accessed nowHitEvicted

map: key → node

empty
Setup

A cache of capacity 3. The list keeps keys from most to least recently used; the hash map finds a key's node in O(1).

Step 1 / 18
Java
1class LRUCache {
2 private final int capacity;
3 private final Map<Integer, DNode> map = new HashMap<>(); // key -> node: O(1) lookup
4 private final DNode head = new DNode(), tail = new DNode(); // head.next is the most recently used
5 boolean access(int key) {
6 DNode node = map.get(key);
7 if (node != null) { unlink(node); addFirst(node); return true; } // hit: move to the front
8 node = new DNode(key); map.put(key, node); addFirst(node); // miss: insert at the front
9 if (map.size() > capacity) { DNode lru = tail.prev; unlink(lru); map.remove(lru.key); } // evict the oldest
10 return false;
11 }
12}

Live state

capacity

3

No condition evaluated on this step
Execution trace (dry run table)Show 18 steps
StepLineStateWhat happens
1L4capacity=3A cache of capacity 3. The list keeps keys from most to least recently used; the hash map finds a key's node in O(1).
2L8access=1 hits=0 misses=1Access 1: miss. Insert a new node at the front and record it in the map.
3L8access=2 hits=0 misses=2Access 2: miss. Insert a new node at the front and record it in the map.
4L8access=3 hits=0 misses=3Access 3: miss. Insert a new node at the front and record it in the map.
5L7access=1 hits=1 misses=3Access 1: hit. Unlink its node and re-insert it at the front (O(1) because the node knows both neighbours).
6L8access=4 hits=1 misses=4Access 4: miss. Insert a new node at the front and record it in the map.
7L9evict=2The cache holds 4 > 3 keys: evict the least recently used, 2, from the tail and from the map.
8L9size=32 is gone.
9L8access=2 hits=1 misses=5Access 2: miss. Insert a new node at the front and record it in the map.
10L9evict=3The cache holds 4 > 3 keys: evict the least recently used, 3, from the tail and from the map.
11L9size=33 is gone.
12L8access=5 hits=1 misses=6Access 5: miss. Insert a new node at the front and record it in the map.
13L9evict=1The cache holds 4 > 3 keys: evict the least recently used, 1, from the tail and from the map.
14L9size=31 is gone.
15L8access=1 hits=1 misses=7Access 1: miss. Insert a new node at the front and record it in the map.
16L9evict=4The cache holds 4 > 3 keys: evict the least recently used, 4, from the tail and from the map.
17L9size=34 is gone.
18L12hits=1 misses=7 return=[1, 5, 2]1 hit and 7 misses out of 8 accesses. Final order, most recent first: [1, 5, 2]. Every operation is O(1).
14.09.3

LFU cache concept

The **least frequently used (LFU)** policy evicts the key that has been used the fewest times, on the bet that popular keys stay popular. LRU forgets a hot key after a burst of one-off requests; LFU keeps it. The price is bookkeeping: counts must be kept, and a once-hot key can stay forever unless counts are aged.

To make every operation O(1), keep a map from key to its count and a map from count to a LinkedHashSet of keys with that count (oldest first), plus the current minimum count. On a hit the key moves from the set of count f to the set of f+1; if the old set was the minimum and is now empty, the minimum rises by one. On a miss with a full cache, evict the first key of the minimum-count set, which breaks ties by age. A new key always has count 1.

Java
1class LfuCache {
2 private final int capacity; private int minFreq;
3 private final Map<Integer, Integer> freq = new HashMap<>();
4 private final Map<Integer, LinkedHashSet<Integer>> byFreq = new HashMap<>(); // oldest key first
5 LfuCache(int capacity) { this.capacity = capacity; }
6 void access(int key) {
7 if (freq.containsKey(key)) { touch(key); return; } // hit
8 if (freq.size() == capacity) { // miss on a full cache
9 int victim = byFreq.get(minFreq).iterator().next(); // lowest frequency, oldest first
10 byFreq.get(minFreq).remove(victim); freq.remove(victim);
11 }
12 freq.put(key, 1); byFreq.computeIfAbsent(1, f -> new LinkedHashSet<>()).add(key); minFreq = 1;
13 }
14 private void touch(int key) {
15 int f = freq.get(key);
16 byFreq.get(f).remove(key);
17 if (byFreq.get(f).isEmpty() && minFreq == f) minFreq++;
18 freq.put(key, f + 1); byFreq.computeIfAbsent(f + 1, x -> new LinkedHashSet<>()).add(key);
19 }
20}

LFU cache: evict the least frequently used key

Interactive · edit the input

Your input

Try

accesses

10
21
12
33
14
25
46
37

cache · 0 of 3 · minFreq -

∅
Setup

Every key has a use count. When the cache is full, the key with the smallest count is evicted; among equal counts the one used longest ago goes first.

Step 1 / 20
Java
1class LfuCache {
2 private final int capacity; private int minFreq;
3 private final Map<Integer, Integer> freq = new HashMap<>();
4 private final Map<Integer, LinkedHashSet<Integer>> byFreq = new HashMap<>(); // oldest key first
5 void access(int key) {
6 if (freq.containsKey(key)) { touch(key); return; } // hit
7 if (freq.size() == capacity) { // miss on a full cache
8 int victim = byFreq.get(minFreq).iterator().next(); // lowest frequency, oldest first
9 byFreq.get(minFreq).remove(victim); freq.remove(victim);
10 }
11 freq.put(key, 1); byFreq.computeIfAbsent(1, f -> new LinkedHashSet<>()).add(key); minFreq = 1;
12 }
13 private void touch(int key) {
14 int f = freq.get(key);
15 byFreq.get(f).remove(key);
16 if (byFreq.get(f).isEmpty() && minFreq == f) minFreq++;
17 freq.put(key, f + 1); byFreq.computeIfAbsent(f + 1, x -> new LinkedHashSet<>()).add(key);
18 }
19}

Live state

0 hits0 misses

capacity

3

No condition evaluated on this step
Execution trace (dry run table)Show 20 steps
StepLineStateWhat happens
1L2capacity=3Every key has a use count. When the cache is full, the key with the smallest count is evicted; among equal counts the one used longest ago goes first.
2L6key=1 size=0 minFreq=0Miss on 1: it is not cached.
3L11key=1 freq=1 minFreq=1Insert 1 with count 1; minFreq resets to 1 because the newest key always has the lowest count.
4L6key=2 size=1 minFreq=1Miss on 2: it is not cached.
5L11key=2 freq=1 minFreq=1Insert 2 with count 1; minFreq resets to 1 because the newest key always has the lowest count.
6L6key=1 freq=1 minFreq=1Hit on 1 (used 1 time).
7L17key=1 freq=2 minFreq=1touch(1): its count moves from 1 to 2.
8L6key=3 size=2 minFreq=1Miss on 3: it is not cached.
9L11key=3 freq=1 minFreq=1Insert 3 with count 1; minFreq resets to 1 because the newest key always has the lowest count.
10L6key=1 freq=2 minFreq=1Hit on 1 (used 2 times).
11L17key=1 freq=3 minFreq=1touch(1): its count moves from 2 to 3.
12L6key=2 freq=1 minFreq=1Hit on 2 (used 1 time).
13L17key=2 freq=2 minFreq=1touch(2): its count moves from 1 to 2.
14L6key=4 size=3 minFreq=1Miss on 4: it is not cached.
15L8victim=3 its freq=1The cache is full. The lowest use count is 1; the oldest key with that count is 3, so it is evicted.
16L11key=4 freq=1 minFreq=1Insert 4 with count 1; minFreq resets to 1 because the newest key always has the lowest count.
17L6key=3 size=3 minFreq=1Miss on 3: it is not cached.
18L8victim=4 its freq=1The cache is full. The lowest use count is 1; the oldest key with that count is 4, so it is evicted.
19L11key=3 freq=1 minFreq=1Insert 3 with count 1; minFreq resets to 1 because the newest key always has the lowest count.
20L19hits=3 misses=5 return=[1×3, 2×2, 3×1]3 hits, 5 misses. Cached (key×count): [1×3, 2×2, 3×1].
14.09.4

Memoization table

**Memoization** caches the results of a pure function so each distinct input is computed once. A hash map from the arguments to the answer is the usual table: look up first, and compute and store only on a miss. It turns exponential recursion such as fib(n) = fib(n-1) + fib(n-2) into a linear number of calls, because every fib(k) is computed once and answered from the table afterwards.

Use computeIfAbsent with care for recursive functions: the mapping function must not touch the same map, or Java throws ConcurrentModificationException. Check with get and store with put instead. Chapter 23 (Dynamic Programming: The Core Method) studies this in depth, where the memo table is often an array indexed by the state.

Java
1static final Map<Integer, Long> memo = new HashMap<>();
2
3static long fib(int n) {
4 if (n <= 1) return n;
5 Long cached = memo.get(n);
6 if (cached != null) return cached; // answered from the table
7 long result = fib(n - 1) + fib(n - 2);
8 memo.put(n, result); // store after the recursive calls (no computeIfAbsent here)
9 return result;
10}

Fibonacci with memoization

Interactive · edit the input

Your input

Try

Recursion tree · 1 call

fib(6)…
Running now
Call

Call fib(6).

Step 1 / 52
Java
1static long[] memo = new long[100];
2static long fib(int n) {
3 if (n <= 1) return n; // base cases
4 if (memo[n] != 0) return memo[n]; // already solved: reuse it
5 return memo[n] = fib(n - 1) + fib(n - 2); // solve once, remember
6}

Live state

1 calls

n

6

depth

1

No condition evaluated on this step

Call stack (top first)

  1. fib(6)
Execution trace (dry run table)Show 52 steps
StepLineStateWhat happens
1L2n=6 depth=1Call fib(6).
2L3n=6Not a base case.
3L4n=6 memo[n]=0memo[6] is empty, so compute it.
4L5n=6fib(6) = fib(5) + fib(4). Call fib(5) first.
5L2n=5 depth=2Call fib(5).
6L3n=5Not a base case.
7L4n=5 memo[n]=0memo[5] is empty, so compute it.
8L5n=5fib(5) = fib(4) + fib(3). Call fib(4) first.
9L2n=4 depth=3Call fib(4).
10L3n=4Not a base case.
11L4n=4 memo[n]=0memo[4] is empty, so compute it.
12L5n=4fib(4) = fib(3) + fib(2). Call fib(3) first.
13L2n=3 depth=4Call fib(3).
14L3n=3Not a base case.
15L4n=3 memo[n]=0memo[3] is empty, so compute it.
16L5n=3fib(3) = fib(2) + fib(1). Call fib(2) first.
17L2n=2 depth=5Call fib(2).
18L3n=2Not a base case.
19L4n=2 memo[n]=0memo[2] is empty, so compute it.
20L5n=2fib(2) = fib(1) + fib(0). Call fib(1) first.
21L2n=1 depth=6Call fib(1).
22L3n=1Base case: fib(1) = 1.
23L3n=1 return=1return 1.
24L5n=2 left=1fib(1) = 1. Now call fib(0).
25L2n=0 depth=6Call fib(0).
26L3n=0Base case: fib(0) = 0.
27L3n=0 return=0return 0.
28L5n=2 left=1 right=0 return=1fib(2) = 1 + 0 = 1. Store it in memo[2].
29L5n=3 left=1fib(2) = 1. Now call fib(1).
30L2n=1 depth=5Call fib(1).
31L3n=1Base case: fib(1) = 1.
32L3n=1 return=1return 1.
33L5n=3 left=1 right=1 return=2fib(3) = 1 + 1 = 2. Store it in memo[3].
34L5n=4 left=2fib(3) = 2. Now call fib(2).
35L2n=2 depth=4Call fib(2).
36L3n=2Not a base case.
37L4n=2 memo[n]=1memo[2] is already 1. No need to recurse again.
38L4n=2 return=1Cache hit: return 1 immediately. This whole subtree is skipped.
39L5n=4 left=2 right=1 return=3fib(4) = 2 + 1 = 3. Store it in memo[4].
40L5n=5 left=3fib(4) = 3. Now call fib(3).
41L2n=3 depth=3Call fib(3).
42L3n=3Not a base case.
43L4n=3 memo[n]=2memo[3] is already 2. No need to recurse again.
44L4n=3 return=2Cache hit: return 2 immediately. This whole subtree is skipped.
45L5n=5 left=3 right=2 return=5fib(5) = 3 + 2 = 5. Store it in memo[5].
46L5n=6 left=5fib(5) = 5. Now call fib(4).
47L2n=4 depth=2Call fib(4).
48L3n=4Not a base case.
49L4n=4 memo[n]=3memo[4] is already 3. No need to recurse again.
50L4n=4 return=3Cache hit: return 3 immediately. This whole subtree is skipped.
51L5n=6 left=5 right=3 return=8fib(6) = 5 + 3 = 8. Store it in memo[6].
52L5return=8fib(6) = 8 with 11 calls. Plain recursion would need 25 calls.

Practice ladder

Now try it yourself

Easy

  • Design HashMap
  • Climbing Stairs (memoised)

Medium

  • LRU Cache
  • Design Browser History

Advanced

  • LFU Cache
  • All O`one Data Structure

Key takeaways

  • LinkedHashMap with accessOrder = true and removeEldestEntry is an LRU cache in a few lines.
  • A hand-built LRU is a hash map to nodes plus a doubly linked list, all O(1).
  • LFU adds counts and per-count lists; memoization is a hash map from arguments to results.