Preventing Async
Event-Loop Starvation.
Start with the behavior.
Implement a fixed-capacity cache of integer keys and values. Every successful access makes that entry the most recently used (MRU). When a new entry exceeds capacity, remove the least recently used (LRU) entry.
“I’ll assume a positive capacity and single-threaded use. Both operations need O(1) average time, and storage must stay bounded by capacity.”
LRUCache(int capacity)Create an empty cache. Reject a capacity less than 1 with IllegalArgumentException.int get(int key)Return the stored value and promote the entry to MRU. Return -1 on a miss; do not change the order.void put(int key, int value)Update or insert, then promote to MRU. Evict the LRU entry only if the size exceeds capacity.-1 miss sentinel is ambiguous if stored values can also be -1; a general-purpose API would use an optional result.Lookup is not ordering.
A hash map finds the node.
Map<Integer, Node> maps each key directly to its list node. No traversal to locate an entry.
A linked list tracks recency.
A doubly linked list lets us unlink any known node and insert it at the front with a fixed number of pointer updates.
| Approach | Lookup | Move to MRU | Why / why not |
|---|---|---|---|
| Hash map only | O(1) avg. | Not tracked | Missing eviction order |
| Array / list only | O(n) | O(n) | Searching or shifting entries |
| Hash map + doubly linked list | O(1) avg. | O(1) | Direct node access and unlinking |
The invariants to protect
- Every map entry refers to exactly one live list node, and vice versa.
head.nextis MRU;tail.previs LRU when the cache is nonempty.- Sentinel nodes are never stored in the map or evicted.
- After each public operation,
map.size() ≤ capacity. - Neighbor pointers agree:
node.prev.next == nodeandnode.next.prev == node.
Follow the pointers.
- Get: find, promote, return.
Look up the node. On a miss, return -1. On a hit, unlink it, insert it after head, and return its value.
- Put existing: update in place.
Change the node’s value and move it to MRU. Size stays the same; no eviction is necessary.
- Put new: insert, then enforce capacity.
Add the new node to both structures. If over capacity, unlink tail.prev and remove its key from the map.
Try get(2), then put(4, 40). Key 1 is evicted; key 2 remains cached.
Small helpers. Fewer edge cases.
Dummy head and tail nodes make insertion and removal uniform, even when the cache has only one entry. Store the key in each node so eviction can remove its map entry without a search.
import java.util.HashMap;
import java.util.Map;
public final class LRUCache {
private static final class Node {
final int key;
int value;
Node prev, next;
Node(int key, int value) {
this.key = key;
this.value = value;
}
}
private final int capacity;
private final Map<Integer, Node> map = new HashMap<>();
private final Node head = new Node(0, 0);
private final Node tail = new Node(0, 0);
public LRUCache(int capacity) {
if (capacity < 1) {
throw new IllegalArgumentException(
"Capacity must be positive"
);
}
this.capacity = capacity;
head.next = tail;
tail.prev = head;
}
public int get(int key) {
Node node = map.get(key);
if (node == null) return -1;
moveToFront(node);
return node.value;
}
public void put(int key, int value) {
Node node = map.get(key);
if (node != null) {
node.value = value;
moveToFront(node);
return;
}
Node added = new Node(key, value);
map.put(key, added);
insertAtFront(added);
if (map.size() > capacity) {
Node victim = tail.prev;
unlink(victim);
map.remove(victim.key);
}
}
private void moveToFront(Node node) {
unlink(node);
insertAtFront(node);
}
private void unlink(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
node.prev = null;
node.next = null;
}
private void insertAtFront(Node node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
}This implementation is intentionally not thread-safe. Synchronizing only the map would not protect the list or the cross-structure invariants.
Prove the behavior, not just the code.
| Operation / resource | Complexity | Reason |
|---|---|---|
| get / put | O(1) average | Hash map operation + fixed pointer updates |
| Eviction | O(1) average | Direct LRU pointer + map removal |
| Space | O(capacity) | One map entry and one node per cached key |
“Average” matters: hash map operations are not a strict worst-case O(1) guarantee. No throughput or nanosecond latency claims are needed to establish this design’s complexity.
A minimal test checklist
- Empty cache: get returns -1 without changing state.
- Capacity one: the next distinct insertion evicts the previous entry.
- Existing key: value updates, size stays fixed, and the key becomes MRU.
- Get hit: the accessed key survives the next eviction.
- Get miss: the eviction order remains unchanged.
- Repeated eviction: removed keys are absent from both structures.
- Invalid capacity: construction throws immediately.
put(1, 10) → [1:10]
put(2, 20) → [2:20, 1:10]
get(1) → 10 [1:10, 2:20]
put(3, 30) → evict 2 [3:30, 1:10]
get(2) → -1 [3:30, 1:10]
put(1, 99) → update [1:99, 3:30]
Order shown: MRU → LRUWhy isn’t get() a read-only operation?
Extend only when asked.
The base solution is complete. Keep follow-up discussion local to the cache’s contract and implementation, rather than introducing a distributed architecture.
What if concurrent access is required?
Start with one lock covering each complete get and put operation, including map and list mutations. A get hit also writes recency state, so a read lock is not enough. This preserves exact global LRU but serializes access. Sharding can reduce contention, but independent shard eviction is not exact global LRU.
Could I use LinkedHashMap instead?
Yes. An access-order LinkedHashMap can track recency, and removeEldestEntry can enforce capacity. It reduces custom code, but in a data-structure interview, first demonstrate the node and pointer mechanics. It is not thread-safe by default.
What about expiration or a byte limit?
Those change the contract. TTL requires expiration tracking and cleanup; a byte limit requires size accounting and may evict multiple entries per insertion. Do not carry the base O(1) put guarantee over without reanalyzing that extra work.
What is the main weakness of LRU?
A scan of many one-time keys can evict frequently useful entries. Alternatives such as LFU or segmented policies address different workloads, but they are separate requirements—not necessary additions to this problem.
“A hash map finds each node; a doubly linked list maintains recency. Hits and writes move entries to the front. Overflow removes the tail entry from both structures. The result is O(1) average get and put, using O(capacity) space.”