Pattern 23 of 24 · Search & optimisation · 40 min

Design Data Structures: LRU, LFU and Friends

Combine a hash map with a list, an array or sorted buckets so that every operation of a class costs O(1) or O(log n).

Spot it:design a class with these methodseach operation in O(1)evict the least recently / frequently usedremember the history of every keyJavaScript you'll use: Classes for data structures Map and Set Arrays: create, read, update
Say it in 30 seconds
  • O(1)LRU and LFU get and put
  • O(log m)Time-based get: binary search over m writes
  • O(capacity)Cache space: one entry and node per key
Show what to say

“When a problem says design a class with O(1) methods, I list what each method needs and pair structures to cover it. A hash map finds the entry, and a doubly linked list, count buckets, a sorted history or an array keeps the order I need. LRU is map plus list, and every call is O(1).”

Trade-offs

  • Hand-built list vs Map insertion order: the list shows the mechanism in any language; Map order is ten lines but leans on a built-in.
  • LRU vs LFU: LRU is simpler and adapts to shifts; LFU protects popular keys but can guard cold ones.
  • Swap and pop vs splice: swap and pop removes in O(1) and loses order, which a set ignores; splice keeps order at O(n).

Bring it up before they ask

A put on an existing key is also a use, and after swap and pop the moved element's index in the map must be updated.

0 of 0 sections ticked
30-second answer

A "design a class" question asks you to pick the structures so that every method is O(1) or O(log n). No single structure does it alone, so you pair them: a map to find things, and a list, an array or sorted buckets to keep the order you need.

When to reach for it

Spot it in the problem0 of 4

Tap the signals you can see in the problem.

Rememberdesign a classO(1)evictionhistoryrandom

Pick your variant
  • Find fast, evict the oldest

    Pairmap + doubly linked list

    LRU cache
  • Evict the least used

    Pairmap + buckets by count

    LFU cache
  • Value at a past time

    Pairmap of sorted histories + binary search

    time-based store
  • Pick a random member

    Pairarray + map of positions

    insert delete getrandom
  • Extra answer on every push

    Pairstack of (value, answer so far)

    min stack

The core idea: pair a structure that finds with one that orders

Every structure is fast at one thing and slow at another.

Read more · 102 words

A hash map finds a key in O(1) but keeps no order. A list keeps order but needs O(n) to find anything. Ask what each method needs, then glue structures together.

The below keeps a map from key to value, and the order of use. Watch a hit move a key to the end, and a miss throw out the front:

Rule: for every method, name the structure that makes it O(1). None? Add one.

LRU cache: a map that also remembers the order of use

recency order (least recent first) (0)
empty
cache (key to value) (size 0)
empty
capacity
2
hits
0
misses
0

The cache is empty and holds at most 2 keys. It keeps its keys in order of use: the oldest sits at the front.

function access(cache, capacity, key) {       // cache: a Map, oldest key first
  if (cache.has(key)) {
    const value = cache.get(key);
    cache.delete(key);
    cache.set(key, value);
    return "hit";
  }
  if (cache.size === capacity) {
    const oldest = cache.keys().next().value;
    cache.delete(oldest);
  }
  cache.set(key, key * 10);
  return "miss";
}
All steps as text
  1. The cache is empty and holds at most 2 keys. It keeps its keys in order of use: the oldest sits at the front.
  2. Request 1: not in the cache, so this is a miss and we must add it.
  3. Insert 1 at the end: the newest key is the most recently used.
  4. Request 2: not in the cache, so this is a miss and we must add it.
  5. Insert 2 at the end: the newest key is the most recently used.
  6. Request 1: it is in the cache, so this is a hit.
  7. Take 1 out of the order...
  8. ...and put it back at the end. 1 is now the most recently used, so it is the last to be evicted.
  9. Request 3: not in the cache, so this is a miss and we must add it.
  10. The cache already holds 2 keys. The front of the order, 2, was used longest ago.
  11. Evict 2: removing the front key is one step, no search needed.
  12. Insert 3 at the end: the newest key is the most recently used.
  13. Done: 1 hits and 3 misses. Every request cost a constant number of map operations.

Play with it

Request keys one by one and compare an with an LFU cache on the same requests.

Read more · 111 words

Try a key that was used often long ago.

Then work in this order.

  1. List the operations and the target cost

    Write each method and its budget, such as get in O(1).

  2. Give each operation a structure

    Lookup by key: a map. Oldest first: a list. By time: a sorted array.

  3. Connect the structures

    Store in the map a pointer into the other structure, such as a list node or an array index.

  4. Update both on every change

    Every put or remove must fix the map and the second structure. Write the invariant down.

  5. Test the edges

    Empty, capacity 1, updating an existing key, removing the last element, repeated keys.

Interactive

Cache duel

The same requests, two eviction rules. Tap a key to request it.

LRU: drop the longest unusedhits 0 · misses 0oldest use on the leftLFU: drop the fewest useshits 0 · misses 0fewest uses on the left, oldest firstrequests: none yet

Both caches hold 3 entries. Request a key and watch which entry each policy throws out.

What to try

Run Popular + scan: LFU keeps A because it has the most uses, while LRU loses it to a stream of one-off keys. Then run Old favourite: A stops being asked for, but LFU still guards it with its high count and keeps missing on B, C and D, while LRU adapts at once.

Templates

1. Hash map plus a recency list (LRU)

The map finds the node, and a doubly linked list moves it to the front in O(1) because a node knows both neighbours.

Read more · 39 words

Two sentinel nodes remove the "first or last" special cases.

This is LRU CacheMedium. Shortcut: a JavaScript Map keeps insertion order, so delete and re-set moves a key to the end. Design HashMapEasy builds the bucket idea the built-in map hides.

// Template 1: a hash map for O(1) lookup + a doubly linked list for O(1) "move to front" and "evict the oldest".
// head.next is the most recently used entry, tail.prev the least recently used. Sentinels remove edge cases.
class Entry {
  constructor(key, val) {
    this.key = key;
    this.val = val;
    this.prev = null;
    this.next = null;
  }
}

export class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.entries = new Map(); // key -> Entry
    this.head = new Entry(0, 0);
    this.tail = new Entry(0, 0);
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }

  unlink(e) {
    e.prev.next = e.next;
    e.next.prev = e.prev;
  }

  pushFront(e) {
    e.prev = this.head;
    e.next = this.head.next;
    this.head.next.prev = e;
    this.head.next = e;
  }

  get(key) {
    const e = this.entries.get(key);
    if (!e) return -1;
    this.unlink(e); // a read is a use: move to the front
    this.pushFront(e);
    return e.val;
  }

  put(key, value) {
    const old = this.entries.get(key);
    if (old) {
      old.val = value;
      this.unlink(old);
      this.pushFront(old);
      return;
    }
    if (this.entries.size === this.capacity) {
      const lru = this.tail.prev; // nobody touched it for the longest time
      this.unlink(lru);
      this.entries.delete(lru.key); // the node remembers its key for exactly this
    }
    const e = new Entry(key, value);
    this.entries.set(key, e);
    this.pushFront(e);
  }
}

// The shortcut: a JavaScript Map iterates in insertion order, so delete + set moves a key to the end.
export class LRUCacheMap {
  constructor(capacity) {
    this.capacity = capacity;
    this.map = new Map(); // oldest key first
  }

  get(key) {
    if (!this.map.has(key)) return -1;
    const value = this.map.get(key);
    this.map.delete(key);
    this.map.set(key, value); // now the newest
    return value;
  }

  put(key, value) {
    this.map.delete(key);
    if (this.map.size === this.capacity) this.map.delete(this.map.keys().next().value); // evict the oldest
    this.map.set(key, value);
  }
}

2. Frequency buckets (LFU)

LFU needs two orders: by use count, then by recency inside a count.

Read more · 77 words

A use raises a count by exactly one, so keep a bucket per count and move the key up one bucket. Track minCount, the lowest non-empty bucket, and from it.

Use it for LFU CacheHard. Counts that move by one also power All O(1) Data StructureHard, where linked buckets keep the minimum and maximum at the ends.

// Template 2: group keys into buckets by use count. A hit moves a key from bucket c to bucket c + 1 only,
// so eviction needs just the lowest non-empty count (minCount) and, inside it, the oldest key.
export class LFUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.values = new Map(); // key -> value
    this.counts = new Map(); // key -> use count
    this.buckets = new Map(); // count -> Set of keys, oldest first (Sets keep insertion order)
    this.minCount = 0;
  }

  touch(key) {
    const c = this.counts.get(key);
    const bucket = this.buckets.get(c);
    bucket.delete(key);
    if (bucket.size === 0) {
      this.buckets.delete(c);
      if (this.minCount === c) this.minCount = c + 1; // the key itself just moved to c + 1
    }
    this.counts.set(key, c + 1);
    if (!this.buckets.has(c + 1)) this.buckets.set(c + 1, new Set());
    this.buckets.get(c + 1).add(key);
  }

  get(key) {
    if (!this.values.has(key)) return -1;
    this.touch(key);
    return this.values.get(key);
  }

  put(key, value) {
    if (this.capacity === 0) return;
    if (this.values.has(key)) {
      this.values.set(key, value);
      this.touch(key);
      return;
    }
    if (this.values.size === this.capacity) {
      const bucket = this.buckets.get(this.minCount);
      const oldest = bucket.values().next().value; // fewest uses, and the oldest among ties
      bucket.delete(oldest);
      if (bucket.size === 0) this.buckets.delete(this.minCount);
      this.values.delete(oldest);
      this.counts.delete(oldest);
    }
    this.values.set(key, value);
    this.counts.set(key, 1);
    if (!this.buckets.has(1)) this.buckets.set(1, new Set());
    this.buckets.get(1).add(key);
    this.minCount = 1; // a new key always has the lowest count
  }
}

For reads about the past, keep every write.

Read more · 40 words

Timestamps only grow, so appending keeps the list sorted. The answer at time t is the last write at or before t.

This is Time Based Key-Value StoreMedium. The search is the "last position that still satisfies a condition" form from Binary Search.

Time-based store: binary search a key's history

write times (5)
10
20
30
40
50
▲ lo
▲ hi
values (5)
v1
v2
v3
v4
v5
query
35
lo
0
hi
5

We want the value at time 35: the newest write at or before it. The history is sorted, so search it. lo = 0, hi = 5.

function get(history, t) {                    // history: sorted by time
  let lo = 0, hi = history.length;
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (history[mid].time <= t) lo = mid + 1;
    else hi = mid;
  }
  return lo === 0 ? "" : history[lo - 1].value;
}
All steps as text
  1. We want the value at time 35: the newest write at or before it. The history is sorted, so search it. lo = 0, hi = 5.
  2. Write at time 30 is at or before 35, so it qualifies, and so does everything left of it. Look for something newer: lo = 3.
  3. Write at time 50 is after 35, so it and everything right of it is too new. hi = 4.
  4. Write at time 40 is after 35, so it and everything right of it is too new. hi = 3.
  5. lo is the first write that is too new, so the answer is the one just before it: index 2, time 30.
// Template 3: keep every key's history in time order (appends keep it sorted), then binary search it.
// lastAtOrBefore returns the index of the newest entry with time <= t, or -1.
export function lastAtOrBefore(history, t) {
  let lo = 0;
  let hi = history.length; // first index whose time is greater than t
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (history[mid][0] <= t) lo = mid + 1;
    else hi = mid;
  }
  return lo - 1;
}

export class TimeMap {
  constructor() {
    this.history = new Map(); // key -> [[time, value], ...] sorted by time
  }

  set(key, value, time) {
    if (!this.history.has(key)) this.history.set(key, []);
    this.history.get(key).push([time, value]);
  }

  get(key, time) {
    const list = this.history.get(key);
    if (!list) return '';
    const i = lastAtOrBefore(list, time);
    return i === -1 ? '' : list[i][1];
  }
}

More templates

4. Array plus map for O(1) random

An array picks a random member in O(1) but deletes slowly from the middle.

Read more · 36 words

A map from value to position finds the slot, and swap and pop fills the hole with the last element.

This solves Insert Delete GetRandom O(1)Medium. Update the moved element's position in the map, or the next removal breaks.

O(1) removal from an array: swap the last element into the hole

items (5)
4
7
9
2
6
index (value to position) (size 5)
4→07→19→22→36→4
size
5

The set stores its values in an array, plus a map that says where each value sits. Picking a random member is items[random index].

function remove(val) {
  if (!index.has(val)) return false;
  const i = index.get(val);
  const last = items[items.length - 1];
  items[i] = last;
  index.set(last, i);
  items.pop();
  index.delete(val);
  return true;
}
All steps as text
  1. The set stores its values in an array, plus a map that says where each value sits. Picking a random member is items[random index].
  2. remove(7): the map says 7 sits at position 1. No scan of the array.
  3. Copy the last element, 6, over the hole at position 1. The order of a set does not matter.
  4. 6 now lives at position 1, so the map must say so. Forgetting this step breaks the next removal.
  5. Drop the last slot with pop(): O(1), nothing shifts.
  6. Delete 7 from the map. Done: the array has no holes, so a random index always lands on a member.
  7. remove(6): the map says 6 sits at position 1. No scan of the array.
  8. Copy the last element, 2, over the hole at position 1. The order of a set does not matter.
  9. 2 now lives at position 1, so the map must say so. Forgetting this step breaks the next removal.
  10. Drop the last slot with pop(): O(1), nothing shifts.
  11. Delete 6 from the map. Done: the array has no holes, so a random index always lands on a member.
  12. Every removal took a constant number of steps. The set now holds 3 values.
// Template 4: O(1) insert, remove and random pick. An array makes random picks easy, a map from value to index
// makes removal easy. Remove by copying the LAST element into the hole, then pop.
export class RandomizedSet {
  constructor() {
    this.items = [];
    this.index = new Map(); // value -> position in items
  }

  insert(val) {
    if (this.index.has(val)) return false;
    this.index.set(val, this.items.length);
    this.items.push(val);
    return true;
  }

  remove(val) {
    if (!this.index.has(val)) return false;
    const i = this.index.get(val);
    const last = this.items[this.items.length - 1];
    this.items[i] = last; // fill the hole
    this.index.set(last, i); // tell the map where the moved element lives now
    this.items.pop();
    this.index.delete(val); // after the set above: if val was last, it must end up deleted
    return true;
  }

  getRandom() {
    return this.items[Math.floor(Math.random() * this.items.length)];
  }
}

5. Auxiliary state on a stack

To answer "what is the minimum?", store with each value the answer for everything below it.

Read more · 25 words

That entry never changes, so pop needs no repair.

This is Min StackMedium, and the same idea gives a max stack. For stack basics see Stacks.

// Template 5: auxiliary state. Store, with every pushed value, the answer for "everything below me":
// the entry never changes while it lives, so pop needs no repair. O(1) push, pop, top, min and max.
export class MinMaxStack {
  constructor() {
    this.items = []; // [value, min so far, max so far]
  }

  push(val) {
    const top = this.items[this.items.length - 1];
    this.items.push([val, top ? Math.min(val, top[1]) : val, top ? Math.max(val, top[2]) : val]);
  }

  pop() {
    return this.items.pop()[0];
  }

  top() {
    return this.items[this.items.length - 1][0];
  }

  getMin() {
    return this.items[this.items.length - 1][1];
  }

  getMax() {
    return this.items[this.items.length - 1][2];
  }
}

Try it

try-it.js
// Edit the calls and press Run. These are the templates above.
const lru = new LRUCache(2);
lru.put(1, 1);
lru.put(2, 2);
lru.get(1);
lru.put(3, 3); // evicts key 2
console.log(lru.get(2), lru.get(1), lru.get(3)); // -1 1 3

const lfu = new LFUCache(2);
lfu.put(1, 1);
lfu.put(2, 2);
lfu.get(1);
lfu.put(3, 3); // evicts key 2: fewest uses
console.log(lfu.get(2), lfu.get(1), lfu.get(3)); // -1 1 3

const tm = new TimeMap();
tm.set('love', 'high', 10);
tm.set('love', 'low', 20);
console.log([5, 10, 15, 20].map((t) => tm.get('love', t) || '(none)').join(' ')); // (none) high high low

const set = new RandomizedSet();
console.log(set.insert(5), set.insert(5), set.remove(7), set.remove(5), set.insert(9), set.getRandom()); // true false false true true 9

const s = new MinMaxStack();
[3, 5, 2].forEach((v) => s.push(v));
console.log(s.getMin(), s.getMax()); // 2 5
s.pop();
console.log(s.getMin(), s.getMax()); // 3 5

Complexity

Read more · 56 words
StructureTime per operationExtra space
LRU: map + listO(1) get and putO(capacity)
LFU: map + count bucketsO(1) get and putO(capacity)
Time store: sorted historyO(1) set, O(log m) getO(total writes)
Randomized setO(1) averageO(n)
Min / max stackO(1) allO(n)

JavaScript you'll use

JavaScript refresher: Classes for data structures Map and Set Arrays: create, read, update
Read more · 117 words

A Map and a Set both remember insertion order, which gives you a free queue of keys. Deleting and re-adding a key moves it to the end.

const m = new Map();
m.set(k, v); m.get(k); m.has(k); m.delete(k);   // O(1) average
m.keys().next().value;                          // the OLDEST key (insertion order)
const s = new Set(); s.values().next().value;   // the oldest member of a Set
arr[i] = arr.at(-1); arr.pop();                 // swap and pop

Interview questions

Why a doubly linked list and not a singly linked one for LRU?

To unlink a node in O(1) you must change its predecessor's next, so each node needs a prev pointer. A singly linked list would need a scan to find the predecessor.

LRU or LFU: which would you pick for a cache?

is simpler and adapts quickly when popularity shifts. LFU keeps genuinely popular items but can hold on to items that were hot long ago, so it is usually combined with aging.

How would you make the LRU cache thread safe?

One lock around get and put is correct but serialises everything, since even a read reorders the list. into several LRUs keyed by hash to cut contention.

How does this connect to a real cache like Redis?

approximates LRU by sampling a few keys and evicting the oldest of the sample, which avoids the pointer overhead of a perfect list. See caching techniques.

What if the time-based store may receive writes out of order?

Appending no longer keeps the history sorted. Use a sorted structure that supports insertion, such as a balanced tree, or insert with binary search and accept O(n) shifting.

Check yourself

Question 1 of 3

Score 2 out of 3 or better to mark this page complete.

1. An LRU cache must find a key in O(1) and also evict the least recently used key in O(1). Which pair of structures delivers both?

Practice problems

Solve these in the browser editor. Each runs against hidden tests; the step-by-step solution unlocks after a wrong submission.

Open these in the problem list →

In real systems

This pattern is not only for coding rounds. Here is where it does the work in a design interview.

Next: Grids have their own toolkit: boundaries, direction arrays and in-place tricks.