LRU cache with container/list and a map
The design question disguised as a coding question: two structures, each covering what the other cannot.
- Write an LRU cache with O(1) Get and Put using a map and container/list.
- Explain why the list entry must store its key.
- Predict which key is evicted after a sequence of calls.
The problem
Build a cache with a fixed capacity. Get(key) and Put(key, val) must both be O(1). When it is full, Put evicts the least recently used key.
Brute force first
Keep a plain slice of {key, val} pairs, most recent at the front. Get scans for the key, removes it and puts it at the front. Put does the same, and drops the last pair when the slice is full.
Every operation scans and shifts: O(capacity) time per call, O(capacity) space. Correct, but the question asks for O(1).
The approach
A map alone gives O(1) lookup but no order. A list alone gives order but O(n) lookup. Together: the map points at list nodes, and the doubly linked list keeps them in recency order. Move a node to the front on every use; evict from the back. O(1) for both operations, O(capacity) space.
Go’s container/list is that doubly linked list, so you do not write one in the interview unless asked.
package mainimport ( "container/list" "fmt")type entry struct { key, val int}// LRU: the map finds a node in O(1), the list keeps recency order in O(1).// Front = most recently used, back = next to evict.type LRU struct { cap int ll *list.List items map[int]*list.Element}func NewLRU(capacity int) *LRU { return &LRU{cap: capacity, ll: list.New(), items: map[int]*list.Element{}}}func (c *LRU) Get(key int) (int, bool) { el, ok := c.items[key] if !ok { return 0, false } c.ll.MoveToFront(el) // a read counts as a use return el.Value.(*entry).val, true}func (c *LRU) Put(key, val int) { if el, ok := c.items[key]; ok { el.Value.(*entry).val = val c.ll.MoveToFront(el) return } c.items[key] = c.ll.PushFront(&entry{key, val}) if c.ll.Len() > c.cap { old := c.ll.Back() c.ll.Remove(old) delete(c.items, old.Value.(*entry).key) // why the entry stores its key }}func main() { c := NewLRU(2) c.Put(1, 1) c.Put(2, 2) fmt.Println(c.Get(1)) // 1 true (1 is now most recent) c.Put(3, 3) // evicts 2 fmt.Println(c.Get(2)) // 0 false c.Put(4, 4) // evicts 1 fmt.Println(c.Get(1)) // 0 false fmt.Println(c.Get(3)) // 3 true fmt.Println(c.Get(4)) // 4 true}Dry run
Capacity 2, following the main in the program. The list is shown front (most recent) to back:
| call | result | list after | map keys |
|---|---|---|---|
| Put(1, 1) | - | 1 | {1} |
| Put(2, 2) | - | 2, 1 | {1, 2} |
| Get(1) | 1 true | 1, 2 | {1, 2} |
| Put(3, 3) | full, evict back = 2 | 3, 1 | {1, 3} |
| Get(2) | 0 false | 3, 1 | {1, 3} |
| Put(4, 4) | full, evict back = 1 | 4, 3 | {3, 4} |
Go-specific gotchas
- Store the key inside the list entry. On eviction you only have the list node, and you need the key to
deleteit from the map. - `list.Element.Value` is `any`. The type assertion
el.Value.(*entry)is the price of a pre-generics package. Storing a pointer lets you update the value in place. - Not safe for concurrent use. Even
Getmutates (it moves the node), so async.RWMutexread lock is not enough — use a plainsync.Mutex. Ask whether they want it thread-safe before adding one. - A `map` of pointers keeps nodes alive. Forgetting the
deleteon eviction is a memory leak that no test ofGetwill catch.
- Map for O(1) find, doubly linked list for O(1) reorder and evict.
- Every
Gethit is a use: move to front. - The entry stores its key so eviction can clean up the map.
Peek(key) method that returns the value without making it most recent. Show with a test that a peeked key can still be evicted.LRU cache: NewLRU(capacity), Get(key) (int, bool) and Put(key, val). Both Get and Put make a key the most recently used; updating an existing key changes its value and refreshes it. When a Put adds a new key to a full cache, evict the least recently used key. Example with capacity 2: Put(1,1) Put(2,2) Get(1) Put(3,3) evicts 2.Tab indents. Press Esc, then Tab, to leave the editor.
- 1The main task: Add a
Peek(key)method that returns the value without making it most recent. Show with a test that a peeked key can still be evicted. - 2Remove the
delete(c.items, ...)line on eviction and write a test that catches it. - 3After the Concurrency section: make
GetandPutsafe for concurrent use and explain whyRLockis wrong forGet. - 4Capacity 2. Run
Put(1,1),Put(2,2),Put(1,10),Put(3,3). Which key is evicted, and what doesGet(1)return? - 5Why does the brute-force slice version cost O(n) per
Get, even though finding a key in a map is O(1)? What exactly doescontainer/listfix?