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).
- 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.
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
Tap the signals you can see in the problem.
Rememberdesign a classO(1)evictionhistoryrandom
- 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
- 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
- The cache is empty and holds at most 2 keys. It keeps its keys in order of use: the oldest sits at the front.
- Request 1: not in the cache, so this is a miss and we must add it.
- Insert 1 at the end: the newest key is the most recently used.
- Request 2: not in the cache, so this is a miss and we must add it.
- Insert 2 at the end: the newest key is the most recently used.
- Request 1: it is in the cache, so this is a hit.
- Take 1 out of the order...
- ...and put it back at the end. 1 is now the most recently used, so it is the last to be evicted.
- Request 3: not in the cache, so this is a miss and we must add it.
- The cache already holds 2 keys. The front of the order, 2, was used longest ago.
- Evict 2: removing the front key is one step, no search needed.
- Insert 3 at the end: the newest key is the most recently used.
- 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.
List the operations and the target cost
Write each method and its budget, such as
getin O(1).Give each operation a structure
Lookup by key: a map. Oldest first: a list. By time: a sorted array.
Connect the structures
Store in the map a pointer into the other structure, such as a list node or an array index.
Update both on every change
Every
putorremovemust fix the map and the second structure. Write the invariant down.Test the edges
Empty, capacity 1, updating an existing key, removing the last element, repeated keys.
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
}
}3. Per-key history with binary search
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
- 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
- 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.
- 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.
- Write at time 50 is after 35, so it and everything right of it is too new. hi = 4.
- Write at time 40 is after 35, so it and everything right of it is too new. hi = 3.
- 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
- 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
- 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].
- remove(7): the map says 7 sits at position 1. No scan of the array.
- Copy the last element, 6, over the hole at position 1. The order of a set does not matter.
- 6 now lives at position 1, so the map must say so. Forgetting this step breaks the next removal.
- Drop the last slot with pop(): O(1), nothing shifts.
- Delete 7 from the map. Done: the array has no holes, so a random index always lands on a member.
- remove(6): the map says 6 sits at position 1. No scan of the array.
- Copy the last element, 2, over the hole at position 1. The order of a set does not matter.
- 2 now lives at position 1, so the map must say so. Forgetting this step breaks the next removal.
- Drop the last slot with pop(): O(1), nothing shifts.
- Delete 6 from the map. Done: the array has no holes, so a random index always lands on a member.
- 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
// 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 5Complexity
Read more · 56 words
| Structure | Time per operation | Extra space |
|---|---|---|
| LRU: map + list | O(1) get and put | O(capacity) |
| LFU: map + count buckets | O(1) get and put | O(capacity) |
| Time store: sorted history | O(1) set, O(log m) get | O(total writes) |
| Randomized set | O(1) average | O(n) |
| Min / max stack | O(1) all | O(n) |
JavaScript you'll use
JavaScript refresher: Classes for data structures Map and Set Arrays: create, read, updateRead 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 popInterview 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 3Score 2 out of 3 or better to mark this page complete.
Practice problems
Solve these in the browser editor. Each runs against hidden tests; the step-by-step solution unlocks after a wrong submission.
- Design HashMapImplement a map from integer keys to integer values using only arrays and your own hashing, with no built-in map or object.Easy
- Insert Delete GetRandom O(1)Build a collection that adds, removes and returns a uniformly random member, each in constant time.Medium
- Time Based Key-Value StoreStore values with timestamps and answer what a key held at any past moment.Medium
- LRU CacheBuild a fixed-size cache that throws out the entry nobody has touched for the longest time, with O(1) reads and writes.Medium
- LFU CacheBuild a fixed-size cache that evicts the entry used the fewest times, breaking ties by the oldest use, in O(1) per call.Hard
- All O(1) Data StructureCount how often each string was seen, and report a most frequent and a least frequent string, all in constant time.Hard
- Min StackBuild a stack that also reports its current minimum in O(1), even after pops.Medium
In real systems
This pattern is not only for coding rounds. Here is where it does the work in a design interview.
- Design an LRU Cache (LLD)The LLD version of the LRU problem: a hash map plus a doubly linked list, so get and put both cost O(1), guarded by a lock.
- CachingCaches must evict when full. LRU drops the least recently used key and LFU the least often used, and both are built from the pairs of structures in this pattern.
- Design a Real-Time LeaderboardNo single plain structure gives both a fast score update and a fast rank. The page pairs them in a sorted set, the same pick-the-structures move.
- Design a Rate LimiterA limiter keeps one small record per user, such as a token bucket, and updates it in O(1) per request: a class whose every method has a time budget.